Marvin's Blog【程式人生】

Ability will never catch up with the demand for it

07 Aug 2020

读[Build Your Own Async]有感

Build Your Own AsyncDavid Beazley发布在YouTube上的一个Workshop视频。讲述了一些的Python的Async机制。看完之后有感而发,写了这篇文章。

一个程序按顺序一步接着一步执行,这叫同步执行。但是执行某一步的时候,如果所需要的条件还没有满足,那么程序就需要等待,知道条件满足位置。一个例子是程序从网络接收数据,如果数据没有到来,那么程序就必须等待。

通常情况下,程序的等待是由操作系统来帮助完成的。程序请求网络数据的操作需要经过操作系统,当操作系统发现网络数据还没有到来的时候,就把程序挂起。当所需数据来临的时候,才又把程序恢复执行。

举一个不那么恰当的比方。如果把程序请求网络数据比作一个人上农户家取牛奶。这个人到农户家的时候,牛奶可能还没挤好。为了不让取牛奶的人着急,农户先让来人喝完水。水中含有蒙汗药,喝完人就晕倒。当农户挤完奶之后,就喂给来人解药,把他唤醒,再把牛奶给他。取牛奶的人压根不知道自己晕倒过,于是带着牛奶高高兴兴回家了。

这种让操作系统帮忙等待的案例会造成一些问题,在恢复和挂起这两个操作本身浪费操作时间不说,程序可能并不太想被操作系统挂起,因为如果等待不到网络数据的话,完全还可以利用这段时间来干别的事情。也就是说应用程序可以有很多并发操作。

一个用来解释并发操作的比喻。一个人虽然没有三头六臂,但是也可以同时跟很多人下棋,在每个棋局中下完一步,趁对手还在思考的时候,换到另一个棋局跟另外一个人下。如此循环反复,只要这个人足够聪明,下手够快,就完全应付得过来。

当然,动不动就把程序挂起,操作系统也说不过去。所以操作系统提供的解决方案是采用线程来支持并发。一个程序可以有多个线程,某个线程被挂起,其他线程还可以继续执行。至于怎么在线程之间协调,这个包袱也就落到了程序之上。也就是说操作系统让程序真的长出了三头六臂。至于这三个头听哪个头的,要往哪里去,操作系统完全不管。

但是问题还是没有解决,三头六臂未必够,如果需要的是千手观音呢?如果并发数目一多,线程可能也应付不过来,线程间的切换开销也会陡增。因为程序不能光靠自己来进行线程切换,而是必须经过操作系统。

接下来这个问题就呼之欲出了:能不能让程序自身来协调呢?答案肯定是可以的,就像Erlang那样,在一个操作系统线程内搞出多个轻量级进程,然后自己进行上下文切换。如果处理器有多个处理核心的话,Erlang还可以通过多线程来并发调度。

上面举的Erlang的例子显然很特殊。并不是所有的编程语言都像Erlang这样特立独行,自成一派。大部分编程语言都是工作在操作系统提供的共享内存机制之下,饱受线程间同步的煎熬。

为了简化对并发的处理,多线程编程往往采用这样一种模型,一个线程作为主控线程,其他线程作为工作线程。主控线程把任务分配给工作线程,等待工作线程完成后,再把结果返回给主控线程。在这种情况下,结果的返回是异步的,主控线程并不能里面获得结果。只有当工作线程完成后,通过回调或者是发送消息的方式来把结果告知主控线程。

主控线程所期待的结果需要在未来的某个时候返回。很多编程语言里面都引入了一种叫future的概念来表示这种未来某个时候才能获取的结果。中文的话,或许可以把future翻译成候缺。

有了工作线程的帮助,主控线程可以将那些阻塞的操作分配给工作线程。这样其实主线程就很有多空余时间来响应外部事件,从而达到更大的并发度。一个例子是GUI程序,其主事件循环跑在主控线程上,然后遇到阻塞的或者耗时的操作,就把这些甩给工作线程。以此来保证主动线程的空闲,以便能够在需要的时候更快响应外部事件,包括用户操作,达到更好的体验。

主控线程接下来会遇到的问题是,如何让提供一个更好的编程模型,来支持异步操作,从而达到更大的并发度。C语言中,异步操作只能通过回调函数来执行。也就是提供一个回调函数的入口,然后在条件满足的时候,调用这个入口。

大量使用回调的话会使程序变得难以追踪,并且难以理解,给编写和阅读程序的人带来困难。或许原因是人脑的并发度是有限的,无法记忆并处理大量的未完成的任务。在Build Your Own Async视频中,作者最早举的例子就是使用回调来进行并发。回调的管理是通过一个队列来进行的。回调的执行以及结果的获取需要一个调度器来协调。·

为什么回调不讨人喜欢,举一个例子你就明白了。假设你组织一些人一起工作,给他们分配任务。但是这些人领完任务之后就蒙头进行,在他们获得结果之前你获得不了任何反馈,也无法从中干预,大部分时候只能干着急。回到编程语言上,回调是一个函数,获取输入并产生输出,调用之后就无法控制其执行过程,只能等待结果,而且结果有可能成功,也有可能失败。

更糟糕的是,不同的回调之间如果需要通信的话就很难协调。如果他们之间需要发数据,那么需要调度器来提供一些协调机制。但是对于异常错误处理的话,可能调度器就没办法了。异常通常需要在更高层级进行协调解决。但是回调之间并没有明显的层级关系。

有什么解决办法?我们可以继续扩展前面的例子来探讨一种可能性。作为组织者,当你把工作分配之后,如果能够在任务进行的过程中设置一些例会进行协调,那么就有可能更早发现问题,更早重新调整优先级和依赖关系,更好推进工作。这个例子比较接近现实生活中的场景。

如果以这个眼光来看传统的函数,其问题是无法对其执行进行干预(在内核级别可以,但是程序自身不行)。如果有一种办法可以对函数的执行进行干预,比如在函数执行过程中可以将其暂停,然后提供一定的通信协调机制,在函数恢复执行的时候根据协调的结果决定下一步的走向。这样这些可被暂停的函数间就可以有更好的通信。就像上面举的例子,组织者通过例会来协调工作进度,而不是苦苦等待任务执行者来返回结果。

越来越多的语言提供了一种机制,叫做coroutine,中文管它叫做协程,来对函数的执行进行干预。协程有普通函数的样子,写起来比回调要直观。但是可以中断执行,并且随后可以恢复。在中断和回复之间,可以进行任务的协调。协程和协程之间可以互相调用,因为存在这种调用的上下级关系,对于异常处理也会比回调要更加直观一点。Build Your Own Async的作者自己还创作了一个协程库,叫做Curio,来说明他心中理想的协程库是什么样子的。Curio是基于队列的,而Python自带的AsyncIO是基于事件循环的。但不管队列也好,还是事件循环也好,都是调度的手段,核心思想没有太大差异。

Python还有另外一款协程库:[Trio],据说是受到Curio启发的。What is the core difference between asyncio and trio?对比了AsyncIO和Trio。Trio的作者Nathaniel J. Smith在自己的博客njs blog也对Trio的设计思考做了很多的分享。

(本篇完)

comments powered by Disqus