IO多路复用select/poll/epoll介绍

1,525 阅读8分钟

IO多路复用

多路复用是指使用一个线程来检查多个文件描述符(Socket)的就绪状态,比如调用select和poll函数,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。得到就绪状态后进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如使用线程池)。

使用 select() 进行轮询

要使用select,开发人员需要使用文件描述符要监视的事件来初始化和填充几个fd_set结构,然后调用 select() 。 典型的工作流程如下所示:

fd_set fd_in, fd_out; 
struct timeval tv;
 
// Reset the sets
FD_ZERO( &fd_in ); // bitmap 最大为1024
FD_ZERO( &fd_out );
 
// Monitor sock1 for input events
FD_SET( sock1, &fd_in );
 
// Monitor sock2 for output events
FD_SET( sock2, &fd_out );
 
// Find out which socket has the largest numeric value as select requires it
int largest_sock = sock1 > sock2 ? sock1 : sock2;
 
// Wait up to 10 seconds
tv.tv_sec = 10;
tv.tv_usec = 0;
 
// Call the select
int ret = select( largest_sock + 1, &fd_in, &fd_out, NULL, &tv );
 
// Check if select actually succeed
if ( ret == -1 )
    // report error and abort
else if ( ret == 0 )
    // timeout; no event detected
else
{
    if ( FD_ISSET( sock1, &fd_in ) )
        // input event on sock1
    if ( FD_ISSET( sock2, &fd_out ) )
        // output event on sock2
}

select()带有相当多的设计缺陷,使其在现代网络应用程序中作为轮询机制不受欢迎。主要缺点包括:

  • select修改传递的fd_sets,因此它们都不能被重用。即使你不需要更改任何内容(例如,如果其中一个描述符接收到数据并需要接收更多数据),也必须再次重新创建整个集合或通过FD_COPY从备份副本中恢复。每次调用select时都必须这样做。
  • 在 Linux 上,支持的描述符的最大数量由FD_SETSIZE常量定义,Linux 愉快地将其定义为 1024。虽然某些操作系统允许通过在包含sys/select.h之前重新定义FD_SETSIZE来破解此限制,但这不是可移植的。事实上,Linux 会忽略这种 hack,并且限制将保持不变。
  • 要找出哪些文件描述符引发了事件,必须手动遍历集合中的所有描述符并在每个描述符上调用FD_ISSET。当拥有 1000 个描述符并且其中只有一个处于活动状态时——而且很可能是最后一个——你每次等待时都在浪费 CPU 周期。即在确定哪些事件被触发的事件复杂度为 O(N)
  • 在将文件描述符从用户态拷贝到内核态的时候,select 会带来额外的开销。

当然,操作系统开发人员认识到了这些缺点,并在设计poll方法时解决了其中的大部分问题。因此你可能会问,有任何理由使用select吗?为什么不把它放在计算机科学博物馆的架子上呢?那么你可能会很高兴地知道,是的,有两个原因,可能对你非常重要或根本不重要。

第一个原因是便携性。select已经存在了很长时间,你可以确定每个具有网络支持和非阻塞 socket 的平台都会有一个有效的select实现,而它可能根本没有poll

第二个原因,select可以(理论上)以 1 纳秒精度处理超时有关,而pollepoll都只能处理 1 毫秒精度。这在台式机或服务器系统上不太可能成为问题,因为它们的时钟甚至不能以如此精确的速度运行,但在实时嵌入式平台上与某些硬件组件交互时可能需要它。例如降低控制棒以关闭核反应堆——在这种情况下,请使用select来确保我们都保持安全!

上面的情况可能是你必须使用 select 而不能使用其他任何东西的唯一情况。但是,如果你正在编写一个永远不必处理多个 socket (例如 200 个)的应用程序,那么使用 pollselect 之间的区别将不是基于性能,而是更多地取决于个人偏好或其他因素。

使用 poll() 进行轮询

poll是一种较新的轮询方法,它的设计要好得多,并且不会遇到select的大多数问题。

