概述
本文是笔者的系列博文 《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技术文档中,有专门的章节如下:
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实现机制有很好的参考作用。这个结构比较复杂,这类就不再展示了,有兴趣的读者可以参考以下链接:
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等等。这些方式都有各自不同的技术特定和应用的场景。合理使用,可以大大简化应用程序开发和集成的工作,并能够提升程序执行的性能和效率。