← 返回首页
读书笔记

《Operating System:Three Easy Pieces》第三十三章 基于事件的并发(进阶)

目前为止,我们提到的并发,似乎只能用线程来实现。就像生活中的许多事,这不完全对。具体来说,一些基于图形用户界面(GUI)的应用,或某些类型的网络服务器,常常采用基于事件的并发(event-based concurrency),在一些现代系统中较为流行,比如 node.js,但它源自于 C/UNIX 系统,我们下面将讨论。 基于事件的并发针对两方面的问题:

因此,某些时候操作系统的调度并不是最优的。关键问题如下。不用线程,如何构建并发服务器不用线程,同时保证对并发的控制,避免多线程应用中出现的问题,我们应该如何构建一个并发服务器?

基本想法:事件循环

我们等待某事(即“事件”)发生;当它发生时,检查事件类型,然后做少量的相应工作(可能是 I/O 请求,或者调度其他事件准备后续处理)。 基于一个简单的结构,称为事件循环(event loop):

c
while (1) { events = getEvents(); for (e in events) processEvent(e); }

主循环等待某些事件发生(通过 getEvents()调用),然后依次处理这些发生的事件。处理事件的代码叫作事件处理程序(event handler)。重要的是,处理程序在处理一个事件时,它是系统中发生的唯一活动。因此,调度就是决定接下来处理哪个事件。这种对调度的显式控制,是基于事件方法的一个重要优点。 但这也带来一个更大的问题:基于事件的服务器如何决定哪个事件发生,尤其是对于网络和磁盘 I/O?具体来说,事件服务器如何确定是否有它的消息已经到达?

重要 API:select()(或 poll())

接下来必须解决如何接收事件的问题。大多数系统提供了基本的 API,即通过 select()poll()系统调用。 这些接口对程序的支持很简单:检查是否有任何应该关注的进入 I/O。例如,假定网络应用程序(如 Web 服务器)希望检查是否有网络数据包已到达,以便为它们提供服务。这些系统调用就让你做到这一点。

c
int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds, struct timeval *restrict timeout);

select()检查 I/O 描述符集合,它们的地址通过 readfds、writefds和 errorfds 传入,分别查看它们中的某些描述符是否已准备好读取,是否准备好写入,或有异常情况待处理。在每个集合中检查前 nfds 个描述符,即检查描述符集合中从 0 到 nfds-1的描述符。返回时,select()用给定请求操作准备好的描述符组成的子集替换给定的描述符集合。select()返回所有集合中就绪描述符的总数

补充:阻塞与非阻塞接口 阻塞(或同步,synchronous)接口在返回给调用者之前完成所有工作。非阻塞(或异步,asynchronous)接口开始一些工作,但立即返回,从而让所有需要完成的工作都在后台完成。 通常阻塞调用的主犯是某种 I/O。例如,如果一个调用必须从磁盘读取才能完成,它可能会阻塞,等待发送到磁盘的 I / O 请求返回。 非阻塞接口可用于任何类型的编程(例如,使用线程),但在基于事件的方法中非常重要,因为阻塞的调用会阻止所有进展。

select()有几点要注意:

一种常见的技术是将超时设置为零,因此让调用 select()立即返回。poll()系统调用非常相似。

无论哪种方式,这些基本原语为我们提供了一种构建非阻塞事件循环的方法,它可以简单地检查传入数据包,从带有消息的套接字中读取数据,并根据需要进行回复。

使用 select()

c
#include <stdio.h> #include <stdlib.h> #include <sys/time.h> #include <sys/types.h> #include <unistd.h> int main(void) { // open and set up a bunch of sockets (not own) // main loop while (1) { // initialize the fd_set to all zero fd_set readFDs; FD_ZERO(&readFDs); // now set the bits for the descriptors // this server is interested in // (for simplicity, all of them from min max) int fd; // 将初始化完的句柄都加入到readFDs句柄集合中 for (fd= minFD; fd< maxFD; fd++) FD_SET(fd,&readFDs); // 调用select检测句柄是否可读,可读的句柄作为子集替换readFDs指针内容作为返回值 int rc= select(maxFD+1,&readFDs, NULL,NULL, NULL); // check which actually have data using_ISSET() int fd; for (fd= minFD; fd< maxFD; fd++) // 检测在readFDs中存在句柄 if (FD_ISSET(fd,&readFDs)) // 处理对应的句柄 processFD(fd); } }

初始化完成后,服务器进入无限循环。在循环内部,它使用 FD_ZERO()宏首先清除文件描述符集合,然后使用 FD_SET()将所有从 minFD 到 maxFD的文件描述符包含到集合中。

