Locks(四)
到目前为止,我们来构建并发程序的唯一方法似乎就是使用线程。就像生活中的其他事物一样,这并不是完全对的,在GUI-based程序当中或者一些服务器中,有一种完全不一样的并发模型,叫做基于事件的并发模型(event-based concurrency),在现代的很多系统中十分流行,包括著名的node.js,但是它的根源来自于C/UNIX系统中。
为什么要发明这样一种模型呢?那么就首先需要来关注传统的多线程模型有什么缺陷,第一个原因是,管理传统的多线程模型有很大的苦难,正如前面几篇文章所了解的内容那样,对临界区上锁,代码中死锁的问题。第二个问题就是对于多线程程序,开发者不能决定对于各个线程的调度没有完全的掌控权。
所以我们希望引入一种方法来构建并发程序,但是不使用多线程以此来避免多线程程序带来的一些问题。
Basic Idea: An Event Loop
这种模型就是event-based concurrency。它的思想非常简单,代码一直循环等待事件的到达,当事件到达后,判断该事件的类型并且选择对应的事件处理程序。下面是一个简单的示例代码:
这个代码非常简单,在while循环中,主线程调用getEvents()来获取某种事件的到达,。所以,接下里的问题就是,如何得知哪些事件到达了?
select() or poll()
接下来要探讨的第一个问题是,如何知道哪些事件发生了?在大多数的系统中都支持select()或者poll()这两个系统调用。poll()和select()之间的差别不大,主要关注的是select()。
select()函数会监督着一堆描述符集合(descriptor set),分别通过参数readfds,writefds,errorfds传递给参数select()。nfds参数是表示:the descriptors from 0 through nfds-1 in the descriptor sets are examined。对于select()的解释,我认为man page中已经相当直观了:
select() allows a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become "ready"
for some class of I/O operation (e.g., input possible)
timeout参数是表示select()函数会等待多久,通常我们会将timeout设置为null,于是select()就会永久的陷入阻塞,直到某些描述符可以被使用(如描述符可以被读取,或者可以被写入)。
在使用的时候,只需要判断此时到达的事件是位于哪一个文件描述符的,来选择对应的事件处理函数,只要事件处理函数尽量的快,那么我们就不再需要多线程了。
IO multiplexing:
和select()相关的另外一个话题就是IO multiplexing--IO多路复用。因为普通的IO函数会阻塞当前的程序,如read()会阻塞当前程序,直到有数据读取,然后进行数据的读取。这一过程往往需要很长的时间。加入在read()函数后面,还有其他的IO操作,如从控制台读取键盘输入。如果read很长,那么我们的程序就会在很长一段时间内都无法进入到等待来自键盘的输入。
另外一个例子是,服务器中accept()也会一直阻塞当前线程,直到链接被建立。所以如果我们想让我们的程序既可以往socket中相应数据,也能够接受来自键盘的内容。普通的单线程程序并不能完成这样的任务。除非引入多线程。另外一个解决办法就是使用select()来监听一组文件描述符,select()一开始会陷入阻塞,只有当其中的一个或者多个IO发生时会解除阻塞。我们来判断是何种事件发生了,在选择合适的事件处理函数即可。
使用select()
select()中的参数int nfds表示被监听的描述符的最大基数。fed_set看作是一串比特串,某位为1表示要监听该描述符。比如说,我们所监听的描述符是0(stdin)和3(socket):
此时的nfds就是为listenfd+1 = 4。在实际中,这个参数的设置很简单,只要将它设置为后最后出现的文件描述符+1就行。使用几个宏函数来操作:FD_ZERO,FD_SET,FD_CLR,FD_ISSET。
下面附上一段原文中的代码:
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);
}
}
Asynchronous I/O
到目前为止,select()看起来挺好用的,每次只处理一个事件,就不需要锁。但是如果事件的处理函数中的会阻塞的函数,程序还是陷入了阻塞。所以,我们就希望在事件处理函数中不使用会阻塞的函数,来采用异步IO函数。
所为异步IO函数,就是IO函数会立即返回,然后再使用其他的函数来判断IO是否已经完成。这样以来的好处是,可以在进入函数的时候尽早发起IO请求,然后在IO请求在后台执行,晚一些时候再去判断异步IO是否结束,节省了时间。下面介绍在Unix中的异步IO:
在我们发起异步IO之前,需要提供一下的结构:
这个结构分别指示了:要读取的文件描述符,从该文件何处的偏移量开始读取,存放读取到的数据的缓冲区位置,要读取多少字节的数据。
然后在调用int aio_read(struct aiocb *aiocbp)
来发起异步读取。另外一个问题是,我们如何知道异步IO是否完成。通过int aio_error(const struct aiocb *aiocbp)
来判断异步IO是否完成。如果IO还没有完成,那么这个函数会返回EINPROGRESS,如果完成就返回0。
这种方法虽然简单,但是设想一下如果我们发起了很多个异步IO,都采用这种方法来判断的话,就很麻烦了。所以另外一种方法是使用Linux中的signal。我们可以让异步IO完成后发起一个signal,然后自己在程序中定义好signal的处理函数。这样就省去了重复调用aio_error。
In systems without asynchronous I/O, the pure event-based approach cannot be implemented
其他内容
这一片内容,我个人感觉写的不怎么好,当然了最直观的感受还是了解了select。select在很多编程语言中都有对应的实现,java中的NIO selectors,golang中的select。各种语言中也有类似的异步IO的实现,比如说Java中的AsynchronousFileChannel ,这是NIO2中的内容。NIO是来自java 1.4的。
很多文档中会提到一集中IO模型,比如说synchronous IO,blocking IO什么的。在一定程度上asynchronous IO和not blocking IO是差不多的东西,至少我这么认为。