Skip to content

04流与异步:为什么说掌握流计算先要理解异步编程?

在前面的课时中,我们详细分析了"异步"的工作原理,并且在解决异步系统的 OOM 问题时,使用了"反向压力"的方法。在讨论过程中,我们已经明确地使用到,诸如上游、下游、streams 这样的概念都暗示着我们,"流"和"异步"之间有着某种关联。

所以今天,我们就借助于目前四种主流的异步编程方案,来详细分析下"流"和"异步"之间这种紧密关系。

异步编程框架

说到"异步编程"或者"高并发编程",你首先想到的是什么呢?

根据我以往当面试官的经验:

  • 青铜级的求职者,一般会说多线程、synchronized、锁等知识,更有甚者还会扯到 Redis 神马的。很显然,这类求职者对异步和高并发编程,其实是没有什么概念的;

  • 白银级的求职者,则会说线程池、executor、ConcurrentHashMap 等,这类同学对异步和高并发编程,已经有了初步认识,但却还不够深入;

  • 王者级别的求职者,则会对 NIO、Netty、CompletableFuture 等技术如数家珍,甚至还会谈到 Fiber。

其实很多时候,我问求职者的问题,都是在实际开发过程中,需要使用或注意的知识点,要求并不苛刻。毕竟面试的目的,是尽快招到合适的开发人员一起做事,而不是为了刁难人家。但可惜的是,我遇到最多的是青铜,少有白银,王者则更是稀有了。我自己也曾面试过某 BAT 大厂之一,记得当时最后一轮技术面,是三个不同部门的老大同时面我。他们问了我很多问题,其中印象最深的一个,就是关于异步和高并发编程的问题。当时我从"流"的角度,结合 NIO 和 CompletableFuture 等工具,跟他们详细讲解了我在平时开发过程中,总结出的最佳实践方案。最后,我顺利拿到了 offer。

所以,回到问题本身,当我们谈论"异步"和"高并发"编程时,到底是在说什么呢?通过第 02 课时的学习,我们已经知道,"高并发"其实是我们要解决的问题,而"异步"则是为了更有效地利用 CPU 和 IO 资源,来解决"高并发"问题时的编程方式。在"高并发"场景下,我们通常会使用"异步"的编程方式,来提升 CPU 和 IO 的使用效率,从而提高程序性能。

所以进一步地,我们的问题落在了选择"异步"编程方案上。那究竟怎样实现异步编程呢?其实,异步编程的框架非常多,目前主流的异步编程可以分为四类模式:Promise、Actor、ReactiveX 和纤程(或者说协程)。下面我们逐一讨论。

Promise 模式

Promise 模式是非常基本的异步编程模式,在 JavaScript、Java、Python、C++、C# 等语言中,都有 Promise 模式的实现。Promise 正如其名,代表了一个异步操作在其完成时会返回并继续执行的承诺

Promise 模式在前端 JavaScript 开发中,是非常常见的。这是因为 JavaScript 本身是单线程的,为了解决诸如并发网络请求的问题,JavaScript 使用了大量异步编程的技巧。早期的 JavaScript 还不支持 Promise 模式,为了实现异步编程,采用的都是回调的方式。但是回调会有一个问题,就是所谓的"回调陷阱"。

举个例子,当你需要依次调用 A、B、C、D 四次网络请求时,如果采用回调的编程方式,那么四次网络请求的回调函数,会依次嵌套起来。这样,整个回调函数的实现会非常长,逻辑会异常复杂,不容易理解和维护。

为了解决"回调陷阱"的问题,JavaScript 引入了 Promise 模式。类似于下面这样:

javascript
let myPromise = new Promise(function(myResolve, myReject) {
  setTimeout(function() { myResolve("Hello World!"); }, 5000);
});
myPromise.then(function(value) {
  document.getElementById("test").innerHTML = value;
})

在上面的这段 JavaScript 代码中,实现了一个异步的定时器。定时器定时 5 秒后返回,然后将 id 为"test"的元素设置为"Hello World!"。

可以看出,Promise 模式将嵌套的回调过程,变成了平铺直叙的 Promise 链,这极大地简化了异步编程的复杂程度。

那在 Java 中的 Promise 模式呢?在 Java 8 之前,JDK 是不支持 Promise 模式的。好在 Java 8 为我们带来了 CompletableFuture 类,这就是 Promise 模式的实现。比如在 03 课时的异步执行代码,正是一个 Promise 链。

java
 CompletableFuture
            .supplyAsync(() -> this.decode(ctx, req), this.decoderExecutor)
            .thenApplyAsync(e -> this.doExtractCleanTransform(ctx, req, e), this.ectExecutor)
            .thenApplyAsync(e -> this.send(ctx, req, e), this.senderExecutor);

在上面的代码中,我们使用的 executor 都是带队列的线程池,也就是类似于下面这样。

从上面的图 1 可以看出,这个过程有生产者,有队列,有消费者,是不是已经非常像"流"?

