Bun技术评估 - 13 Worker和Child Process

162 阅读10分钟

概述

本文是笔者的系列博文 《Bun技术评估》 中的第十三篇。

在上一章节中,我们探讨了bun应用程序运行的相关问题,但都是应用程序本身运行的问题。本章节中,笔者想要来探讨一下应用程序运行子进程、协程和外部程序运行的问题。

程序执行架构

为了充分的利用现代计算机多CPU硬件架构的处理能力,现代化的高性能应用程序系统一般都不是单一简单的程序解构,而是使用一种主进程+多子进程的执行架构。

主进程作为程序的入口,来执行程序的启动;主程序启动后,可以根据具体执行的任务,甚至负载的情况,启动多个子进程(还可以是不同类型的子进程),并且将任务负载分配到不同的子进程来执行。

这样的实现,笔者理解有下面几个好处:

  • 应用程序功能的模块化,容易开发、维护和步进式的演进
  • 由于是多个逻辑进程,操作系统就可以将不同的任务负载,分配到多个硬件模块(如CPU、网络、存储)上并行执行,提高程序的总体处理能力
  • 除了并行处理相同任务,对于不同类型的任务,也可以创建和执行不同的处理进程,并且实现按需加载处理,更加充分的利用硬件,或者节省硬件资源

例如,在nodejs中,可以直接以Cluster(群集)的方式来启动程序,它可以使用同一份代码,运行在主程序(master)和子程序(slave)模式下。虽然是主从进程模式,但在操作系统中,是可以共享一下资源(如相同的网络侦听端口)的,这样非常方便程序的开发和管理。

和nodejs不同,bun好像并没有直接的cluster模式。它提供了另外一种思路,但应该可以达到类型的目的和效果。这个思路应该借鉴了浏览器中的处理方案,就是worker(工作进程)。笔者理解,这个worker和cluster的区别,就好像协程和子进程一样,相对而言更轻量级,更方便灵活。

Worker

worker的典型使用方式如下,很像浏览器中的使用方式:

// main 主进程 创建worker
const worker = new Worker("./worker.ts");

// 向worker发消息
worker.postMessage("hello");

// 从worker接收消息
worker.onmessage = event => {
  console.log(event.data);
};

// 终止worker
worker.terminate();

// worker进程 worker.ts
declare var self: Worker; // prevents TS errors

self.onmessage = (event: MessageEvent) => {
  // do something
  console.log(event.data);
  postMessage("world");
};

// 检查是否主进程
if (Bun.isMainThread) {
  console.log("I'm the main thread");
} else {
  console.log("I'm in a worker");
}

// 关闭worker
process.exit();

一般情况下,worker常用作一些临时性的,但比较耗时的操作,就可以考虑创建一个worker来完成这个工作,然后在工作完成后,就可以关闭这个worker,回收资源,这样应用程序的执行,就可以更加高效稳定。

在bun中,还提供了很多worker相关的管理和配置功能,比如unref(暂停)和ref(恢复),设置smol(内存和执行优化)模式等等。笔者觉得,如果使用得当,worker应该可以达成比child process更好的效果,而是应用的模式和浏览器环境更加接近,更加容易保证前后端开发的一致性。

worker在bun技术文档中,有专门的章节如下:

bun.sh/docs/api/wo…

Child Process

其实,Bun也有一个"Child Process"的特性模块。笔者理解,它本质上就是一种外部应用程序的调用执行的封装机制。就是在主进程中,通过命令行调用的方式,执行另外一个程序,来达到业务操作的实现。这个程序,和主进程,就是子进程的关系,即Child Process。

基本形式

我们来看一个示例代码:

const proc = Bun.spawn(["ls", "-l"], {
  cwd: import.meta.dir, // specify a working directory
  env: { ...process.env, FOO: "bar" }, // specify environment variables
  onExit(proc, exitCode, signalCode, error) {
    // exit handler
    console.log("Exit:", proc.pid, exitCode);
  },
});

console.log("Process:", proc.pid);

const text = await new Response(proc.stdout).text();
console.log("Result:", text);

// 执行和结果
localhost% bun w2.ts
Process: 1910280
Result: total 484
-rw-rw-r-- 1 yanjh yanjh 450256 Nov 12  2024 hyperfine_1.19.0_arm64.deb
-rw-rw-r-- 1 yanjh yanjh   1059 Jun 24 14:30 l2.ts
-rw-rw-r-- 1 yanjh yanjh    204 Jun 24 17:00 r1.ts
...

Exit: 1910280 0

