1. 到目前为止,我们已经写了关于并发的文章,好像构建并发应用程序的唯一方法就是使用线程。就像生活中的许多事情一样,这并不完全正确。具体来说,在基于GUI的应用程序[O96]和某些类型的互联网服务器[PDZ99]中经常使用一种不同风格的并发编程。这种风格被称为**基于事件的并发(event-based concurrency)**,在一些现代系统中已经变得很流行,包括诸如 **node.js** [N13]这样的服务器端框架,但它的根源是我们将在下面讨论的C/UNIX系统。<br />基于事件的并发处理的问题有两个方面。首先,在多线程应用程序中正确管理并发性可能是一个挑战;正如我们已经讨论过的,可能会出现缺少锁、死锁和其他严重的问题。其次,在一个多线程应用程序中,开发人员几乎无法控制在给定时间调度什么;相反,程序员只需创建线程,然后希望底层OS在可用的CPUs之间以合理的方式调度它们。考虑到构建一个在所有情况下都能很好地处理所有工作负载的通用调度器的难度,有时操作系统会以一种不是最优的方式调度任务。因此,我们有……

关键的问题:如何构建没有线程的并发服务器 我们如何在不使用线程的情况下构建并发服务器,从而保持对并发的控制,并避免一些似乎困扰多线程应用程序的问题?

33.1 基本思想:一个事件循环 The Basic Idea: An Event Loop

如上所述,我们将使用的基本方法称为基于事件的并发(event-based concurrency)。方法非常简单:你只需等待某事(例如,一个“事件”)的发生;当它发生时,您检查它是什么类型的事件,并执行它需要的少量工作(可能包括发出I/O请求,或为将来的处理调度其他事件,等等)。就是这样!
在深入讨论细节之前,让我们先看看规范的基于事件的服务器是什么样子的。此类应用程序基于一个简单的构造,即事件循环(event loop)。事件循环的伪代码如下所示:

  1. while (1) {
  2. events = getEvents();
  3. for (e in events)
  4. processEvent(e);
  5. }
  1. 这真的很简单。主循环只是等待一些事情做(通过在上面的代码中调用 getEvents() )然后,对于每个返回的事件,一次一个处理它们;处理每个事件的代码称为**事件处理程序(event handler)**。**重要的是,当处理程序处理事件时,它是系统中发生的唯一活动;因此,决定接下来处理哪个事件相当于调度**。**这种对调度的显式控制是基于事件的方法的基本优势之一**。<br />但是这个讨论给我们留下了一个更大的问题: 基于事件的服务器究竟如何确定发生了哪些事件,特别是关于网络和磁盘I/O的事件?具体来说,事件服务器如何知道是否有消息到达?

33.2一个重要的API:select() (或者 poll()) An Important API: select() (or poll())

记住这个基本的事件循环之后,我们接下来必须解决如何接收事件的问题。在大多数系统中,都可以通过select()或poll()系统调用获得基本API。
这些接口使程序能够做的事情很简单:检查是否有需要处理的输入I/O。例如,假设一个网络应用程序(如web服务器)希望检查是否有网络数据包已经到达,以便为它们提供服务。这些系统调用让你做到这一点。
以select()为例。手册页面(在Mac上)是这样描述API的:

  1. int select( int nfds,
  2. fd_set *restrict readfds,
  3. fd_set *restrict writefds,
  4. fd_set *restrict errorfds,
  5. struct timeval *restrict timeout);
  1. 来自手册页的实际描述: select()检查在readfdswritefdserrorfds中传递地址的I/O描述符集,以查看它们的一些描述符是否已经准备好读取、准备好写入、或者有一个异常条件挂起。在每个set中检查第一个nfds描述符,即检查描述符sets中从0nfds-1的描述符。在返回时,select()将给定的描述符sets替换为那些已经为请求的操作做好准备的描述符组成的子集(subsets)。select()返回所有sets中已准备好的描述符的总数。<br />**关于select()有两点需要说明**。首先,请注意,它允许您检查描述符是否可以读取和写入;前者让服务器确定一个新包已经到达并需要处理,而后者让服务知道何时可以应答(即出站队列没有满)。<br />其次,注意timeout参数。这里的一个常见用法是将超时设置为NULL,这将导致select()无限期阻塞,直到某个描述符准备就绪。然而,更健壮的服务器通常会指定某种类型的超时;**一种常见的技术是将超时设置为零,从而使用select()调用立即返回**。<br />poll()系统调用非常类似。详情请参阅其手册页或StevensRago [SR05]。

