选择/投票/投票:实际差异

在设计具有无阻塞套接字的高性能网络应用程序时,决定我们将使用哪种监视网络事件的方法很重要。 它们有好几种,每种都有自己的好坏。 选择正确的方法对您的应用程序体系结构至关重要。

在本文中,我们将考虑:

  • 选择()
  • 民意调查()
  • epoll()
  • libevent

使用选择()


多年来经过实践证明的古老的辛苦工人select()创建于当时的“插座”被称为“ 伯克利插座 ”的年代。 这些方法最初并未包含在那些Berkeley套接字本身的规范中,因为在那时,还没有非阻塞I / O的概念。 但是在80年代,她出现了,选择了()。 从那时起,其界面没有任何重大变化。

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

fd_set fd_in, fd_out; struct timeval tv; //   FD_ZERO( &fd_in ); FD_ZERO( &fd_out ); //        sock1 FD_SET( sock1, &fd_in ); //        sock2 FD_SET( sock2, &fd_out ); //       (select   ) int largest_sock = sock1 > sock2 ? sock1 : sock2; //    10  tv.tv_sec = 10; tv.tv_usec = 0; //  select int ret = select( largest_sock + 1, &fd_in, &fd_out, NULL, &tv ); //    if ( ret == -1 ) //  else if ( ret == 0 ) // ,    else { if ( FD_ISSET( sock1, &fd_in ) ) //    sock1 if ( FD_ISSET( sock2, &fd_out ) ) //    sock2 } 

设计select()时,没有人期望在将来我们需要编写服务于数千个连接的多线程应用程序。 Select()有几个明显的缺点,使其不太适合在此类系统上工作。 主要的是:

  • select修改传递给它的fd_sets结构,以便它们中的任何一个都不能重用。 即使您不需要更改任何内容(例如,在接收到一条数据之后,您想要获得更多信息),也必须重新初始化fd_sets结构。 好吧,或者使用FD_COPY从先前保存的备份中复制。 在每次选择调用之前,必须一次又一次地执行此操作。
  • 为了准确找出哪个描述符生成了事件,您必须使用FD_ISSET对其进行手动轮询。 当您监视2000个描述符时,该事件仅发生于其中一个(根据均值定律,该事件将是列表中的最后一个)-您将浪费大量处理器资源。
  • 我刚才提到2000个描述符吗? 我为此感到兴奋。 选择不那么支持。 好吧,至少在具有常规内核的常规Linux上。 同时观察到的描述符的最大数目受常数FD_SETSIZE限制,该常数在Linux中严格等于1024。某些操作系统允许您通过在包含sys / select.h头文件之前覆盖FD_SETSIZE值来实现hack,但是该hack不是某些通用标准的一部分。 相同的Linux将忽略它。
  • 您不能使用来自另一个线程的可观察集中的描述符。 想象一个线程执行上面的代码。 因此,它开始并等待其select()中的事件。 现在,假设您有另一个线程监视系统的总体负载,现在他决定从sock1套接字获取数据的时间还不够长,现在该断开连接了。 由于可以重新使用此套接字来为新客户端提供服务,因此正确关闭它是很好的。 但是第一个线程现在正在观察该描述符。 如果我们全部关闭,将会发生什么? 哦,文档对这个问题有一个答案,您将不会喜欢它:“如果用select()观察到的句柄被另一个线程关闭,您将得到未定义的行为。”
  • 尝试通过sock1发送一些数据时,也会出现相同的问题。 在select完成工作之前,我们不会发送任何东西。
  • 我们可以监视的事件选择非常有限。 例如,要确定远程套接字已关闭,您首先应该监视数据到达它的事件,其次,尝试读取此数据(对于已关闭的套接字,读取将返回0)。 从套接字读取数据(读为0-套接字已关闭)时,这仍然可以被接受,但是如果我们当前的任务是向该套接字发送数据并且现在没有数据从该套接字读取怎么办?
  • select给您不必要的负担,以计算“最大描述符”并将其作为单独的参数传递

当然,以上所有都不是什么新闻。 操作系统开发人员早就意识到了这些问题,在设计轮询方法时已考虑了其中许多问题。 在这一点上,您可能会问,为什么我们现在甚至在研究古代历史,今天还有什么理由使用古代选择? 是的,有两个这样的原因。 它们不是某个时候对您有用的事实,而是为什么不了解它们。

第一个原因是可移植性。 select()与我们在一起已有一百万年的历史了。 无论硬件和软件平台的丛林如何带给您,如果那里有网络,就会有选择。 可能没有其他方法,但是几乎可以保证选择。 而且不要以为我现在陷入老年衰老,还记得打孔卡和ENIAC之类的东西,没有。 例如,在Windows XP中,不再有现代的轮询方法。 但是选择是。