上面的代码,展示了一个相对完整的外部命令执行的模式和过程。

  • 调用Bun.spawn(包裹)来执行一个应用程序指令
  • 指令内容和参数,使用一个数组来表示
  • spawn时,可以提供一些选项,包括设置工作目录,提供环境变量等等
  • 可以通过process的stdout/stderr处理运行过程中输出的信息
  • 使用onExit来捕获外部进程退出的事件和状态

输入处理

在spawn的时候,是可以设置和选择向子进程输入信息的方式的,这个过程可以是动态的,并不是简单的调用时的参数。有点类似于一个可交互操作的程序,下面是一个简单的示例代码,帮助我们理解这个问题:


const proc = Bun.spawn(["cat"], {
  stdin: "pipe", // return a FileSink for writing
});

// enqueue string data
proc.stdin.write("hello");

// enqueue binary data
const enc = new TextEncoder();
proc.stdin.write(enc.encode(" world!"));

// send buffered data
proc.stdin.flush();

// close the input stream
proc.stdin.end();

这个标准输入接口,支持多种形式,包括:

  • null: 默认,无输入
  • pipe: 流水线,可以支持增量写入
  • inherit: 继承主进程的输入
  • Bun.file: Bun文件
  • TypeArray/DtaView: 二进制数据
  • Response: 响应对象
  • Request: 请求对象
  • ReadableStream: 可读流
  • BLOB: 二进制文件
  • number: 文件描述符

输出

前面已经看到了输出处理的一般方式,但它也有其他多种形式:

  • pipe: 标准输出,增量文本,对于stdout是默认的
  • inherit: 继承,就是主进程输出,对于stderr是默认的
  • ignore: 忽略
  • Bun.file: 输出到文件
  • number: 通过描述符,输出到文件

退出和处理

可以使用同步或者promise方法,来处理子进程的退出:

// onExit方法:
const proc = Bun.spawn(["bun", "--version"], {
  onExit(proc, exitCode, signalCode, error) {
    // exit handler
  },
});

// 等待执行完成,检测状态
await proc.exited; // resolves when process exit
proc.killed; // boolean — was the process killed?
proc.exitCode; // null | number
proc.signalCode; // null | "SIGABRT" | "SIGALRM" | ...

// 主动退出,状态检测
proc.kill();
proc.killed; // true

// 退出代码
proc.kill(15); // specify a signal code
proc.kill("SIGTERM"); // specify a signal name

其实,Bun推荐使用AbortController来控制子进程的中断,而不是简单粗暴的kill:


const controller = new AbortController();
const { signal } = controller;

const proc = Bun.spawn({
  cmd: ["sleep", "100"],
  signal,
});

// Later, to abort the process:
controller.abort();

超时控制

bun spawn执行子进程,可以直接使用超时控制机制,无需开发者自己实现:

// Kill the process with SIGKILL after 5 seconds
const proc = Bun.spawn({
  cmd: ["sleep", "10"],
  timeout: 5000,
  killSignal: "SIGKILL", // Can be string name or signal number
});

资源使用

可以使用proc相关的属性,来检测和评估子进程对于计算资源的使用。

const proc = Bun.spawn(["bun", "hello.ts"]);
await proc.exited;

const usage = proc.resourceUsage();
console.log(`Max memory used: ${usage.maxRSS} bytes`);
console.log(`CPU time (user): ${usage.cpuTime.user} µs`);
console.log(`CPU time (system): ${usage.cpuTime.system} µs`);

// 执行...
yanjh@tridebian:~$ bun r5.ts
Max memory used: 32072 bytes
CPU time (user): 15011 µs
CPU time (system): 11258 µs

从这个结果应该可以感觉到,bun的执行效率是非常高的。从冷状态加载执行一个ts文件,所需要的时间大约是几十毫秒这个量级。内存占用更是尽量精简到只有几十K(最简单和基础的功能)。

还可以使用maxBuffer控制子进程输出的内容(竟然真的有如此无聊的命令?):

// KIll 'yes' after it emits over 100 bytes of output
const result = Bun.spawnSync({
  cmd: ["yes"], // or ["bun", "exec", "yes"] on windows
  maxBuffer: 100,
});
// process exits

IPC

所有的子进程包裹和调用过程,一般都需要考虑IPC,即进程间通信的问题,来更好的集成主程序和外部进程。Bun也提供了一种非常简单直观的IPC应用模型。下面是一个示例代码:

// ===============主进程
const childProc = Bun.spawn(["bun", "child.ts"], {
  ipc(message, childProc) {
    /**
     * The message received from the sub process
     **/
    childProc.send("Respond to child, OK");
  },
});

childProc.send("I am your father"); 

// =============子进程 child.ts

// 从主进程接收信息
process.on("message", (message) => {
  // print message from parent
  console.log(message);
});

