后台工作、进度、取消与任务生命周期
通过线程和 utility process 两类实验理解主线程阻塞、任务关联、协作取消与终态保护。
本页内容
一个函数写成 async,为什么窗口还是卡住#
你熟悉浏览器中的长任务:同步循环占住 JavaScript 线程,点击和动画无法及时处理。Electron main 也有事件循环,但它同时负责窗口、IPC、菜单与应用生命周期。把大文件解析、海量 JSON 转换或同步计算放在 main,会让所有窗口感觉失去响应。函数前面加 async 只改变返回形式,不会把同步部分自动搬到另一条线程。
本章目标是能把一个有进度、可取消的 CPU 任务移出主线程,并描述从开始到结束每个状态。前置是进程模型、Promise 和消息通信。我们会实际运行 Node worker_threads 实验;utilityProcess 示例需要 Electron 环境,提供完整文件与操作,但不虚构已经在桌面启动。
先判断任务在等什么。如果主要等待文件或网络,优先使用已有异步 API 和合理并发,不必为每次 readFile 建线程;如果主要执行 JavaScript CPU 计算,worker_threads 可以提供独立 JavaScript 执行环境;如果需要更强的进程故障隔离、独立服务生命周期或特殊原生模块,utilityProcess 更值得考虑。名字带 background 不代表自动安全、自动并行或自动恢复。
线程、进程与任务是三个不同层次#
worker_threads 的 Worker 在同一操作系统进程中拥有自己的 JavaScript 环境,可以使用 Node 能力。它不是 DOM Web Worker,也不会默认拥有 BrowserWindow 等主进程 API。共享内存需要显式 SharedArrayBuffer,普通消息通常按结构化克隆传递。共享越多,同步问题越多,因此先把输入输出设计成不可变消息。
utilityProcess 由 Electron 在 main 中启动独立 Node 子进程,提供消息通信和生命周期事件。它可以把崩溃影响与主进程进一步分离,但不是自动限制文件权限的安全沙箱。把不可信文档交给它解析,仍需控制资源、输入、依赖和输出。独立进程解决故障范围的一部分,不等于可以执行任意下载脚本。utilityProcess
业务任务则是用户眼中的“这一次导入”。它应有 jobId、输入快照、创建者、阶段与最终结果。一个进程可以执行多个任务,一个任务也可能跨重试使用多个工作进程。因此不能只用 PID 当业务编号,更不能在窗口重新打开后把任何迟到消息都归到当前操作。进程编号用于排查资源,任务编号用于关联业务,两者各有生命周期。
先定义任务合同,再选择执行器#
一个最小状态机可以是 queued、running、cancelling、succeeded、failed、cancelled。succeeded、failed 与 cancelled 是终态,不能收到一条迟到进度又回到 running。取消请求只表示用户提出停止,不一定已经停止;按钮文字应先进入正在取消,直到执行器确认后再显示已取消。
进度也必须有含义。completed/total 适合工作量可提前确定的扫描;解析阶段不知道总页数时,用阶段与已处理数比伪造百分比更诚实。不要把请求已发送当作进度百分之十,把连接建立当作百分之五十,再在结束时突然跳满。真实进度应由执行实际工作的位置发出,UI 只负责展示与节流。
每条进度至少带 jobId 和单调递增 sequence。输入参数、阶段编号、已完成数都要校验。即使当前只有一个窗口,保留这个合同也能防止取消后重开任务时,旧消息覆盖新进度。总数变化时则明确重估语义,不能简单用新分母把进度条倒退而不解释。
实验一:带进度和共享取消标志的计算任务#
创建 worker-lab,保存三个完整文件,执行 node jobs-check.cjs。使用 Node 22.22.0,无需依赖。计算从零到 limit-1 的求和只是可预测工作负载,目的是观察协议与取消;业务项目可以把每个批次替换成受控解析步骤,而不是照搬这个计算算法。
const { parentPort, workerData } = require("node:worker_threads");
const { jobId, limit, cancelBuffer } = workerData;
const cancelled = new Int32Array(cancelBuffer);
let completed = 0, sum = 0, sequence = 0;
function finish(kind) {
parentPort.postMessage({ jobId, sequence: ++sequence, kind, completed, total: limit, sum });
parentPort.close();
}
function step() {
if (Atomics.load(cancelled, 0) === 1) return finish("cancelled");
const end = Math.min(completed + 10000, limit);
for (; completed < end; completed++) sum += completed;
parentPort.postMessage({
jobId, sequence: ++sequence, kind: "progress", completed, total: limit
});
if (completed === limit) return finish("succeeded");
setImmediate(step); // 分批给本 worker 的事件循环留出处理机会。
}
step();
const { Worker } = require("node:worker_threads");
const { randomUUID } = require("node:crypto");
const path = require("node:path");
function startSum(limit, onProgress = () => {}) {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100000000) {
throw new Error("INVALID_LIMIT");
}
const jobId = randomUUID();
const cancelBuffer = new SharedArrayBuffer(4);
const flag = new Int32Array(cancelBuffer);
const worker = new Worker(path.join(__dirname, "sum-worker.cjs"), {
workerData: { jobId, limit, cancelBuffer }
});
let settled = false, sequence = 0;
const promise = new Promise((resolve, reject) => {
function fail(error) {
if (settled) return;
settled = true;
void worker.terminate();
reject(error);
}
worker.on("message", message => {
if (settled) return;
if (message.jobId !== jobId || !Number.isInteger(message.sequence) ||
message.sequence <= sequence || message.total !== limit ||
!Number.isInteger(message.completed) ||
message.completed < 0 || message.completed > limit) {
return fail(new Error("INVALID_WORKER_MESSAGE"));
}
sequence = message.sequence;
if (message.kind === "progress") {
try { onProgress({ ...message }); } catch (error) { fail(error); }
return;
}
if (!["succeeded", "cancelled"].includes(message.kind)) {
return fail(new Error("UNKNOWN_TERMINAL"));
}
if (message.kind === "succeeded" && message.completed !== limit) {
return fail(new Error("INCOMPLETE_SUCCESS"));
}
settled = true;
resolve({ ...message });
});
worker.once("error", fail);
worker.once("exit", code => {
if (!settled) fail(new Error("WORKER_EXIT_BEFORE_RESULT:" + code));
});
});
return {
jobId, promise,
cancel() {
if (settled) return false;
Atomics.store(flag, 0, 1);
return true; // 只确认请求已发出,最终状态仍从 promise 得到。
}
};
}
module.exports = { startSum };
const assert = require("node:assert/strict");
const { startSum } = require("./jobs.cjs");
(async () => {
const progress = [];
const completed = startSum(50000, value => progress.push(value.completed));
const result = await completed.promise;
assert.equal(result.kind, "succeeded");
assert.equal(result.sum, 49999 * 50000 / 2);
assert.deepEqual(progress, [10000, 20000, 30000, 40000, 50000]);
assert.equal(completed.cancel(), false);
const interrupted = startSum(100000000);
assert.equal(interrupted.cancel(), true);
const cancelled = await interrupted.promise;
assert.equal(cancelled.kind, "cancelled");
assert.ok(cancelled.completed < cancelled.total);
assert.throws(() => startSum(-1), /INVALID_LIMIT/);
console.log("worker checks passed: success, progress, cancellation, invalid input");
})().catch(error => { console.error(error); process.exitCode = 1; });
输出应为 worker checks passed,后面列出四项验证。Worker 构造函数接收绝对入口路径与 workerData;workerData 不是共享普通对象的快捷方式,这里的共享只来自显式传入的 cancelBuffer。SharedArrayBuffer 只存一个整数标志,Atomics.store/load 负责跨线程可见性,避免用普通变量假装另一条线程能立即看到更改。
分批执行有两个用途:限制取消检查间隔,并避免工作线程自己永远处理不了其他事件。它不会降低所有 CPU 使用,也不保证每批恰好花固定毫秒数。批次过大,取消响应慢;批次过小,消息与调度成本上升。应根据真实解析步骤测量,不把一万条作为通用最佳参数。
cancel 返回 true 后,任务仍可能已经完成,只是结果消息尚未到达。真实产品必须接受这个竞态:如果成功结果先完成提交,就返回成功;如果取消先阻止提交,就返回取消。不能仅因用户点击过取消便丢弃已提交的业务结果,也不能把取消消息理解为撤销已经完成的外部动作。CPU 求和没有外部副作用,才可以如此简单地结束。
本例失败会请求 terminate,但正常取消使用协作标志。强制终止可能打断文件写入、数据库事务之外的清理或原生库资源释放,因此应该作为有限超时后的兜底,而不是每次点击取消的默认动作。对有提交阶段的任务,要先计算到暂存位置,再由主进程决定是否发布,取消只能保证尚未提交的结果不发布。
实验二:由 Electron 启动独立 utility process#
创建 utility-lab,保存 package.json、main.cjs、utility.cjs。安装 Electron 44.3.0 后执行 npm start。这个实验不创建窗口,使用 Electron 的 app 生命周期启动工作进程并在结束后退出;依然需要适用的桌面运行环境。这里没有调用 shell、没有动态拼接命令,也没有加载用户提供的脚本路径。
{
"name": "utility-lab",
"version": "1.0.0",
"private": true,
"main": "main.cjs",
"scripts": { "start": "electron ." },
"devDependencies": { "electron": "44.3.0" }
}
let finished = false;
let finalCode = 0;
const send = data => process.parentPort.postMessage(data);
process.parentPort.on("message", event => {
const message = event.data; // utility 子进程收到的是带 data 的事件。
if (finished && message?.type === "ack") { process.exit(finalCode); return; }
if (finished) return;
if (message?.type !== "count" || !Array.isArray(message.values) ||
message.values.length > 1000 ||
message.values.some(value => typeof value !== "string")) {
finished = true;
send({ type: "failed", jobId: message?.jobId ?? "", code: "INVALID_INPUT" });
finalCode = 1; // 等父进程收到最终结果并确认后再退出。
return;
}
finished = true;
send({ type: "completed", jobId: message.jobId, count: message.values.length });
// 父进程会发送 ack,避免尚未送达的结果因提前退出丢失。
});
const { app, utilityProcess } = require("electron");
const path = require("node:path");
let child;
app.whenReady().then(() => {
const jobId = "utility-count-1";
let received = false;
child = utilityProcess.fork(path.join(__dirname, "utility.cjs"), [], {
serviceName: "Course Count Worker", stdio: "pipe"
});
child.stdout?.on("data", bytes => process.stdout.write(bytes));
child.stderr?.on("data", bytes => process.stderr.write(bytes));
const timeout = setTimeout(() => { child.kill(); }, 5000);
child.once("spawn", () => {
child.postMessage({ type: "count", jobId, values: ["a", "b", "c"] });
});
child.on("message", message => {
if (message.jobId !== jobId) return;
received = message.type === "completed" && message.count === 3;
console.log(message);
child.postMessage({ type: "ack" });
});
child.once("exit", code => {
clearTimeout(timeout);
if (code === 0 && received) app.quit();
else app.exit(1);
});
}).catch(error => { console.error(error); app.exit(1); });
app.on("before-quit", () => { if (child?.pid) child.kill(); });
预期主进程打印 completed、对应 jobId 与 count:3,然后正常退出。把输入数组中一项改成数字,应打印 INVALID_INPUT 并退出非零。五秒超时用于阻止示例无限等待,不是业务任务通用时限。这里的 child.pid 在 spawn 前或退出后可能不存在,不能把构造返回对象误认为操作系统进程已经完成启动。
utilityProcess.fork 必须在 app ready 之后调用,入口是受控文件的绝对路径。stdio:pipe 让父进程主动读取输出;默认继承时不应假设 child.stdout 一定是流。父进程接收 message 的回调直接拿消息值,子进程的 process.parentPort 接收的是事件并从 data 读取,两边写法不同,照抄 worker_threads 的 parentPort.on("message", value) 容易得到 undefined。
子进程发送最终消息后等待父进程 ack,再退出;确认只是消息已被父进程处理,不代表数据已经持久化。utility 的 exit 事件是独立证据。收到 completed 不一定意味着进程已正确释放所有资源,反过来退出码零也不等于业务结果符合合同。示例同时检查结果与退出状态,因此工作进程提前正常退出但没有结果也会判失败。真实长任务可持续复用工作进程,仍应为每次 jobId 单独记录结果,不能把某次子进程退出当成所有排队任务都完成。
窗口关闭之后,任务归谁#
这取决于业务。用户关闭“导入进度”窗口,可能只想隐藏界面,也可能要求取消;关闭整个应用则可能要求等待、保存恢复点或确认退出。应在需求中明确,而不是把任务对象绑定 Vue 组件,组件卸载时随手杀进程。任务由 main 的管理器拥有,窗口订阅状态;窗口关闭先移除订阅,再按规则决定是否取消任务。
renderer 只能订阅自己有权查看的任务。main 在 IPC 开始入口检查发送者,为 jobId 记录拥有者;取消和订阅请求再次验证归属,不能只因 jobId 看起来难猜就跳过检查。任务进度可能包含文件名、错误行或数据统计,也属于需要控制的业务信息。
事件订阅要返回清理函数,preload 的回调只传业务数据,不能把 Electron event 对象转发给页面。组件重复挂载而没有取消订阅,会出现一条进度显示多次、关闭页面后仍持有旧回调等问题。消息到达时还要检查窗口是否销毁,避免一次 UI 生命周期变化把主进程任务管理器变成异常源。
任务开始时复制输入,为什么比共享对象更容易维护#
用户点击导入后仍可能修改表单或切换资料。后台任务应该处理点击时确认的输入快照,而不是每个批次重新读取 renderer 当前状态。否则任务的前半部分可能使用旧选项,后半部分使用新选项,最后却只显示一个成功结果。创建任务时记录输入版本或内容摘要,可以解释它究竟处理了什么。
普通 Worker 消息使用结构化克隆,传递大型数组会带来复制与内存成本。需要传递二进制数据时可以研究转移所有权,但转移后原侧缓冲区的使用规则会改变,不能继续把它当作完整数据。为了省一次复制直接引入共享内存,则必须承担并发读写协议。先用有上限的小批次消息,通常更容易验证。
进度消息也不能无限积压。工作线程每处理一行就发一次消息,可能让 main 忙于转发、renderer 忙于重绘,反而拖慢总体任务。可以在执行器中按批次或时间合并,在主进程保留最新快照,UI 以适当频率刷新。最终结果和错误不能被普通进度节流丢掉,因为它们改变任务生命周期。
有限并发与背压是桌面体验的一部分#
为每次点击创建一个 Worker 最容易写,也最容易耗尽资源。用户重复点击、大批文件拖入或多个窗口同时提交时,线程数量可能快速增长。任务管理器应该限制运行数、排队数和单任务输入大小;队列满时明确返回忙碌或让用户取消已有任务,不把内存无限积累当作高吞吐。
限制并发不只看 CPU 核心数。每个任务可能持有大文件缓冲区、解析树、原生库状态和输出临时文件;两个任务的峰值内存可能比十个轻量任务更高。先测单任务峰值,再决定并发预算。对于桌面应用,还要为 renderer 和系统本身留下余量,不能把所有 CPU 都占满再认为“后台跑得很快”。
背压表示下游处理不过来时,上游需要放慢。假设工作线程快速产出解析块,main 还要逐块写数据库;若消息只发不等,队列会越来越长。可以采用有限批次数量与确认消息,让生产者等到前一批被接受再继续。确认必须表示哪一层完成:消息接收、缓存写入与持久提交不是同一个保证。
失败与重试必须尊重提交点#
纯计算失败后重新计算通常简单;导入数据可能已经写入一部分,再从头运行会重复创建记录。重试协议要保留稳定业务编号、批次键和已提交进度,或者将整次结果写到暂存空间,最后一次性发布。重试不是把 catch 后面加一个递归调用,必须说明此前副作用处于什么状态。
工作进程在发出成功消息后立即崩溃,也可能已经完成所有计算;若主进程还没提交结果,应按结果是否持久保存判断,而不是只看退出码。相反,进程正常退出但没有发送符合合同的终态,不能算成功。实验用结果与退出事件帮助区分这些状态,真实应用还应记录任务的提交标识。
取消请求同样不一定可以自动重试。用户已经明确停止某次导入,网络重连后不能把它重新排队继续执行。任务记录中的取消状态应持久到必要范围,窗口重开时可查询到它。对于仅存在内存中的临时计算,应用退出即放弃可以接受,但应在产品说明中清楚界定。
为工作执行器安排独立的故障观察#
主进程日志至少关联 jobId、执行器类型、尝试次数、开始时间与终态。不要只记录“Worker error”,因为同一时刻可能有多个任务。输入摘要可以用于判断是否同一批资料,但不要把文件全文和用户正文塞进故障日志。敏感路径可以记录文件角色或受控标识,必要时在本地受保护诊断中查看完整路径。
内存持续增长时,要区分任务输入没有释放、订阅回调仍引用旧对象、输出队列积压和原生模块泄漏。终态后移除监听器与任务上下文,不意味着立刻销毁所有可诊断信息;可以保留有限摘要并回收大缓冲区。任务列表长期保留完整输入,常常会把“历史记录”变成另一个无上限缓存。
长期复用 Worker 可以减少启动成本,但一个任务留下的全局状态可能污染下一个任务。重新创建进程隔离更直观,却增加启动和内存成本。应依据任务频率、依赖行为和故障隔离要求选择。不要把进程池作为必学前置;先让一个任务的启动、完成、失败、取消都可解释,再讨论复用。
选择执行器时做一次小测量#
选一个代表性输入,记录主线程响应、总耗时、峰值内存和取消延迟,再分别尝试主进程异步 I/O、Worker 或独立进程。比较时保持算法和输入一致,否则“更快”可能只是其中一个版本少做了校验。桌面体验也不是只看总耗时:任务稍慢但窗口一直可交互,往往比短时间完全卡死更符合用户需要。
测量要包含失败输入,例如格式损坏、空文件和极大文件。只在成功小样本上比较,会遗漏真正导致崩溃的路径。对来自用户的文档设置大小、页数和处理时长上限,并在执行器外保留取消与终止能力。这些限制应从业务需要与资源预算得到,不是随机写一个足够大的数字。
练习:用状态机拒绝迟到进度#
这个练习是协议层完整实验,不启动线程。保存 task-state.mjs 与 task-state-check.mjs,执行 node task-state-check.mjs。需求是不同任务编号、倒序消息、终态之后的进度都不能覆盖当前状态。它可以用于 Vue store 前面的适配层,但真正的任务授权仍由 main 完成。
完整参考实现
export function applyTaskEvent(state, event) {
if (event.jobId !== state.jobId || !Number.isInteger(event.sequence) ||
event.sequence <= state.sequence) return state;
if (["succeeded", "failed", "cancelled"].includes(state.phase)) return state;
if (!["progress", "succeeded", "failed", "cancelled"].includes(event.kind)) return state;
if (!Number.isInteger(event.completed) || event.completed < state.completed ||
event.completed > state.total || event.total !== state.total) return state;
if (event.kind === "succeeded" && event.completed !== state.total) return state;
return {
...state, sequence: event.sequence, completed: event.completed,
phase: event.kind === "progress" ? "running" : event.kind
};
}
import assert from "node:assert/strict";
import { applyTaskEvent } from "./task-state.mjs";
let state = { jobId: "j1", sequence: 0, phase: "running", completed: 0, total: 10 };
const progress = { jobId: "j1", sequence: 1, kind: "progress", completed: 4, total: 10 };
state = applyTaskEvent(state, progress);
assert.equal(state.completed, 4);
assert.equal(applyTaskEvent(state, { ...progress, jobId: "j2", sequence: 2 }), state);
assert.equal(applyTaskEvent(state, progress), state);
state = applyTaskEvent(state, { ...progress, sequence: 2, kind: "cancelled" });
assert.equal(state.phase, "cancelled");
assert.equal(applyTaskEvent(state, { ...progress, sequence: 3, completed: 9 }), state);
console.log("task state checks passed");
这里返回旧对象表示忽略事件,方便 Vue 层避免无意义更新。忽略迟到消息与吞掉未知协议错误不同:协议边界仍应记录未知类型或异常序号,业务状态机只负责不被污染。事件验证应有观测计数,出现大量被忽略消息时检查订阅清理和重试设计,而不是把日志全部关闭。
验收应包含成功、非法输入、取消、工作进程提前退出和窗口关闭策略。实际离线验证覆盖 worker 计算、进度、取消与状态机;utilityProcess 的启动、平台资源与桌面退出尚需真实 Electron 验证。自测一:async 会自动让 CPU 运算离开主线程吗?不会。自测二:terminate 等于业务撤销吗?不等于。自测三:独立进程自动成为安全沙箱吗?不是。进一步查阅 Node worker_threads 与 Electron 性能建议。
本章验证记录#
Node 22.22.0 已运行 jobs-check.cjs 与 task-state-check.mjs,验证真实 worker_threads 的结果、进度和取消,以及终态后迟到事件拒绝。utilityProcess 的 main.cjs 与 utility.cjs 仅通过语法检查;没有用 Node mock 冒充 Electron 子进程运行,也没有执行桌面任务订阅。