一、I/O复用的特点
- 能同时监听多个文件描述符
- 自身是阻塞的
- 当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符
由于其第三个特点,所以服务器程序看起来仍像是串行工作的,如果要实现并发,只能使用多进程或多线程等编程手段。
二、select系统调用
/* 进程阻塞在select调用上监听多个事件,并在事件就绪或超时后返回 */ int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); /* 参数说明 */ // nfds:被监听的文件描述符的总数 // readfds:指向可读事件对应的文件描述符集合 // writefds:指向可写事件对应的文件描述符集合 // exceptfds:指向异常事件对应的文件描述符集合 // timeout:select函数的超时时间
1. 功能:在一段指定时间内,监听用户感兴趣的那些文件描述符上的可读、可写和异常事件。
2. nfds参数:select函数监听的文件描述符的总数
假设在我们感兴趣的文件描述符集合中,文件描述符的最大值为maxfd,那么select函数监听的文件描述符总数nfds = maxfd + 1,因为文件描述符是从0开始计数的。这意味着,select函数监听的文件描述符的个数nfds可能超过我们感兴趣的文件描述符的个数。
{我们感兴趣的文件描述符} ⊆ {select函数监听的文件描述符}
3. 中间三个指针参数:分别指向可读、可写和异常事件对应的文件描述符集合
- 调用select函数时,通过这三个参数传入我们感兴趣的文件描述符
- select函数返回时,内核将修改它们所指向的文件描述符集合来通知哪些文件描述符已经就绪
当select函数返回时,那些未就绪的文件描述符对应的位均被置零。所以,每次重新调用select函数时,这三个参数指向的文件描述符集均需重新置位。
4. timeout参数:select函数的超时时间
内核将修改它以告诉应用程序select等待了多久。不过一般把它设置为NULL,让select一直阻塞到有事件就绪。
5. fd_set结构体
struct fd_set { int fds_bits[FD_SETSIZE / 32]; // 因为int类型占32位,故该数组的总位数等于FD_SETSIZE };
fd_set结构体仅包含一个整型数组,该数组的每个元素的每一位标记一个文件描述符。
故fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这就限制了select函数能同时处理的文件描述符的总量。
/* 访问fd_set结构体中的位 */ FD_ZERO(fd_set *fdset); // 清除fdset的所有位 FD_SET(int fd, fd_set *fdset); // 设置fdset的位fd FD_CLR(int fd, fd_set *fdset); // 清除fdset的位fd int FD_ISSET(int fd, fd_set *fdset); // 测试fdset的位fd是否被设置
6. select函数可被信号中断,此时select立即返回-1,并设置errno为EINTR。
7. select函数的缺点
①监听的文件描述符的数目太多。比如我感兴趣的两个文件描述符的值分别为1和711,这种情况下,select函数将监听712个文件描述符。
②一次监听的文件描述符的总量受FD_SETSIZE的限制。
③每次重新调用select函数时,总要重新设置感兴趣的文件描述符。
④当select成功返回后,我们需要遍历所有我们感兴趣的文件描述符及对应的事件,以找出就绪的文件描述符及对应的事件。
⑤采用轮询的方式,每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序。
三、poll系统调用
/* 在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者 */ int poll(struct pollfd *fds, nfds_t nfds, int timeout); /* 参数说明 */ // fds:指定所有我们感兴趣的文件描述符上发生的可读、可写和异常事件 // nfds:被监听事件集合fds的大小 // timeout:poll函数的超时时间
1. 功能:与select类似,poll也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。与select不同,poll不是为每个条件(可读性、可写性和异常条件)构造一个描述符集,而是构造一个pollfd结构的数组,每个数组元素指定一个描述符编号以及我们对该描述符感兴趣的条件。
2. pollfd结构体
struct pollfd { int fd; // 文件描述符 short events; // 注册的事件 short revents; // 实际发生的事件,由内核填充 };
events成员:告诉poll监听fd上的哪些事件,它是一系列事件的按位或
revents成员:由内核修改,以通知应用程序fd上实际发生了哪些事件
poll函数没有改变events成员。这与select函数不同,select函数修改其参数以指示哪些描述符上相应的事件已就绪。
3. poll事件类型
事件 | 是否能够输入至events | 是否能够从revents输出 | 说明 |
POLLIN | √ | √ | 数据可读(除高优先级数据,相当于POLLRNORM|POLLRDBAND) |
POLLRDNORM | √ | √ | 普通数据可读 |
POLLRDBAND | √ | √ | 优先级带数据可读(Linux不支持) |
POLLPRI | √ | √ | 高优先级数据可读,如TCP带外数据 |
POLLOUT | √ | √ | 数据可写 |
POLLWRNORM | √ | √ | 普通数据可写 |
POLLWRBAND | √ | √ | 优先级带数据可写 |
POLLRDHUP | √ | √ | TCP连接被对方关闭或对方关闭了写操作 |
POLLERR | √ | 错误 | |
POLLHUP | √ | 挂起,如管道的写端被关闭后,读端描述符将收到此事件 | |
POLLNVAL | √ | 文件描述符没有打开 |
- 在Linux内核2.6.17之前,还没有POLLRDHUP事件,故当时为了区分socket上接收到的是有效数据还是对方关闭连接的请求,需要参考recv调用的返回值。
- 文件尾端与挂起并无联系,如我们正从终端输入数据,并键入文件结束符,那么revents中的POLLIN就会打开,而POLLHUP则不会打开。
- 当一个文件描述符被挂起后,就不能再写该描述符,但是有可能仍然可以从该描述符读取到数据。
4. poll函数的缺点
①当poll成功返回后,我们需要遍历所有我们注册的事件(包括就绪的和未就绪的),以找出就绪的文件描述符及对应的事件。
②采用轮询的方式,每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序。
相对于select,poll的优势之处:
- 内核每次修改的是revents成员,而events成员保持不变,因此下次调用poll时无需重置pollfd类型的事件集参数。
- 用nfds参数指定最多监听多少个文件描述符和事件,该数值能达到系统允许打开的最大文件描述符数目。
四、epoll系列系统调用
1. 内核事件表:epoll把用户感兴趣的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集
2. epoll需要使用一个额外的文件描述符来唯一标识上述的内核事件表
/* 创建一个用于标识内核事件表的文件描述符 */ int epoll_create(int size); /* 参数说明 */ // size:内核事件表的大小,现在并不起作用
补:epoll_create返回的文件描述符将用作其他所有epoll系统调用的第一个参数,以指定要访问的内核事件表。
3. 操作epoll的内核事件表
/* 控制往内核事件表中添加、修改、删除事件 */ int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); /* 参数说明 */ // op:指定操作类型,包含EPOLL_CTL_ADD、EPOLL_CTL_MOD和EPOLL_CTL_DEL // fd:要操作的文件描述符 // event:指定事件
- EPOLL_CTL_ADD:往事件表中注册fd上的事件
- EPOLL_CTL_MOD:修改fd上的注册事件
- EPOLL_CTL_DEL:删除fd上的注册事件
4. epoll_event结构体
struct epoll_event { __uint32_t events; // epoll事件 epoll_data_t data; // 用户数据 }; union epoll_data_t { void *ptr; // 指定与fd相关的用户数据 int fd; // 指定事件所从属的目标文件描述符 uint32_t u32; uint64_t u64; };
epoll支持的事件类型和poll基本相同,只是多了两个额外的事件类型——EPOLLET和EPOLLONESHOT,它们对于epoll的高效运作非常关键。
表示epoll事件类型的宏是在poll对应的宏前加上“E”,比如epoll的数据可读事件是EPOLLIN。
5. epoll_wait:如果检测到事件,就将所有就绪的事件从内核事件表中复制到它的第二个参数events指向的数组中
/* 在指定时间内等待一组文件描述符上的事件 */ int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); /* 参数说明 */ // events:指向由epoll_wait检测到的就绪事件 // maxevents:最多监听多少个事件,它必须大于0 // timeout:epoll_wait函数的超时时间
events指向的结构数组只用于输出epoll_wait检测到的就绪事件,而不像select和poll的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。
这极大地提高了应用程序索引就绪文件描述符的效率。
6. 示例——poll和epoll在索引就绪文件描述符上的差别
/* 索引poll返回的就绪文件描述符 */ int ret = poll(fds, MAX_EVENT_NUMBER, -1); // 必须遍历所有已注册文件描述符并找到其中的就绪者 forr(int i = 0; i < MAX_EVENT_NUMBER; ++i) { if(fds[i].revents & POLLIN) { // 判断第i个文件描述符是否就绪 int sockfd = fds[i].fd; /* ...处理sockfd... */ } } /* 索引epoll返回的就绪文件描述符 */ int ret = epoll_wait(epfd, events, MAX_EVENT_NUMBER, -1); // 仅遍历就绪的ret个文件描述符 for(int i = 0; i < ret; ++i) { int sockfd = events[i].data.fd; /* ...处理sockfd... */ }
补:epoll_wait系统调用的events参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度达到O(1)。
7. LT模式 & ET模式——epoll操作文件描述符的两种模式
LT是默认的工作模式,ET则是epoll的高效工作模式。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。
①采用LT工作模式的文件描述符:
- 当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。
- 当应用程序下一次调用epoll_wait时,epoll_wait会再次向应用程序通告此事件,直到该事件被处理。
②采用ET工作模式的文件描述符:
- 当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件。
- 当应用程序下一次调用epoll_wait时,epoll_wait不会再次向应用程序通告此事件。
- 可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。
注:每个使用ET模式的文件描述符都应该是非阻塞的。否则,读或写操作将会因为没有后续的事件而一直处于阻塞状态。
8. EPOLLONESHOT事件
即使使用了ET模式,一个socket上的某个事件还是可能被触发多次。这在并发程序中就会引发一个问题。如一个进程(线程)在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket上又有新数据可读(EPOLLIN再次被触发),此时另外一个进程(线程)被唤醒来读取这些新的数据,于是出现了两个进程(线程)同时操作一个socket的局面。而我们期望的是一个socket连接在任一时刻都只被一个进程(线程)处理。
对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。这样,当一个进程(线程)在处理某个socket时,其他进程(线程)是不可能有机会操作该socket的。此外,注册了EPOLLONESHOT事件的socket一旦被某个进程(线程)处理完毕,就应该立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被触发,进而让其他工作进程(线程)有机会继续处理这个socket。
9. epoll的优点
epoll的优点 | poll的缺点 | select的缺点 |
epoll_wait直接从内核事件表中取得用户注册的事件,无须每次调用都传入用户感兴趣的事件 epoll_wait的events参数仅用于返回epoll_wait检测到的就绪事件 |
每次调用都需通过pollfd.events传入用户感兴趣的事件 pollfd数组既用于传入用户注册的事件,又用于输出内核检测到的就绪事件 |
每次调用都需通过3个文件描述符集传入用户感兴趣的事件 3个文件描述符集既用于传入用户注册的事件,又用于输出内核检测到的就绪事件 |
应用程序索引就绪文件描述符的时间复杂度为O(1) | 应用程序索引就绪文件描述符的时间复杂度为O(n) | 应用程序索引就绪文件描述符的时间复杂度为O(n) |
采用回调方式来检测就绪事件,算法时间复杂度为O(1) | 采用轮询方式来检测就绪事件,算法时间复杂度为O(n) | 采用轮询方式来检测就绪事件,算法时间复杂度为O(n) |
支持ET高效模式 | 只支持LT工作模式 | 只支持LT工作模式 |
允许监听的最大文件描述符数量可达到系统允许打开的最大文件描述符数目 | 允许监听的最大文件描述符数量受到限制 | |
每次重新调用都需重新设置用户感兴趣的事件 | ||
监听的文件描述符数量远远超过用户感兴趣的文件描述符数量 |
五、余音绕梁
1. 三种I/O复用检测就绪事件的实现原理有何不同,epoll_wait函数是如何达到O(1)的时间效率的?
select和epoll采用轮询的方式,即扫描整个注册文件描述符集合,以找到其中就绪的文件描述符,然后将它们返回给用户程序。
epoll_wait则采用回调的方式,即内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此,epoll_wait无须轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度是O(1)。
2. 关于红黑树的内核实现(红黑树 + 双向链表 + 回调函数)
参见博客:https://blog.csdn.net/russell_tao/article/details/7160071