Linux:多路转接
多路转接select
初识select
系统提供select函数来实现多路复⽤输⼊/输出模型.
- select系统调⽤是⽤来让我们的程序监视多个⽂件描述符的状态变化的;
- 程序会停在select这⾥等待,直到被监视的⽂件描述符有⼀个或多个发⽣了状态改变;
select函数原型
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout);
参数解释:
- 参数nfds是需要监视的最⼤的⽂件描述符值+1;
- rdset,wrset,exset分别对应于需要检测的可读⽂件描述符的集合,可写⽂件描述符的集 合及异常⽂件描述符的集合;
- 参数timeout为结构timeval,⽤来设置select()的等待时间
参数timeout取值:
- NULL:则表⽰select()没有timeout,select将⼀直被阻塞,直到某个⽂件描述符上发⽣了事 件;
- 0:仅检测描述符集合的状态,然后⽴即返回,并不等待外部事件的发⽣。
- 特定的时间值:如果在指定的时间段⾥没有事件发⽣,select将超时返回。
关于fd_set结构
其实这个结构就是⼀个整数数组, 更严格的说, 是⼀个 "位图". 使⽤位图中对应的位来表⽰要监视的⽂件描述符.
提供了⼀组操作fd_set的接⼝, 来⽐较⽅便的操作位图.
void FD_CLR(int fd, fd_set *set); // ⽤来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // ⽤来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // ⽤来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // ⽤来清除描述词组set的全部位 关于timeval结构
timeval结构⽤于描述⼀段时间⻓度,如果在这个时间内,需要监视的描述符没有事件发⽣则函数返回,返回值为0。

函数返回值:
- 执⾏成功则返回⽂件描述词状态已改变的个数
- 如果返回0代表在描述词状态改变前已超过timeout时间,没有返回
- 当有错误发⽣时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和 timeout的值变成不可预测。
错误值可能为:
- EBADF ⽂件描述词为⽆效的或该⽂件已关闭
- EINTR 此调⽤被信号所中断
- EINVAL 参数n 为负值
- ENOMEM 核⼼内存不⾜
常见的程序⽚段如下:
fs_set readset;
FD_SET(fd,&readset);
select(fd+1,&readset,NULL,NULL,NULL);
if(FD_ISSET(fd,readset)){……}
理解select执行过程
理解select模型的关键在于理解fd_set,为说明⽅便,取fd_set⻓度为1字节,fd_set中的每⼀bit可以对应⼀个⽂件描述符fd。则1字节⻓的fd_set最⼤可以对应8个fd.
- (1)执⾏fd_set set; FD_ZERO(&set);则set⽤位表⽰是0000,0000。
- (2)若fd=5,执⾏FD_SET(fd,&set);后set变为00010000(第5位置为1)
- (3)若再加⼊fd=2,fd=1,则set变为00010011
- (4)执⾏select(6,&set,0,0,0)阻塞等待
- (5)若fd=1,fd=2上都发⽣可读事件,则select返回,此时set变为00000011。注意:没有事件发⽣的fd=5被清空。
socket就绪条件
读就绪
- socket内核中, 接收缓冲区中的字节数, ⼤于等于低⽔位标记SO_RCVLOWAT. 此时可以⽆阻塞的读该⽂件描述符, 并且返回值⼤于0;
- socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
- 监听的socket上有新的连接请求;
- socket上有未处理的错误;
写就绪
- socket内核中, 发送缓冲区中的可⽤字节数(发送缓冲区的空闲位置⼤⼩), ⼤于等于低⽔位标记 SO_SNDLOWAT, 此时可以⽆阻塞的写, 并且返回值⼤于0;
- socket使⽤⾮阻塞connect连接成功或失败之后;
- socket上有未读取的错误
(写错误:socket的写操作被关闭(close或者shutdown). 对⼀个写操作被关闭的socket进⾏写操作, 会触发SIGPIPE信号)
select的特点
可监控的⽂件描述符个数取决于sizeof(fd_set)的值.若sizeof(fd_set)=512字节,每个字节8位,所以总位数4096位
将fd加⼊select监控集的同时,还要再使⽤⼀个数据结构array保存放到select监控集中的fd,
- ⼀是⽤于在select 返回后,array作为源数据和fd_set进⾏FD_ISSET判断。
- ⼆是select返回后会把以前加⼊的但并⽆事件发⽣的fd清空,则每次开始select前都要重新从array取得fd逐⼀加⼊(FD_ZERO最先),扫描array的同时取得fd最⼤值maxfd,⽤于select的第 ⼀个参数。
select缺点
- 每次调⽤select, 都需要⼿动设置fd集合, 从接⼝使⽤角度来说也⾮常不便.
- 每次调⽤select,都需要把fd集合从⽤⼾态拷贝到内核态,这个开销在fd很多时会很⼤
- 同时每次调⽤select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很⼤
- select⽀持的⽂件描述符数量太⼩.
select使用示例: 检测标准输⼊输出
#include <stdio.h>
#include <unistd.h>
#include <sys/select.h>
int main() {fd_set read_fds;FD_ZERO(&read_fds);FD_SET(0, &read_fds);for (;;) {printf("> ");fflush(stdout);int ret = select(1, &read_fds, NULL, NULL, NULL);if (ret < 0) {perror("select");continue;}if (FD_ISSET(0, &read_fds)) {char buf[1024] = {0};read(0, buf, sizeof(buf) - 1);printf("input: %s", buf);} else {printf("error! invaild fd\n");continue;}FD_ZERO(&read_fds);FD_SET(0, &read_fds);}return 0;
}
多路转接poll
poll函数接口
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd {int fd; /* file descriptor */short events; /* requested events */short revents; /* returned events */
};
参数说明
- fds是⼀个poll函数监听的结构列表. 每⼀个元素中, 包含了三部分内容: ⽂件描述符, 监听的事件集合, 返回的事件集合.
- nfds表⽰fds数组的⻓度.
- timeout表⽰poll函数的超时时间, 单位是毫秒(ms).
events和revents的取值:

