Linux环境开发(二):IO复用之select/poll/epoll之原理和差异分析

  select/poll/epoll算是Linux中最常用的IO复用形式了,select是POSIX的标准,所以在Windows平台也是支持的。通常来说,select相对于poll和epoll的限制比较的多,但是在连接数小流量大的时候,select的性能表现也不见得比poll/epoll要差,而epoll对于侦听大量描述符,同时只有少量描述符活跃的时候更为的有效。
  其实这几个IO复用的内部实现,select和poll比较接近,但epoll跟前几两者已经完全不同了,在此做一个总结吧。

  这些函数都是系统调用,其中select/pselect/poll/ppoll定义在fs/select.c当中,而epoll被单独定义在了fs/eventpoll.c文件当中。

一、select

1
2
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);

  select主要是通过三个fd_set记录要监测的read/write/except事件的文件描述符,fd_set的大小被__FD_SETSIZE这个宏所限制,如果要增加这个值势必要重新编译系统内核,这也是select最大的诟病:监听的事件个数是有限的(1024)。外边通过FD_SET、FD_CLR等操作宏,实际内部是将这1024个文件描述符映射成1024bit=128byte=32long(32位系统)中的比特位的。
  在进入系统调用后,接着:
  (1) 调用core_sys_select,对于每个描述符read/write/except以及输入和输出,内核需要为这些描述符申请6倍的映射空间,然后将select输入的参数拷贝进内核空间,然后调用do_select;
  (2) do_select中,按照bit扫描看是否需要r/w/ex的检查,如果需要,就调用文件系统file_operations->poll函数,检测POLLIN_SET/POLLOUT_SET/POLLEX_SET,并标志保存位图的对应位置;
  (3) do_select调用返回到core_sys_select,将结果拷贝到用户空间(复用传参的地址),调用结束返回;

  对于socket或者文件系统fd的poll调用,都会调用注册文件系统提供的poll函数,比如对于网络socket的poll,底层的poll函数是定义在net/ipv4/tcp.c中的tcp_poll(),调用过程如下:
  a. fs/select.c: 在do_select开始的时候,会调用poll_initwait()函数,这个函数会将poll_wqueues.poll_table的_qproc设置为__pullwait函数;
  b. fs/select.c:在下面遍历检测的时候,会调用文件系统/网络系统的poll函数(*f_op->poll)(f.file, wait),这里调用参数f.file是每个文件描述符,wait是上面的poll_table;
  c. 映射到socket上面,就是调用的net/sock.c中的sock_poll;
  d. 如果是TCP,那么就映射到底层的net/tcp.c中的tcp_poll;

1
2
3
4
static unsigned int sock_poll(struct file *file,
struct poll_table_struct *wait);
unsigned int tcp_poll(struct file *file, struct socket *sock,
struct poll_table_struct *wait);

  tcp_poll就会根据sk的状态设置各种标志,其中最重要的一条是调用了sock_poll_wait(file, sk_sleep(sk), wait);这里的三个参数都有作用:
  file:跟踪的具体文件描述符;
  sk_sleep(sk):得到一个等待队列wait_address,维持了有阻塞在这个sock上的进程,通知唤醒用户进程就是通过这个等待队列来做的;
  wait:poll_table的等待队列;
  (4) poll_wait(file, wait_address, wait);也就是wait->_qproc(file, wait_address, wait);
  这个过程比较的绕,就是在程序开始的时候,建立一个poll_wqueue的队列以及poll_table结构,并将__poll_wait注册为回调函数,然后遍历每个文件描述符的时候调用对应文件系统的底层poll函数,在文件系统驱动中去调用这个回调函数(主要就是把current当前进程挂载到设备的等待队列上去),设置相应的标志,并且返回。如果驱动程序的数据可用了,就会唤醒挂载到这个等待队列上的进程(没有区分到底是IN/OUT/EX事件触发的唤醒哦)。

  小结:
  由此可见,select系统调用就是依照顺序检索1024个文件描述符,直观明了。但是缺点是:每次调用需要将侦听的fd_set拷贝到内核态,结果也需要从内核态拷贝到用户态;select内部需要线性扫描每个文件描述符,如果有侦听需求,就需要调用文件系统的poll调用,返回后,用户态也需要用FD_ISSET来依次检测到底是那个文件描述符被设置了,如果fd_set侦听的描述符比较多的话,开销是比较大的;调用要求传递max_fd+1,系统检测当前打开文件最大描述符等,无时不在拷贝数据和扫描检查上面优化。
  此外,select内部是一个忙等待,睡眠时间介于本进程slack和100ms之间,根据超时时间(如果传递了的话)的0.1%~0.5%之间,如果唤醒发展超时、有fd就绪、出错、接收到信号这些事件就返回,否则继续睡眠。当然,如果设备支持唤醒队列的话,数据可用后select系统调用的进程会被重新唤醒,系统调用只需要重新扫描所有的文件描述符就可以了。
  当select返回的时候,会把自己从所有设备的唤醒队列中移除掉,不再接受唤醒事件。
  此外,select是跨平台的,在某些情况下算是比较重要的特性的,使得libevent这种跨平台的异步库得以可能实现。

