# 后台工作、进度、取消与任务生命周期

## 一个函数写成 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](https://www.electronjs.org/docs/latest/api/utility-process)

业务任务则是用户眼中的“这一次导入”。它应有 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 的求和只是可预测工作负载，目的是观察协议与取消；业务项目可以把每个批次替换成受控解析步骤，而不是照搬这个计算算法。

```javascript sum-worker.cjs
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();
```

```javascript jobs.cjs
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 };
```

```javascript jobs-check.cjs
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、没有动态拼接命令，也没有加载用户提供的脚本路径。

```json package.json
{
  "name": "utility-lab",
  "version": "1.0.0",
  "private": true,
  "main": "main.cjs",
  "scripts": { "start": "electron ." },
  "devDependencies": { "electron": "44.3.0" }
}
```

```javascript utility.cjs
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，避免尚未送达的结果因提前退出丢失。
});
```

```javascript main.cjs
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 完成。

<details><summary>完整参考实现</summary>

```javascript task-state.mjs
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
  };
}
```

```javascript task-state-check.mjs
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");
```

</details>

这里返回旧对象表示忽略事件，方便 Vue 层避免无意义更新。忽略迟到消息与吞掉未知协议错误不同：协议边界仍应记录未知类型或异常序号，业务状态机只负责不被污染。事件验证应有观测计数，出现大量被忽略消息时检查订阅清理和重试设计，而不是把日志全部关闭。

验收应包含成功、非法输入、取消、工作进程提前退出和窗口关闭策略。实际离线验证覆盖 worker 计算、进度、取消与状态机；utilityProcess 的启动、平台资源与桌面退出尚需真实 Electron 验证。自测一：async 会自动让 CPU 运算离开主线程吗？不会。自测二：terminate 等于业务撤销吗？不等于。自测三：独立进程自动成为安全沙箱吗？不是。进一步查阅 [Node worker_threads](https://nodejs.org/api/worker_threads.html) 与 [Electron 性能建议](https://www.electronjs.org/docs/latest/tutorial/performance)。

## 本章验证记录

Node 22.22.0 已运行 jobs-check.cjs 与 task-state-check.mjs，验证真实 worker_threads 的结果、进度和取消，以及终态后迟到事件拒绝。utilityProcess 的 main.cjs 与 utility.cjs 仅通过语法检查；没有用 Node mock 冒充 Electron 子进程运行，也没有执行桌面任务订阅。
