并发-基于事件的并发
2023-1-13
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property
 
 
一些基于图形用户界面(GUI)的应用,或某些类型的网络服务器,常常采用另一种并发方式。这种方式称为基于事件的并发,在一些现代系统中较为流行。
基于事件的并发针对两方面的问题。一方面是多线程应用中,正确处理并发很有难度。另一方面,开发者无法控制多线程在某一时刻的调度。程序员只是创建了线程,然后就依赖操作系统能够合理地调度线程,但是某些时候操作系统的调度并不是最优的。
 
基本想法:事件循环
等待某些事件的发生,当它发生时,检查事件类型,然后做少量的相应工作(可能是I/O请求,或者调度其他事件准备后续处理)。一个典型的基于事件的服务器。这种应用都是基于一个简单的结构,称为事件循环(event loop),伪代码如下:
主循环等待某些事件发生(通过getEvents()调用),然后依次处理这些发生的事件。处理事件的代码叫作事件处理程序(event handler)。处理程序在处理一个事件时,它是系统中发生的唯一活动。因此,调度就是决定接下来处理哪个事件。这种对调度的显式控制,是基于事件方法的一个重要优点。
但这也带来一个更大的问题:基于事件的服务器如何知道哪个事件发生,尤其是对于网络和磁盘I/O?
 
重要API:select()/poll()
大多数系统提供了基本的API,即通过select()poll()系统调用,检查是否接收到任何应该关注的I/O。例如,假定网络应用程序(如Web服务器)希望检查是否有网络数据包已到达,以便为它们提供服务。
select()为例,定义如下:
select()检查I/O描述符集合,它们的地址通过readfds、writefds和errorfds传入,分别查看它们中的某些描述符是否已准备好读取,是否准备好写入,或有异常情况待处理。在每个集合中检查前nfds个描述符,返回时用给定操作已经准备好的描述符组成的子集替换给定的描述符集合。select()返回所有集合中就绪描述符的总数。
一个常见用法是将超时设置为NULL,这会导致select()无限期地阻塞,直到某个描述符准备就绪。但是,更健壮的服务器通常会指定某个超时时间。一种常见的做法是将超时设置为零,让调用select()立即返回。
 
使用select()
使用select()来查看哪些描述符有接收到网络消息,下面是一个简单示例:
初始化完成后,服务器进入无限循环。在循环内部,它使用FD_ZERO()宏首先清除文件描述符集合,然后使用FD_SET()将所有从minFD到maxFD的文件描述符包含到集合中。最后,服务器调用select()来查看哪些连接有可用的数据。然后,通过在循环中使用FD_ISSET(),事件服务器可以查看哪些描述符已准备好数据并处理传入的数据。
使用单个CPU和基于事件的应用程序,并发程序中常见的问题不再存在。因为一次只处理一个事件,所以不需要获取或释放锁。基于事件的服务器是单线程的,因此也不能被另一个线程中断。
 
为何更简单?无须锁
使用单个CPU 和基于事件的应用程序,并发程序中发现的问题不再存在。具体来说,因为一次只处理一个事件,所以不需要获取或释放锁。基于事件的服务器不能被另一个线程中断,因为它确实是单线程的。因此,线程化程序中常见的并发性错误并没有出现在基本的基于事件的方法中。
 
 
问题:阻塞系统调用
如果某个事件要求你发出可能会阻塞的系统调用,该怎么办?
例如,假定一个请求从客户端进入服务器,要从磁盘读取文件并将其内容返回给发出请求的客户端。为了处理这样的请求,某些事件处理程序会发出open()系统调用来打开文件,然后通过read()调用来读取文件。当文件被读入内存时,服务器可能会开始将结果发送到客户端。
open()read()调用都可能向存储系统发出I/O请求,因此可能需要很长时间才能提供服务。使用基于线程的服务器时,这不是问题:在发出I/O请求的线程挂起时,其他线程可以运行。但是,使用基于事件的方法时,没有其他线程可以运行。这意味着如果一个事件处理程序发出一个阻塞的调用,整个服务器就会阻塞直到调用完成。当事件循环阻塞时,系统处于闲置状态,因此是潜在的巨大资源浪费。因此,在基于事件的系统中必须遵守一条规则:不允许阻塞调用
 