二、poll

1
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

  poll机制使用的是结构体struct pollfd

1
2
3
4
5
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};

  然后将要侦听的描述符建立成struct pollfd的数组传递给poll系统调用,这样就没有1024的限制了,只要修改系统打开文件的限制,能打开多少文件就可以创建多少个struct pollfd来侦听。
  (1) 首先调用do_sys_poll函数,也需要将整个fds的用户态结构数组拷贝到内核态中,然后调用do_poll;
  需要注意的是这时候系统内核用了一个新的结构体poll_list来保存,这时候如果256字节的栈空间足够拷贝,那么next为NULL,所有的数据拷贝到entries上面,否则逐个申请GFP_KERNEL页面来拷贝,并以链表的形式组织到poll_list上面.

1
2
3
4
5
struct poll_list {
struct poll_list *next;
int len;
struct pollfd entries[0];
};

  (2) do_poll中依次对每个entries调用do_pollfd处理;
  (3) do_pollfd一样调用file_operations->poll检查POLLIN_SET/POLLOUT_SET/POLLEX_SET,然后将结果保存在内核态的revents上面;
  (4) 函数调用依次返回,到达do_sys_poll的时候,将结果拷贝到用户空间,调用结束。

  小结:
  其实poll和select没有本质的大区别,而且底层很多的数据和函数都是公用的,只是调用时候换了一种新的数据结构,让侦听的描述符个数没有限制了。同时,传递的就是要侦听的数据结构,不会像select会产生很多无效的拷贝和扫描了。
  还需要注意的是,这里的poll和下面的epoll虽然名字比较像,但是事件的宏是不兼容的,不要误用哦。

三、epoll

1
2
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);

  epoll用户应用层的具体用法样例,可以参见st_epoll.c,增加epoll_create/epoll_ctl(EPOLL_CTL_ADD/EPOLL_CTL_DEL/EPOLL_CTL_MOD)/epoll_wait这些系统调用来操作的,其内部的实现已经非常复杂了(居然还搞嵌套)。
  (1) 在epoll_create的时候,会创建一个eventpoll类型的ep数据结构,然后创建了一个匿名[eventpoll]文件,并占用一个文件描述符返回,而这里返回的target文件描述符,成为了特定进程和内核eventpoll通信的标识;
  (2) epoll_ctl进行ADD/DEL/MOD操作的时候,会进行一次epoll_event用户到内核的拷贝(修改会拷贝,一直侦听不会拷贝),然后调用ep_insert/ep_remove/ep_modify封装好的函数。这里搜索是采用的红黑二叉树结构,所以即使维持了大量的侦听队列,也可以快速的找到特定的对象;
还有个关键点在调用ep_insert的时候,同时也调用了ep_item_poll->epi->ffd.file->f_op->poll(epi->ffd.file, pt),而pt的回调函数设置了ep_ptable_queue_proc,所以跟上面select/poll的情况类似,还是把当前进程注册到了特定设备的等待队列中,然后设备OK之后调用回调函数ep_ptable_queue_proc->ep_poll_callback,就把自己对应的事件添加到rdllist上面去了;
  (3) epoll_wait,调用ep_poll函数,这个函数直接查询ep结构的rdllist就需链表,如果没有就绪的,就把自己添加到等待队列然后睡眠(可被ep_poll_callback()唤醒);否则就调用ep_send_events->ep_send_events_proc,它会收集rdllist中就绪队列,然后使用f_op->poll(epi->ffd.file, NULL)去检测收集事件,如果满足要求就会将他拷贝到用户空间。