返回结果
返回值⼩于0, 表⽰出错;
返回值等于0, 表⽰poll函数等待超时;
返回值⼤于0, 表⽰poll由于监听的⽂件描述符就绪⽽返回
socket就绪条件
同select
poll的优点
不同于select使⽤三个位图来表⽰三个fdset的⽅式,poll使⽤⼀个pollfd的指针实现.
- pollfd结构包含了要监视的event和发⽣的event,不再使⽤select“参数-值”传递的⽅式. 接⼝使⽤⽐select更⽅便.
- poll并没有最⼤数量限制 (但是数量过⼤后性能也是会下降).
poll的缺点
poll中监听的⽂件描述符数⽬增多时
- 和select函数⼀样,poll返回后,需要轮询pollfd来获取就绪的描述符.
- 每次调⽤poll都需要把⼤量的pollfd结构从⽤户态拷贝到内核中.
- 同时连接的⼤量客户端在⼀时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增⻓, 其效率也会线性下降.
poll示例: 使用poll监控标准输入
#include <poll.h>
#include <unistd.h>
#include <stdio.h>
int main() {struct pollfd poll_fd;poll_fd.fd = 0;poll_fd.events = POLLIN;for (;;) {int ret = poll(&poll_fd, 1, 1000);if (ret < 0) {perror("poll");continue;}if (ret == 0) {printf("poll timeout\n");continue;}if (poll_fd.revents == POLLIN) {char buf[1024] = {0};read(0, buf, sizeof(buf) - 1);printf("stdin:%s", buf);}}
}
多路转接epoll
epoll_create
int epoll_create(int size);
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数
它不同于select()是在监听事件时告诉内核要监听什么类型的事件, ⽽是在这⾥先注册要监听的事
件类型.
- 第⼀个参数是epoll_create()的返回值(epoll的句柄).
- 第⼆个参数表⽰动作,⽤三个宏来表⽰.
- 第三个参数是需要监听的fd.
- 第四个参数是告诉内核需要监听什么事.
第⼆个参数的取值
- EPOLL_CTL_ADD :注册新的fd到epfd中;
- EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
- EPOLL_CTL_DEL :从epfd中删除⼀个fd;
struct epoll_event结构如下
events可以是以下⼏个宏的集合:
- EPOLLIN : 表⽰对应的⽂件描述符可以读 (包括对端SOCKET正常关闭);
- EPOLLOUT : 表⽰对应的⽂件描述符可以写;
- EPOLLPRI : 表⽰对应的⽂件描述符有紧急的数据可读 (这⾥应该表⽰有带外数据到来);
- EPOLLERR : 表⽰对应的⽂件描述符发⽣错误;
- EPOLLHUP : 表⽰对应的⽂件描述符被挂断;
- EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于⽔平触发(Level Triggered)来说的.
- EPOLLONESHOT:只监听⼀次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加⼊到EPOLL红⿊树⾥.
epoll_wait
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int
timeout);
- 参数events是分配好的epoll_event结构体数组.
- epoll将会把发⽣的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在⽤户态中分配内存).
- maxevents告之内核这个events有多⼤,这个 maxevents的值不能⼤于创建epoll_create()时的 size.
- 参数timeout是超时时间 (毫秒,0会⽴即返回,-1是永久阻塞).
- 如果函数调⽤成功,返回对应I/O上已准备好的⽂件描述符数⽬,如返回0表⽰已超时, 返回⼩于0表⽰函数失败.
epoll工作原理

