Node工具库性能提升
2022-05-18
TL;DR
本文记录了最近将 Node 工具 coderfly 性能提升 55.8% 的过程。主要优化手段为:
- Node.js 多线程:
work_threads
- 增加缓存
- 合理的编写异步代码
优化前性能情况
可以在这篇博文了解 coderfly
项目含有 3279 个有效文件,所有变更文件实际变更的函数为 74 个,性能情况如下:
time node coderfly_performance.js
# 搜索配置文件:0.002
# 获取函数变更耗时:0.641
# 文件过滤耗时:0.395
# 一共需要处理 3279 个文件
# 构建文件树耗时:37.692
# 需要搜索的目标函数个数:74 个
# 搜索耗时:0.581
# 总耗时:39.314
可以看到耗时集中于「函数树构建」。
为什么慢
函数树的构建主要对每一个文件做如下几个工作:
- 将文件解析为 AST,遍历 AST 提取所有函数和函数内部的被调用函数信息
- 解析 Vue 文件的 Template AST,将节点中疑似使用的函数提取出来
- 在遍历文件 AST 时同时会获取模块(依赖)导入信息、Mixin 信息
- 处理完上述内容后,会对每个文件再进行一次处理,将所有函数的来源路径都确定
将这些工作的总耗时统计出来,平均下来每个耗时大概是 0.007 秒,但积少成多。以 3000 个文件计算达到了 0.007 * 3000 = 21
秒。
这部分的代码很简单,就是 for 循环所有文件。
let tree: FileInfoTree = {};
for (const file of files) {
const fileInfo = getFileInfo(file, options);
tree[file] = fileInfo;
}
也就是说这个时间复杂度是 O(files count)
。
问题原因知道了,想法随之而来:由一个一个处理,改成同时处理多个文件。
多线程方式生成文件树
Node.js 中,由 Libuv 提供了事件轮询机制,因此在「io 密集型」处理上,不用太担心性能,但是在「CPU 密集型」问题上,由于 JavaScript 是单线程,因此这种耗时操作会阻塞主线程,导致整体耗时增加,同时也无法有效的利用执行环境的多核优势。对于这种「CPU 密集型」问题,在 Node.js 中有两种方案,一种是使用 children_process
或者 cluster
开启多进程进行计算;另一种是使用 worker_thread
开启多线程进行计算。这里选择更轻量级的 worker_threads
。
重构
使用 worker_threads
重构这段循环代码:
// parent worker
// run_worker.ts
import { cpus } from 'os';
import path from 'path';
function getFileInfoWorker (files: string[], options?: GetTreeOptions): Promise<FileInfoTree> {
return new Promise((resolve, reject) => {
// 根据 cpu 数目将文件进行均分
const threadCount = cpus().length;
const everyWorkerFileCount = Math.ceil(files.length / threadCount);
// 线程池
const threads: Set<Worker> = new Set();
for (let i = 0; i < threadCount; i++) {
threads.add(new Worker(path.resolve(__dirname, './get_file_info_thread.js'), {
workerData: {
files: files.splice(0, everyWorkerFileCount),
options,
}
}));
}
for (const worker of threads) {
worker.on('error', (err) => {
reject(new Error(`worker stopped with ${err}`));
});
worker.on('exit', (code) => {
threads.delete(worker);
if (code !== 0) {
reject(new Error(`stopped with ${code} exit code`))
}
if (threads.size === 0) {
resolve(tree);
}
})
worker.on('message', (msg) => {
Object.assign(tree, msg);
});
}
});
}
每一个 child worker
执行任务并通过 message channel 将结果发给 parent worker
:
// get_file_info_thread.ts
import fs from "fs";
import { parentPort, workerData } from "worker_threads";
import { FileInfoTree, GetFileInfoWorkerData } from "../type";
import { getFileInfo } from "../utils/handle_file_utils.js";
parentPort?.postMessage(_getFilesInfo(workerData));
function _getFilesInfo (workerData: GetFileInfoWorkerData) {
const currentTree: FileInfoTree = {};
for (const file of workerData.files) {
const fileInfo = getFileInfo(file, workerData.options);
currentTree[file] = fileInfo;
}
return currentTree;
}
效果
使用 worker_threads
重构后,「构建文件树」的耗时由 37.692 秒降低到了 12.253 秒,提升 67.5%。可以预见的是,当文件数目更大时,这个效果会更突出。
增加缓存
在第一次执行扫描后,第二次扫描可能 99% 的文件都是没有变化的,不应该再重新去构建它们的文件树,因此需要增加「缓存」来记录状态。
做法很简单,只需要在第一次扫描时,本地生成一份以 key-value
形式记录 文件路径-最后一次编辑时间
的 json 文件, 第二次扫描时用「最后一次编辑时间」作为判断依据即可。如果没有更改,则使用旧数据;已更改方才需要重新构建。这个工作放在 child worker
中即可。
增加缓存后时间从 12.253 秒降低到了 3.24 秒。
合理的编写异步代码
对于 JavaScript 开发者来说,async await 的出现让异步代码变得更加的清晰,可以写出很多好看的代码。
async function test (items) {
for (const item of items) {
const userInfo = await getUserInfo(item);
const someOtherInfo = await getOtherInfo(item);
// do something
}
}
但是在实际开发中,我们常常忽略了这种写法同步阻塞的性能问题。这时候应该想起 Promise 本身提供的「并发」能力。
async function test (items) {
const allPromiseRes = await Promise.all(items.map(item => {
return mergeLogic(item);
}));
}
async function mergeLogic (item) {
const userInfo = await getUserInfo(item);
const someOtherInfo = await getOtherInfo(item);
// do something
}
在一个 for await
写法的计算密集型操作中,15 个输入计算耗时 7.791 秒,改写为 Promise.all
后时间降低到 2.03 秒,效果还是很好的。
后话
在大多数普通业务开发中,大多数逻辑执行耗时并不长,优雅的写法和简单的逻辑结构先行,并不需要非常关注性能问题。我们应该在日常开发和学习中有意识的去了解和对比不同写法、不同 API 的性能差异和不良实践;去关注常用的性能优化手段,这可以更有效的帮助我们写出高性能的代码和排查性能问题。