学习IO模型的价值是什么?协程、响应式编程和它有什么关系?

50 阅读8分钟

I/O模型这个概念出现的频率很高,为何它如此重要?这离不开如何对待 CPU 这个核心的计算资源。本文将从 CPU 执行流程开始,感性地把握 I/O 模型的内容,并探讨回调、响应式编程、协程对高并发的意义。

一、程序的执行流程

程序的运行离不开操作系统:程序首先被加载进内存,被操作系统调度给 CPU 执行逻辑。CPU 运算速度很快,能迅速给出计算结果。

理想情况下,计算流程能运转得很顺畅。但空间有限,数据不是总在内存中,当目标数据在外存(例如硬盘)或网络上的其他设备,就会出现中断。于是,CPU 不得不停下来,等待数据被 I/O 进内存。

如何把 CPU 的休息时间利用起来,充分发挥其计算能力,这离不开选择高效的 I/O 模型。

二、让 CPU 忙一点(I/O 模型)

我们用一个例子来理解 I/O 模型。假设看到这里手机突然卡住了,任何操作都不起作用。但手机是刚买的,不敢强行重启,又想看下面的内容,你有哪些选择?

我想,有这些方法(表的第一列是该方法对应的 I/O 模型):

能采用的方法方法的内容
阻塞 I/OBlocking I/O坚持不懈,自己盯着手机,等它恢复
朴素非阻塞 I/ONon-Blocking I/O把手机放到旁边,自己去读书,不时看下是否恢复
I/O 复用I/O MultiPlex把手机交给朋友盯着,不时问下朋友,好了再去拿手机
事件驱动 I/OEvent-Driven I/O手机恢复了自动给你发微信(假设提前装了App) ,好了再去拿手机
异步 I/OAsync I/O手机恢复了会自动“飞”到你手上,自己不用管,想做什么都行

这些方案也是 CPU 在 I/O 时能做的 5 种选择,即I/O 模型

在实现上,各种 I/O 模型都离不开这两个基本步骤:

  1. 查询 I/O状态(Query I/O Status)

检查数据是否从其他位置送达,有数据才能拉取数据。

  1. 从操作系统拉取数据到程序(Fetch Data)

数据首先会进入到操作系统的内核空间,需要拉取到程序的空间才能使用。

阻塞 I/O 和朴素非阻塞 I/O:状态查询和数据拉取这两步都需要程序完成,效率较低。朴素非阻塞在第一步不会阻塞等待,但要循环去查询 I/O 状态。

I/O 复用和事件驱动 I/O:更高效的机制,引入了程序外的“第三者”来管理 I/O 状态,但数据要程序自取。虽然都是“第三者”,也有区别:

  • I/O 复用借助复用器(MUX)一次性管理大量的 I/O 请求。I/O 复用靠很少的资源就能监听大量 I/O 请求。实现较为简单,在 1983 年发布的 4.2BSD 就有相应的能力。
  • 事件 I/O 有独特的优势,无需程序主动查询状态(I/O 复用需要程序包含复用器逻辑),但实现较为复杂。

异步 I/O :可视为处理 I/O 任务的“全能手”,I/O 全程无需用户程序参与。

事件驱动 I/O 和异步 I/O 理论上有更高效的性能,但 I/O 复用在各个操作系统都有充分的实现,而且性能优良,与后两种 I/O 模型差距较小,I/O 复用仍是非阻塞 I/O 的首选

三、回调与 I/O

要充分发挥 CPU 的性能,I/O 这样的慢速操作不应该阻塞 CPU 运转,而是采取异步非阻塞的形式:CPU 在发起 I/O 调用后继续进行运算工作, 待 I/O 结束再自动“回去调用”(Callback)后续的业务逻辑。

用一个小需求来学习下回调。假设现在要调用百度的接口来搜索关键字,并把结果渲染出来,如果用 JavaScript 实现:

let searchQuery = "LiZi";
let endpoint = "baidu?q=" + encodeURIComponent(searchQuery);
let xhr = new XMLHttpRequest();
xhr.open("GET", endpoint, true);
// 注册调用结束后的回调函数
xhr.onload = function() {
  // 查询成功
  if (xhr.readyState === 4 && xhr.status === 200) {
  // 弹框显示
    alert(xhr.responseText)
  }
};
// 发起请求
xhr.send();
......
// 做其他事情

JavaScript 很早就提供了这种非阻塞 I/O 的形式。这时因为浏览器采用单线程进行 UI 渲染。如果使用多线程,当出现并发问题,如死锁, 就会导致浏览器界面卡顿。单线程也就意味着浏览器不能等待 I/O,等待 I/O 会卡住界面,非阻塞的 XHR 调用不得不用。

