多进程打包:用 worker_threads 改写 thread-loader(2)

151 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的24天,点击查看活动详情

码字不易,感谢阅读,你的点赞让人振奋!
如文章有误请移步评论区告知,万分感谢!
未经授权不得转载!

一、前情回顾

上一篇小作文准备把由 child_process 实现的 thread-loader 变成 worker_threads 的 thread-loader,这才是名副其实的多线程(multi-threads),主要盘点了以下内容:

  1. 回顾 thread-loader 的工作原理;
  2. 简单介绍进程、线程区别和联系;
  3. 简单了解 worker_threads 模块的能力;
  4. 分析了 worker_threads 在 js 模块执行和通信方面满足 thread-loader 的要求;

从本篇开始,我们手把手的改造 thread-loader 代码,跟上步伐开始吧~

二、src/WorkerPool.js

PoolWorker 是用于创建 worker 的类,在 worker_threads 层面的改造共分以下三种情况:

  1. worker 有 child_process.spawn 创建的子进程改为 worker_threads 创建的线程;
  2. 修改 child_process 的自定义管道通信方式为子线程 postMessage 方式;
  3. 修改处理收到来自子进程的消息后获取结果回调的方式;

2.1 修改 worker 创建方式

  1. 原有的 child_process.spawn 创建子进程 worker

childProcess.spawn 方法来自 Node.js 的 child_process 模块,用于衍生子进程,其接收的参数及作用如下:

- 1. 第一个参数是要执行的命令,process.execPath 是你的机器上的 node 可执行文件路径;
- 2. 第二个参数是传递个要执行的命令的参数,workerPath 是 dist/worker.js;
- 3. 第三个参数,是配置子进程行为的,例如下面的 detached 表示脱离父进程独立运行,stdio 是配置子进程 stdin,stdout,stderr 以及自定义管道的;
class PoolWorker {
  constructor(options, onJobDone) {
   
    // Empty or invalid node args would break the child process
    const sanitizedNodeArgs = (options.nodeArgs || []).filter((opt) => !!opt);
    
    // 子进程的方式创建 worker
    this.worker = childProcess.spawn(
       process.execPath,
       [].concat(sanitizedNodeArgs).concat(workerPath, options.parallelJobs),
       {
         detached: true,
         stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']
       }
    );
  }
}
  1. 改为 worker 创建子线程 worker
    • 2.1 导入 worker_threads 模块,并结构出 Worker 构造函数;
    • 2.2 在 PoolWorker 类的构造函数中初始化 Worker 实例,该实例以为一个线程,在实例化时传入 workerPath,即 dist/worker.js 这个文件;argv 对应的是线程的进程参数,这里我们只传入了一个并发任务数,原则上也可以吧 sanitizedNodeArgs 也传入;
    • 2.3 调用 worker.unref 方法,该方法可以让线程独立运行;
import { Worker } from 'worker_threads';
class PoolWorker {
  constructor(options, onJobDone) {
    
  
    const sanitizedNodeArgs = (options.nodeArgs || []).filter((opt) => !!opt);

    // 使用 worker_threads 下的 Worker 创建 woker 线程
    this.worker = new Worker(workerPath, {
      argv: [options.parallelJobs]
    })

    this.worker.unref();

    this.readNextMessage();
  }

}

经过上面的操作,把原来的子进程变成了子线程,据传创建线程的开销比创建进程的小,有希望改善一下性能,现在大有希望 YY 一波了~~~

2.2 修改通信方式

关于通信方式估计是要费点笔墨的,为了让前面没有看过 thread-loader 源码系列的看官老爷们也能看得懂,我觉得下一篇专门写一篇这个通信方式修改的文章。说句实话,当时看 thread-loader 源码的时候最让我不理解的就是这个进程间通信,着实让我耗掉了不少头发!

2.3 获取结果回调

上面两个主题已经介绍了如何把进程改为线程、通信方式的变更,这第三部分相当于是第二个主题的进阶改造。