第二个原因更奇怪,与以下事实有关:select可以(理论上)与大约1纳秒的超时(如果硬件允许)一起工作,而poll和epoll仅支持毫秒级的精度。 在普通台式机(甚至服务器)上,这应该没有特殊作用,在台式机上,您仍然没有硬件纳秒精度计时器。 但是,世界上仍然有带有此类计时器的实时系统。 所以,求求您,当您编写核反应堆或火箭的固件时-不要太懒惰以至于无法测量纳秒级的时间。 你知道,我想生活。

上面描述的情况可能是唯一您真的没有选择使用哪种情况的选择(只有select是合适的)。 但是,如果您正在编写用于在普通硬件上运行的常规应用程序,并且将使用足够数量的套接字(数十个,数百个-且没有更多)进行操作,则轮询和选择性能的差异将不会明显,因此选择将基于其他因素。

用民意调查()


poll是一种新的轮询套接字的方法,它是在人们开始尝试编写大型且负载较重的网络服务之后创建的。 它的设计要好得多,并且不会遭受select方法的大多数缺点。 在大多数情况下,编写现代应用程序时,将在使用poll和epoll / libevent之间进行选择。

要使用poll,开发人员需要使用可观察的描述符和事件初始化pollfd结构的成员,然后调用poll()。
典型的代码如下所示:

 //   struct pollfd fds[2]; //  sock1      fds[0].fd = sock1; fds[0].events = POLLIN; //   sock2 -  fds[1].fd = sock2; fds[1].events = POLLOUT; //   10  int ret = poll( &fds, 2, 10000 ); //    if ( ret == -1 ) //  else if ( ret == 0 ) // ,    else { //  ,  revents      if ( pfd[0].revents & POLLIN ) pfd[0].revents = 0; //     sock1 if ( pfd[1].revents & POLLOUT ) pfd[1].revents = 0; //     sock2 } 

创建轮询是为了解决select方法的问题,让我们看看结果如何:

  • 观察到的描述符数量没有限制;可以监视超过1024个
  • 没有修改pollfd结构,这使得可以在调用poll()之间重用它-您只需要重置revents字段即可。
  • 观察到的事件结构更好。 例如,您可以确定是否断开远程客户端的连接,而不必从套接字读取数据。

我们已经谈到过轮询方法的缺点:它在某些平台(例如Windows XP)上不可用。 从Vista开始,它就存在了,但被称为WSAPoll。 原型是相同的,因此对于平台无关的代码,您可以编写覆盖,例如:

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

好吧,超时的精度是1 ms,这在很少情况下是不够的。 但是,民意调查还有其他缺点:

  • 与使用select一样,在没有完全通过所有观察到的结构并检查其中的revents字段的情况下,不可能确定哪些描述符生成了事件。 更糟糕的是,它也在操作系统的内核中实现。
  • 与select一样,无法动态更改观察到的事件集

但是,对于大多数客户端应用程序,以上所有内容都可以认为是相对微不足道的。 唯一的例外可能是p2p协议,其中每个客户端都可以与数千个其他客户端关联。 即使大多数服务器应用程序也可以忽略这些问题。 因此,轮询应该是您对select的默认偏好,除非上述两个原因之一限制了您。

展望未来,在以下情况下,与更现代的epoll(如下所述)相比,民意测验更可取:

  • 您想编写跨平台代码(epoll仅在Linux上)
  • 您不需要监视超过1000个套接字(在这种情况下epoll不会给您任何重要的信息)
  • 您需要监视1000个以上的套接字,但每个套接字的连接时间非常短(在这种情况下,poll和epoll的性能将非常接近-等待epoll中较少事件的收益将被添加/删除它们的开销所抵消)
  • 您的应用程序并非旨在从一个线程更改事件,而另一个线程正在等待它们(或者您不需要它)

使用epoll进行轮询()


epoll是在Linux上(且仅在Linux上)等待事件的最新最佳方法。 嗯,并不是说“最新”一词是直接的,而是自2002年以来一直处于核心地位。 它与poll和select的不同之处在于,它提供了一个用于添加/删除/修改观察到的描述符和事件列表的API。

使用epoll需要更彻底的准备。 开发人员必须:

  • 通过调用epoll_create创建epoll描述符
  • 使用必要的事件和指向连接上下文的指针初始化epoll_event结构。 这里的“上下文”可以是任何内容,epoll只是在返回的事件中传递该值
  • 调用epoll_ctl(... EPOLL_CTL_ADD)将一个句柄添加到可观察对象列表
  • 调用epoll_wait()来等待事件(我们确切地表示一次要接收多少个事件,例如20个)。 与以前的方法不同,我们分别获得这些事件,而不是在输入结构的属性中。 如果我们观察到200个描述符,并且其中5个收到新数据-epoll_wait将仅返回5个事件。 如果发生了50个事件,则前20个事件将退还给我们,其余30个事件将等待下一个电话,它们不会丢失
  • 处理收到的事件。 这将是相对较快的处理,因为我们不会查看那些什么也没发生的描述符