// 发送信息到主进程
process.send("Hello from child as string");
process.send({ message: "Hello from child as object" });

可以看到,虽然模式和worker很像,但具体实现还是不同的。在主进程中,接收信息是通过注入ipc方法来实现的,发送信息是子进程实例的send方法;而在子进程中,发送信息使用process.send方法,接收信息是process.on("message"), message={ })设置。

Bun的技术文档,还探讨了bun调用node应用程序执行,和消息序列化等方面的内容,这里笔者只是想说明IPC的基础概念和形式,就不再展开讨论了。

此外,Bun技术文档中,提供的示例,都是基于bun或者node应用程序的,但虽然没有其他相关的信息,但笔者猜想,在其他类型的应用程序中,应当有类似的IPC实现方式。最低程度,传输的信息,都应该是可以支持二进制这一基本形式,来达成不同应用程序之间的集成。

阻塞API

bun中,spawn其实是可以以同步方式执行(通过spawnSync方法)的:

const proc = Bun.spawnSync(["echo", "hello"]);

console.log(proc.stdout.toString());
// => "hello\n"

显然,这种方式,有一些限制:

  • stdout和stderr,就是简单的buffer,而非readableStream了
  • 不能支持stdin
  • 通过一个success属性,来在完成后,标识完成退出状态

Bun给出的建议,是在如HTTP Server等服务型应用中,使用spawn;而在一些CLI应用中,可以考虑使用spawnSync。

性能

不出意外,bun spawn也可以提供比Nodejs更好的性能,按照它的说法,bun的spawn的底层实现,是posix_spawn机制。但其实两者差别也不是很大:


bun spawn.mjs

cpu: Apple M1 Max
runtime: bun 1.x (arm64-darwin)

benchmark              time (avg)             (min … max)       p75       p99      p995
--------------------------------------------------------- -----------------------------
spawnSync echo hi  888.14 µs/iter    (821.83 µs … 1.2 ms) 905.92 µs      1 ms   1.03 ms

node spawn.node.mjs

cpu: Apple M1 Max
runtime: node v18.9.1 (arm64-darwin)

benchmark              time (avg)             (min … max)       p75       p99      p995
--------------------------------------------------------- -----------------------------
spawnSync echo hi    1.47 ms/iter     (1.14 ms … 2.64 ms)   1.57 ms   2.37 ms   2.52 ms

类和接口设计

这部分也是对我们理解bun的spawn实现机制有很好的参考作用。这个结构比较复杂,这类就不再展示了,有兴趣的读者可以参考以下链接:

bun.sh/docs/api/sp…

node:cluster

虽然Bun原生并没有提供cluster的实现,但实际上,在比较新的版本中,bun通过node:cluster模块,提供了类似于nodejs应用的cluster实现,而且使用方式和机制,几乎完全等同于nodejs。所以这类就不再展开说明,下面是一个简单的示例:

// bun 程序内容

import cluster from "node:cluster";
import { cpus } from "node:os";

if (cluster.isPrimary) {
  // 主进程:按 CPU 核心数创建子进程
  console.log(`主进程 ${process.pid} 启动`);
  for (let i = 0; i < cpus().length; i++) {
    cluster.fork();
  }
  // 监控子进程退出事件
  cluster.on("exit", (worker) => {
    console.log(`工作进程 ${worker.process.pid} 退出,重启中...`);
    cluster.fork();
  });
} else {
  // 子进程:启动 HTTP 服务器
  Bun.serve({
    port: 3000,
    fetch(request) {
      return new Response(`响应自工作进程 ${process.pid}`);
    },
  });
  console.log(`工作进程 ${process.pid} 已启动`);
}


// 启动bun程序
localhost% bun w.ts
主进程 1854123 启动
工作进程 1854131 已启动
工作进程 1854136 已启动
工作进程 1854141 已启动
工作进程 1854135 已启动
工作进程 1854133 已启动
工作进程 1854137 已启动

node:cluster最常用的一个场景,就是启动多个相同端口侦听的HTTP服务,来实现多实例负载均衡和平滑重启。在底层实现机制上,利用了linux系统的SO_REUSEPORT端口复用机制。其他的平台,要注意是否有类似的实现。

笔者认为,可能一个比较理想的情况,就是使用node:cluster启动一个多子进程的主应用程序作为常规使用,但通过worker来处理一些临时的比较耗时的任务,这样可以综合利用两者的优势,达到一个比较高效的状态。

小结

本文探讨了Bun中创建子进程和调用其他程序的几种实现方式,包括worker,child process和兼容于node:cluster等等。这些方式都有各自不同的技术特定和应用的场景。合理使用,可以大大简化应用程序开发和集成的工作,并能够提升程序执行的性能和效率。