CocaColf

庭前桃李满,院外小径芳

Node工具库性能提升

2022-05-18


TL;DR

  本文记录了最近将 Node 工具 coderfly 性能提升 55.8% 的过程。主要优化手段为:

优化前性能情况

可以在这篇博文了解 coderfly

  项目含有 3279 个有效文件,所有变更文件实际变更的函数为 74 个,性能情况如下:

time node coderfly_performance.js

# 搜索配置文件:0.002
# 获取函数变更耗时:0.641
# 文件过滤耗时:0.395
# 一共需要处理 3279 个文件
# 构建文件树耗时:37.692
# 需要搜索的目标函数个数:74 个
# 搜索耗时:0.581

# 总耗时:39.314

可以看到耗时集中于「函数树构建」。

为什么慢

  函数树的构建主要对每一个文件做如下几个工作:

  将这些工作的总耗时统计出来,平均下来每个耗时大概是 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 的性能差异和不良实践;去关注常用的性能优化手段,这可以更有效的帮助我们写出高性能的代码和排查性能问题。

Comments: