并发与异步

当我们讨论「异步处理」的时候,最好把它和「多线程」分开。因为多线程实在是一个从概念上来讲最简单的设计,而且与其说多线程是「异步」,到不如说它是「并行」。

因此,在这篇文章里面想给大家讲的是与多线程垂直相交(最近写文章经常用到的词:orthogonal)的一些技术。为什么说是「垂直相交」呢?因为这些技术可以与多线程搭配使用,实现性能的最大化。

首先向介绍的就是polling机制。可能很多同学以为polling是一种blocking的,同步的机制,恰恰相反,polling是最经典的异步方案!

我们提交一个任务,然后没有马上获得任务的结果,代码没有block在提交任务,等待任务返回的阶段,后续我们通过while或者不断地if来polling获取任务的结果,这实际上就是把任务的执行和结果的获得拆分开了,所以polling是异步的。只不过polling是效率很低的异步方案而已,它的结果查询阶段是blocking的,而且还要轮询占用CPU时间。

Java里面还有Future,也是类似的异步解决方案,我们执行一个任务,获得一个Future<Object>的返回值,后续我们需要得到任务结果的时候,执行Future.get(...)方法来获得结果。如果此时任务还没执行完,get(...)方法会block住,直到得到结果。所以说Future接口其实就是polling的一个实现而已,只不过封装了polling的等待过程,然后在提交任务的时候使用了多线程来实现。Future接口隐藏了这些细节。

更加「异步」的解决方案就是callback机制。简单来讲,就是不要让caller去executor那边不断询问task的执行结果,而是task的executor执行好了以后直接去叫caller。而callback机制就是这样实现的:task caller会告诉task executor执行完成以后该做什么。也就是说task caller直接把自己要做的任务,连同要executor做的任务,一起都交给executor,这样executor做完自己的任务后,就顺手把task caller交代要做的任务也执行完。

从callback的设计就可以看出来,这种设计是完全「异步」的,task caller既不需要等待任务完成,也不知道任务什么时候完成,这种情况下整体的执行顺序完全随意。

因此为了能够控制程序的执行顺序,又能够不需要polling任务的执行方,就在callback的基础上实现listener的机制。也就是说,task executor执行完成后,给task caller发信号,告诉caller好了,这样caller再做处理。这样caller既不用等待executor,也能够控制程序的执行逻辑。

listener模式在操作系统层面,通过signal和中断的机制来实现,而更下层是通过硬件来支持各种信号的产生。这个在之前的一些列操作系统文章里给大家讲过,这里不展开。

明白了上面的设计思路后,大家可以发现所有的所谓「新的」异步式设计,都是万变不离其宗,无非是:polling, callback, listener或是它们的变种。而具体的实现脱不开:thread,lock,signal,interrupt。

比如Java NIO,以及架构在之上的Netty,整体设计核心就是Selector / Channel,本质上是listener的机制,在I/O的信号支持层面依赖各个平台的具体实现,比如在Linux平台上使用了高效的epoll(epoll的具体实现又依赖于各个cpu的平台架构),而在一些比较奇葩的内核(不点名了)上面只好使用threads来实现。

WebSocket则是使用NIO来实现,大大减少了所需的threads,本质上也是listener。

最近和WebSocket一样火的ServerSentEvent,仍然是基于NIO。

消息队列,用的是subscriber-publisher的模式,实际上WebSocket和SSE也支持这种模式,本质上是polling的机制。

因此在设计上,「异步」的模式就是:polling, callback, listener。

在实现上,依赖于thread,lock,signal,interrupt。

首先我们必须明白,不管各个语言平台如何实现自己的异步解决方案,它们都依赖于操作系统的具体实现。操作系统提供什么样的功能,语言平台就能基于这个系统实现上层功能。

因此,各个语言平台里面的「异步」实现可以大概分为三类:

  1. 直接封装操作系统的异步功能
  2. 基于语言平台自身的缓存处理
  3. 伪异步

关于第一类,比如Ruby里面有的开源项目对Linux操作系统的epoll封装,就是直接使用底层操作系统的相关功能1,但是这种实现是平台相关的,我们知道只有Linux提供epoll功能,而MacOS或者FreeBSD下面的相关实现完全不同,叫做kqueue。因此为了实现平台无关,项目最好能够封装这些平台特性,比如这个讨论帖2

大概意思就是,语言平台的异步实现要根据操作系统的具体实现来自动切换方案:在Linux下要使用epoll,在MacOS或FreeBSD下面要使用kqueue,在没有这些高效方案的情况下只能fallback回select。

关于第二类基于平台自身的缓存处理,Java NIO的实现是一个典型,它在JVM平台上实现了buffer层,用来在异步处理的时候和自己的selector及channel混合使用。而Java的selector则是在实现方面根据各平台不同采用epoll,kqueue或select。这个大家在jdk的源代 码中就可以看到相关的实现34

上面是jdk代码在Java层面的封装,JVM对于底层操作系统相关的实现是C++代码,这里不赘述。

关于第三种所谓「伪异步」,就是给用户一个异步的接口,在内部实现方面使用的其实是多线程的方式,或者是很低效的实现方式。比如臭名昭著的POSIX的asynchouous IO库,内部实现全部使用pthread,而不是真正调用linux的aio,epoll或者macos/unix的kqueue。这样的设计纯属欺骗消费者。

大家如果要学习异步设计,可以重点看Linux的epoll,至于kernel aio,虽然很先锋,但是真正在部署实战方面远没有epoll的应用铺的广,这里面有多方面原因不详细展开。

在上层应用方面,Java NIO的设计是最值得学习的,大家可以看看Java是怎样利用操作系统功能,同时加上自己的语言层面设计,来完成整体功能的,非常棒。

最新的可看的是JDK8里面加入的CompletionStage接口和CompletionFuture,可以算是语言层面一个颗粒度很细的异步接口设计了,可以让大家领悟如何合理地去定制listener的lifecycle,以及优秀的callback设计是什么样的。最重要的,看到这一层面异步接口与lambda expression的结合方式。

  1. https://github.com/ksss/epoll 

  2. https://www.reddit.com/r/ruby/comments/5oamf3/ruby_and_epoll/ 

  3. https://github.com/openjdk-mirror/jdk7u-jdk/blob/master/src/solaris/classes/sun/nio/ch/EPoll.java 

  4. https://github.com/frohoff/jdk8u-jdk/blob/master/src/solaris/classes/sun/nio/ch/KQueue.java