type
status
date
slug
summary
tags
category
icon
password
Property
Apr 15, 2023 03:23 PM
目前为止,我们提到的并发,似乎只能用线程来实现。就像生活中的许多事,这不完全对。具体来说,一些基于图形用户界面(GUI)的应用,或某些类型的网络服务器,常常采用
基于事件的并发
(event-based concurrency),在一些现代系统中较为流行,比如 node.js,但它源自于 C/UNIX 系统,我们下面将讨论。
基于事件的并发针对两方面的问题:- 多线程应用中,正确处理并发很有难度
- 开发者无法控制多线程在某一时刻的调度。程序员只是创建了线程,然后就依赖操作系统能够合理地调度线。要实现一个在各种不同负载下,都能够良好运行的通用调度程序,是极有难度的。
因此,某些时候操作系统的调度并不是最优的。关键问题如下。不用线程,如何构建并发服务器不用线程,同时保证对并发的控制,避免多线程应用中出现的问题,我们应该如何构建一个并发服务器?
基本想法:事件循环
我们等待某事(即“事件”)发生;当它发生时,检查事件类型,然后做少量的相应工作(可能是 I/O 请求,或者调度其他事件准备后续处理)。
基于一个简单的结构,称为事件循环(event loop):
主循环等待某些事件发生(通过
getEvents()
调用),然后依次处理这些发生的事件。处理事件的代码叫作事件处理程序
(event handler)。重要的是,处理程序在处理一个事件时,它是系统中发生的唯一活动
。因此,调度
就是决定接下来处理哪个事件。这种对调度的显式控制,是基于事件方法的一个重要优点。
但这也带来一个更大的问题:基于事件的服务器如何决定哪个事件发生,尤其是对于网络和磁盘 I/O?具体来说,事件服务器如何确定是否有它的消息已经到达?重要 API:select()(或 poll())
接下来必须解决如何接收事件的问题。大多数系统提供了基本的 API,即通过
select()
或 poll()
系统调用。
这些接口对程序的支持很简单:检查是否有任何应该关注的进入 I/O。例如,假定网络应用程序(如 Web 服务器)希望检查是否有网络数据包已到达,以便为它们提供服务。这些系统调用就让你做到这一点。select()检查
I/O 描述符
集合,它们的地址
通过 readfds、writefds和 errorfds 传入,分别查看它们中的某些描述符是否已准备好读取,是否准备好写入,或有异常情况待处理。在每个集合中检查前 nfds 个描述符,即检查描述符集合中从 0 到 nfds-1的描述符。返回时,select()用给定请求操作准备好的描述符组成的子集替换给定的描述符集合。select()返回所有集合中就绪描述符的总数
。补充:阻塞与非阻塞接口 阻塞(或同步,synchronous)接口在返回给调用者之前完成所有工作。非阻塞(或异步,asynchronous)接口开始一些工作,但立即返回,从而让所有需要完成的工作都在后台完成。 通常阻塞调用的主犯是某种 I/O。例如,如果一个调用必须从磁盘读取才能完成,它可能会阻塞,等待发送到磁盘的 I / O 请求返回。 非阻塞接口可用于任何类型的编程(例如,使用线程),但在基于事件的方法中非常重要,因为阻塞的调用会阻止所有进展。
select()有几点要注意:
- select()可以让你检查描述符是否可以读取和写入。前者让服务器确定新数据包
已到达
并且需要处理,而后者则让服务知道何时可以回复(即出站队列未满
)。
- 请注意超时参数。这里的一个常见用法是将超时设置为
NULL
,这会导致 select()无限期地阻塞,直到某个描述符准备就绪。但是,更健壮的服务器通常会指定某种超时。
一种常见的技术是将超时设置为零,因此让调用 select()立即返回。poll()系统调用非常相似。
无论哪种方式,这些基本原语为我们提供了一种构建非阻塞事件循环的方法,它可以简单地检查传入数据包,从带有消息的套接字中读取数据,并根据需要进行回复。
使用 select()
初始化完成后,服务器进入
无限循环
。在循环内部,它使用 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)。该结构的简化版本如下所示:
异步读取
(asynchronous read)API:该调用尝试
发出 I/O
。如果成功,它会立即返回并且应用程序(即基于事件的服务器)可以继续其工作。检查 aiocbp 引用的请求是否已完成。如果有,则函数返回成功(用零表示)。如果不是,则返回 EINPROGRESS。对于每个未完成的异步 I/O,应用程序可以通过调用 aio_error()来周期性地轮询(poll)系统,以确定所述 I/O 是否尚未完成:
一些系统提供了基于
中断
(interrupt)的方法。此方法使用 UNIX信号
(signal)在异步 I/O 完成时通知应用程序
,从而消除
了重复询问
系统的需要。另一个问题:状态管理
基于事件的方法的另一个问题是,这种代码通常比传统的基于线程的代码更复杂。
原因如下:当事件处理程序发出
异步 I/O
时,它必须打包一些程序状态
,以便下一个事件处理程序在 I/O 最终完成时使用。这个额外的工作在基于线程的程序中是不需要的,因为程序需要的状态在线程栈
中。Adya 等人称之为手工栈管理
(manual stack management),这是基于事件编程的基础一个简单的例子,在这个例子中,一个基于线程的服务器需要从文件描述符(fd)中读取数据,一旦完成,将从文件中读取的数据写入网络套接字描述符 SD)。代码:
在一个
多线程程序
中,做这种工作很容易
。当 read()最终返回时,代码立即知道
要写入哪个套接字
,因为该信息位于线程堆栈
中(在变量 sd
中)。在
基于事件
的系统中,为了执行相同的任务,我们首先使用上面描述的 AIO 调用异步
地发出读取
。假设我们使用 aio_error()
调用定期检查
读取的完成情况。当该调用告诉我们读取完成
时,基于事件的服务器如何知道该怎么做?使用一种称为“
延续
(continuation)”的老编程语言结构。在某些数据结构
中,记录完成处理该事件需要的信息。当事件发生时(即磁盘 I/O 完成时),查找所需信息
并处理事件。在这个特定例子中,解决方案是将
套接字描述符(sd)
记录在由文件描述符(fd)索引
的某种数据结构(例如,散列表
)中。当磁盘 I/O 完成时,事件处理程序将使用文件描述符来查找延续
,这会将套接字描述符的值返回给调用者。此时(最后),服务器可以完成最后的工作将数据写入套接字。什么事情仍然很难
- 当系统从单个 CPU 转向多个 CPU 时,基于事件的方法的一些
简单性
就消失了。
为了利用多个 CPU,事件服务器必须
并行运行
多个事件处理程序。发生这种情况时,就会出现常见的同步问题(例如临界区),并且必须采用通常的解决方案(例如锁定)。因此,在现代多核系统上,无锁的简单事件处理已不再可能。- 它不能很好地与某些类型的系统活动集成,如分页(paging)。例如,如果事件处理程序发生页错误,它将被阻塞(
隐式阻塞
,无法规避),并且因此服务器在页错误完成之前不会有进展。
- 随着时间的推移,基于事件的代码可能很难管理,因为各种函数的
确切语义
发生了变化。例如,如果函数从非阻塞变为阻塞,调用该例程的事件处理程序也必须更改以适应其新性质,方法是将其自身分解
为两部分
阻塞
对于基于事件的服务器而言是灾难性的,因此程序员必须始终注意每个事件使用的 API 语义的这种变化。- 虽然异步磁盘 I/O 现在可以在大多数平台上使用,但是花了很长时间才做到这一点,而且与异步网络 I/O 集成不会像你想象的那样有简单和统一的方式。
参考
- 作者:GJJ
- 链接:https://blog.gaojj.cn/article/blog-60
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。