注意这里调用底层的poll的时候,使用了pt->_qproc=NULL的参数,就是不要求将当前进程加入到设备维持的等待队列上去,不想被通知到(因为此时的epoll_wait只做收集检测工作)。这里的拷贝是有实际事件消息的,算是净拷贝吧!
  (4) 而对于epoll的边沿触发和水平触发,其实就是在检测收集完事件之后,如果是边沿触发就不做,否则再次将这个事件添加到rdllist上面去,那么下次这个事件还会被再次检测直至返回。

  小结:
  epoll的实现是很复杂的,但是无论select/poll/epoll,他们的思路都是一致的,把自己添加到设备的等待队列上,实现高效的异步通知机制,而优化的思路都是减少每次调用的数据拷贝,提高搜索的效率而已。
  此外,是不是感觉这种处理方式和Linux调度的进化十分相似啊:之前是每轮调度完了,全部重新计算优先级和时间片信息,而现在维持着红黑二叉树和链表结构,同时将每个进程的调度信息分摊到每次切换的计算上,这样就保证了调度性能不会因为进程的数量而被影响。

四、pselect/ppoll/epoll_pwait

  这些函数主要是用于需要同时侦听socket/fd和信号的情况。这点附录的参考文献说的比较好,要实现它,可能的解决方式是:
  (1) 在select/poll等待之前设置信号的处理函数为空,然后调用select/poll的时候,如果返回的是EINTR,那么说明是接收到信号了。但是设置信号处理函数和select/poll之间不是原子的,如果在调用select/poll之前信号就来了,那么该信号就会丢失掉。
  (2) self-pipe技术,建立一个pipe,写端是信号处理函数,读端让select/poll来监听。的确可以满足需求,信号不会被丢失,但是实现比较复杂。
  (3) pselect/ppoll的产生就是为了解决这个问题的,先屏蔽要侦听的信号,设置该信号的处理句柄(或者NULL),再调用pselect、ppoll(如果还要屏蔽掉其它的信号,就把他们传给pselect/ppoll函数)。这样,只有在调用pselect/ppoll的时候,该信号才会被UNMASK掉,select返回后该信号又会被屏蔽起来,实际的效果就是将要侦听的信号压缩在pselect/ppoll调用之中才不会被屏蔽。

1
2
3
sigprocmask(SIG_SETMASK, &sigmask, &sigsaved);
ready = select(nfds, &readfds, &writefds, &exceptfds, timeout);
sigprocmask(SIG_SETMASK, &sigsaved, NULL);

  上面的C模拟了这个逻辑,实际系统调用基本也是这么实现的,虽然两者之间还可能被抢占式内核抢占,但是只要不返回用户态,这个信号就不会被消耗掉,仍然能够导致select/poll因为有信号pend而返回。

  最后,还需要描述一下在非阻塞模式下IO操作所需要注意的事项:
  (1). 对于读来说,select、poll以及epoll(LT)都是水平触发的,即使已经读了一些数据,只要fd底层仍然有数据可读,那么下次调用select/poll/epoll_wait还是会返回这个fd。这种方式比较简单,编写程序一般也不容易发生问题,epoll_wait(LT)此时的语义跟前者一样,可以看作是faster的poll;
  (2). 对于epoll(ET)的边缘触发模式,epoll_wait只会在fd的event发生变化的时候才会返回,所以这种情况下一定要确保read/write直到返回EAGAIN,程序才能够正常工作。一个例子比如:客户端发送2Kb的请求,而epoll_wait后服务端只读取了1Kb,下次调用epoll_wait就不会返回这个socket,因此服务端不会再次读取,而客户端还在等待返回,整个程序就会僵持下去;
  (3). 对于写来说,检查write/send的返回结果是很有必要的,尤其是对于非阻塞的socket,因为底层socket的缓冲区大小是有限的,如果发送端太快而接收端比较慢,当缓冲区满了后发送操作会返回EAGAIN,实际的数据并没有发送,发送端需要在while中不断重复。这种情况排除网络因素的话很可能生产者和消费者阻抗不匹配了,需要增加消费者的处理能力了!

本文完!

参考