当然,CompletableFuture 类也可以使用其他类型的 executor,比如,使用栈管理线程的 executor。在这种 executor 的实现中,每次调用 execute() 方法时,都是从栈中取出一个线程来执行任务,像这种不带任务队列的执行器,就和"流"相差甚远了。

Actor 模式

Actor 模式是另外一种非常著名的异步编程模式。在这种模式中,用 Actor 来表示一个个的活动实体,这些活动实体之间以消息的方式,进行通信和交互。

Actor 模式非常适用的一种场景是游戏开发。比如 DotA 游戏里的小兵,就可以用一个个 Actor表示。如果要小兵去攻击防御塔,就给这个小兵 Actor 发一条消息,让它移动到塔下,再发一条消息,让它攻击塔。

必须强调的是,Actor 模式最好是构建在纤程上,这样 Actor 才能随心所欲地想干吗就干吗,你写代码时就不会有过多的约束。

如果 Actor 是基于线程构建,那么当存在较多 Actor 时,Actor 的代码就不宜做过多 IO 或 sleep 操作。但大多数情况下,IO 操作都是难以避免的,所以为了减少 IO 和 sleep 操作对其他 Actor 的影响,应将涉及 IO 操作的 Actor 与其他非 IO 操作的 Actor 隔离开。给涉及 IO 操作的 Actor 分配专门的线程,不让这些 Actor 和其他非 IO 操作的 Actor 分配到相同的线程。这样可以保证 CPU 和 IO 资源,都能充分利用,提高了程序的性能。

在 JVM 平台上,比较有名的 Actor 模式是 Akka。但是 Akka 是构建在线程而非纤程上,所以使用起来就存在上面说的这些问题。

如果你要用 Akka 的话,需要注意给以 IO 操作为主的 Actor ,分配专门的线程池。另外,Akka 自身不具备反向压力功能,所以使用起来时,还需要自己进行流量控制才行。

我自己曾经实现过,感觉还是有点小麻烦的。主要的问题在于 Actor 系统对邮箱的定位,已经要求邮箱,也就是 Actor 用于接收消息的队列,最好不要阻塞。所以如果是做流量控制的话,就不能直接将邮箱,设置为容量有限的阻塞队列,这样在 Actor 系统中,非常容易造成死锁。

ReactiveX 模式

ReactiveX 模式又称之为响应式扩展,它是一种观察者模式。在Java 中,ReactiveX 模式的实现是 RxJava。ReactiveX 模式的核心是观察者(Observer)和被观察者(Observable)。被观察者(Observable)产生一系列的事件,比如网络请求、数据库操作、文件读取等,然后观察者会观察到这些事件,之后就触发一系列后续动作。

下面就是使用 RxJava 编写的一段异步执行代码。

java
//被观察者
Observable observable = Observable.create(new Observable.OnSubscribe<String>() {
    @Override
    public void call(Subscriber<? super String> subscriber) {
        subscriber.onNext("Hello");
        subscriber.onNext("World");
        subscriber.onNext("!!!");
        subscriber.onCompleted();
    }
});
 
//观察者
Observer<String> observer = new Observer<String>() {
    @Override
    public void onNext(String s) {
        Log.d(tag, "onNext: " + s);
    }
 
    @Override
    public void onCompleted() {
        Log.d(tag, "onCompleted called");
    }
 
    @Override
    public void onError(Throwable e) {
        Log.d(tag, "onError called");
    }
};
 
// 订阅
observable.subscribe(observer);

在上面的代码中,被观察者依次发出"Hello""World""!!!"三个事件,然后观察者观察到这三个事件后,就将每个事件打印出来。

非常有趣的是,ReactiveX 将其自身定义为一个异步编程库,却明确地将被观察者的事件序列,按照"无限流"(infinite streams)的方式来进行处理,还实现了 Reactive-Streams 标准,支持反向压力功能。

你说,是不是他们也发现了,流和异步之间有着相通之处呢?

不过,相比 Java 8 的 CompletableFuture,我觉得这个 RxJava 还是显得有些复杂,理解和使用起来都更加麻烦,但明显的优势又没有,所以我不太推荐使用这种异步编程模式。

我在之前的工作中,也有见过其他同事在 Android 开发时使用这种模式。所以,如果你感兴趣的话,也可以了解一下。如果是我,我就直接使用 CompletableFuture 了。

纤程/协程模式

最后是纤程(fiber)模式,也称之为协程(coroutine)模式。应该说纤程是最理想的异步编程方案了,没有之一!它是用"同步方式写异步代码"的最高级别形态。

下面的图 3 是纤程的工作原理,纤程是一种用户态的线程,其调度逻辑在用户态实现,从而避免过多地进出内核态,进行调度和上下文切换。

实现纤程的关键,是要在执行过程中,能够在恰当的时刻和地方中断,并将 CPU 让给其他纤程使用。具体实现起来就是,将 IO 操作委托给少量固定线程,再使用另外少量线程负责 IO 状态检查和纤程调度,再使用另外一批线程执行纤程。