因为通信方式的改造,之前通信时的数据格式和协议已经发生了巨大变化。着这些通信方式中,最主要的是运行于 worker(子进程或线程)中 loader 在完成其工作后,需要把 loader 的运行结果发送会主进程。

在此之前,为了保证没有读过前面源码的看官老爷能够看懂,我先需要先简单介绍一下原发送方式,再介绍新的发送方式;

  1. 基于自定义管道的发送方式

首先是在 loader 运行的结果的回调函数中获取 loader 运行结果中的这个中依赖:fileDependency/contextDependency/missingDependency,接着对这些依赖进行加工,为啥要加工呢?

是为了适配基于自定义管道的发送方式,这些依赖中有的是字符串、有的是 buffer,直接发送会被 JSON.stringify 序列化丢失掉一些数据,因此需要把这些数据都搞成另一个 bufferToSend ,然后把这些依赖的描述信息比如长度、是否是字符串等信息作为需要序列化的信息发生给父进程。

待到父进程接收到了描述信息之后,再按照描述信息读取 bufferToSend 中的二进制数据还原成数据原本的样子。

代码如下:

    loaderRunner.runLoaders(
      {
        loaders: data.loaders,
        resource: data.resource,
        readResource: fs.readFile.bind(fs),
        context: {}, // 构造一个 loaderContext 对象,其中模拟了很多原 loaderContext 对象上的方法
      },
      (err, lrResult) => {
        // loader 结果回调
        const {
          result,
          cacheable,
          fileDependencies,
          contextDependencies,
          missingDependencies,
        } = lrResult;
        const buffersToSend = [];
        const convertedResult =
          Array.isArray(result) &&
          result.map((item) => {
            const isBuffer = Buffer.isBuffer(item);
            return {
              data: item,
            };
          });
        writeJson({
          type: 'job',
          id,
          error: err && toErrorObj(err),
          result: {
            result: convertedResult,
            cacheable,
            fileDependencies,
            contextDependencies,
            missingDependencies,
            buildDependencies,
          },
          data: buffersToSend.map((buffer) => buffer.length),
        });
        buffersToSend.forEach((buffer) => {
          writePipeWrite(buffer);
        });
        setImmediate(taskCallback);
      }
    );
  } catch (e) {
  }
}, PARALLEL_JOBS);
  1. 基于 message 事件和 postMessage 方法的新通信方式的发送

上一步已经说的很请求了,为啥自定义管道通信需要经过复杂的转换才能把结果发送过去,主要还是因为自定义管道本质上发送的 JSON.stringify 序列化出来的字符串,具体的数据如果直接序列化就会丢失,别说二进制的数据,就是正则直接序列化都会丢失😂。

而 message 事件和 postMessage 就方便多了,它除了可以发 JSON 字符串,还可以发送 Javascript 对象,不过也不是发送引用地址,而是经过了一个克隆算法,简单来说就是发送了一个对象过去。所以我们就可以废弃调用在结果回调中的转换过程:

loaderRunner.runLoaders(
  {
    loaders: data.loaders,
    resource: data.resource,
    readResource: fs.readFile.bind(fs),
    context: {},
  },
  (err, lrResult) => {
    writeJson({
      type: 'job',
      id,
      error: err && toErrorObj(err),
      result: lrResult
    });
    setImmediate(taskCallback);
  }
);

注意:你会发现我们改造的通信方式中还是使用的 JSON.stringify 序列化的,这么做是为了偷懒,虽然 postMessage 可以发送对象,但是面对一些循环引用还是不行。这么做就会导致有些返回二进制的 loader 会卡住不动了。

三、总结

从这篇小左开始,我们已经开始动手该改造 thread-loader 了,这个改造目前主要分为以下三方面:

  1. 改用 worker_threads 创建 Worker 线程的 woker;
  2. 将原有的自定义管道的通信方式改为 message 事件和 postMessage 方法的方式;
  3. 将原有的对 loader 运行结果的改造的部分代码移除,直接发送 loader 运行结果;

其中第二点在下一篇小作文用一个个专门的篇幅讲述!