火力全开,100%CPU利用率,看懂IO模型、回调、RX响应式编程

CPU 是服务器的核心资源,如何让它火力全开,用"百分百的热情"服务用户,是对开发者不小的考验,更是在高并发下不得不面对的挑战(不然系统就崩溃了😂 )。

坚持看完本文,我将从 CPU 执行流程开始,逐步探索 I/O 模型、回调、响应式编程、协程对高并发的意义。

一、CPU 执行流程

程序的运行离不开操作系统,操作系统调度程序给 CPU 执行。CPU 负责运算,CPU 很快,一般来说,它能很快给出结果。

CPU 不停地算啊算,整个流程运转完美。但突然,他发现数据没了🤔 。这些数据要么在硬盘,要么在网络上的其他设备。CPU 只得停下来休息,并向总线(Bus)发出命令:"把数据给我取来!",这就是 I/O(Input/Output) 等待。

CPU 现在就没事做了,能不能找点"算数"工作给它,让它能把实力发挥出来。

二、让 CPU 忙一点(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)

    检查其他设备是不是把数据送来了,有数据了才能拉数据。

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

阻塞 I/O 和朴素非阻塞 I/O 都完整地做了这两步,又是查状态又是拉数据,效率低。I/O 复用和事件驱动 I/O 则引入了"第三者"来管理 I/O 状态,I/O 复用的帮手能一次性管理大量的 I/O 请求,但却不必事件 I/O 更好。事件 I/O 有"绝招",不需要程序自己去查状态 OK 了吗,I/O 好了能直接通知程序来拉数据。异步 I/O 则是真正的"扫地僧",I/O 的任务自己全做完,数据"喂到嘴里"。

回到主题。CPU 这么快,要让它发挥全部实力,I/O 这样的慢操作就不能等(要非阻塞 I/O),而是 I/O 完成了自动"回去调用"(Callback)后续的业务逻辑。

三、回调与 I/O

现在有一个需求,调用 Bing 接口搜索关键字并把结果渲染出来,用 JavaScript 实现:

ini 复制代码
let searchQuery = "LiZi";
let endpoint = "bingSearch?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();
......
// 做其他事情

为了避免并发问题(如死锁) 导致浏览器界面卡顿,浏览器在 UI 渲染时只有一个线程。但 I/O 是低速的,等待 I/O 就会让浏览器界面卡住。为了解决这个问题,JavaScript 提供了非阻塞 XHR 调用:调用前注册回调函数,不等待请求完成先响应其他界面渲染,当 I/O 成功后会再把数据传送给回调(Callback)来渲染到界面上。

在底层实现上,XHR 监听 I/O 状态借助了 I/O 复用(非阻塞 I/O)来实现,但整个过程隐藏在了简洁的 XHR onload API 之下。Java 也有基于非阻塞 I/O 框架 Netty 构建的类 XHR 功能库:AsyncHttpClient,下面的代码也实现了同样的关键字搜索功能:

java 复制代码
String searchQuery = "LiZi";
AsyncHttpClient asyncHttpClient = Dsl.asyncHttpClient();
asyncHttpClient.prepareGet("bingSearch?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 条信息流内容。如果用户存在关注列表,就获取关注内容的详情。如果不存在,就自动给用户推荐。用伪代码实现:

rust 复制代码
// 获取关注列表
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),一切就清晰明了:

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

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

五、百分百资源利用率

回到主题,做到 100% 资源利用率具体需要什么?"木桶效应"说得好,性能的上限取决于性能的短板。我们分情况来看:

对于计算密集型应用,CPU 统筹计算资源。短板就在 CPU,它的利用率长期在 100%。不换硬件,就没有优化的空间。

对于 I/O 密集型应用 ,程序和外部环境有太多的关联沟通,对 I/O 的依赖很高。但 I/O 相比 CPU 慢太多了,通常是 I/O 满载,CPU 却只用了七七八八。I/O 是瓶颈,但在分布式环境下,主机间沟通存在天生的不确定性,I/O 速度是肯定赶不上 CPU。

仔细分析 I/O 密集应用的执行流程。应用进程以线程作为代码逻辑执行的基本单元,但为了避免维护线程本身消耗过大,线程的数量会有固定的上限 。这就是导致在阻塞 I/O 下,如果 I/O 过多(高并发),大部分线程就会处于 I/O 等待状态。没有线程,CPU 也没法工作。虽然利用率不高,但却无法处理新请求

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

根据闲鱼的实践(详情见资料5),在高并发环境下,异步化能在响应时间降低 50% 的同时,使系统的吞吐量提升约 30%,而且 CPU 在 100% 满载前还能一直处理请求。作为对比,同步 I/O 在线程资源耗尽后,CPU 利用率只达到约 75%,程序也出现不可用的情况。

在高并发环境下,使用同步 I/O 的程序,线程资源很快就会耗尽。再多的请求就会诱导 CPU "停转",使程序不能处理新请求,程序崩溃。有没有例外呢?能不能假设这样一种可能,线程可以没有上限一直新建,并且不会带来过大的线程维护成本。

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

协程正在走向我们,把我们从高难度的异步响应式编程拉出来。Go 语言有 Goroutine,JDK 21 也正式发布了虚拟线程。在不久的将来,高并发编程不再依赖这些"套娃"理念,编程又能回归到淳朴时代。

六、参考资料

  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
  5. RxJava在闲鱼系统吞吐量提升上的实践 - 掘金 (juejin.cn)
相关推荐
Jiaberrr3 小时前
前端实战:使用JS和Canvas实现运算图形验证码(uniapp、微信小程序同样可用)
前端·javascript·vue.js·微信小程序·uni-app
everyStudy3 小时前
JS中判断字符串中是否包含指定字符
开发语言·前端·javascript
城南云小白3 小时前
web基础+http协议+httpd详细配置
前端·网络协议·http
前端小趴菜、3 小时前
Web Worker 简单使用
前端
web_learning_3213 小时前
信息收集常用指令
前端·搜索引擎
tabzzz3 小时前
Webpack 概念速通:从入门到掌握构建工具的精髓
前端·webpack
凡人的AI工具箱4 小时前
AI教你学Python 第11天 : 局部变量与全局变量
开发语言·人工智能·后端·python
200不是二百4 小时前
Vuex详解
前端·javascript·vue.js
滔滔不绝tao4 小时前
自动化测试常用函数
前端·css·html5
是店小二呀4 小时前
【C++】C++ STL探索:Priority Queue与仿函数的深入解析
开发语言·c++·后端