本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我将以我的理解从头开始梳理一遍异步编程。
从网络IO开始
作为一个服务器程序,最重要的就是维护网络的IO。我们知道,一个TCP连接对应一个TCP套接字,服务器程序需要做的,就是妥善处理这些套接字中的数据。粗略地说,一个服务器程序做的事如下:
- 告诉内核自己监听了哪些套接字端点(socket endpoint)
- 内核维护TCP连接,并将接收到的数据传递给服务器程序
- 服务器程序处理数据
这就好比一家快餐店,而内核中的一个套接字端点就是厨房,厨房中有许多条流水线,比如说薯条流水线、汉堡流水线和炸鸡流水线,这些流水线就是与该套接字端点连接的套接字。服务器程序的作用就是服务员,他需要在每条流水线制作完成一个食材后就将对应的食材取出,并进行进一步的处理。
怎样实现这样的功能呢?这实际上是经历了长久的演变(以Linux为例)
accpet
与recvfrom
最原始的方法就是accept
与recvfrom
,这默认是一种阻塞式的IO。用快餐店的例子类比的话,服务员就在厨房门口干等,啥事也不干,看着哪个流水线好了就处理哪边的食材,处理完了继续回来干等着。
也就是说,我们以下的程序
int s = socket(...); // create socket endpoint
// bind and listen
int c = accept(s, ...); // create socket
recvfrom(c, ...); // receive data
做了什么事呢?
- 告诉我们的服务员去哪个厨房工作,也就是创建、绑定和监听套接字端口。
- 如果此时厨房里没有流水线在工作,那么服务员啥也不干。也就是当前套接字端口没有连接时,该进程被移至等待队列中。在程序中就是调用
accept
函数被阻塞。 - 当厨房中出现了一个流水线,服务员就盯着这个流水线,啥也不干。也就是当
accept
出现返回值时,调用recvfrom
函数,当没有数据返回时进程被阻塞。 - 等到流水线处理完毕食材,服务员就开始处理对应的食材了。也就是当
recvfrom
函数返回之后,进程就可以继续运行了。
这样的设计看上去就让人很难受。首先,accept
函数会阻塞进程,但这是合理的,因为我们假定我们的服务器程序只用作处理网络连接,那么没有网络连接的时候自然就不用工作。但是,在accept
之后,进程只能处理这一个套接字,并且当没有数据的时候,进程就被彻底阻塞了。
想象一下这样的情景:汉堡流水线最先开始工作,所以服务员就到汉堡流水线前面干等着,盯着看。之后,周围的薯条流水线、炸鸡流水线也开始工作了。但是,做汉堡的师傅比较慢,周围薯条流水线都做好十份了,炸鸡流水线也做好五份了,汉堡流水线还没做好一份汉堡,但这时服务员只能干等着汉堡流水线的师傅,造成极大的资源浪费。
有了多线程技术以后,这种现象稍微有所缓解。我们可以想象成,快餐店多请了几个服务员,比如说一共有8个服务员了。那么可以每个服务员盯着一个流水线,这样就不会产生刚刚的现象了。真的吗?并不。我们知道,服务员的数量是有限的,那么如果此时流水线的数量大于服务员的数量,依然会有流水线得不到照顾。与此同时,流水线的另一端并不是我们掌控的,而是用户发起的。那么,会不会有坏用户,只与我们的服务器建立连接,但不发送数据。那么我们如果有一个服务员被分配到这样一个流水线上,就会造成在很长一段时间里,这个服务员都啥也不干,而别的流水线却极度需要服务员来处理食材。如果坏用户多了,把我们的服务员都占用了,那么我们也就没有服务员能处理正常的流水线了。这就是DoS攻击的一种手段。
非阻塞IO
缓解这种困境的一种手段就是是由非阻塞的IO。服务员不再在流水线前干等,而是走到流水线前,看一眼流水线好了没有。如果流水线好了,生产出了我们要的食物,那么服务员就正常工作;如果流水线还没好,那服务员就不再干等了,可以去干别的事了。对于单个套接字来说,这个工作就是由非阻塞IO完成的。我们用socket
创建套接字端点的时候,可以指定为非阻塞套接字,那么我们接下来对非阻塞的套接字使用recvfrom
的时候,如果数据还没有准备好,函数可以直接返回,而不是被阻塞。
IO多路复用:select
与epoll
除此之外,recvfrom
每次只能处理一个套接字,所以人们引入了select
。select
相当于我们雇用了一个厨房的总管,服务员每次向厨房总管调用select
语句之后,厨房总管看一遍所有的流水线,然后告诉服务员,有没有流水线是已经完工的。如果有流水线已经为做好食材,那么服务员就去挨个看流水线,找到是哪个流水线完成的,然后处理那个流水线的食材就好了。
之后select
由于一些设计上的缺陷,又产生了poll
函数,但两者实际原理都是差不多的。
但是我们想象一下,如果是一个大型的服务器程序,那么可能会有成千上万个套接字连接,那么厨房总管告诉服务员有已经好了的流水线时,服务员又得从头遍历一遍所有流水线,这样的事件损耗是巨大的。
epoll
就是为了改善这种情况而发明的。改善的方法也很简单,既然厨房总管也要看一遍哪个流水线好了,那么厨房总管就拿个纸,记下来好了的流水线,然后交给服务员让他去找就完事了,完全不需要服务员再遍历,这就是epoll
的作用。
信号驱动与异步IO
我们发现,随着人们技术的提高,服务器程序的水平也越来越高了。我们快餐店的服务员,从原来只会干等,到现在会看一眼进行判断,或者和厨房总管进行交接了。
但是,厨房里面的水平却没有多少提高。这导致我们的服务员的效率依然很低。比如说,我们终于用上了非阻塞IO,然后服务员负责某一个流水线。他首先看到流水线还没好,就去干别的事了。别的事干完之后,他又来看一遍,发现还没好,然后又去干别的事,然后又来流水线这看一眼。如果他此时并没有别的事,那他还是一直在流水线跟前看一眼,看一眼,看一眼,和之前的阻塞式IO没有区别。反映到程序里,我们的代码依然是
// create nonblocking socket c via fcntl
while (true) {
if (recvfrom(c, ...) != -1) {
// receive data
} else if (errno == EAGAIN) {
// do something else
}
}
依然是这样的循环结构。
虽然我们用上了非阻塞IO、IO多路复用,但这样始终让人感觉别扭。我们回想一下,现实中服务员和流水线,似乎也没有这么别扭的事发生啊。
这一切的原因,都是我们的编程思路没有跳脱开来,仍然局限在同步编程的概念里。我们想象一下现实生活中究竟是怎样的:服务员在忙别的事,这条流水线的食材做好了,师傅就按个铃。服务员听到铃声之后,要么立刻停下手头的事去流水线,要么默默记下来,等做完手头的事以后就去流水线。如果没有铃声,那么服务员就始终不用去流水线门口等着了。这就是异步编程的思想。
Linux中,在网络IO中引入了信号驱动型IO和aio来完成厨师的按铃工作。其主要思想就是,将一个函数传递给内核,告诉内核如果好了就调用这个函数。信号驱动型IO是在数据报的传输和内核处理层面的异步,也就是说,信号驱动型IO需要我们传递一个函数给内核,告诉内核如果来数据报了,并且内核已经把数据报处理好了,就调用我们之前传入的信号处理函数。而我们依然要在信号处理函数中使用recvfrom
等函数,将内核数据拷贝到用户态来。而aio则是更进一步,告诉内核,如果来数据报了,内核把数据报处理好了,并且也已经拷贝到用户态了,再调用我们传入的函数。
异步编程
从最原始的网络IO开始,我们一步步终于接近了异步编程。异步究竟是什么呢?异步实际上就是一种编程的思维,它在代码上就体现为,我们现在写的东西不会立刻被调用,甚至这个东西也不是被它的执行者直接调用。假如我们是快餐店的老板,那么我们告诉服务员,如果厨师按铃了,那你就去端菜。「服务员去端菜」这件事并不是在他知道这件事之后就立刻去做,而是要等到厨师按铃之后才做;同时,这件事虽然是服务员自己执行的,但是并不是服务员自己调用的,而是厨师通过按铃调用的。这就是异步编程的思想。
回调函数
异步编程最原始的实现就是回调函数。比如说我们用Swift来实现我们的异步快餐店:
func makeHamburger(completionHandler: (_ hamburger: Hamburger)