本系列文章以我的个人博客的搭建为线索(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) -> Void) {
let hamburger = // make hamburger
completionHandler(hamburger: hamburger)
}
func serve(_ hamburger: Hamburger) {
holdTheHamburgerToCustomers(hamburger)
}
makeHamburger(completionHandler: serve)
makeHamburger
是厨师该干的活,而我们需要让厨师接受一个回调函数作为参数。接着,我们实现服务员听到铃声后该干的事,也就是serve
函数。我们需要做的,就是将服务员该干的事传递给makeHamburger
。在这之后,当汉堡制作完成后,我们需要在函数内部调用回调函数即可。
看上去很不错,这就是我们的异步编程了!我们还需要别的吗?这看上去多简洁明了!
然而,我们还是太天真了。我们来看callbackhell.com官网上给出的一个JavaScript中的例子:
fs.readdir(source, function (err, files) {
if (err) {
console.log('Error finding files: ' + err)
} else {
files.forEach(function (filename, fileIndex) {
console.log(filename)
gm(source + filename).size(function (err, values) {
if (err) {
console.log('Error identifying file size: ' + err)
} else {
console.log(filename + ' : ' + values)
aspect = (values.width / values.height)
widths.forEach(function (width, widthIndex) {
height = Math.round(width / aspect)
console.log('resizing ' + filename + 'to ' + height + 'x' + height)
this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
if (err) console.log('Error writing file: ' + err)
})
}.bind(this))
}
})
})
}
})
这就是完全按照回调函数的思路写出来的异步编程代码。我们来看最后几行,全都是})
或}
。一层一层的回调函数看上去让人头皮发麻。就好像:
- 我们告诉养鸡的,去把鸡养大,等鸡养大之后:
- 让他告诉杀鸡的,让他准备好刀,刀准备好了之后:
- 去杀鸡,然后告诉炸鸡的去准备好油,油准备好之后:
- 去炸鸡,然后告诉服务员让他做准备:
- 去把炸鸡端给顾客。
- 去炸鸡,然后告诉服务员让他做准备:
- 去杀鸡,然后告诉炸鸡的去准备好油,油准备好之后:
- 让他告诉杀鸡的,让他准备好刀,刀准备好了之后:
这一层一层的传递关系已经够乱的了,接下来,为了让我们的程序更稳健,我们还需要做错误处理。我们得:
- 让服务员出错的时候,告诉炸鸡的不能端菜了
- 炸鸡的自己出错,或者收到服务员出错的消息,就告诉杀鸡的不能炸鸡了
- 杀鸡的自己出错,或者收到炸鸡的出错的消息,就告诉养鸡的不能杀鸡了
- 养鸡的自己出错,或者收到杀鸡的出错的消息,就告诉我们
- 杀鸡的自己出错,或者收到炸鸡的出错的消息,就告诉养鸡的不能杀鸡了
- 炸鸡的自己出错,或者收到服务员出错的消息,就告诉杀鸡的不能炸鸡了
合起来,我们得这样写:
- 我们告诉养鸡的,去把鸡养大,等鸡养大之后:
- 让他告诉杀鸡的,让他准备好刀,刀准备好了之后:
- 去杀鸡,然后告诉炸鸡的去准备好油,油准备好之后:
- 去炸鸡,然后告诉服务员让他做准备:
- 去把炸鸡端给顾客。
- 服务员出错的时候,告诉炸鸡的不能端菜了
- 炸鸡的自己出错,或者收到服务员出错的消息,就告诉杀鸡的不能炸鸡了
- 去炸鸡,然后告诉服务员让他做准备:
- 杀鸡的自己出错,或者收到炸鸡的出错的消息,就告诉养鸡的不能杀鸡了
- 去杀鸡,然后告诉炸鸡的去准备好油,油准备好之后:
- 养鸡的自己出错,或者收到杀鸡的出错的消息,就告诉我们
- 让他告诉杀鸡的,让他准备好刀,刀准备好了之后:
- 我们来处理所有人出错的问题
让人头皮发麻,并且没办法维护。
响应式编程与观察者模式
除了回调函数之外,我们还有别的异步编程的方法。试想,使用回调函数的方法,我们为了让服务员正确服务,我们却得告诉厨师去调用服务员的函数。厨师本来就应该做好自己的事,而不是去关心别的人的事。这在程序开发中叫做弱耦合。为了实现弱耦合,我们可以引入观察者模式,它发扬光大就是著名的响应式编程。
观察者模式说的是,我们可以将我们需要关心的东西设置为可观察的对象,然后将某些东西设置为它的观察者。当这个对象发生改变的时候,它的观察者就会做出相应的举动。在我们的快餐店中,我们可以让服务员成为汉堡的观察者。当汉堡做好的时候,服务员就去端汉堡。
C#中的Rx是响应式编程的元老(但我没用过),以及Android框架中的LiveData就是观察者模式的一个很好的实现。
class WaiterViewModel : ViewModel() {
private var hamburger: MutableLiveData<Hamburger?> = MutableLiveData(null)
fun getHamburger(): LiveData<Hamburger?> {
return this.hamburger
}
fun makeHamburger() {
val hamburger = // make hamburger
this.hamburger.value = hamburger // or this.hamburger.postValue(hamburger) if in another thread
}
}
class WaiterFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// get cook the viewModel by delegation
val cook: WaiterViewModel by viewModels()
// create Observer
cook.getHamburger().observe(viewLifeCycleOwner, Observer { hamburger in
hamburger?.let {
holdTheHamburgerToCustomer(it)
}
})
}
}
在这里,我们将服务员看作一个界面,厨师看作它的view model,重点就在observe
函数,指定了如果汉堡发生了变化,也就是产出了汉堡,那么服务员要去端汉堡。
值得注意的是,这和我们最开始的同步模式,也就是服务员要一直盯着汉堡不同,这里服务员依然可以去做别的事。
与观察者模式类似的,我们还有订阅-发布模式,其本质与观察者模式相同,只不过,我们让厨师做好汉堡之后,发一个公告,说汉堡做好了。然后服务员订阅这个布告栏,如果发布了公告,那么服务员就去端汉堡。
实践订阅-发布模式的,就是Apple在去年推出的Combine框架。利用Combine框架,我们可以这么写:
func makeHamburger() -> Publisher<Hamburger> {
// make some hamburger
}
func serve(_ hamburger: Hamburger)
makeHamburger()
.sink { hamburger in
holdTheHamburgerToCustomer(hamburger)
}
.store(in: disposables) // store in disposables in case we lose reference
不管怎么样,我们都是把回调函数从厨师那边解放了,而把回调函数独立于两者之外,由我们来指定。
这种模式还有一种巨大的好处是我们可以主动取消回调。比方说客人点了一份炸鸡,然后服务员让厨师做炸鸡。服务员就观察着这个炸鸡,一旦它做好,就端给客人。但是客人临时有事,又要走了。服务员就取消观察这个炸鸡就ok了。但是在回调函数的方案中,函数是直接传给厨师的,取消起来很麻烦。
协程,Promise
与async
/await
观察者模式可以做的远远不止服务员端汉堡这么多。比如说服务员可以一直观察着厨师做汉堡的过程。当厨师烤好肉了之后,服务员大喊一声“厨师肉烤好啦!”,之后厨师又把肉、菜、酱加在一起,服务员大喊一声“厨师加了菜和酱啦!”,厨师最后做完汉堡,服务员才开始把汉堡端给顾客。也就是说,观察可以是一段时间持续的观察,期间任何的变动都可以反馈给观察者让观察者做出相应的举动。
但是,有时候我们的需求不是这样的。我们仅仅需要的是一个结果,某人做了某事,我们就怎么样。比方说,我们的薯条厨师,开始做薯条的时候,要先打个电话给土豆场,让他们送土豆过来。土豆送过来之后,厨师又让学徒切土豆。切好之后,厨师又让助手把油烧开,等油烧开之后,厨师才开始炸薯条。这个过程,依然是一个需要异步调用的过程,而且如果用回调函数的方式写,又会产生之前所说的callback hell。
Promise
那我们就模仿之前观察者模式的思路,看看能不能解决。这里,我们发现,这里面的环节只会变化一次。土豆会从无变成有,从没切好变成切好,从没炸变成炸过。我们模仿之前的LiveData<T>
或者Publisher<T>
,这里我们记为Promise<T>
。它有两种状态:还没好的状态,和好了的状态。根据之前观察者模式的经验,我们似乎可以这样写:
function bringTomato(): Promise<Tomato>;
function sliceTomato(tomato: Tomato): Promise<SlicedTomato>;
function fryTomato(slicedTomato: SlicedTomato): Promise<Chips>;
当我们调用bringTomato
之后,会立刻返回一个Promise<Tomato>
。当土豆到了以后,这个变量会自动变成OK的状态。然后,就像之前的观察者或者订阅者一样,我们对这个变量进行观察或订阅,并传入一个回调函数:
bringTomato()
.then(sliceTomato);
当Promise<Tomato>
从没好的状态变成好的状态的时候,会调用then
中的函数,并把好了的值传进去。看上去很OK嘛!
那么,then
应该是怎样的工作呢?这很像我们之前在函数式编程中讲的functor和monad,这里,我们应该把then
看作monad中的bind
。这是因为,我们的sliceTomato
依然会返回一个Promise
类型的值,如果是functor的fmap
的话,在执行完毕后,会产生Promise<Promise<SlicedTomato>>
这样奇奇怪怪的类型,所以还是用monad比较顺心一点。也就是说,会自动“折叠”我们的Promise
,让它只剩下最后一个。这样的话,我们就可以把它串起来了:
bringTomato()
.then(sliceTomato)
.then(fryTomato)
.then(doSomethingWithChips);
看上去还是挺OK的。
协程与async
/await
那我们换一种思路,用最原始的同步方法行不行?行!薯条厨师打电话给土豆场之后,啥也不干,等着土豆送到之后,让学徒切土豆。切好之前,又啥也不干……这样确实可以有效地解决回调地狱的问题,但是这又回到了最原始的同步思路上,在我们实际程序中,一个厨师可能就是一个线程。让厨师啥也不干就干等着,这线程的利用率也就太低了。如果我们单纯写
let tomato = bringTomatoSync();
let slicedTomato = sliceTomatoSync(tomato);
let chips = fryTomatoSync(slicedTomato);
这里sync
代表是同步的函数,那么当事情还没完成的时候,线程会被阻塞,不符合我们异步的想法。我们能不能用类似于上面的语句,来写出之前用Promise
的功能呢?像这样:
let tomato = await bringTomato();
let slicedTomato = await sliceTomato(tomato);
let chips = await fryTomato(slicedTomato);
它和我们的同步写法究竟有什么区别呢?要解决这个问题,就要首先思考,我们为什么不用同步写法。同步的写法就意味着当函数调用还没结束的时候,我们需要一直处于等待状态。但是,如果可以在bringTomato
的时候,我们的炸薯条的师傅去做别的事,那么效率就可以得到提升。也就是说,我们之所以不用同步的写法,是为了让程序在等待的过程中能做别的事。
那么,我们如果要实现这样的功能,需要怎样的策略呢?作为一个厨师,有条理地做这种事,应该是:
- 打电话给土豆场,让他们运土豆过来
- 做别的事
- 别的事做完以后,再来处理运来的土豆
也就是说,我们需要在特定的时候主动停下手头的事,然后去做别的事。也就是说,我们需要有主动停止的能力,或者说,主动让出控制权的能力,这就是协程。在JavaScript中,语言提供了一个叫generator的机制来模拟这种事:
function *foo() {
yield bar1();
bar2();
yield bar3();
bar4();
return;
}
当我们调用foo
的时候,会首先执行bar1
,然后函数就会返回。当我们再次调用这个函数的时候,它并不会从头开始执行,而是从yield
的后一行,也就是bar2
开始执行,一直运行到下一个yield
,也就是执行完bar3
,然后再次停止。也就是说,这个函数的执行状态是未执行——执行——挂起——继续执行——挂起——继续执行——返回。与我们正常函数的未执行——执行——返回有着一些区别。我们还可以从状态机的角度来看这个问题:
function foo() {
let state = 0;
stateMachine = () => {
switch (state) {
case 0:
bar1();
state = 1;
break;
case 1:
bar2();
bar3();
state = 2;
break;
case 2:
bar4();
state = 3;
break;
default:
return;
}
};
return stateMachine;
}
上面的这个函数返回的是一个闭包,我们可以这样调用:
let stateMachine = foo(); // state = 0
stateMachine(); // state = 1
stateMachine(); // state = 2
stateMachine(); // state = 3
这里通过状态机,使我们的程序看上去真的有了暂时让出执行之后,继续恢复挂起的能力!
当然,协程的实现方法还有很多种,有有栈协程、无栈协程等。除了Go语言之外,大多数编程语言都选择的是无栈协程,往往把我们上述的generator编译成一种状态机来实现。
通过协程,我们的async
/await
机制似乎就容易理解了。await
实际上就是一种yield
的机制,把这个函数的执行权让出去。但是,这又和async
有什么关系呢?我们来看上面用状态机实现的协程,会发现很难实现在层叠调用的时候的yield
,比如说:
function somethingUnreal() {
bar5();
yield bar6();
bar7();
}
function *foo() {
yield bar1();
bar2();
yield bar3();
bar4();
somethingUnreal();
return;
}
我们希望在somethingUnreal
里的bar6
的yield
能够停止foo
的执行,然后恢复之后,继续执行bar7()
,然后从somthingUnreal
返回到foo
里执行它后面的return
语句。这用状态机似乎很难实现。所以,我们需要用async
来做这个标记:
async function somethingReal() {
bar5();
await bar6();
bar7();
}
async function foo() {
await bar1();
bar2();
await bar3();
bar4();
await somethingReal();
return;
}
通过这样具有感染性的async
,就能正确地用状态机实现协程了。