33.3

使用select() Using select() 为了使其更具体,让我们研究一下如何使用select()来查看哪些网络描述符上有传入消息。图33.1显示了一个简单的示例。

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <sys/time.h>
  4. #include <sys/types.h>
  5. #include <unistd.h>
  6. int main(void) {
  7. // open and set up a bunch of sockets (not shown)
  8. // main loop
  9. while (1) {
  10. // initialize the fd_set to all zero
  11. fd_set readFDs;
  12. FD_ZERO(&readFDs);
  13. // now set the bits for the descriptors
  14. // this server is interested in
  15. // (for simplicity, all of them from min to max)
  16. int fd;
  17. for (fd = minFD; fd < maxFD; fd++)
  18. FD_SET(fd, &readFDs);
  19. // do the select
  20. int rc = select(maxFD+1, &readFDs, NULL, NULL, NULL);
  21. // check which actually have data using FD_ISSET()
  22. int fd;
  23. for (fd = minFD; fd < maxFD; fd++)
  24. if (FD_ISSET(fd, &readFDs))
  25. processFD(fd);
  26. }
  27. }

Figure 33.1: Simple Code Using select()
这段代码实际上非常容易理解。在一些初始化之后,服务器进入一个无限循环。在循环中,它使用FD_ZERO()宏首先清除文件描述符集合,然后使用FD_SET()包括集合中从minFD到maxFD的所有文件描述符。例如,这组描述符可以表示服务器所关注的所有网络套接字(sockets)。最后,服务器调用select()来查看哪些连接上(connections)有可用的数据。然后在循环中使用FD_ISSET(),事件服务器就可以看到哪些描述符已经准备好了数据,并处理传入的数据。
当然,实际服务器要比这复杂得多,在发送消息、发出磁盘I/O和许多其他细节时需要使用逻辑。欲了解更多信息,请参阅Stevens和Rago [SR05]获取API信息,或参阅Pai等人或Welsh等人对基于事件的服务器的一般流程的良好概述[PDZ99, WCB01]。

Tip:不要阻塞基于事件的服务器 基于事件的服务器支持对任务调度的细粒度控制。然而,为了保持这样的控制,不能执行阻塞调用者执行的调用;如果不遵守这一设计技巧,将导致基于事件的服务器被阻塞,客户端受挫,以及关于您是否读过本书的这一部分的严重问题。

33.4 为什么更简单?不需要锁

Why Simpler? No Locks Needed 对于单个CPU和基于事件的应用程序,并发程序中发现的问题不再存在具体来说,因为一次只处理一个事件,所以不需要获取或释放锁;基于事件的服务器不能被另一个线程中断,因为它绝对是单线程的。因此,在线程程序中常见的并发错误不会在基本的基于事件的方法中显示。

33.5

一个问题:阻塞系统调用 A Problem: Blocking System Calls 到目前为止,基于事件的编程听起来很棒,对吧?您编写了一个简单的循环,并在事件发生时处理它们。您甚至不需要考虑锁定!但是有一个问题:如果一个事件要求您发出一个可能阻塞的系统调用,该怎么办?
例如,假设一个请求来自客户端(client),请求服务器从磁盘读取文件并将其内容返回给请求客户端(非常类似于简单的HTTP请求)。要处理这样的请求,某个事件处理程序最终必须发出一个open()系统调用来打开文件,然后是一系列read()调用来读取文件。当文件被读入内存时,服务器很可能开始将结果发送给客户端。
open()和read()调用都可能向存储系统发出I/O请求(当所需的元数据(metadata)或数据(data)已经不在内存中时),因此可能需要很长时间来提供服务。对于基于线程的服务器,这不是问题:当发出I/O请求的线程挂起(等待I/O完成)时,其他线程可以运行,从而使服务器能够进行处理。事实上,这种I/O和其他计算的自然重叠(overlap)使得基于线程(thread-based)的编程非常自然和直接。
然而,对于基于事件的方法,不需要运行其他线程:只有主事件循环。这意味着,如果一个事件处理程序发出一个阻塞的调用,整个服务器就会这样做:阻塞直到调用完成。当事件循环阻塞时,系统处于空闲状态,因此是巨大的潜在资源浪费。因此我们有一个规则,在基于事件的系统中必须遵守:不允许阻塞调用(no blocking calls are allowed)。

33.6

