IO多路复用的select, poll, epoll之间的区别和联系总结

IO多路复用

select, poll和epoll都是IO多路复用的模型,所以在深入了解这三个系统调用之前,需要先简单介绍一下IO多路复用。
IO多路复用是一种复用技术,复用(multiplexing)技术很普遍,例如通信中有多路时分复用(OFDM)、频分复用、码分复用。再例如我们一个办公室的人可以共用办公室的一台打印机,大家在不同的时段使用即可实现复用,这就是时分复用。
“IO多路复用”是指复用了同一个处理线程(在Java中被抽象为选择器selector),由操作系统进行托管,当与选择器绑定的socket满足就绪的条件后,可以直接以事件驱动的形式(即释放select调用处的阻塞)获取到该socket。
对比于同步阻塞IO:服务端(server)通过监听(listen)绑定的端口,不得不通过recvfrom系统调用阻塞在此等待客户端的接入;因此IO多路复用比阻塞式IO并发处理性能明显提高了许多,因为阻塞式的IO不得不处理完该IO连接再处理下一个IO连接,监听线程无法得到复用。相比于非阻塞IO,又免去了多次轮训之苦(轮询意味着始终占用CPU)。各种IO模型的对比如图1.
IO多路复用的select, poll, epoll之间的区别和联系总结_第1张图片
我们将5种不同的IO模型简单梳理一下:

对比类别 blocking IO nonblocking IO IO multiplexing signal driven IO asynchronous IO
阻塞在哪 recvfrom read select/poll/epoll read -
交互形式 串行,逐条处理 轮询,通过状态判断是否有连接到达 事件驱动,连接到达时select释放阻塞 回调,由SIGIO信号通知数据拷贝 回调,数据已经拷贝完毕

虽然相比于异步IO,仍然需要通过同步的形式将内核态数据拷贝到用户态,性能上仍然不如异步IO高,但是,却有着更简便的实现方式,因此,仍然是当前实现高并发IO系统的一种主要途径。如PostgreSQL、redis等数据库,再如Netty等都是通过IO多路复用实现的。(截止目前)

IO多路复用的系统调用

IO多路复用的系统调用主要有三个,分别是select, poll 与epoll,当然,这是在Linux上的实现,在其他操作系统上还有不同的实现,例如kqueue,我们此处只论Linux.
从发展进程上看,首先是select 诞生,然后是poll,最后才是epoll. 因此,根据常识,后诞生的系统调用肯定是有改进之处的,所以,我们先从select开始介绍起。

select

诞生于2000年左右,系统调用的原型函数为:

       #include 

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

详情地址:

http://man7.org/linux/man-pages/man2/select.2.html

我们前面说过,IO多路复用这种IO模式,它所复用的是处理线程,也就是复用了“哪个IO就绪了,可以处理哪个IO了”这个过程。它是通过监控文件描述符(file descriptor, fd)来实现,如果有一个或以上的文件描述符处于“就绪”(ready)状态,就返回其中一个即可。
那么,这些fd在系统内核中是通过何种数据结构存储的呢?是通过bitmap存放的(数据结构为fd_set),它默认大小是1024(对于64bit有2048个)。因此,对于大于1024的fd,基本效果就不可控了,这客观限制了并发量的上限。
因此,总结一下select的问题:

  1. fd存储数据结构的存储上限对高并发非常不友好;
  2. 轮询的时间复杂度是O(n)
  3. 涉及较多用户态和内核态拷贝

poll

poll对select有了些许改进,如修正了fd数量的上限等等,但其他改进的幅度不大,此处不详谈。

epoll

epoll最初的版本是在2.5.44,后来在2.6的版本后陆续稳定。epoll的诞生就是为了解决历史遗留问题,因此,epoll的优势主要有:

  1. 修正了fd数量的限制
  2. 放弃使用bitmap数据结构,底层改用了链表、红黑树.
  3. 采用事件驱动
  4. 无需重复拷贝fd

epoll的系统调用函数原型:

       #include 

       /* open an epoll file descriptor */
       int epoll_create(int size);
       int epoll_create1(int flags);

       /* control interface for an epoll file descriptor */
       int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

       /* wait  for  an I/O event on an epoll file descriptor */
       int epoll_wait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout);
       int epoll_pwait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout,
                      const sigset_t *sigmask);

上述详情参考此处:

http://www.man7.org/linux/man-pages/man7/epoll.7.html

对上述三个epoll的API进行简单理解,就是:

  • epoll_create: 初始化数据结构,开辟空间,返回epfd 句柄
  • epoll_ctl:注册IO监听事件
  • epoll_wait:等待注册的IO事件就绪的通知

上述API中,有一个关键的数据结构epoll_event,它的定义是:

           typedef union epoll_data {
               void    *ptr;
               int      fd;
               uint32_t u32;
               uint64_t u64;
           } epoll_data_t;

           struct epoll_event {
               uint32_t     events;    /* Epoll events */
               epoll_data_t data;      /* User data variable */
           };

来看一下官方给出的example:

/*
*     While the usage of epoll when employed as a level-triggered interface
*     does have the same semantics as poll(2), the edge-triggered usage
*     requires more clarification to avoid stalls in the application event
*     loop.  In this example, listener is a nonblocking socket on which
*     listen(2) has been called.  The function do_use_fd() uses the new
*     ready file descriptor until EAGAIN is returned by either read(2) or
*     write(2).  An event-driven state machine application should, after
*     having received EAGAIN, record its current state so that at the next
*     call to do_use_fd() it will continue to read(2) or write(2) from
*     where it stopped before.
*/
           #define MAX_EVENTS 10
           struct epoll_event ev, events[MAX_EVENTS];
           int listen_sock, conn_sock, nfds, epollfd;

           /* Code to set up listening socket, 'listen_sock',
              (socket(), bind(), listen()) omitted */

           epollfd = epoll_create1(0);
           if (epollfd == -1) {
               perror("epoll_create1");
               exit(EXIT_FAILURE);
           }

           ev.events = EPOLLIN;
           ev.data.fd = listen_sock;
           if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
               perror("epoll_ctl: listen_sock");
               exit(EXIT_FAILURE);
           }

           for (;;) {
               nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
               if (nfds == -1) {
                   perror("epoll_wait");
                   exit(EXIT_FAILURE);
               }

               for (n = 0; n < nfds; ++n) {
                   if (events[n].data.fd == listen_sock) {
                       conn_sock = accept(listen_sock,
                                          (struct sockaddr *) &addr, &addrlen);
                       if (conn_sock == -1) {
                           perror("accept");
                           exit(EXIT_FAILURE);
                       }
                       setnonblocking(conn_sock);
                       ev.events = EPOLLIN | EPOLLET;
                       ev.data.fd = conn_sock;
                       if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
                                   &ev) == -1) {
                           perror("epoll_ctl: conn_sock");
                           exit(EXIT_FAILURE);
                       }
                   } else {
                       do_use_fd(events[n].data.fd);
                   }
               }
           }

代码的总体注释上面已经给出了,翻译一下,就是说这里的epoll不仅监听了listen获取到的新连接的socket,当新连接的socket接入后,又将新连接的socket的读写作为事件进行监听,当监测到新连接可以进行读写(因为 ev.events = EPOLLIN | EPOLLET)时,则调用do_use_fd() 函数. 当然,在具体的实现上,你可以只监听新到达的socket,然后创建一个新的线程去消费它。

epoll有两种模式提供给用户,分别是LT与ET模式,这两种模式在此处不便展开谈,篇幅会比较长,简单总结一下:
epoll默认的模式是LT. 考虑这样一个场景,通过epoll_wait() 函数获取事件后,如果事件没被处理或者没被处理完,那么这个事件还应不应该继续通知?在LT下是接着通知,在ET下是直接忽略。另外ET仅支持非阻塞的socket. 总之,关于这两个模式,读取数据还好,但是写数据时就比较麻烦了,可以单独再开一篇文章来讨论了。

总结

以上就是对select、epoll的简单剖析,当前的服务器的IO模型主要都是IO多路复用,即有一个主线程用于轮询,获取连接到的socket,当获取到新的接入socket后,通过线程或子进程来消费这个socket. 例如,PostgreSQL的主线程ServerLoop函数,就是阻塞在select() 函数处,当一个新的客户端接入时,创建一个新的子进程,消费该socket. 对于这种数据库场景,很多时候是CPU密集的,因此必须要通过一个新进程来消费socket, 否则巨大的CPU开销会严重影响系统的并发。但是,对于某些重IO轻CPU的场景,其实也可以不必采用这种方式,可以采用类似上述demo代码的形式,在一个线程中完成IO的事件的监听和处理。

你可能感兴趣的:(Linux)