XHR 调用是在 I/O 前注册回调函数,不等待请求完成而先渲染其他界面。当 I/O 成功后,再将收到的数据传送给回调(Callback)来渲染。

在底层实现上,XHR 借助I/O 复用(非阻塞 I/O)来监听 I/O 状态,但整个过程隐藏在了简洁的 XHR onload API 之下。类似地,Java 也有基于非阻塞 I/O 框架 Netty 构建的异步 HTTP 请求库:AsyncHttpClient。下面的代码展示了使用 AsyncHttpClient 来实现相同的功能:

String searchQuery = "LiZi";
AsyncHttpClient asyncHttpClient = Dsl.asyncHttpClient();
asyncHttpClient.prepareGet("baidu?q=" + searchQuery)
        .execute(new AsyncCompletionHandler<Response>() {
             // 请求成功后的回调
            @Override
            public Response onCompleted(Response response) throws Exception {
                System.out.println(response.getResponseBody());
                return response;
            }
        });

四、“回调地狱”(Callback Hell)与响应式(RX)编程

对于大多数的代码流程,我们需要组合一系列小操作,而不是前面展示的一次回调就能实现。这通常导致要在回调函数内嵌回调函数。当这样的嵌套过大时,就会显著影响业务逻辑的正确性和代码可读性,形成了所谓的“回调地狱”(Callback Hell)。

我们一起考虑这个场景,假定要向用户推送 10 条信息流内容。如果用户已存在关注的作者列表,就获取关注内容的详细信息。如果不存在关注列表,就自动为其推荐内容。以下是伪代码实现:

// 获取关注列表
getFavorites(userId, favList ->{
  	// 如果关注列表不为空
    if favList not empty{
  			// 获取详情
        getDetails(favList, detailList -> {
  					// 取 10 条
            return detailList.sub(10)
        })
    } else {
      	// 获取推荐
        getRecommendations(userId, recomList ->{
          	// 取 10 条
            return recomList.sub(10)
        })
    }
})

在上述代码中,回调函数在一层层向下推进。如果不从头到尾把代码捋一遍,会很难摸透代码逻辑。但如果有了响应式编程(Reactive Programming),一切就可以清晰明了:

// 只需要 4 行调用代码
getFavorites(userId)
        .flatMap(getDetails)
        .ifEmpty(getRecommendations(userId))
        .take(10)

响应式编程通过链式调用(Method Chaining)把处理逻辑前后串联起来,调用操作本身能包含逻辑判断,不再需要函数内嵌函数这种不直观的语法形态。但响应式编程和传统的编程范式有很多区别,掌握它有较高的学习成本。

五、IO模型与协程,第二种不让 CPU 休息的方法

仔细分析 I/O 密集应用的执行流程(这种情况 CPU “休息”比较多):应用程序以线程作为代码逻辑执行的基本单元。但为了避免维护线程本身消耗资源过大,线程的数量会有固定的上限。在阻塞 I/O 的情况下,如果 I/O 过多(比如高并发),大部分线程将处于 I/O 等待状态。进程没有线程可用,CPU 无法工作,虽然总体利用率不高,但却不能处理新请求

非阻塞 I/O 是解决这个问题的方法之一,它把 I/O 慢操作非阻塞异步化。只要计算任务量还符合 I/O 密集型,就总能有线程资源来处理新的用户请求。

还有没有其他方法?能不能假设这样一种可能,即线程可以无限制地新建,且不会造成过大的线程维护成本。

协程就基本上解决了这个问题。线程消耗资源较大的主要原因在于需要操作系统内核进行高成本的线程调度。但是协程把这些调度工作交给了用户程序本身,调度成本的下降为“线程”带来了数量不设上限的可能性

Go 语言有 Goroutine,JDK 21 也正式发布了虚拟线程。在不久的将来,我们可能可以回到这种模式:来一个用户请求就新建一个线程,不需要回调,不需要响应式编程。维护线程没什么消耗,线程没有上限,CPU 不会空转......

一切都很简单。

六、参考资料

  1. A brief history of select(2) — Idea of the day
  2. JS线程和UI线程是同一个线程吗? - Sebastian·S·Pan - 博客园 (cnblogs.com)
  3. GitHub - AsyncHttpClient/async-http-client: Asynchronous Http and WebSocket Client library for Java
  4. reactor-core/docs/asciidoc/reactiveProgramming.adoc at main · reactor/reactor-core · GitHub