当某⼀进程调⽤epoll_create⽅法时,Linux内核会创建⼀个eventpoll结构体,这个结构体中有
两个成员与epoll的使⽤⽅式密切相关.
struct eventpoll{
..../*红⿊树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/struct rb_root rbr;/*双链表中则存放着将要通过epoll_wait返回给⽤户的满⾜条件的事件*/struct list_head rdlist;
....
}; - 每⼀个epoll对象都有⼀个独⽴的eventpoll结构体,⽤于存放通过epoll_ctl⽅法向epoll对象中添 加进来的事件.
- 这些事件都会挂载在红⿊树中,如此,重复添加的事件就可以通过红⿊树⽽⾼效的识别出来(红⿊树的插⼊时间效率是lgn,其中n为树的⾼度).
- ⽽所有添加到epoll中的事件都会与设备(⽹卡)驱动程序建⽴回调关系,也就是说,当响应的事件发⽣时会调⽤这个回调⽅法.
- 这个回调⽅法在内核中叫ep_poll_callback,它会将发⽣的事件添加到rdlist双链表中.
- 在epoll中,对于每⼀个事件,都会建⽴⼀个epitem结构体.
struct epitem{struct rb_node rbn;//红⿊树节点struct list_head rdllink;//双向链表节点struct epoll_filefd ffd; //事件句柄信息struct eventpoll *ep; //指向其所属的eventpoll对象struct epoll_event event; //期待发⽣的事件类型
}
- 当调⽤epoll_wait检查是否有事件发⽣时,只需要检查eventpoll对象中的rdlist双链表中是否有 epitem元素即可.
- 如果rdlist不为空,则把发⽣的事件复制到⽤⼾态,同时将事件数量返回给⽤⼾. 这个操作的时间复杂度是O(1).
总结⼀下, epoll的使⽤过程就是三部曲:
- 调⽤epoll_create创建⼀个epoll句柄;
- 调⽤epoll_ctl, 将要监控的⽂件描述符进⾏注册;
- 调⽤epoll_wait, 等待⽂件描述符就绪;
epoll的优点(和 select 的缺点对应)
- 接⼝使⽤⽅便: 虽然拆分成了三个函数, 但是反⽽使⽤起来更⽅便⾼效. 不需要每次循环都设置关注的⽂件描述符, 也做到了输⼊输出参数分离开
- 数据拷贝轻量: 只在合适的时候调⽤ EPOLL_CTL_ADD 将⽂件描述符结构拷贝到内核中, 这个操作并不频繁(⽽select/poll都是每次循环都要进⾏拷贝)
- 事件回调机制: 避免使⽤遍历, ⽽是使⽤回调函数的⽅式, 将就绪的⽂件描述符结构加⼊到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些⽂件描述符就绪. 这个操作时间复杂度O(1).即使⽂件描述符数⽬很多, 效率也不会受到影响.
- 没有数量限制: ⽂件描述符数⽬⽆上限.
可以总结select, poll, epoll之间的优点和缺点
epoll工作方式
epoll有2种⼯作⽅式-⽔平触发(LT)和边缘触发(ET)
假如有这样⼀个例⼦:
- 我们已经把⼀个tcp socket添加到epoll描述符
- 这个时候socket的另⼀端被写⼊了2KB的数据
- 调⽤epoll_wait,并且它会返回. 说明它已经准备好读取操作
- 然后调⽤read, 只读取了1KB的数据
- 继续调⽤epoll_wait......
水平触发Level Triggered 工作模式
epoll默认状态下就是LT⼯作模式.
- 当epoll检测到socket上事件就绪的时候, 可以不⽴刻进⾏处理. 或者只处理⼀部分.
- 如上⾯的例⼦, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第⼆次调⽤ epoll_wait 时, epoll_wait 仍然会⽴刻返回并通知socket读事件就绪.
- 直到缓冲区上所有的数据都被处理完, epoll_wait 才不会⽴刻返回.
- ⽀持阻塞读写和⾮阻塞读写
边缘触发Edge Triggered工作模式
如果我们在第1步将socket添加到epoll描述符的时候使⽤了EPOLLET标志, epoll进⼊ET⼯作模式
- 当epoll检测到socket上事件就绪时, 必须⽴刻处理.
- 如上⾯的例⼦, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第⼆次调⽤ epoll_wait 的时候, epoll_wait 不会再返回了.
- 也就是说, ET模式下, ⽂件描述符上的事件就绪后, 只有⼀次处理机会.
- ET的性能⽐LT性能更⾼( epoll_wait 返回的次数少了很多). Nginx默认采⽤ET模式使⽤epoll.
- 只⽀持⾮阻塞的读写
select和poll其实也是⼯作在LT模式下. epoll既可以⽀持LT, 也可以⽀持ET
对比LT和ET
LT是 epoll 的默认⾏为.
使⽤ ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序员⼀次响应就绪过程中就把所有的数据都处理完.
理解ET模式和非阻塞文件描述符
使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞. 这个不是接⼝上的要求, ⽽是 "⼯程实践" 上的要求.
假设这样的场景: 服务器接收到⼀个10k的请求, 会向客户端返回⼀个应答数据. 如果客户端收不到应答, 不会发送第⼆个10k请求.

如果服务端写的代码是阻塞式的read, 并且⼀次只 read 1k 数据的话(read不能保证⼀次就把所有的数据都读出来, 参考 man ⼿册的说明, 可能被信号打断), 剩下的9k数据就会待在缓冲区中.

此时由于 epoll 是ET模式, 并不会认为⽂件描述符读就绪. epoll_wait 就不会再次返回. 剩下的 9k
数据会⼀直在缓冲区中. 直到下⼀次客户端再给服务器写数据. epoll_wait 才能返回
问题是
服务器只读到1k个数据, 要10k读完才会给客户端返回响应数据.
客户端要读到服务器的响应, 才会发送下⼀个请求.
客户端发送了下⼀个请求, epoll_wait 才会返回, 才能去读缓冲区中剩余的数据.
所以, 为了解决上述问题(阻塞read不⼀定能⼀下把完整的请求读完), 于是就可以使⽤⾮阻塞轮轮询的⽅式来读缓冲区, 保证⼀定能把完整的请求都读出来.
而如果是LT没这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回⽂件描述符读就绪.
epoll的使用场景
epoll的⾼性能, 是有⼀定的特定场景的. 如果场景选择的不适宜, epoll的性能可能适得其反.
对于多连接, 且多连接中只有⼀部分连接⽐较活跃时, ⽐较适合使⽤epoll.
例如, 典型的⼀个需要处理上万个客户端的服务器, 例如各种互联⽹APP的⼊⼝服务器, 这样的服务器就很适合epoll.
如果只是系统内部, 服务器和服务器之间进⾏通信, 只有少数的⼏个连接, 这种情况下⽤epoll就并不合适. 具体要根据需求和场景特点来决定使⽤哪种IO模型.