一个解决方案:异步I/O A Solution: Asynchronous I/O 为了克服这个限制,许多现代操作系统引入了向磁盘系统发出 I/O 请求的新方法,通常称为异步 I/O(asynchronous I/O)这些接口使应用程序能够在 I/O 完成之前发出 I/O 请求并将控制权立即返回给调用者附加接口(additional interfaces)使应用程序能够确定各种 I/O 是否已完成
例如,让我们检查一下 Mac 上提供的接口(其他系统也有类似的 API)。API 围绕一个基本结构,即 struct aiocb 或通用术语中的 AIO 控制块(AIO control block)。结构的简化版本如下所示(有关更多信息,请参阅手册页):

  1. struct aiocb {
  2. int aio_fildes; // File descriptor
  3. off_t aio_offset; // File offset
  4. volatile void *aio_buf; // Location of buffer
  5. size_t aio_nbytes; // Length of transfer
  6. };

要发出对文件的异步读取,应用程序应该首先在这个结构中填入相关信息:要读取的文件的文件描述符(aio_fildes)、文件中的偏移量(aio_offset)以及请求的长度(aio_nbytes),最后是应该将读取结果复制到的目标内存位置(aio_buf)。
在填充了这个结构之后,应用程序必须发出异步调用来读取文件;在Mac上,这个API就是异步读取(asynchronous read)API:
int aio_read(struct aiocb *aiocbp);
这个调用试图发出I/O;如果成功,它将立即返回,应用程序(即基于事件的服务器)可以继续其工作。
然而,还有最后一块拼图我们必须解决。我们如何知道I/O何时完成,以及缓冲区(由aio_buf指向)中现在是否有被请求的数据?
还需要最后一个API。在Mac上,它被称为aio_error()(有点令人困惑)。API看起来像这样:
int aio_error(const struct aiocb *aiocbp);
他的系统调用检查 aiocbp 所指的请求是否已经完成。如果是,则例程返回成功(用零表示);如果不是,则返回 EINPROGRESS。因此,对于每个未完成的异步 I/O,应用程序可以通过调用 aio_error() 定期轮询(poll)系统以确定所述 I/O 是否尚未完成。
您可能已经注意到的一件事是检查 I/O 是否已完成很痛苦。如果一个程序在给定的时间点发出了数十或数百个 I/O,它是否应该简单地重复检查每个 I/O,还是先等一会儿,或者……?
为了解决这个问题,一些系统提供了一种基于中断(interrupt)的方法。该方法使用UNIX信号(signals)在异步I/O完成时通知应用程序,从而消除了重复询问系统的需要。这种轮询与中断的问题也可以在设备中看到,正如您将在关于I/O设备的一章中看到(或已经看到)的那样。
在没有异步 I/O 的系统中,无法实现纯基于事件的方法。然而,聪明的研究人员已经推导出了在他们的位置上运行良好的方法。例如,Pai 等人。[PDZ99] 描述了一种混合方法,其中事件用于处理网络数据包,线程池用于管理未完成的 I/O。阅读他们的论文了解详情。

33.7 另一个问题:状态管理 Another Problem: State Management

基于事件的方法的另一个问题是,编写此类代码通常比编写传统的基于线程的代码要复杂原因如下:当一个事件处理程序发出异步I/O时,它必须打包一些程序状态,以便下一个事件处理程序在I/O最终完成时使用;这种额外的工作在基于线程的程序中是不需要的,因为程序需要的状态是在线程的堆栈中。Adya等人称这种工作为手动堆栈管理(manual stack management),它是基于事件编程的基础[A+02]。
为了使这一点更具体,让我们看一个简单的示例,在这个示例中,基于线程的服务器需要从文件描述符(fd)读取数据,完成后将从文件读取的数据写入网络套接字(network socket)描述符(sd)。代码(忽略错误检查)如下所示:

  1. int rc = read(fd, buffer, size);
  2. rc = write(sd, buffer, size);
  1. 如你所见,在多线程程序中,做这种工作是很简单的;当read()最终返回时,代码立即知道要写入哪个套接字,**因为该信息在线程的堆栈上(在变量sd中)**。<br />在以事件为基础的系统中,生活并不容易。要执行相同的任务,我们首先使用上面描述的AIO调用异步地发出读取。假设我们然后使用aio_error()调用定期检查读取的完成情况;当调用通知我们读取已完成时,基于事件的服务器如何知道要做什么?<br />正如Adya等人[A+02]所描述的,解决方案是使用一种称为**延续(continuation)**的旧编程语言结构[FHK84]。虽然听起来很复杂,但其思想相当简单:大体上,**在某些数据结构中记录完成该事件处理所需的信息;当事件发生时(即磁盘I/O完成时),查找所需的信息并处理该事件**。<br />在这种特定的情况下,解决方案是将套接字描述符(sd)记录在某种数据结构中(例如,一个哈希表),由文件描述符(fd)索引。当磁盘I/O完成时,事件处理程序将使用文件描述符查找continuation,这将把套接字描述符的值返回给调用者。此时(最后),服务器可以完成将数据写入套接字的最后一项工作。