解决方案:异步I/O
为了克服这个限制,许多现代操作系统引入了新的方法来向磁盘系统发出I/O请求,一般称为异步I/O(asynchronous I/O)。这些接口使应用程序能够发出I/O请求,在I/O完成之前可以立即将控制权返回给调用者,并可以让应用程序能够确定各种I/O是否已完成。
当程序需要读取文件时,可以调用异步I/O的相关接口。如果成功,它会立即返回,应用程序可以继续其工作。对于每个未完成的异步I/O,应用程序可以通过调用接口来周期性地轮询(poll)系统,以确定所述I/O是否已经完成。
如果一个程序在某个特定时间点发出数十或数百个I/O,重复检查它们中的每一个是否完成是很低效的。为了解决这个问题,一些系统提供了基于中断(interrupt)的方法。此方法使用UNIX信号(signal)在异步I/O完成时通知应用程序,从而消除了重复询问系统的需要
信号提供了一种与进程进行通信的方式。具体来说,可以将信号传递给应用程序。这样做会让应用程序停止当前的任何工作,开始运行信号处理程序(signal handler),即应用程序中某些处理该信号的代码。完成后,该进程就恢复其先前的行为。
 
另一个问题:状态管理
当事件处理程序发出异步I/O时,它必须打包一些程序状态,以便下一个事件处理程序在I/O最终完成时使用。
一个简单的例子,一个基于线程的服务器需要从文件描述符(fd)中读取数据,一旦完成,将从文件中读取的数据写入网络套接字描述符sd。
在一个多线程程序中,做这种工作很容易。当read()最终返回时,程序立即知道要写入哪个套接字,因为该信息位于线程堆栈中。在基于事件的系统中,为了执行相同的任务,我们使用AIO调用异步地发出读取,然后定期检查读取的完成情况。当读取完成时,基于事件的服务器如何知道该怎么做?也即该向哪个套接字写入数据?
解决方案很简单:在某些数据结构中,记录完成处理该事件需要的信息。当事件发生时(即磁盘I/O完成时),查找所需信息并处理事件。
在这个特定例子中,解决方案是将套接字描述符(sd)记录在由文件描述符(fd)索引的某种数据结构(例如,散列表)中。当磁盘I/O完成时,事件处理程序将使用文件描述符来查找该数据结构,这会将套接字描述符的值返回给调用者。然后,服务器可以完成最后的工作将数据写入套接字。
 
 
 
基于事件的方法还有其他一些困难。例如,当系统从单个CPU 转向多个CPU 时,基于事件的方法的一些简单性就消失了。具体来说,为了利用多个CPU,事件服务器必须并行运行多个事件处理程序。发生这种情况时,就会出现常见的同步问题(例如临界区),并且必须采用通常的解决方案(例如锁定)。因此,在现代多核系统上,无锁的简单事件处理已不再可能。
基于事件的方法的另一个问题是,它不能很好地与某些类型的系统活动集成,如分页(paging)。例如,如果事件处理程序发生页错误,它将被阻塞,并且因此服务器在页错误完成之前不会有进展。尽管服务器的结构可以避免显式阻塞,但由于页错误导致的这种隐式阻塞很难避免,因此在频繁发生时可能会导致较大的性能问题。
还有一个问题是随着时间的推移,基于事件的代码可能很难管理,因为各种函数的确切语义发生了变化。例如,如果函数从非阻塞变为阻塞,调用该例程的事件处理程序也必须更改以适应其新性质,方法是将其自身分解为两部分。由于阻塞对于基于事件的服务器而言是灾难性的,因此程序员必须始终注意每个事件使用的API 语义的这种变化。
最后,虽然异步磁盘I/O 现在可以在大多数平台上使用,但是花了很长时间才做到这一点,而且与异步网络I/O 集成不会像你想象的那样有简单和统一的方式。例如,虽然人们只想使用select()接口来管理所有未完成的I/O,但通常需要组合用于网络的select()和用于磁盘I/O 的AIO调用
 
  • 计算机基础
  • 操作系统
  • 并发-常见并发问题单机数据持久-I/O 设备
    目录