Event-based Concurrency (Advanced)
我们前面讨论并发问题都是使用多线程。但是,关于并发的问题不止这一方面,还有使用基于GUI的应用程序和因特网服务器的并发,这些叫做基于事件的并发(event-based concurrency)。
要解决的基于事件的并发问题有两个:
- 在多线程应用程序中如何正确管理并发性
- 在多线程应用程序中开发人员如何控制在给定时刻的调度内容
那么问题来了,(带着问题学习):我们如何在不使用线程的情况下构建并发服务器,从而保持对并发的控制以及避免多线程应用程序的并发问题?
1. The Basic Idea: An Event Loop
基本方法就是题目名event-based concurrency:首先等待事件发生;事件发生后,检查事件的类型并做一小部分该事件需要的工作。
首先看一个事件循环(event loop)的伪代码:
while (1)
{
events = getEvents();
for (e in events)
processEvent(e);
}
处理每个事件的代码叫做事件处理程序(event handler)。决定哪个事件被处理是由调度程序(sheduling)决定的。
直接被调度程序控制是基于事件并发方法的优点。
但是也留下了更大的问题:基于事件的服务器如何决定哪个事件发生?事件服务器如何判断消息已经到达?
2. An Important API: select()
(or poll()
)
nfds:描述符集合中从0到nfds - 1的描述符序号
readfds:读状态
writefds:写状态
errorfds:待处理的异常
timeout:超时参数(常设置为ULL,导致select()
无限期阻塞,直到准备好一些描述符;功能更强大的服务器通常指定某种超时时间)
函数的返回值为所有集合中就绪描述符的总数。
更多信息需要去查阅手册~
3. Using select()
看一下如何使用select()
来查看哪些网络描述符上有传入消息:
// simple code using select()
#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 shown)
// 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 to max)
int fd;
for (fd = minFD; fd < maxFD; fd++)
FD_SET(fd, &readFDs);
// do the select
int rc = select(maxFD + 1, &readFDs, NULL, NULL, NULL);
// check which actually have data using FD_ISSET()
int fd;
for (fd = minFD; fd < maxFD; fd++)
if (FD_ISSET(fd, &readFDs))
processFD(fd);
}
}
当然,真实的服务器比上述代码更复杂,而且当发送信息时需要使用逻辑。
基于事件的服务器可对任务计划进行精细控制(fine-grained control)。但是,要保持这种控制,就不能进行阻止调用者执行的调用。
4. Why Simpler? No Locks Needed
在单个CPU和基于事件的应用程序中,多线程并发的一些问题不再存在。因为某一时刻只会有一个事件被处理;不需要获取和释放锁;正在处理的事件也不会被打断因为是单线程。
5. A Problem: Blocking System Calls
基于事件编程听起来非常棒!你只需要写个简单的循环程序就行了。但是,有一个问题:如何某个事件要求您发出可能阻止的系统调用怎么办?
不妨来看一个例子:
客户端(client)向服务器(server)发送一个请求(request):从服务器磁盘读一个文件然后返回文本内容给客户端(类似于HTTP)。
为了服务这个请求,事件处理程序最终需要调用系统函数open()
去打开文件,然后调用read()
去读这个文件,当文件被读入内存后,服务器开始发送结果给客户端。调用open()和read()可能要向存储系统发出I/O请求(当数据不在内存中时),这可能会使服务器花费相当长的时间。
对于基于线程的服务器(thread-based server)来说,这不是问题:当某线程发出I/O请求后,其他线程可以运行。但是,对于基于事件的服务器(event-based server),由于某一时刻只能一个事件被处理,所以只能等待I/O请求完成,这会花费大量的等待时间,造成资源浪费。
6. A Solution: Asynchronous I/O
为了解决发出I/O请求而导致的问题,很多现代操作系统引进了新的方法叫asynchronous I/O:这些接口使应用程序可以在I/O完成之前发出I/O请求并立即将控制权返回给调用方。其他接口使应用程序可以确定各种I/O是否已经完成。
看一个基于Mac的接口例子:
aiocb means AIO control block
读操作:
int aio_read(struct aiocb *aiocbp);
该调用尝试发出I/O,如果成功,立即返回,应用程序可以继续工作。
上述代码解决了一部分问题,但还剩一个问题:如何知道何时I/O完成,从而使缓冲区(由aio_buf指向)现在已包含请求的数据?
所以需要如下调用函数:
int aio_error(const struct aiocb *aiocbp);
上述代码返回0表示I/O完成,非0表示错误处理。
上述检查是否成功的调用函数代码很方便,但是如果在给定的时间有很多I/O请求需要处理的话,就需要一个一个的检查,这就很浪费时间了。为了节约时间,一些系统使用中断程序(interrupt):引入信号(signal)表示I/O请求是否完成。
7. Another Problems: State Management
基于事件的方法还有个问题就是要写的程序比基于线程的复杂。理由是:当事件处理程序发出异步I/O(asynchronous I/O),它必须把一些程序状态打包给I/O完成后下一次事件处理程序的使用。
基于线程的方法不需要如此,因为它把状态存入栈中。
对于基于事件的程序,当调用程序告诉我们读操作完成后,服务器如何知道接下来做什么?
解决的方法就是使用旧式编程语言结构continuation:用一些数据结构记录完成处理某事件必要的信息,然后当一些事件发生后,查找必要信息。
8. What Is Still Difficult With Events
基于事件的方法还有一些困难的地方:
- 当系统从单CPU变成多CPU后,该方法某些方面的简洁性就消失了。
- 它与某些类型的系统活动(如page)无法很好地集成在一起。
- 随着各种例程的确切语义发生变化,基于事件的代码可能难以管理超时状态。
- 尽管异步I/O现今在多平台都能使用,但是设计非常困难,而且未和异步网络以简单统一的方式集成。