Aside:UNIX信号 在所有现代的UNIX变体中都有一个巨大而有趣的基础结构,称为信号(signals)。简单地说,信号提供了一种与进程通信的方法。具体地说,可以将信号传递给应用程序;这样做会阻止应用程序,并运行信号处理程序(signal handler),即应用程序中的一些代码来处理该信号。当完成时,进程将恢复其先前的行为。 每个信号都有一个名字,比如HUP(挂断)、INT(中断)、SEGV(分段违规)等;有关详细信息,请参阅手册页。有趣的是,有时是内核本身发出信号。例如,当您的程序遇到分段违规时,操作系统会向它发送一个 SIGSEGV(在信号名称前添加 SIG 很常见);如果您的程序配置了捕获该信号,您实际上可以运行一些代码来响应这种错误的程序行为(这有助于调试)。当信号发送到没用配置处理信号的进程时,将执行默认行为;对于 SEGV,进程被终止。 下面是一个进入无限循环的简单程序,但它首先设置了一个信号处理程序来捕获SIGHUP: image.png 您可以使用kill命令行工具向它发送信号(是的,这是一个奇怪而咄咄逼人的名字)。这样做将中断程序中的主while循环,并运行处理程序代码handle(): image.png 关于信号还有很多东西需要学习,单一章,更不用说一页,是远远不够的。一如既往,有一个很好的来源:史蒂文斯和拉格[SR05]。如果感兴趣,请阅读更多。

33.8

事件仍有什么困难 What Is Still Difficult With Events 我们应该提到基于事件的方法还有其他一些困难。例如,当系统从单个CPU移动到多个CPU时,基于事件的方法的一些简单性就消失了。具体来说,为了使用多个CPU,事件服务器必须并行运行多个事件处理程序;当这样做时,通常的同步问题(例如,临界区域)就会出现,并且必须使用通常的解决方案(例如,锁)。因此,在现代多核系统上,不带锁的简单事件处理不再可能
基于事件的方法的另一个问题是,它不能很好地与某些类型的系统活动集成,比如分页(paging)例如,如果一个事件处理程序分页错误(page faults),它将阻塞,因此服务器在分页错误完成之前不会取得进展。尽管服务器在结构上避免了显式阻塞,但由于分页错误而导致的这种类型的隐式阻塞是很难避免的,因此在普遍情况下可能会导致很大的性能问题。
第三个问题是,随着时间的推移,基于事件的代码可能难以管理,因为各种例程的确切语义会发生变化 [A+02]。例如,如果一个例程从非阻塞变为阻塞,调用该例程的事件处理程序也必须改变以适应它的新性质,将自身撕成两部分。因为阻塞对于基于事件的服务器来说是灾难性的,程序员必须始终注意每个事件使用的 API 语义的这种变化
最后,尽管异步磁盘I/O现在在大多数平台上都是可能的,但它花了很长时间才实现[PDZ99],而且它从来没有像你想的那样简单而统一地与异步网络I/O集成。例如,虽然人们只希望使用select()接口来管理所有未完成的I/O,但通常需要将select()用于网络和磁盘I/O的AIO调用结合起来

33.9

总结 Summary 我们已经简要介绍了一种基于事件的不同类型的并发。基于事件的服务器将调度控制权交给应用程序本身,但这样做的代价是复杂性和与现代系统的其他方面(例如,分页)集成的难度。由于这些挑战,没有哪一种方法是最好的;因此,线程和事件很可能在未来的许多年里作为同一并发问题的两种不同方法而持续存在。阅读一些研究论文(例如,[A+02, PDZ99, vB+03, WCB01])或者更好,写一些基于事件的代码,学习更多。

References

