CXD Linux Engineer

select poll epoll之间该如何决择

2018-01-10

前言

本文翻译自select/poll/epoll: practical difference for system architects主要介绍了select、poll、epoll三者之间的区别和各自的优缺点,以及在实际场景下如何选择使用哪个接口。分析的非常到位遂决定把它翻译下来。

正文

当设计一个高性能并且是非阻塞套接字I/O的网络应用程序架构时需要考虑使用哪种轮询方式来监听这些套接字所产生的事件。这里有几种方式可供选择并且每种方式都有各自的特点和区别,所以选择一种合适的轮询方式对应用程序来说是至关重要的。
本文将重点介绍各种轮询方式的区别并且给出所适用的场景。

select()

select一个古老的,历经时间考验的接口,也被称为Berkeley sockets。一个八十年代就出现的接口直到现在也没有任何改变,但它并没有成为规范因为那时还没有非阻塞IO的概念。
在使用select之前开发人员需要初始化并用被监听事件的描述符来填充多个fd_set数据结构,然后调用select()函数。下面是一个典型的程序流程:

fd_set fd_in, fd_out;
struct timeval tv;
 
// Reset the sets
FD_ZERO( &fd_in );
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接口对于现代网络应用程序来说有很多设计上的缺陷,以至于不能满足需求。它的缺陷主要主要有如下几点:

  • select会修改传递进来的fd_sets,导致它们不能被复用。即使你不需要做任何改变,例如当一个描述符接收到数据后还需要接收更多的数据,整个集合都需要再次重新构建或者使用FD_COPY来从备份中恢复。并且每次调用select时都需要这些操作。
  • 找到是哪个描述符产生的事件,需要调用FD_ISSET遍历集合中的所有描述符。当你有2000个描述符时,并且只有一个产生了事件而且是最后一个,导致每次循环都会浪费大量CPU资源。
  • 我刚刚提到了2000个描述符吗?好吧select并不能支持这个多个描述符。至少在Linux上所支持的最大描述符数量是1024个,它保存在FD_SETSIZE常量中。有些操作系统允许你在包含sys/select.h头文件之前重新定义FD_SETSIZE的值,但是这就失去了可移植性。而且Linux会忽略它,保持原有的限制不变。
  • 当描述符在select中被监听时其他的线程不能修改它。假设你有一个管理线程检测到sock1等待输入数据的时间太长需要关闭它,以便重新利用sock1来服务其他工作线程。但是它还在select的监听集合中。如果此时这个套接字被关闭会发生什么?select的man手册中有解释:如果select正在监听的套接字被其他线程关闭,结果是未定义的。
  • 相同的问题,如果另外一个线程突然决定通过sock1发送数据,在等待select返回之前不能监听这个套接字的写事件。
  • 选择监听的事件类型是有限的;例如,检查一个远程套接字是否关闭你只有两种方法:监听它的读事件或者尝试实际去读取这个套接字的数据来探测它是否关闭(当关闭时会返回0)。如果你希望从这个套接字中读取数据这种方法是可行的,但是如果你是在发送文件完全不需要关心读事件该怎么办了?
  • 当填充描述符集合时,select会给你带来额外的负担,因为你需要计算描述符中的最大值并把它当作函数参数传递给select

当然操作系统开发人员也会意识到这些缺陷,并且在设计poll接口时解决了大部分问题,因此你会问,还有任何理由使用select吗?为什么不直接淘汰它了?其实还有两个理由使用它:

  1. 第一个原因是可移植性。select已经存在很长时间了,你可以确定每个支持网络和非阻塞套接字的平台都会支持select,而它可能还不支持poll。另一种选择是你仍然使用poll然后在那些没有poll的平台上使用select来模拟它。
  2. 第二个原因非常奇特,select的超时时间理论上可以精确到纳秒级别。而pollepoll的精度只有毫秒级。这对于桌面或者服务器系统来说没有任何区别,因为它们不会运行在纳秒精度的时钟上,但是在某些与硬件交互的实时嵌入式平台上可能是需要的。

只有在上面提到的原因中你必须使用select没有其他选择。但是如果你编写的程序永远不会处理超过一定数量的连接(例如:200),此时selectpoll之间选择不在于性能,而是取决于个人爱好或者其他原因。