这样,少量线程就可以支撑大量纤程的执行,从而保证了 CPU 和 IO 资源的使用效率,提升了程序的性能。

使用纤程还可以极大地降低异步和并发编程的难度。但可惜的是,当前 Java 还不支持纤程。Java 对纤程的支持还在路上,你可以查阅一个被称之为 Loom 的项目来跟踪进度。所以这里,我就先借助当前另外一款火爆的编程语言 Golang,来对纤程做一番演示。

下面就是一个 Golang 协程的示例代码。

go
package main
import (
   "fmt"
   "time"
)
func produce(queue chan int) {
   for i := 0; true; i++ {
      time.Sleep(1 * time.Second)
      queue <- i
      fmt.Printf("produce item[%d]\n", i)
   }
}
func consume(queue chan int) {
   for {
      e := <-queue
      fmt.Printf("consume item[%d]\n", e)
   }
}
func waitForever() {
   for {
      time.Sleep(1 * time.Second)
   }
}
func main() {
   // 创建传递消息的队列,这是一个容量为10的阻塞队列
   queue := make(chan int, 10)
   // 启动生产者协程
   go produce(queue)
   // 启动消费者协程
   go consume(queue)
   // 防止程序退出
   waitForever()
}

在上面的代码中,我们使用了 Golang 最核心的两个概念,即 goroutine 和 channel。Golang 最推崇的并发编程思路就是,通过通信来共享内存,而不是通过共享内存来进行通信。所以,我们可以非常直观地看到,这里的 channel 就是一个容量有限的阻塞队列,天然就具备了反向压力的能力。

所以,Golang 这种生产者、队列、消费者的模式,不就是"流"的一种雏形吗?

异步和流之间的关系

至此,我们已经讨论了四种不同的异步编程模式。除了像 async/await 这样的异步编程语法糖外,上面讨论的四种模式,基本覆盖了当前所有主流的异步编程模式。这里稍微提一下,async/await 这个异步编程语法糖,还是非常有趣的,Python 和 JavaScript 都支持,建议你了解一下。

我们再回过头来看下,这四种异步编程模式,它们都已经暗含了"流"的影子。

首先是 Promise 模式,当 CompletableFuture 使用的执行器,是带队列的线程池时,Promise 异步调用链的过程,在底层就是事件在队列中"流"转的过程。

然后是 Actor 模式,每个 Actor 的邮箱就是一个非阻塞的队列,Actor 之间的通信过程,就是消息在这些非阻塞队列之间"流"转的过程。

接下来就是 ReactiveX 模式,将自己定义为异步编程库的 ReactiveX,明确地将事件按照"无限流"的方式来处理,还实现了 Reactive-Streams 标准,支持反向压力功能。

最后是纤程和协程,Golang 语言明确将"队列"作为了异步和并发编程时最主要的通信模式,甚至将"通过通信来共享内存,而不是通过共享内存来进行通信",作为一种编程哲学思想来进行推崇。

所以,在四种异步编程模式中,我们都能够发现"队列"的身影,而"队列"正是"流"计算系统最重要的组成结构。我们可以利用这种结构,来实现"流"计算的过程。

有"队列"的系统,注定了它会是一个异步的执行过程,这也意味着"流"这种计算模式注定了是"异步" 的。异步系统中存在的 OOM 问题,在"流"计算系统中也存在,而且同样是使用"反向压力"的方式来解决。

小结

今天,我们分析了四种主流的异步编程模式,并讨论了"流"和"异步"之间的关系。总的来说,"流"和"异步"是相通的。其中,"异步"是"流"的本质,而"流"是"异步"的一种表现形式

至此,我们已经讨论完了所有有关异步编程的内容。可以看到,我在模块一中,花了整整三个课时,来讲解有关 NIO、异步编程和流计算的工作原理,以及它们之间的关联关系。这是因为这些知识,不仅仅是后面流计算内容的基础,它们也可以被用于其他任何高并发编程场景。而且根据我多年的工作经验,我坚定地认为,理解 NIO 和异步编程,是程序员成为大神的必要条件。所以,我希望你能够彻底地掌握和理解模块一的内容,它们一定会对于你以后的工作,有所帮助!

下一讲,我们将正式进入流计算系统的模块。那对于"流"和"异步"之间的关系,你还有什么问题或想法呢?有的话可以在留言区写下来,我看到后会进行分析和解答!

最后,用一个脑图来概括下本课时讲解的内容,以便于你理解。

点击此链接查看本课程所有课时的源码


[

](https://kaiwu.lagou.com/data_enhancement.html?utm_source=lagouedu&utm_medium=zhuanlan&utm_campaign=大数据开发高薪训练营#/index)
PB 级企业大数据项目实战 + 拉勾硬核内推,5 个月全面掌握大数据核心技能。点击链接,全面赋能!