[A+02] “Cooperative Task Management Without Manual Stack Management” by Atul Adya, Jon Howell, Marvin Theimer, William J. Bolosky, John R. Douceur. USENIX ATC ’02, Monterey, CA, June 2002.
这篇优秀的论文第一次清晰地阐述了基于事件的并发的一些困难,并提出了一些简单的解决方案,还探索了将两种类型的并发管理合并到一个应用程序中的更疯狂的想法!
[FHK84] “Programming With Continuations” by Daniel P. Friedman, Christopher T. Haynes, Eugene E. Kohlbecker. In Program Transformation and Programming Environments, Springer Verlag, 1984.
这是编程语言世界对这个老想法的经典参考。在一些现代语言中越来越流行。
[N13] “Node.js Documentation” by the folks who built node.js.
许多很酷的新框架之一,可以帮助您轻松地构建web服务和应用程序。每个现代系统黑客都应该精通像这样的框架(很可能不止一个)。花时间在这些领域中做一些开发,成为专家。
[O96] “Why Threads Are A Bad Idea (for most purposes)” by John Ousterhout. Invited Talk at USENIX ’96, San Diego, CA, January 1996.
关于线程如何不适合基于gui的应用程序的一个很好的讨论(但思想更普遍)。Ousterhout在开发Tcl/Tk时形成了许多这样的观点,Tcl/Tk是一种很酷的脚本语言和工具包,它使开发基于gui的应用程序比当时的技术水平简单了100倍。当Tk GUI工具包继续存在时(例如在Python中),Tcl似乎正在慢慢消亡(很不幸)。
[PDZ99] Flash: An Efficient and Portable Web Server by Vivek S. Pai, Peter Druschel, Willy Zwaenepoel. USENIX 99, Monterey, CA, June 1999.
这是一篇关于如何在当时迅速发展的互联网时代构建网络服务器的开创性论文。请阅读本文以了解基本知识,并了解作者关于在缺乏异步I/O支持的情况下如何构建混合模式的想法。
[SR05] “Advanced Programming in the UNIX Environment” by W. Richard Stevens and Stephen A. Rago. Addison-Wesley, 2005.
我们再次引用了经典的UNIX系统编程必备书籍。如果有什么你需要知道的细节,都在这里。
[vB+03] “Capriccio: Scalable Threads for Internet Services” by Rob von Behren, Jeremy Condit, Feng Zhou, George C. Necula, Eric Brewer. SOSP ’03, Lake George, New York, October 2003.
关于如何使线程在极端尺度下工作的论文;一个针对当时正在进行的所有基于事件的工作的点评。
[WCB01] “SEDA: An Architecture for Well-Conditioned, Scalable Internet Services” by Matt Welsh, David Culler, and Eric Brewer. SOSP ’01, Banff, Canada, October 2001.
这是对基于事件的服务的一个很好的改进,它将线程、队列和基于事件的处理组合成一个精简的整体。其中一些想法已经进入了谷歌、Amazon等公司的基础设施。

Homework (Code)

在这个(简短的)作业中,您将获得一些关于基于事件的代码及其一些关键概念的经验。祝你好运!

Questions

  1. 首先,编写一个可以接受和服务TCP连接的简单服务器。如果你还不知道怎么做,你就得在网上找找看了。构建它,一次只服务一个请求;让每个请求都非常简单,例如,获取一天中的当前时间。
  2. 现在,添加select()接口。构建一个可以接受多个连接的主程序,以及一个检查哪些文件描述符上有数据的事件循环,然后读取并处理这些请求。请确保仔细测试是否正确使用select()。
  3. 接下来,让我们让请求变得更有趣一点,模拟一个简单的web或文件服务器。每个请求都应该读取文件的内容(在请求中命名),服务器应该通过将文件读入缓冲区来响应,然后将内容返回给客户端。使用标准的open()、read()、close()系统调用来实现此功能。这里要小心一点:如果你让它运行很长一段时间,有人可能会知道如何使用它来读取你电脑上的所有文件!
  4. 现在,不再使用标准的I/O系统调用,而是使用本章中描述的异步I/O接口。将异步接口合并到程序中有多难?
  5. 为了好玩,可以在代码中添加一些信号处理。信号的一个常见用途是拨动服务器以重新加载某种配置文件,或采取某种其他管理操作。也许一种自然的解决方法是向服务器添加一个用户级文件缓存,它存储最近访问的文件。实现一个信号处理程序,当信号被发送到服务器进程时清除缓存。
  6. 最后,我们有困难的部分:如何判断构建异步、基于事件的方法是否值得?你能创建一个实验来展示好处吗?您的方法增加了多少实现复杂性?