核心思路
对于低优先级计算(CPU)密集型任务运行优化主要有两种的思路(推荐优先使用第一种):
- 使用单独的线程(进程)去运行,这样就可以利用操作系统的调度
- 将任务切片,手动去模拟实现类似操作系统的任务调度功能(简单一点就轮转;复杂一点就按照优先级进行调度,实现任务循环)
首先最原始的方式是先执行任务 1
,再执行任务 2
,再执行任务 3
。。。,以此类推;按照这样的方式也能拉满 CPU,理论上是没什么问题。
但是实际中我们一般遇到的场景是:任务 1
可能既不重要又耗时,比如像定时任务;任务 2
比较重要,比如用户的请求。这种情况下就会造成用户请求被阻塞比较长时间,这样的结果显然是不合适的,所以这就需要我们进行优化了。
推荐优先使用第一种方式,比如前端使用 worker 线程,后端开独立的线程去处理耗时任务。但也不是线程开的越多越好,还需要结合我们 CPU 的核数来决定,因为过多的线程会导致线程频繁切换造成开销。但是一般情况下还是两者结合着使用。如果还是不能解决问题,那么说明是我们机器的性能确实不够了,需要去升级机器了。
值得一提的是:我们的思路本质上是在想办法增加我们任务之间的调度 ,无论开线程利用操作系统的调度,还是任务切片,手动模拟调度,都是为了增加任务之间的调度,从而达到低优先级的耗时任务不会阻塞其他任务的目的。 那么如果耗时任务本身的在系统中优先级就比较高,那么需要采取其他的优化手段,比如数据缓存、优化数据处理流程、关联查询等等。
上面就是本文的核心内容啦,后面的内容就是一些测试以及一些对 Egg 源码的分析,有兴趣的朋友可以继续看一看。我目前从事的是全栈工作,对于后端,写业务,写 CRUD 还行,哈哈哈,但是对于性能优化,基础建设,系统运行原理(内存管理、多线程、事件循环)等方面还在学习探索中。文中的内容可能会有一些错误,欢迎各位朋友和各位大佬在评论区讨论和指正。
操作系统的调度测试
编写了一个 CPU 密集型任务,以下是对多进程与多线程下运行情况的测试:
-
多进程测试:
每一个进程中开单个线程去运行任务,发现单个进程占用的 CPU 资源差不多,都是是 9.9%。
一开始我会有这样的疑问,为什么 CPU 占用不是百分之百呢?因为我的任务里面写的是类似于死循环这样的代码。
其实这是操作系统调度的结果,操作系统不会让我们单个线程(进程)占用我们全部的系统资源,导致其他线程(进程)饥饿。
-
多线程测试:
这个是在一个进程里面开多个线程进行的测试,发现占 CPU 资源与多进程的情况差不多。所以这也可以侧面验证线程是操作系统进行运算调度的最小单元。
Egg 测试分析
场景搭建
先编写一个这样的测试用例:
js
// app/controller/home.js
const Controller = require('egg').Controller
class HomeController extends Controller {
async index() {
this.ctx.body = 'Hello world'
}
test() {
let i = 0
while (i < 1e1000) {
i++
}
return i
}
}
module.exports = HomeController
js
// app/router.js
module.exports = (app) => {
const { router, controller } = app
router.get('/', controller.home.index)
router.get('/test', controller.home.test)
}
再配置一下,egg 的 worker 线程数量为 1。
json
"dev": "npx egg-bin dev --workers=1"
先访问一下 http://127.0.0.1:7001
发现获得 hello world
,说明我们的链路没有问题。
接下来访问一下 http://127.0.0.1:7001/test
,把我们的耗时任务跑起来。
发现 CPU 暂用并不高,也不会一直飙升,而是会维持在一个 0.5%左右,这也验证了我们前面说的,这是操作系统对线程调度的结果。
接下来我们继续去访问 http://127.0.0.1:7001
,发现完全卡住了。这样的结果也符合我们的预期,因为我们只开了一个线程去处理我们的请求,所以这个线程被密集型计算任务占用时,其他请求都会被卡住。
简单优化一(任务切片,进行轮转):
既然我们已经知道了是我们的密集型计算任务长时间占用了我们的线程造成的,那接下来就来改造一下我们的任务。
js
// app/controller/home.js
const Controller = require('egg').Controller
const sleep = (n) => new Promise((r) => setTimeout(r, n * 1000))
class HomeController extends Controller {
async index() {
this.ctx.body = 'Hello world'
}
async test() {
let i = 0
let start = Date.now()
while (i < 1e1000) {
i++
const now = Date.now()
if (now - start >= 1000) {
start = now
await sleep(0.2) // 这里也可以改成 await sleep(0)
}
}
return i
}
}
module.exports = HomeController
这样我们的计算任务就是跑 1s 休息 0.2s。其实休息 0s,效果也是有的。只要我们的其他请求在任务进行的那 1s 里面进来,就可以优先进入到事件循环队列,这样得到在下一次计算任务之前运行的机会。这里使用 0.2s 只是为了增加获得机会的概率,大家可以测试一下。这样我们请求就不会感觉到明显的卡顿了。
简单优化二(开多线程或进程):
将我们的配置更改 worker 线程数量为 2。
json
"dev": "npx egg-bin dev --workers=2"
同样的先访问一下 http://127.0.0.1:7001/test
,把我们的耗时任务跑起来。接下来再访问一下 http://127.0.0.1:7001
,发现非常流畅。这样的结果也比较符合我们的预期,因为只有一个线程在跑我们的密集型计算任务,还有一个空闲的线程来处理我们的其他请求。当然如果我们再请求一下 http://127.0.0.1:7001/test
,就会由于线程占满导致其他请求又开始卡顿,大家可以自己尝试一下。
其实这里面还有一个问题,就是如果我们多次访问 http://127.0.0.1:7001
会怎样?其实按照我最简单的推测,应该是有 1/2 的概率是正常的,1/2 的概率是卡顿。而实际上却不是这样的,而是卡顿的概率非常小,这一点大家可以多测试一下。所以我不由得怀疑 egg 可能实现了类似于故障检测的机制,这不由得让我想去看看它的这一套调度逻辑的具体实现。
扩展(Egg worker 调度逻辑源码分析)
首先我们先找到源代码相关的位置,egg 把 master、worker、agent 线程(进程)相关的代码放到了 egg-cluster 这个包中。首先他们三个的关系是这样的,先启动 master 进程。master 启动后,master 进程会先去启动 agent 进程,然后再去启动 worker 进程。
默认情况下是由底层 node 的 cluster 模块来实现请求的负载均衡。以下是测试代码:
js
// main.js
const cluster = require('cluster')
if (cluster.isMaster) {
for (let i = 0; i < 2; i++) {
cluster.fork() //启动子进程
}
} else {
require('./app')
}
js
// app.js
const express = require('express')
const app = express()
app.listen(8080, () => {
console.log('express server running。。。')
})
app.get('/', (req, res) => {
res.send('hello world!')
})
在这个例子中,我们在 main.js 中启动了两个子进程,每个子进程都启动了 app.js 中的 express 服务。而至于请求进来,分给哪个子进程去处理处理,这一层是由 node 底层去支持的。有兴趣的朋友可以看看这篇文章:《通过源码解析 Node.js 中 cluster 模块的主要功能实现》
在 egg 中,如果配置了 sticky,那么会才会走startMasterSocketServer
里面的逻辑,走自己的 worker 调度逻辑。
由 master 进程进行网络请求的监听,当 master 进程监听到网络连接(connection)后,会通过一定的算法找到一个 worker,并把连接句柄传递给这个 worker。worker 拿到这个 connection 句柄,就可以拿到请求的参数和进行相应的响应了。
具体源代码如下:
接下来我们来重点看一下 stickyWorker
这个方法,这个方法就是我们关心的:egg 究竟是如何选择 worker 进行请求处理的。
大概看了一下,其实他的方法很简单,就是根据 worker 数量和 ip 地址组合找到一个随机的 worker。所以只要我们的 ip 和 worker 数量不变,那么我们的请求就会一直被分配到同一个 worker 上面去。
进一步优化(把耗时任务运行线程独立出去):
使用 schedule
之前为了方便测试,我们将耗时任务写在了请求处理函数中,现在我们将它独立出去。
首先我先想到的是 egg 为我们提供的 schedule 定时任务这个工具,这就很符合我们的场景,定时任务。仔细看了一下文档才发现被骗了,egg 居然把定时任务搞到 worker 上去运行了,我一开始还以为是开一个单独的线程出来运行的。
大家可以试一下这段代码:
js
// app/schedule/work.js
const Subscription = require('egg').Subscription
class Work extends Subscription {
static get schedule() {
return {
interval: '1s',
type: 'all',
}
}
async subscribe() {
let i = 0
while (i < 1e1000) {
i++
}
}
}
module.exports = Work
1s 之后我们再发送的请求就被卡死了。
配置 app.js
既然 schedule 不太适合,那么我接下来的想法是在程序启动的时候,自己手动去开一个线程。而 app.js 可以帮助我们拿到程序启动的时机。
先配一个简单的测试一下:
js
// app.js
class AppBootHook {
constructor(app) {
this.app = app
console.log('App 初始化')
}
async didReady() {
console.log('应用已经启动完毕')
}
}
module.exports = AppBootHook
发现链路是通了,不过好像也有一点问题。
发现它打印了两次,当我更改启动时的 worker 数量时,打印的数量也对应的跟着变化,所以 app.js 也是与 worker 挂钩的,有多少个 worker 就初始化多少个 app.js 的 class。
app.js 实际上是在配置我们的 worker 线程,所以这貌似也不太符合我们的要求的。
配置 agent.js
通过继续翻阅 egg 的官方文档,发现 egg 还给我们提供给了一个好东西,那就是 agent 线程。这个线程类似于 master 线程,只创建一个且不处理请求的独立线程。
先简单配置测一下:
js
// agent.js
module.exports = (agent) => {
console.log('Agent initialized')
}
运行发现没什么问题,打印次数也只有一次。在这个线程中去创建运行我们耗时任务的线程,或者使用该线程作为我们的独立线程来跑耗时任务都是比较合适的。
js
const work = () => {
let i = 0
while (i < 1e1000) {
i++
}
}
// agent.js
module.exports = (agent) => {
console.log('Agent initialized')
setTimeout(work, 5000)
}
我简单测试了一下,结果符合预期,大家可以自己测试一下。值得一提的是:这里需要加一些延时,因为 agent 线程初始化完成之后,才会初始化 worker 线程。
扩展(实现高优先级计算(CPU)密集型任务的运行优化)
根据 egg 的设计,agent 线程还可以跟 worker 线程进行通信。我们可以利用这一点在 worker 线程中把耗时任务丢给 agent 线程去跑,然后再异步等待 agent 的返回结果,这样就可以实现高优先级计算(CPU)密集型任务的运行优化。
扩展(多线程模型与多进程模型的区别)
其实选用哪种模型,个人感觉,主要看我们的业务场景。如果是与一块业务强相关的,就比较建议使用线程,而且线程具有切换开销小、通信方便等优点。
而对于之前我遇到的那个业务场景就比较适合使用多进程,那个业务场景是需要开发一个自动化设备控制系统,这系统需要对上(中央数据处理系统)获取控制数据以及回传设备数据,对下需要实现对设备的控制逻辑以及设备数据采集逻辑,同时还需要支持单机运行。所以我当时采用的是多进程单线程模型,每一个进程其实也是一个工程,一方面方便项目管理,另一方面也提高系统的稳定性和可维护性。
补充
文章中用到了CPU密集型任务、耗时任务、密集型计算任务等这几个词语,其实语义都是一样的,都是指代需要运行较长时间的才能得到结果的任务。
文章对于线程和进程的概念并没有严格区分,关于 egg 中具体的细节到底是使用线程还是进程没有深究了,但是这并不影响我们的所推导的结果。