截止到目前为止,我们提到的并发,似乎只能通过线程来实现。这不是绝对的。还有一种基于事件的并发(event-based concurrency),在一些现代系统中比较流行,比如node.js。
基于事件的并发主要是针对两方面的问题,一方面是在多线程应用中,要很好的处理并发难度是很高的。想想我们之前讨论的,忘记加锁,死锁以及一系列问题都会发生。
另一方面,程序员没法具体的控制多线程在某一时刻的调度。程序员只是创建了线程,随后就全依赖操作系统能够合理的调度线程。要实现一个在各种负载情况下,都能够良好运行的通用调度程序,是极其有难度的。因此某些时候操作系统的调度并不是最优的。
因此,我们的关键问题在于:不用线程,如何构建并发服务器,避免多线程应用中出现的问题?
基本想法:事件循环
我们使用一个很简单的方式,等待某个事件发生:当事件发生时,检查具体事件类型,然后做对应的处理(可能是I/O,或是其它)。在深入细节之前,我们来看一个典型的基于事件的服务器。这种应用都是基于一个简单的结构,称为事件循环(event loop)。事件循环的伪代码如下:
上述代码很简单,主循环会等待事件发生(通过 getEvents()调用),然后依次处理这些事件,处理事件的代码叫做事件处理程序(event handler)。这里的重点是,处理程序在处理某一个事件时,它是系统中发生的唯一活动。因此调度就是决定接下来处理哪个事件,这种对调度的显示控制,是基于事件方法一个重要优点。
但这同时也带来了更大的问题:基于事件的服务器如何决定哪个事件发生,尤其是对于网络和磁盘I/O?换句话说,事件服务器是怎么是知道有新的事件到达了?
重要API: select() (或poll)
知道了基本的事件循环,我们现在来解决一下事件服务器是如何知道新的事件发生了的?这得益于大多数操作系统都会提供一些基本的API,即
select()
或者poll()
系统调用。这些API的接口很简单:检查是否有关注的进入I/O。如网络应用程序(如Web服务器)希望检查是否有网络数据包已到达,以便为它们提供服务。通过这个系统调用就可以做到这一点。
如下是select(),手册页(man手册中也可以查到)以这种方式描述API:
readfds,writefds和errorfds是是哪个文件描述符的集合,select会去检查这些集合中每个项中的描述符是否已经准备好,分别找到可以读取,可以写入,发生错误的描述符,统称“就绪”的描述符。然后用找到的子集替换参数中的对应集合(将0标记成1),返回所有就绪描述符的总数。
fd_set 在Unix/Linux平台中,是一个数组,数组中的每一项都是一个64位的二进制数。
什么是文件描述符 fd
文件描述符(file descriptor)是一个非负整数,从 0 开始的无符号整数。进程使用文件描述符来标识一个打开的文件。
系统为每一个进程维护了一个文件描述符表,表示该进程打开文件的记录表,而文件描述符实际上就是这张表的索引。当进程打开(
open
)或者新建(create
)文件时,内核会在该进程的文件列表中新增一个表项,同时返回一个文件描述符 —— 也就是新增表项的下标。一般来说,每个进程最多可以打开 64 个文件,
fd ∈ 0~63
。在不同系统上,最多允许打开的文件个数不同,Linux 2.4.22 强制规定最多不能超过 1,048,576。每个进程默认都有 3 个文件描述符:0 (stdin)、1 (stdout)、2 (stderr)。
这篇文章以图示的方式对文件描述符作了深入地讲解,可以进一步阅读。
socket 与 fd 的关系
socket 是 Unix 中的术语。socket 可以用于同一台主机的不同进程间的通信,也可以用于不同主机间的通信。一个 socket 包含地址、类型和通信协议等信息,通过socket()
函数创建:返回的就是这个 socket 对应的文件描述符fd
。操作系统将 socket 映射到进程的一个文件描述符上,进程就可以通过读写这个文件描述符来和远程主机通信。可以这样理解:socket 是进程间通信规则的高层抽象,而 fd 提供的是底层的具体实现。socket 与 fd 是一一对应的。通过 socket 通信,实际上就是通过文件描述符fd
读写文件。这也符合 Unix“一切皆文件”的哲学。后面可以将 socket 和 fd 视为同义词。
使用select()
我们来看一段代码,以更深入的理解select(),下述代码主要做了以下几件事
- 初始化一个readFDs,然后进入主循环
- 先调用FD_ZERO(),将所有readFDs重置为零
- 调用FD_SET将所有从minFD到maxFD的文件描述符设置到集合中
- 执行select,会将已经就绪的文件描述符数量返回
- 当select返回后,调用FD_ISSET检查时候那些描述符的数据准备好了
我们知道,fd_set中每一项都是一个64位的二进制数字
如下是ChatGPT写的一段代码,这段代码在监听1,3,5号文件描述符是否可以读取了
这里我们需要注意,每次调用select时,只要任何一个文件描述符已就绪,那么selct就会返回,并且返回值是已就绪的数量,否则会等待超时时间到达。
那这也就意味着,假设我们在监听1,3,5文件描述符,它们分别在 3ms,7ms,20ms就绪。select会在3ms后发现1号文件已就绪,然后进行返回,且返回值时1。如果还想要检查其它的文件描述符是否也就绪,则需要再次调用select去进行检查,再次调用的返回值就会是2和3了。所以上述的代码中会把select放在while循环中,不断的轮询检查描述符是否都就绪。
poll调用
poll调用和select类似,区别在于poll没有大小的限制,select能监听的文件描述符大小会有一定的限制,通常是1024个,poll没有这个限制。select会修改传入的集合,poll不会,poll会返回一个新的集合。扩展性能会比select更好一些。
epoll
除了select和poll之外,epoll是对这俩的改进,它不需要一直循环的去检查描述符已就绪,epoll是通过中断的方式来通知主线程的。它可以在文件描述符已就绪时主动通知应用程序。
为何更简单?不需要锁
如果是使用单个CPU和基于事件的应用程序,我们之前学习的并发问题就不再存在。因为程序一次只会处理一个事件,所以不需要获取和释放锁。基于事件的服务器不可以被另一个进程中断,因为它是单线程的。
这种基于事件的服务器让程序员可以对任务调度进行更细粒度的控制。但是,为了保持这种控制,不可以有阻塞主线程的调用(如频繁运行长时间的计算任务)。如果不遵守这个设计提示,将会导致基于事件的服务器阻塞,无法响应客户端的请求,甚至出现更严重的故障。
问题来了:阻塞(同步)系统调用
到目前为止,基于事件的编程听起来是很棒的,只需要写一个简单的while循环,然后在事件发生时处理对应的事件。甚至还不需要考虑锁!但有时候,可能有些事件的处理需要你做会阻塞主线程的系统调用,该怎么办?
例如,一个来自客户端的请求进入服务器,要求服务器读取存放在磁盘的
index.html
文件,然后返回给客户端。要处理这个请求,对应的事件处理程序不得不发出open()系统调用来打开文件(open调用拿到文件描述符),然后在通过一系列read()调用来读取文件。当文件读入到内存后,服务器就可以开始将结果返回客户端了。我们知道,open()和read()调用都可能会对存储系统发出I/O请求(当文件并没有在内存时),因此主线程可能需要很长时间才能提供其它服务。如果该服务器是基于多线程的服务器,这肯定不是问题:让其它线程去处理这个I/O请求,主线程等待信号完成,从而使主线程可以继续给其它客户端提供服务。
但是,如果该服务器是使用基于事件的方法时,没有其它线程可以帮助主线程。这意味着如果一个事件处理程序发出一个阻塞的调用,那么整个服务器就会一直被阻塞,等待这个调用完成。当事件循环发生阻塞时,整个系统其实是闲置的,它没有其它事情可以去做,尽管此时可能又有新的其它请求进入了。因此我们在基于事件的系统时必须遵守一条规则:不允许阻塞调用(在有些语言中也叫同步调用)。
如何解决:异步I/O
为了解决I/O操作同步调用会阻塞主线程的问题,很多现代操作系统已经引入了一些新的方法来向磁盘发出I/O请求,我们称为异步I/O(asynchronous I/O)。通过这些接口使得应用程序可以发出异步I/O请求,并在I/O完成之后立即将控制权返回给调用者。与之对应的接口还可以检查I/O是否已经完成。
我们来看一下再macOS上系统的接口(其它系统也有类似的)。这些API围绕着一个基本的数据结构,即struct aiocb或者AIO控制块(AIO control block)(Linux也有一个aiocb)。它的简化版本如下
当要对一个文件发出异步I/O请求时,首先要先将以上这个结构的参数填写完整。当填写完整后再发出
aio_read()
异步调用来读取文件。在macOS上,下面这个API就是异步读取(asynchronous read)API:如果这个调用成功,则会立即返回,应用程序(基于事件的服务器)可以继续去做其它程序。
但我们还有一个问题,这个异步I/O虽然是发出去了,但是它什么时候完成了(即缓冲区(aio buff指向)有数据了),我们还不知道。
这还需要使用到另一个API。在macOS上面,它叫做
aio_error()
,如下它可以检查aiocbp引用的请求是否完成。如果完成了,则会返回0,如果错误,则返回EINPROGRESS。因此,引用程序可以通过轮询的调用aio_error()来检查异步I/O是否完成了。
这里我们也注意到了,我们依旧要轮询(即循环)来检查一个I/O是否已经完成了。如果一个应用程序在某个时间点有数十个异步I/O,想想看,这该怎么检查。
为了解决这个问题,一些系统调用提供了基于中断(interrupt)的方法。这个方法需要用到UNIX信号(signal),在异步I/O完成时通知应用程序,这样就可以避免重复询问系统。如linux的epoll()。
在不支持异步I/O的系统中,只靠基于事件的方法是没法实现的。但是,也有一些聪明的前辈提出了适合他们的方法。如Pai等人描述了一种使用事件处理网络数据包的混合方法,并且使用线程池来管理未完成的I/O。
补充:UNIX信号(中断信号)
UNIX信号其实就是中断信号
https://github.com/torvalds/linux/blob/8ca09d5fa3549d142c2080a72a4c70ce389163cd/arch/parisc/include/uapi/asm/signal.h#L5
https://nodejs.org/dist/latest-v18.x/docs/api/os.html#os-constants
https://github.com/nodejs/node/blob/ab9b4671e4afa458547a11e86f7e1e841d6c9271/typings/internalBinding/constants.d.ts#L91
所有现代UNIX变体都有一个称为信号(signal)的巨大而迷人的基础设施。最简单的信号提供了一种与进程进行通信的方式。具体来说,可以将信号传递给应用程序。这样做会让应用程序停止当前的任何工作,开始运行信号处理程序(signal handler),即应用程序中处理该信号的代码。完成后,该进程就恢复其先前的行为。
每个信号都有一个名称,如HUP(挂断)、INT(中断)、SEGV(段违规)等。有关详细信息,请参阅手册页。有趣的是,有时是内核本身发出信号。
例如,当你的程序遇到段违规时,操作系统发送一个SIGSEGV(在信号名称之前加上SIG是很常见的)。如果你的程序配置为捕获该信号,则实际上可以运行一些代码来响应这种错误的程序行为(这可能对调试有用)。当一个信号被发送到一个没有配置处理该信号的进程时,一些默认行为就会生效。对于SIEGV来说,这个进程会被杀死。
问题来了:状态管理
基于事件的方法的另一个问题是,这种代码通常会比传统的基于线程的更复杂。原因如下:当时间处理程序发出异步I/O时,它必须保存一些状态,以便事件处理程序在I/O最终完成时使用。这个额外的工作在基于线程的程序中是不需要的,因为程序需要的状态在线程栈中(每个线程都有独立的堆栈空间)。Adya等人称之为手工栈管理(manual stack management),这是基于事件编程的基础。
我们来看一个简单的例子,这是一个基于线程的服务器需要从文件描述符(fd)中读取数据,读取到之后将其写入到网络套接字描述符(sd)。代码(忽略错误检查)如下:
如你所见,在多线程的程序中,以上需求很简单。当read()返回时,代码知道要写入到那个套接字,因为该代码位于线程堆栈中(变量sd中)。
但是在基于事件的系统中,这个需求会比较麻烦。为了实现这个需求,我们要使用上面描述的AIO调用异步地发出读取。假设我们使用
aio_error()
定期调用检查读取的完成情况。当调用返回读取完成时。基于事件的服务器如何知道该怎么做?解决方案,如Adya等人所述,是使用一种成为“延续(continuation)”的老数据语言结构。听起来很复杂,其实并没有。它可以看作是一段代码的“延续”,即在代码执行到某个点时,将当前的执行状态保存下来,并在稍后恢复这个状态继续执行。(使用对象,或者回调函数都可以实现延续)。
其实,在
node.js
中,也是使用的“延续”方案,如一个node.js服务器中读取一个文件后,然后将读取到的文件返回给客户端。如下是一个将index.html
文件读取后返回给客户端的场景,读取完之后将文件内容写入到ctx.body
中以上几个使用“延续”的例子,基本都是使用一个变量来将上下文的状态保存,如c语言中可以手动创建一个指针对象,在我们例子中是
io_ctx
,在node.js中,是将上下文对象保存在ctx
对象中。依旧还有问题
除了刚刚提到的状态管理之外,基于事件的方法还有一些问题。如当系统从单核CPU升级到多核CPU时,基于事件方法的一些简单性就消失了。更具体的说,为了充分发挥多核CPU的性能,事件服务器必须并行运行多个事件处理程序。发生这种情况时,就会出现一些同步问题(如临界区),并且采用通用的解决方案(如锁),因此,在现代多核系统上,无锁的简单事件管理以及不再有可能。
除此之外,还有一个问题是,它不能很好的与某些类型的系统活动集成,如分页(paging)。例如,如果事件处理程序发生也错误,它会被阻塞,并且服务器在页错误处理之前不会有进展。尽管服务器的结构可以避免显式阻塞,但由于页错误导致的这种隐式的阻塞很难避免,因此在频繁发生时可能会导致较大的性能问题。(在实际的基于事件的服务中,事件处理程序一般会在单独的线程中执行,因此,当发生缺页错误时,并不会阻塞主线程。但是事件处理函数的耗时会变长,而导致程序的整体性能)
还有一个问题是,随着时间的推移,基于事件的代码会变得比较难维护,因为各种函数的确切语义发生了变化。如函数从非阻塞变成阻塞,调用该例程的事件处理程序也必须要更改以适应其新性质,方法是将其自身分解为两部分。由于阻塞对于基于事件的服务器而言时灾难性的,因此程序员必须始终注意每个事件使用的API语言的变化。
最后,虽然异步磁盘I/O现在现代平台广泛使用,但是花了很长时间才做到这一点,而且与异步网络I/O集成不会像你想像的那样有简单和统一的方式。例如,虽然人们只想使用select()接口来管理所有未完成的I/0,但是为了同时利用磁盘和网络的I/O优势,通常需要使用不同的select(组合)。
这篇论文首次明确阐述了基于事件的并发的一些困难,并提出了一些简单的解决方案,同时也探讨了将两种并发管理整合到单个应用程序中的更疯狂的想法!