例如,这组描述符可能表示服务器正在关注的所有网络套接字。最后,服务器调用 select()来查看哪些连接有可用的数据。然后,通过在循环中使用FD_ISSET(),事件服务器可以查看哪些描述符已准备好数据并处理传入的数据。

为何更简单?无须锁

使用单个 CPU 和基于事件的应用程序,并发程序中发现的问题不再存在。具体来说,因为一次只处理一个事件,所以不需要获取或释放锁。基于事件的服务器不能被另一个线程中断,因为它确实是单线程的。因此,线程化程序中常见的并发性错误并没有出现在基本的基于事件的方法中。

提示:请勿阻塞基于事件的服务器 基于事件的服务器可以对任务调度进行细粒度的控制。但是,为了保持这种控制,不可以有阻止调用者执行的调用。如果不遵守这个设计提示,将导致基于事件的服务器阻塞,客户心塞,并严重质疑你是否读过本书的这部分内容。

一个问题:阻塞系统调用

在多线程框架下,一个线程因 IO 阻塞,其他线程也能执行。而因为事件系统本质是单线程,会导致整个线程阻塞。

我们在基于事件的系统中必须遵守一条规则:不允许阻塞调用

异步I/O

异步 I/O(asynchronous I/O)。这些接口使应用程序能够发出 I/O 请求,并在 I/O 完成之前立即将控制权返回给调用者,另外的接口让应用程序能够确定各种 I/O 是否已完成。

macOS X 上提供的接口(其他系统有类似的 API)。这些 API围绕着一个基本的结构,即 struct aiocb 或 AIO 控制块(AIO control block)。该结构的简化版本如下所示:

c
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 */ };

异步读取(asynchronous read)API:

c
int aio_read (struct aiocb * aiocbp);

该调用尝试发出 I/O。如果成功,它会立即返回并且应用程序(即基于事件的服务器)可以继续其工作。

检查 aiocbp 引用的请求是否已完成。如果有,则函数返回成功(用零表示)。如果不是,则返回 EINPROGRESS。对于每个未完成的异步 I/O,应用程序可以通过调用 aio_error()来周期性地轮询(poll)系统,以确定所述 I/O 是否尚未完成:

c
int aio_error(const struct aiocb *aiocbp);

一些系统提供了基于中断(interrupt)的方法。此方法使用 UNIX信号(signal)在异步 I/O 完成时通知应用程序,从而消除重复询问系统的需要。

另一个问题:状态管理

基于事件的方法的另一个问题是,这种代码通常比传统的基于线程的代码更复杂。

原因如下:当事件处理程序发出异步 I/O 时,它必须打包一些程序状态,以便下一个事件处理程序在 I/O 最终完成时使用。这个额外的工作在基于线程的程序中是不需要的,因为程序需要的状态在线程栈中。Adya 等人称之为手工栈管理(manual stack management),这是基于事件编程的基础

一个简单的例子,在这个例子中,一个基于线程的服务器需要从文件描述符(fd)中读取数据,一旦完成,将从文件中读取的数据写入网络套接字描述符 SD)。代码:

c
int rc= read(fd, buffer, size); rc= write(sd, buffer, size);

在一个多线程程序中,做这种工作很容易。当 read()最终返回时,代码立即知道要写入哪个套接字,因为该信息位于线程堆栈中(在变量 sd 中)。

基于事件的系统中,为了执行相同的任务,我们首先使用上面描述的 AIO 调用异步地发出读取。假设我们使用 aio_error()调用定期检查读取的完成情况。当该调用告诉我们读取完成时,基于事件的服务器如何知道该怎么做?

使用一种称为“延续(continuation)”的老编程语言结构。在某些数据结构中,记录完成处理该事件需要的信息。当事件发生时(即磁盘 I/O 完成时),查找所需信息并处理事件。

在这个特定例子中,解决方案是将套接字描述符(sd)记录在由文件描述符(fd)索引的某种数据结构(例如,散列表)中。当磁盘 I/O 完成时,事件处理程序将使用文件描述符来查找延续,这会将套接字描述符的值返回给调用者。此时(最后),服务器可以完成最后的工作将数据写入套接字。

什么事情仍然很难

参考

本文由 GJJ 创作,内容来源于 Notion 数据库,随时可在 Notion 中编辑更新。 本站由 DeepSeek-v4-flash 辅助构建,项目参考 NotionNext

← 返回首页
61
文章
6
标签
3
分类
962
运行天数