要使用poll,开发者需要初始化struct pollfd的成员(带有要监视的描述符和事件的结构,并调用poll() 。典型的工作流程如下所示:

struct pollfd{
    int fd; //文件描述符
    short events; //fd在意的事件(写POLLOUT/读POLLIN)
    short revents; //re event 对event的回馈,一开始为0
}

// The structure for two events
struct pollfd fds[2];
 
// Monitor sock1 for input
fds[0].fd = sock1;
fds[0].events = POLLIN;
 
// Monitor sock2 for output
fds[1].fd = sock2;
fds[1].events = POLLOUT;
 
// Wait 10 seconds
int ret = poll( &fds, 2, 10000 ); // 置位revents
// Check if poll actually succeed
if ( ret == -1 )
    // report error and abort
else if ( ret == 0 )
    // timeout; no event detected
else
{
    // If we detect the event, zero it out so we can reuse the structure
    if ( pfd[0].revents & POLLIN )
        pfd[0].revents = 0;
        // input event on sock1

    if ( pfd[1].revents & POLLOUT )
        pfd[1].revents = 0;
        // output event on sock2
}

poll主要是为了解决select未解决的问题而创建的,因此它具有以下优点:

  • poll可以监控的描述符数量没有硬性限制,因此 1024 的限制在这里不适用。
  • 它不会修改struct pollfd数据中传递的数据。因此,只要将生成事件的描述符的revents成员设置为零,就可以在 poll() 调用之间重用它。

但是,poll 并未解决 select 中存在的内存拷贝以及找出哪些文件描述符引发了事件的问题,要找出哪些文件描述符引发了事件,必须手动遍历集合中的所有描述符并在每个描述符上调用FD_ISSET。即在确定哪些事件被触发的事件复杂度为 O(N)

使用 epoll() 进行轮询

epoll 是 Linux(并且只有 Linux)中最新、最好、最新的轮询方法。(嗯,它实际上是在 2002 年添加到内核中的,所以它并不是那么新)。它与pollselect的不同之处在于它将有关当前监视的描述符和相关事件的信息保存在内核中,并提供 API 以添加/删除/修改这些。

要使用epoll,需要做更多的准备工作。开发人员需要:

  • 通过调用epoll_create创建 epoll 描述符;
  • 用想要的事件和上下文数据指针初始化struct epoll结构
  • 调用epoll_ctl(... EPOLL_CTL_ADD ) 将描述符添加到监控集中
  • 调用epoll_wait() 等待我们为其预留存储空间的 20 个事件,epoll 描述符在用户态以及内核态是共享内存的。与以前的方法不同,此调用返回触发的事件。例如,如果有 200 个描述符,其中 5 个有事件需要处理,则epoll_wait将返回 5,并且只会初始化pevents结构的前五个成员。
  • 遍历epoll_wait()返回的项目进行相关处理。这将是一个短暂的迭代,因为返回的唯一事件是那些被触发的事件。

典型的工作流程如下所示:

// Create the epoll descriptor. Only one is needed per app, and is used to monitor all sockets.
// The function argument is ignored (it was not before, but now it is), so put your favorite number here
int pollingfd = epoll_create( 0xCAFE ); 

if ( pollingfd < 0 )
 // report error

// Initialize the epoll structure in case more members are added in future
struct epoll_event ev = { 0 };

// Associate the connection class instance with the event. You can associate anything
// you want, epoll does not use this information. We store a connection class pointer, pConnection1
ev.data.ptr = pConnection1;

// Monitor for input, and do not automatically rearm the descriptor after the event
ev.events = EPOLLIN | EPOLLONESHOT;

// Add the descriptor into the monitoring list. We can do it even if another thread is 
// waiting in epoll_wait - the descriptor will be properly added
if ( epoll_ctl( epollfd, EPOLL_CTL_ADD, pConnection1->getSocket(), &ev ) != 0 )
    // report error

// Wait for up to 20 events (assuming we have added maybe 200 sockets before that it may happen)
struct epoll_event pevents[ 20 ];

// Wait for 10 seconds
int ready = epoll_wait( pollingfd, pevents, 20, 10000 );

// Check if epoll actually succeed
if ( ret == -1 )
    // report error and abort
else if ( ret == 0 )
    // timeout; no event detected
else
{
    // Check if any events detected
    for ( int i = 0; i < ret; i++ )
    {
        if ( pevents[i].events & EPOLLIN )
        {
            // Get back our connection pointer
            Connection * c = (Connection*) pevents[i].data.ptr;
            c->handleReadEvent();
         }
    }
}

仅仅看一下实现就应该让你知道 epoll 的缺点是什么,我们将首先提到它。它使用起来更复杂,需要你编写更多的代码,与其他轮询方法相比,它需要更多的库调用。

然而 epoll 在性能和功能方面都比 select/poll 有一些显着的优势:

  • epoll 仅返回触发事件的描述符列表。不再需要遍历 10000 个描述符来找到触发事件的描述符!即解决了上述确定哪些事件被触发为O(N)问题,由于是确定的列表,所以 epoll 在确定哪些事件被触发的事件复杂度为 O(1)
  • 由于文件描述符是在用户态以及内核态中共享内存的,所以较select/poll减少了开销
  • 由于内核知道所有的监控描述符,即使没有人调用epoll_wait,它也可以注册发生在它们上的事件。
  • 使用epoll_wait()可以让多个线程在同一个 epoll 队列上等待,这是select/poll无法做到的。

但是其实,epoll 也有它自己的缺点:

  • 更改事件标志(即从 READ 到 WRITE)需要epoll_ctl系统调用,而当使用poll时,这是一个完全在用户空间中完成的简单位掩码操作。使用epoll 将5000 个 socket 从读取切换到写入将需要 5000 次系统调用,因此上下文切换(截至 2014 年对epoll_ctl的调用仍然无法批处理,并且必须单独更改每个描述符),而在轮询中则需要单个循环pollfd结构。
  • epoll是 Linux 专有的机制,虽然其他平台也有类似的机制,但它们并不完全相同。
  • 高性能处理逻辑更复杂,因此更难调试,特别是对于边缘触发,如果你错过额外的读/写,很容易出现死锁。