poll()

poll是一个比较新的接口,它可能是在有人试图编写高性能网络服务时被创建的。它的设计更加出色并且解决了select中的大多数问题。在绝大多数情况下你应该在pollepoll/libevent之间做选择。
在使用poll之前开发人员需要使用监听的事件类型和描述符来初始化pollfd结构体,然后调用poll()。下面是一个典型的程序流程:

// 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 );
// 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的缺陷,所以它具有以下优点:

  • 它监听的描述符数量没有限制,可以超过1024。
  • 它不会修改pollfd结构体中传递的数据,因此可以复用只需将产生事件的描述符对应的revents成员置0。IEEE规范中规定:“poll()函数应该负责将每个pollfd结构体中revents成员清0,除非应用程序通过上面列出的事件设置对应的标记位来报告事件,poll()函数应该判断对应的位是否为真来设置revents成员中对应的位”。但是根据我的经验至少有一个平台没有遵循这个建议,Linux中的man 2 poll 就没有做出这样的保证。
  • 相比于select来说可以更好的控制事件。例如,它可以检测对端套接字是否关闭而不需要监听它的读事件。

select章节中最后提到的几个缺点poll()函数中也有。值得注意的是微软Vista之前的系统版本中不支持poll()接口,在Vista之后的版本中叫做WSAPoll,但是函数的参数是一样的,可以做如下定义:

#if defined (WIN32)
static inline int poll( struct pollfd *pfd, int nfds, int timeout) { return WSAPoll ( pfd, nfds, timeout ); }
#endif

上面已经提到poll()函数的时间精度是1毫秒,在大多数情况下是没有任何影响的。另外需要记住以下几个问题:

  • select一样必须通过遍历描述符列表来查找哪些描述符产生了事件。更糟糕的是在内核空间也需要通过遍历来找到哪些套接字正在被监听,然后在重新遍历整个列表来设置事件。
  • select一样它也不能在描述符被监听的状态下修改或者关闭套接字。

但是请记住对于大多数客户端网络应用程序来说这些问题不会带来任何影响,除了P2P这种类型的应用程序可能同时打开数千个连接。这些问题甚至对于有些服务器应用程序来说也没有任何影响。所以poll相对于select来说应该是你的默认选项,除非你有上面提到选择select的两个理由。如果是下面提到的这些情况,相比于epoll你更应该选择poll

  • 你需要在不止Linux一个平台上运行,而且不希望使用epoll的封装库。例如libeventepoll是Linux平台上特有的)。
  • 同一时刻你的应用程序监听的套接字少于1000(这种情况下使用epoll不会得到任何益处)。
  • 同一时刻你的应用程序监听的套接字大于1000,但是这些连接都是非常短的连接(这种情况下使用epoll也不会得到任何益处,因为epoll所带来的加速都会被添加新描述符到集合中时被抵消)。
  • 你的应用程序没有被设计成在改变事件时而其他线程正在等待事件。

epoll()

epoll是Linux中最新,最好,最后出现的轮询接口。然而在2002年就已经被加入内核,所以并不是非常新。它相比于pollselect的不同之处在于它将当前监听描述符的信息和对应的事件处理函数保存在内核当中,然后提供add/remove/modify三种功能的API来使用。

在使用epoll之前,开发者需要做下面这些事情:

  • 调用epoll_create函数来创建一个epoll描述符。
  • 使用想要监听的事件和一个数据指针来初始化epoll结构体,这个指针可以指向任何数据,epoll会直接将他传递给返回时的数据结构中。我们在每个连接中存储这样一个指针。
  • 调用epoll_wait()函数并传递20个事件结构体的存储空间。和前面的两个轮询接口不同,这个函数接受的是空的结构体,然后只会将被触发的事件填充到结构体中。例如这里监听了200个描述符,其中5个描述符有事件被触发,epoll_wait()会返回数值5,然后填充传递进来的20个存储空间中的前5个空间。如果有50个描述符有事件被触发,前面20个会被复制到用户程序中,其余30个会保存在队列中,不会丢失。
  • 然后遍历这些被返回的描述符,因为epoll只会返回有事件被触发的描述符所以这里的遍历非常高效。

