33 基于事件的并发(高级)
目前为止,使用多线程看上去是构建并发应用的唯一方法。就像生活中的许多事,这并不一定就是完全正确的。特别的,另外一种风格的并发编程方案在GUI编程[O96]和一些类型的网络服务器[PDZ99]中被使用。这种风格,就是著名的基于事件的并发(event-driven-concurrency),它在很多现代操作系统中很流行,包括了服务器端框架比如node.js[N13],但是它的根源来自于我们下面将提到的C/UNIX系统中。
基于事件驱动的并发指出了两个问题。首先是在多线程应用中正确的管理并发是很危险的;正如我们之前提到的,丢失的锁(missing locks),死锁(dead lock)以及其他让人不爽的问题会发生。第二点是,在多线程应用程序中,开发者对于某一时刻是如何调度的,几乎没有控制权;实际是,程序员创建完线程就只能希望操作系统以某种合理的方式在多CPU间调度。考虑到构造一个能够在所有情况下都可行的通用调度器的难度,有时操作系统不会以最优的方式调度线程。
关键:如何不使用线程构建并发服务器? 如何不使用多线程来构建一个并发服务器,从而重新获得对并发性的控制,同时避免陷入那些多线程程序的问题。
33.1 基本想法:事件循环
我们即将使用的基本方法如标题一样:基于事件循环的并发性。方法很简单:只需等待某些事情(比如:事件)的发生;当事件发生时,检测是什么类型的事件并执行事件请求的任务(包括IO请求,或者为后面的处理调度其他事件)。就这些!
在详细讲解之前,我们先来看一下基于事件的服务器长什么样儿吧。这样的应用程序是基于所谓的事件循环来构造的。事件循环的伪代码如下:
while(1) {
events = getEvents();
for (e in events)
processEvents(e);
}
就是这么简单。主循环简单地等待某些事情(通过调用getEvents() ),等每个事件返回,然后处理他们,一次一个事件;处理每个事件的代码称作事件处理器。重要的是,当一个事件处理器处理一个事件时,它是系统中唯一的活动事件;因此,决定哪个事件将被处理即调度。这个对调度的显示控制是基于事件方法一个重要优势。
但是方法留给了我们一个更大的问题:如何明确的为事件驱动服务器决定哪些事件将要发生,特别是网络和磁盘IO?具体地,事件服务器如何知道有消息已经到达了呢?
33.2 一个重要的API:select()(或者poll())
基于我们脑中的那个基本的事件循环,接着我们必须解决如何接收事件的问题。在许多系统中,有一个基本的API可以用,即select()或poll()系统调用。
这些接口允许程序做的事儿很简单:检查是否有到来的IO需要处理。比如,设想有一个网络应用(比如web服务器)希望检查是否有网络包到达需要处理。这些系统调用就是帮你做这些的。
以select()为例。手册页上是这样描述的:
int select (int nfds,
fd_set *restric readfds,
fd_set *restric writefds,
fd_set *restric errorfds,
struct timeval *restric timeout);
手册页上的详细描述:select()检查那些传地址到readfds、writefds和errorfds的IO描述符集,看看是否有描述符已经准备好读、写或有异常条件发生。每个描述符集中的前nfds个描述符都会被检查,即从检查描述符集中第0个到底nfds-1个描述符。返回时,select()以一个子集替换那些给定的描述符集,这些子集由那些已经准备好执行相关操作的描述符组成。select()返回所有集合中已经准备好的描述符的总数。
关于select()有两点要解释一下。首先,注意它可以让你检查描述符是否可读或可写;前者使得服务器确定一个新的包到达及需要处理,然而后者则让服务器知道何时可以回复(比如,输出队列还没满)。
其次,注意超时参数。通常的用法是设置超时参数为NULL,即让select()无限期的阻塞,直到某些描述符就绪。然而更健壮的服务器通常会指定某个超时值;通常是将超时值设为0,因此select()会立即返回。
poll()系统调用相当类似。详见其手册页,或Stevens和Rago的文章[SR05]。
任意选哪种方法,这些基本原语给我们提供了手段去构建一个非阻塞的事件循环,即简单地的检查即将到达的包,然后从套接字中读取消息,如果需要,再回复它们。
33.3 使用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
7 int main(void) {
8 // open and set up a bunch of sockets (not shown)
9 // main loop
10 while (1) {
11 // initialize the fd_set to all zero
12 fd_set readFDs;
13 FD_ZERO(&readFDs);
14
15 // now set the bits for the descriptors
16 // this server is interested in
17 // (for simplicity, all of them from min to max)
18 int fd;
19 for (fd = minFD; fd < maxFD; fd++)
20 FD_SET(fd, &readFDs);
21
22 // do the select
23 int rc = select(maxFD+1, &readFDs, NULL, NULL, NULL);
24
25 // check which actually have data using FD_ISSET()
26 int fd;
27 for (fd = minFD; fd < maxFD; fd++)
28 if (FD_ISSET(fd, &readFDs))
29 processFD(fd);
30 }
31 }
这段代码实际上相当容易理解。在初始化之后,服务器进入无限循环。在循环中,用FD_ZERO()宏来清除描述符集,然后用FD_SET()来添加从minFD到maxFD的所有文件描述符。举个例子,这个描述符集可能表示服务器关注的所有的网络套接字。最后服务器调用select()来观察那个连接有数据到达。接着,在循环中用FD_ISSET(),事件服务器可以知道哪些描述符有数据就绪,然后处理到达的数据。
当然,真实的服务器肯定比这个更复杂,以及需要逻辑来确定什么时候发送消息、分发磁盘IO以及许多其他细节。要进一步了解的,见Stevens和Rago的文章[SR05]获得API信息,或者Pai或Welsh等人的文章以对基于事件的服务器的通用流程有一个更好的概览[PDZ99, WCB01]。
33.4 为什么更简单呢?不需要锁
在单CPU上,基于事件的应用程序,并发程序中的问题就没有了。具体地,因为同一时刻只处理一个事件,没有加锁和释放锁的必要;基于事件服务器不可能没其他线程打断,因为它是单线程的。因此多线程程序的并发性bug明显不会出现基本的基于事件的方法中。
提示:不要在基于事件的服务器中阻塞
基于事件的服务器可以细粒度的控制任务的调度。然而,要维持这样的控制,不要调用那些阻塞执行的系统调用;如果不遵循这个建议,会导致基于事件的服务器阻塞,使得客户端不爽,严重的问题取决于你是否度过这本书的其他部分.
33.5 一个问题:阻塞系统调用
到目前为止,基于事件编程听起来相当好,是吧?你编写一个简单的循环,当有事件发生了就去处理它。你都不要考虑锁!但是还有一个问题:如果事件需要调用一个可能阻塞的系统调用,怎么办?
例如,设想来自客户端的一个请求到达服务器,要从磁盘上读一个文件,并返回文件内容给请求客户端(很像一个简单的HTTP服务端)。要处理这个请求,某些事件处理器接着会调用open()系统调用来打开这个文件,紧跟着一系列的read()调用来读这个文件。当这个文件读到内存里是,服务器可能就开始发送内容到客户端。
open()和read()调用都会发送IO请求到存储系统(当需要的数据不在内存时),因此需要花较长的时候来处理。在基于线程的服务器中,这不是问题:当线程发出IO请求时暂停(等待IO完成),其他线程可以继续运行,因此是的服务器可以继续。然而,IO请求和其他计算的天然重叠就是使得基于线程编程相当地自然和直接。
然而,在基于事件的方法中,没有其他的线程运行:仅仅只有主事件循环。这意味着如果事件处理器发出一个阻塞调用,那么整个服务器都会:阻塞到该调用完成。当事件循环阻塞,系统就进入闲置,因此这是对资源的极大浪费。因此,在基于事件的系统中有一条规则:不允许阻塞调用。
33.6 一个解决方案:异步I/O
要克服这个限制,许多现代操作系统都引入了新的IO请求方法,即通常所说的异步IO。这些接口是的应用程序发出IO请求以及在IO请求完成前,立即返回控制权给调用者; 额外的接口使得应用程序可以确定各种IO是否完成。
例如,我们测试一下Mac OS X(其他系统也有类似的API)提供的接口。这些API围绕一个基本的数据结构:struct aiocb或AIO 控制块。这个数据结构的简化版如下(更多信息详见手册页):
struct aiocb {
int aio_fildes; /* File descriptor */
off_t aio_offset; /* File offset */
volatile void *aio_buf; /* Location of buffer */
size_t aio_nbytes; /* Length of transfer */
};
要向一个文件发出异步读,应用程序首先应该为这个结构初始化相关信息:要读文件的文件描述符(aio_fildes),文件偏移(aio_offset)以及请求的长度(aio_nbytes),最后还有读请求要拷贝到的目标内存地址(aio_buf)。
在这个数据结构初始化之后,应用程序必须发出一个异步调用来读这个文件;在Mac OS X中,这个API就是异步读API:
int aio_read(struct aiocb *aiocbp);
这个调用尝试发出一个IO请求;如果成功,就立即返回,然后应用程序(比如基于事件的服务器)就可以继续工作。
但是,我们还有最后一部分要解决。我们怎么知道IO已经完成,且缓冲区(由aio_buf指向)已经填满了请求的数据?
还需要一个API。在MAC OS X,即aio_error().API如下:
int aio_error(const struct aiocb *aiocbp);
这个系统调用检查由aiocbp指向的请求是否完成。如果已经完成,此函数就返回成功(即0);如果没有完成,返回EINPROGTRESS。因此,对于每个未完成的异步IO,应用程序可以周期性的通过aio_error()来查询一下该IO是否已经完成。
你可能注意到,检查IO是否完成是一件很痛苦的事儿;如果程序在某个时刻有数十个,上百个IO请求,是该一直不停的检查每个IO,还是先等一小会儿,亦或…?
为了改进这个问题,某些系统提供一种基于中断的方法。这种方法使用UNIX的信号来通知应用程序异步IO何时完成了,因此就不需要重复的询问系统。轮询vs中断的问题在设备中也可以看到,比如在IO设备那章中你也可以看到。
其他:UNIX信号
一个相当重要且有意思的基础构件,即信号已经被引入到所有现代操作系统。简单来说,信号提供了一种与进程通信的方式。具体说来,信号可以递交给一个应用程序;这样的话,无论应用程序正在做什么,它都会停下来执行信号处理器,比如:应用程序中的某些处理这个信号的代码。当其执行结束,进程就继续执行先前的工作。
每个信号都有一个名字,比如HUP(挂起)、INT(中断)、SEGV(段错误)等等;详见手册。有趣的是,有时候是内核自己执行递送信号的工作。比如,当你的程序发生短错误,OS就给程序发送一个SIGSEGV的信号(假设SIG to signal names is common);如果你的程序捕获了该信号,那么你刚好可以运行一些代码来应对这个错误的程序行为(这对调试有帮助)。当进程没有设置如何处理该信号时,会执行默认的行为;对于SEGV,进程就会被kill掉。
这里是一个简单的程序,它进入了死循环,但是开始它设置了一个信号处理器来捕获SIGHUP:
#include <stdio.h>
#include <signal.h>
void handle(int arg) {
printf("stop wakin’ me up...\n");
}
int main(int argc, char *argv[]) {
signal(SIGHUP, handle);
while (1)
; // doin’ nothin’ except catchin’ some sigs
return 0;
}
`
你可以通过kill命令向该进程发送一个信号(是的,这是一个古怪的凶狠的名字)。这样的话,会中断while循环,并执行处理函数handle():
prompt> ./main &
[3] 36705
prompt> kill -HUP 36705
stop wakin’ me up...
prompt> kill -HUP 36705
stop wakin’ me up...
prompt> kill -HUP 36705
stop wakin’ me up...
对于信号,还有很多需要学习,仅仅一页,甚至一章都是不够的。有一个很好的选择:Stevens和Rago[SR05]的书。有兴趣的话可以看看。
在不支持异步IO的系统中,纯粹的基于事件的方法是不可能实现的。然而,聪明的研究者已经有了衍生方法来实现了。比如,Pai等人的[PDZ99]讲述了一个混合的方法,该方法中的事件用来处理网络包,线程池用来处理未完成的IO。详见他们的额文章。
33.7 另一个问题:状态管理
基于事件的方法存在的另一个问题是它的代码相比传统的基于线程的代码通常会更加复杂一些。这是因为:当一个事件处理函数发出一个异步IO时,它必须为下一个事件处理器打包一些程序状态以供在IO执行完成时使用;这个附加工作在基于线程的程序中是不需要的,因为程序需要的状态实在栈上的。Adya等人称之为手工栈管理,并且这对基于时间编程是非常重要的[A+02]。
为了使之更具体一点,我们来看一个简单的示例,一个基于线程的服务器需要从一个文件描述符(fd)中读数据,一旦读操作完成,将读到的数据写到网络套接字描述符(sd)中。这段代码(忽略错误检测)如下:
int rc = read(fd, buffer, size);
rc = write(sd, buffer, size);
正如你所见,在多线程程序中,做这样的工作是很容易的;当read()返回时,代码就立即知道想那个套接字写数据,因为这些信息都在线程栈中(在变量sd中)。
在一个基于事件的系统中,就木有那么容易了。要完成上述的任务,我们首先要调用之前提到的AIO接口发出一个异步读。接着,定期的调用aio_error()检查读操作是否完成;当该调用通知我们读操作完成时,基于事件的服务器如何知道要做什么呢?
如Adya等人[A+02]所说的,解决办法是使用一种古老的编程语言:continuation[FHK84]。尽管听起来很复杂,其实这个思想相当简单:基本地,将那些处理事件需要的信息几路到一些数据结构中;当事件发生时,查询所需要的信息并处理事件即可。
在这个例子里,解决方法就是将套接字描述符sd记录到某种数据结构中(比如hash table),由文件描述fd索引。当磁盘IO完成时,就会返回套接字描述符的值给调用者。此时,服务器就可以将数据写到套接字中。
33.8 还有什么困难呢?
我们人需要注意基于事件的方法还有一些其他的困难。比如,当系统从单CPU转换到多CPU时。基于时间的方法的简洁就不存在了。具体说来,为了利用多个CPU,事件服务器必须并行地运行多个事件处理器;这么做时,一般的同步问题又发生了,又需要使用那些常用的方法。因此,在现代多核系统上,不使用锁的简单的事件处理是不可能的。
另一个问题是没有与某些系统活动很好的融合,比如比如换页。例如,如果一个事件处理器页错误,它就会阻塞,因此服务器不会继续执行直到页错误结束。即使服务器已经避免了显示的阻塞,但是由于页错误引起的阻塞是很难避免的,因此这可能导致比较大的性能问题。
第三个问题,基于事件的代码比较难管理,因为很多函数的准确语义已经变了[A+02]。例如,如果一个函数从非阻塞变为阻塞,调用该函数的事件处理器必须做出适应性的修改。因为阻塞对于基于事件的服务器来说是灾难性的,程序员必须一直注意每个事件使用的API语义上的变化。
最后,尽管异步磁盘IO在大多数平台上已经得到支持,这是花了很长时间才取得的成果[PDZ99]。但它还没有跟异步网络IO以某种简单的统一的方式很好的融合。例如,当想要简单地用select()接口来管理未处理的IO时,通常需要将select()管理网络IO和AIO处理磁盘IO结合起来。
33.9 总结
我们已经很简单的介绍了基于事件的并发编程的不同风格。基于事件的服务器将调度控制权交给了程序本身,但是这就需要有一些与现代系统的其他方面融合上的复杂性和难度上的提升(比如换页)。因为这些挑战,没有哪种方法是最好的;因此,基于线程和基于事件的方法在将来都是解决并发性问题的两种不同方法。阅读一些研究论文([A+02, PDZ99, vB+03,WCB01])会更好些,写一些基于事件的代码以学习更多知识。