典型的代码如下所示:

 //   epoll.       ,      //    (    ,   ),        int pollingfd = epoll_create( 0xCAFE ); if ( pollingfd < 0 ) //  //   epoll_event struct epoll_event ev = { 0 }; //     .    ,   // epoll     . , ,       ev.data.ptr = pConnection1; //    ,     ev.events = EPOLLIN | EPOLLONESHOT; //     .        //      epoll_wait -    if ( epoll_ctl( epollfd, EPOLL_CTL_ADD, pConnection1->getSocket(), &ev ) != 0 ) // report error //       20    struct epoll_event pevents[ 20 ]; //  10  int ready = epoll_wait( pollingfd, pevents, 20, 10000 ); //    if ( ret == -1 ) //  else if ( ret == 0 ) // ,    else { //     for ( int i = 0; i < ret; i++ ) { if ( pevents[i].events & EPOLLIN ) { //        ,   Connection * c = (Connection*) pevents[i].data.ptr; c->handleReadEvent(); } } } 

让我们从epoll的缺陷开始-它们在代码中很明显。 此方法更难使用,需要编写更多代码,它会使系统调用更多。

优势也很明显:

  • epoll仅返回观察到的事件实际发生的那些描述符的列表。 您无需查看成千上万的结构来寻找一种结构,可能是预期事件起作用的结构。
  • 您可以将一些有意义的上下文与每个观察到的事件相关联。 在上面的示例中,我们为此使用了指向连接类对象的指针-这为我们节省了对连接数组的另一次潜在搜索。
  • 您可以随时从列表中添加或删除套接字。 您甚至可以修改观察到的事件。 一切都会正常运行,已得到正式支持和记录。
  • 您可以使用epoll_wait启动多个线程来等待来自同一队列的事件。 选择/轮询无法完成任何事情。

但是您还需要记住,epoll并不是“全面改进的民意调查”。 与民意测验相比,它具有以下缺点:

  • 更改事件标志(例如,从READ切换到WRITE)需要额外的epoll_ctl系统调用,而对于轮询,只需更改位掩码(完全在用户模式下)。 从读写切换5,000个套接字将需要5,000个系统调用和用于epoll的上下文切换,而对于轮询而言,这将是一个循环中的琐碎操作。
  • 对于每个新连接,您必须调用accept()和epoll_ctl()是两个系统调用。 如果使用民意调查,将只有一个电话。 在非常短的连接寿命内,这可以有所作为。
  • epoll仅在Linux上可用。 其他操作系统具有类似的机制,但仍不完全相同。 您将无法使用epoll编写代码,以使其能够在FreeBSD上构建和运行。
  • 编写高负载的并行代码非常困难。 许多应用程序不需要这种基本方法,因为可以使用更简单的方法轻松处理其负载级别。

因此,只有在满足以下所有条件时才应使用epoll:

  • 您的应用程序使用线程池来处理网络连接。 在单线程应用程序中从epoll获得的收益将微不足道,并且您不应该为实现而烦恼。
  • 您期望有相对大量的连接(1000个及以上)。 在少数观察到的套接字上,epoll不会提高性能,并且如果确实有几个套接字,它甚至会变慢。
  • 您的连接寿命相对较长。 在新连接仅传输几个字节的数据并就此关闭的情况下,轮询将工作得更快,因为它将需要进行更少的系统调用来处理它。
  • 您打算在Linux上并且仅在Linux上运行代码。

如果一项或多项失败,请考虑使用poll或libevent。

libevent


libevent是一个库,该库将本文(以及其他一些方法)中列出的轮询方法包装在统一的API中。 这样做的好处是,一旦编写了代码,便可以在不同的操作系统上构建和运行它。 但是,重要的是要了解libevent只是一个包装器,在其中所有上述方法都可以使用,同时具有它们的优点和缺点。 libevent不会强制select监听超过1024个套接字,并且epoll不会在没有其他系统调用的情况下修改事件列表。 因此,了解底层技术仍然很重要。

支持不同的轮询方法的需求使libevent库API更加复杂。 但是,与手动编写用于Linux和FreeBSD的两个不同的事件选择引擎(使用epoll和kqueue)相比,使用它要容易得多。

合并两个事件时,请考虑使用libevent:

  • 您查看了选择和轮询方法,它们肯定对您不起作用。
  • 您需要支持多个操作系统

Source: https://habr.com/ru/post/zh-CN415259/


All Articles