下面是一个典型的程序流程:

// 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在性能和功能上有几个非常大的有点:

  • epoll只会返回有事件发生的描述符,所以不需要遍历所有监听的描述符来找到哪些描述符产生了事件。
  • 你可以将处理对应事件的方法和所需要的数据附加到被监听的描述符上。在上面的例子中我们附加了一个类的指针,这样就可以直接调用处理对应事件的方法。
  • 你可以在任何时间添加或者删除套接字,即使有其他线程正在epoll_wait函数中。你甚至可以修改正在被监听描述符的事件,不会产生任何影响。这种行为是被官方支持的而且有文档说明。这样就可以使我们在写代码时有更大的灵活性。
  • 因为内核知道所有被监听的描述符,所以即使没有人调用epoll_wait(),内核也可以记录发生的事件,这允许实现一些有趣的特性,例如边沿触发,这将在另一篇文章中讲到。
  • epoll_wait()函数可以让多个线程等待同一个epoll队列而且推荐设置为边沿触发模式,这在其他轮询方式中是不可能实现的。

但是请记住epoll不是poll的升级版,相比于poll来说它也有一些缺点:

  • 改变监听事件的类型(例如从读事件改为写事件)需要调用epoll_ctl系统调用,而这在poll中只需要在用户空间简单的设置一下对应的掩码。如果需要改变5000个套接字的监听事件类型就需要5000次系统调用和上下文切换(直到2014年epoll_ctl函数仍然不能批量操作,每个描述符只能单独操作),这在poll中只需要循环一次pollfd结构体。
  • 每一个被accept()的套接字都需要添加到集合中,在epoll中必须使用epoll_ctl来添加–这就意味着每一个新的连接都需要两次系统调用,而在poll中只需要一次。如果你的服务有非常多的短连接它们都接受或者发送少量数据,epoll所花费的时间可能比poll更长。
  • epoll是Linux上独有的,虽然其他平台上也有类似的机制但是他们的区别非常大,例如边沿触发这种模式是非常独特的(FreeBSD的kqueue对它的支持非常粗糙)。
  • 高性能服务器的处理逻辑非常复杂,因此更加难以调试。尤其是对于边沿触发,如果你错过了某次读/写操作可能导致死锁。

因此在满足下面的所有条件下你才应该使用epoll

  • 你的程序通过多个线程来处理大量的网络连接。如果你的程序只是单线程的那么将会失去epoll的很多优点。并且很有可能不会比poll更好。
  • 你需要监听的套接字数量非常大(至少1000);如果监听的套接字数量很少则使用epoll不会有任何性能上的优势甚至可能还不如poll
  • 你的网络连接相对来说都是长连接;就像上面提到的epoll处理短连接的性能还不如poll因为epoll需要额外的系统调用来添加描述符到集合中。
  • 你的应用程序依赖于Linux上的其他特性(这样对于可移植性来说epoll就不是唯一障碍),或者你可以使用libevent这种包装库来屏蔽不同平台的差异。

如果上面的条件都不成立,你更应该使用poll

libevent

libevent是一个网络库,它封装了上面提到的所有轮询方法,并提供了一套统一的API接口。它的主要好处在于允许你的代码在不同平台上运行而不需要改变任何代码。你需要理解一个重要的概念,libevent只是封装了所有轮询方法,所以每种方法的特性仍然不会改变。他不会使select在Linux上支持多余1024个描述符,也不会使epoll改变监听事件时省去系统调用和上下文切换。所以理解上面各种轮询方法的有缺点是非常必要的。

因为需要提供多种轮询方法,所以libevent的API比pollepoll更加复杂。但是如果你需要同时支持epollkqueue,相对于编写两个单独的后端来说使用libevent更加方便。所以在下列情况下应该考虑使用libevent

  • 你的应用程序需要使用epoll,因为只使用poll是不够的(如果poll可以完全满足需求,libevent将不会带来任何好处)。
  • 你需要支持Linux之外的其他操作系统,或者将来可能会有这样的需求。

下一篇 C++11语言学习

Comments

Content