场景

由于需要做一些 CPU 密集型的计算,为了优化性能,吾辈开始尝试使用 worker 将计算任务放到其它线程(主要还是为了避免主线程卡死)。

主要场景包括

  • 浏览器上的 WebWorker/SharedWorker:处理音频数据
  • nodejs 中的 worker_threads:解析 md/ts ast 然后处理

为什么不用 wasm?-- 主要是由于它需要从零开始编写相关的代码,而非可以直接对现有的 js 代码稍微修改便能提高性能。

浏览器

目前 vite 对此默认支持浏览器 worker,不再需要任何配置,仅使用 comlink 简化使用体验即可。

// hello.worker.ts
import { expose } from 'comlink'

export function hello(name: string) {
  return `hello ${name}`
}

expose(hello)
// main.ts
import type { hello } from './hello.worker'
import HelloWorker from './hello.worker?worker'
import { wrap } from 'comlink'
;(async () => {
  const asyncHello = wrap<typeof hello>(new HelloWorker())
  const res = await asyncHello('liuli')
  console.log(res)
})()

nodejs

nodejs 本身提供的解决方案

nodejs 仍然像浏览器一样基于文件系统使用 worker_threads,但官方确实提供了一种有趣的解决方案 -- 在同一个文件中包含主线程与 worker 线程的代码,然后在其中使用逻辑判断。

// worker.ts
import { isMainThread, parentPort, Worker } from 'worker_threads'
import { expose, wrap } from 'comlink'
import nodeEndpoint from 'comlink/dist/umd/node-adapter'
import { wait } from '@liuli-util/async'
import { worker as helloType } from './worker'

async function _hello(name: string) {
  await wait(100)
  return `hello ${name}`
}

let hello: (name: string) => Promise<string>

if (isMainThread) {
  hello = async (name: string) => {
    const worker = new Worker(__filename)
    try {
      const helloWorker = wrap<typeof helloType>(nodeEndpoint(worker))
      return await helloWorker(name)
    } finally {
      worker.unref()
    }
  }
} else {
  hello = _hello
  expose(_hello, nodeEndpoint(parentPort!))
}

export { hello }
// main.ts
import { hello } from './worker.js'
;(async () => {
  console.log(await mixingThreadHello('liuli'))
})()

看起来很简洁,但这种方案也并非尽善尽美,吾辈实际使用时发现以下问题

  • 不支持开发环境测试 ts 代码,必须编译为 js 才能运行
  • 由于基于文件系统,所以打包需要手动分块,或者不进行打包 rollup 参考 output.manualChunksopen in new window

参考:nodejs Workeropen in new window

使用打包工具处理的调研方案

  • rollup-plugin-web-worker-loader
    • 默认不会处理 ts 文件
    • worker 中包含依赖时不会打包
  • rollup-plugin-worker-factory
    • 没有看到 ts 的示例
    • 默认会修改 Worker
  • @surma/rollup-plugin-off-main-thread
    • 没有看到 ts 的示例

目前没有找到满意的插件,后续可能不得不写一个 rollup 插件。还是那句话,没有对比就没有伤害,如果没有 vite 对 ts+worker 的良好支持,或许吾辈还能忍受这种糟糕的开发体验。

尝试编写 rollup 插件 rollup-plugin-worker-threadsopen in new window,以在 nodejs 中对 worker_threads 提供类似 vite 的开发体验。

大致使用方式如下

// src/util/wrapWorkerFunc.ts
import { expose, Remote, wrap } from 'comlink'
import path from 'path'
import { isMainThread, parentPort, Worker } from 'worker_threads'
import nodeEndpoint from 'comlink/dist/umd/node-adapter'

/**
 * 包装需要放到 worker 中执行的函数
 * 1. 当检查到当前文件不是 js 文件时会直接返回函数
 * 2. 当检查到在主线程时执行时,使用 Worker 包装并执行它
 * 3. 当检查到在 Worker 线程时,使用 expose 包装它然后执行
 * 注:目前是每次都创建新的 Worker,也许可以考虑支持复用 Worker
 * @param ep
 */
export function wrapWorkerFunc<T extends (...args: any[]) => any>(
  ep: T,
): Remote<T> {
  if (path.extname(__filename) !== '.js') {
    return ep as Remote<T>
  }
  if (isMainThread) {
    return ((...args: any[]) => {
      const worker = new Worker(__filename)
      const fn = wrap<T>(nodeEndpoint(worker))
      return (fn(...args) as Promise<any>).finally(() => worker.unref())
    }) as Remote<T>
  }
  expose(ep, nodeEndpoint(parentPort!))
  return ep as Remote<T>
}
// src/hello.worker.ts
import { wait } from '@liuli-util/async'
import { wrapWorkerFunc } from './util/wrapWorkerFunc'

async function _hello(name: string) {
  await wait(100)
  return `hello ${name}`
}

export const hello = wrapWorkerFunc(_hello)
// index.ts
export * from './hello.worker'

参考