欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2022-06-13 12:30:39
...

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之间的区别和联系总结
我们将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 <sys/select.h>

       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 <sys/epoll.h>

       /* 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