# Node 运行时与进程

## 本章解决什么问题

前端开发中，刷新页面通常就能恢复状态；后端进程可能连续运行几周，同时服务许多用户。一次同步计算、一个忘记关闭的连接或一次错误退出，会影响所有正在请求的人。本章的目标是建立“代码运行在一个有资源、有生命周期的进程中”的认识，能够写出可配置、可停止、失败时返回正确退出码的 Node 程序。前置是熟练掌握 JavaScript 函数、模块和 Promise，不需要先学 TypeScript，也不要求理解操作系统内核。

把它放进 AI 应用场景：浏览器负责编辑提示词和展示输出，Node 负责保管服务端凭据、访问数据库、协调模型调用。模型密钥属于服务端运行配置，不能因为代码语言相同，就放进 Vue 构建时会公开的环境变量。后端边界首先是运行位置与权限边界，而不是语法边界。

## 从浏览器经验建立运行时模型

JavaScript 是语言；V8 执行 JavaScript；Node 在其外提供文件、网络、进程等能力。浏览器中的 document 和 DOM 不是语言自带的，Node 中默认不存在；Node 的 process 和 node:fs 也不是普通网页能任意调用的。看到一段“都是 JavaScript”的代码时，先确认宿主、模块格式和依赖，再判断能否复用。

一个普通 Node 进程中，主要 JavaScript 回调在事件循环线程执行。网络等待能交给系统，部分文件和加密工作会使用工作线程池，但你的同步循环并不会因为放在 async 函数里自动转移到另一颗 CPU。假设解析一个巨大 JSON 占用主线程三秒，这三秒内其他请求的 JavaScript 回调也难以继续。需要长时间计算时，应考虑分块让出执行机会、worker_threads 或独立任务进程；I/O 密集型请求则优先控制并发和等待时间。[Node 官方事件循环说明](https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop) 给出了两类工作应如何区分。

浏览器页面的状态常以组件生命周期为中心，后端状态则可能属于一次请求、一个进程或一个持久化存储。模块顶层的 Map 是当前进程共享的数据；启动两个实例会得到两份 Map；重启会全部丢失。不要把它误当成数据库，也不要把某位用户的身份写进模块全局变量，否则同时到达的请求可能相互覆盖。

## 核心 API 合同

`process.argv` 返回字符串数组，前两项通常是 Node 可执行文件与入口文件，业务参数从第三项开始；它不会替你校验格式。`process.env` 提供环境变量，读取到的通常是字符串或 undefined，字符串 `"false"` 仍是真值，因此端口、开关、超时都要显式解析。`process.cwd()` 是启动命令所在目录，不等于源码文件目录；需要相对模块定位资源时可使用 `new URL('./data.json', import.meta.url)`。

`process.on('SIGINT', handler)` 注册终端中断处理；在 Windows 终端里通常可以用 Ctrl+C 验证。不同操作系统和进程管理器的信号行为存在差别，部署时应按实际环境验证。`process.exitCode = 1` 设置最终退出状态，但让已安排的必要工作自然完成；`process.exit(1)` 会强制终止，可能截断尚未完成的日志或写入。`exit` 事件只能做同步收尾，不能把等待数据库关闭的 Promise 放进去期待它完成。[Process API](https://nodejs.org/docs/latest-v24.x/api/process.html) 是这些细节的正式合同。

本例还使用 `node:timers/promises` 的 `setTimeout(delay, value, { signal })`，它返回 Promise；到期兑现为 value，收到取消信号时拒绝。与浏览器的定时器一样，delay 表示最早有机会执行的等待时长，不是严格实时保证。

## 完整示例：能配置与中断的批处理进程

运行环境：Node 22.22 或 Node 24；保存为 `runtime.mjs`；无第三方依赖。在 PowerShell 或 Bash 中执行 `node runtime.mjs`。使用 `.mjs` 明确选择 ESM，不需要安装 TypeScript 执行器。

```js runtime.mjs
// runtime.mjs：一次任务对应一个进程，配置只在启动时解析。
import process from 'node:process';
import { setTimeout as delay } from 'node:timers/promises';

const controller = new AbortController();
let interrupted = false;

function readConfig() {
  const raw = process.env.JOB_COUNT ?? '3';
  // 先验证完整字符串，避免 parseInt('3abc') 被当成 3。
  if (!/^[1-9]\d*$/.test(raw)) throw new Error('JOB_COUNT 必须为正整数');
  const count = Number(raw);
  if (!Number.isSafeInteger(count) || count > 20) {
    throw new Error('JOB_COUNT 必须在 1 到 20 之间');
  }
  return { count };
}

function stop() {
  if (interrupted) return;
  interrupted = true;
  console.log('收到中断，停止领取新任务');
  controller.abort();
}

process.on('SIGINT', stop);

try {
  const { count } = readConfig();
  console.log(`启动：计划处理 ${count} 个任务`);
  for (let index = 1; index <= count; index += 1) {
    // 可取消的等待模拟 I/O；这里没有阻塞主线程。
    await delay(300, undefined, { signal: controller.signal });
    console.log(`完成任务 ${index}`);
  }
  console.log('全部完成');
} catch (error) {
  if (interrupted && error.name === 'AbortError') {
    console.log('任务已取消');
    process.exitCode = 130;
  } else {
    console.error(`启动或执行失败：${error.message}`);
    process.exitCode = 1;
  }
} finally {
  // 真实项目还应在此关闭自己持有的连接、文件句柄和定时器。
  process.removeListener('SIGINT', stop);
  console.log('收尾完成');
}
```

正常输出依次为“启动：计划处理 3 个任务”、三个“完成任务”、全部完成、收尾完成，退出码为零。PowerShell 可执行 `$env:JOB_COUNT='abc'` 后再运行，应该显示配置错误并以一退出；随后用 `Remove-Item Env:JOB_COUNT` 清理该终端变量。用 `$env:JOB_COUNT='20'` 运行后按 Ctrl+C，可以观察中断和收尾。进程号、机器路径等没有列为固定输出，因为它们会随运行环境变化。

## 逐段理解关键代码

配置解析放在循环外，因为非法配置应在产生副作用之前暴露。正则验证格式，Number 与安全整数验证负责范围，这与前端表单校验类似，但后端不能依赖用户已经经过表单。进程只打印可公开的配置摘要，不打印整份环境变量，以免日志包含密钥。

取消控制器表达“停止意图”，定时器通过 signal 接受这一意图。这里只在同一个进程里合作取消，不等于操作系统立即杀死任务。finally 是资源所有者的释放位置：谁创建连接、注册监听或打开文件，谁应明确负责关闭。删除监听器不会撤销已经发生的中断，它只防止后续生命周期残留。

真实 HTTP 服务的优雅退出一般是先停止接收新请求，再等待在途请求，最后关闭数据库和日志资源，并设置最大收尾时间。本例没有服务器，因此不假装演示了完整生产停机。能跑完一个脚本与能安全运营一个长期服务，是两个逐步建立的能力。

## 边界与常见错误

不要在每个请求中创建一个永久存活的定时器；它可能让进程无法自然退出。不要用同步文件 API 在高并发请求路径处理大文件。不要捕获所有异常后继续当作健康服务运行，特别是已经破坏内部状态的未知异常。异常处理的目的，是给出可判断的结果并释放资源，不是把失败转换成成功日志。

ESM 的相对导入应写清扩展名。启动目录改变后，`./config.json` 可能指向另一份文件，这也是“开发机正常、服务器失败”的常见来源。环境变量应由部署环境注入，开发文件应排除在提交之外；构建时公开变量与进程运行时秘密配置必须分开管理。

## 把团队文档助手拆成运行中的资源

开发一个页面时，你经常把应用理解为组件树；运行后端时，可以先画出资源关系：一个 Node 进程持有 HTTP 监听端口、数据库连接池、定时器和若干正在处理的请求，每个请求又可能持有文件流或模型连接。页面卸载通常由浏览器负责回收整个环境，后端却需要在不中断其他用户的前提下结束某个请求，并在整个实例停止时释放剩余资源。

这会改变代码组织方式。进程级对象适合在启动阶段创建，例如配置和连接池；请求级身份应该在请求进入时建立；文档记录和任务结果应该保存到持久存储。把请求用户写进全局 currentUser，等下一个 await 以后再使用，可能已经被另一个请求覆盖。它不像组件内部的局部变量那样只属于当前界面，也不能依赖单线程来保证跨 await 的全局状态不会变化。

还要区别“程序文件”与“进程实例”。同一份 server.mjs 可以同时启动两次，每次有独立内存和不同进程号；修改其中一份 Map 不会通知另一实例。部署滚动更新时，新旧实例会短暂并存，用户下一次请求可能到达另一份内存。理解这一点，就能解释为什么登录态、任务状态和业务锁不能只寄存在模块变量里。

## 模块系统与路径不是打包器的附属细节

Vue 项目中，构建工具常替你解析扩展名、别名、环境变量和静态资源；直接运行 Node ESM 时，这些便利并不自动存在。`.mjs` 明确采用 ESM，`.cjs` 明确采用 CommonJS；普通 `.js` 的解释与所在 package.json 等环境有关。教材选择 .mjs 是为了让执行方式清楚，不是宣称所有已有项目都必须立即迁移模块系统。

`import './service.mjs'` 按当前模块地址解析，而文件 API 接收的相对路径通常按 process.cwd() 解析。前者是源码位置，后者是命令启动位置。脚本从项目根目录能读取配置，换成任务调度器从另一目录启动后就失败，往往是这两种基准混在一起。要读取随模块发布的模板，可以用 `new URL('./template.txt', import.meta.url)`；要读取操作员明确指定的输入文件，则可按启动目录解析。

在 Windows 上，URL 的 pathname 不等于可靠的文件系统路径，盘符、空格和中文可能涉及编码。需要字符串路径时使用 node:url 的 fileURLToPath，而不是手动删除 file:// 前缀。反方向使用 pathToFileURL，把路径交给模块加载器时同样不要靠字符串拼接。路径转换是数据格式转换，不应该把路径当成 shell 命令执行。

模块顶层代码在加载时执行，顶层 await 会影响依赖模块的初始化。若导入一个工具函数就顺便启动服务、连接数据库或执行迁移，测试与复用会变得困难。更清楚的写法是导出创建函数，让入口文件负责组装和启动。启动副作用集中后，才能给配置失败、数据库不可用和准备就绪建立可观察的顺序。

## 事件循环：等待不会占住主线程，计算会

async 函数的前半段仍同步运行，只有遇到需要等待的 await 才可能让出执行机会。`await expensiveCalculation()` 会先执行这个函数，再等待它的返回值；如果函数本身同步计算五秒，前面的 await 并不能把五秒移动到后台。团队文档助手中的大 JSON 解析、文本清洗、复杂正则和大量同步对象转换，都可能成为这种隐藏计算。

微任务用于 Promise 后续处理，但反复安排微任务也可能推迟定时器和 I/O 回调。把一个巨大循环改写成每次 `await Promise.resolve()`，并不一定实现你想要的公平调度。需要分块时，应明确每块工作量并有机会进入后续事件循环阶段；需要持续 CPU 计算时，工作线程或独立进程通常比无限微任务链更合适。

定时器只承诺经过指定等待后具备被调度的资格，实际回调还要等主线程空闲。因此“设置二百毫秒超时”不是实时中断保障：若主线程被同步计算占住三秒，超时回调也要等。对不可信文件解析进行严格资源隔离时，可以使用能被独立终止的工作单元，不能仅把一个 setTimeout 放在同一阻塞线程里当作可靠保险。

## 最小实验：观察计算期间的回调延迟

保存为 `event-loop-lab.mjs`，Node 22.22 或 24 执行 `node event-loop-lab.mjs`，无依赖。程序故意短暂占用主线程，展示定时器与 Promise 回调需要等待同步代码结束。不要把其中的忙等待放进真实请求路径。

```js event-loop-lab.mjs
// event-loop-lab.mjs
import { performance } from 'node:perf_hooks';
const start=performance.now();
console.log('1 同步代码开始');
Promise.resolve().then(()=>console.log('3 Promise 回调获得执行机会'));
setTimeout(()=>console.log('4 定时器执行，已过去约',Math.round(performance.now()-start),'毫秒'),0);
while(performance.now()-start<60) { /* 故意占用 CPU，作为反例。 */ }
console.log('2 同步计算结束');
```

预期顺序为一、二、三、四；具体耗时不固定，但定时器不会在同步计算尚未结束时插入执行。这个实验可以帮助你解释用户反馈“连健康检查也变慢”：如果多个请求共享的事件循环被一段同步工作占住，其他轻量路由也要排队。错误发生在资源共享层面，而不一定发生在那个健康检查函数内部。

## API 细读：工作线程、配置与内存观测

`new Worker(filename, options)` 返回独立 JavaScript 执行环境中的工作线程对象。filename 可以是文件 URL，workerData 默认未提供；传入的数据按结构化克隆规则传递，函数和任意闭包不能直接复制过去。Worker 适合 CPU 密集型 JavaScript，不是把所有数据库查询再包一层的默认优化。创建线程也有启动和内存成本，生产通常复用有限大小的工作池。

`worker.postMessage(value)` 发送可克隆值，消息接收是异步的，不应假设调用结束代表对方已完成。需要关联多个任务时，应携带任务编号并定义成功、错误和退出消息。`worker.terminate()` 返回表示终止状态的 Promise，但粗暴终止可能让工作停在中间，所以输出应写到隔离临时位置，成功确认以后再发布。

`process.memoryUsage()` 无参数，返回以字节为单位的内存统计对象。heapUsed 主要反映 V8 堆使用，rss 包含进程驻留内存的更广范围，Buffer 等数据还涉及 external、arrayBuffers。不能看到 heapUsed 稳定就断言没有文件缓冲增长，也不能把某次 rss 上升直接定义为泄漏。先观察相同负载循环后是否持续增长、资源是否释放，再考虑堆快照或更深入诊断。

配置的默认值应该表达明确意图。端口可有本地开发默认值，生产数据库地址和模型凭据则通常应缺失即失败；把缺失密钥默认为空字符串会把启动问题拖到第一个用户请求。开关可约定只接受 true 与 false，数字先校验完整格式再转换。错误消息指出变量名与格式即可，不把变量原值打印出来，尤其不要整份输出 process.env。

## 递进示例：在工作线程计算文档统计

保存为 `worker-document.mjs`，Node 22.22 或 24 执行 `node worker-document.mjs`，无依赖。主线程定期输出心跳，工作线程计算一个确定的词频样本，最终断言结果。样本不使用真实私有文档，也不以固定心跳次数作为性能承诺。

```js worker-document.mjs
// worker-document.mjs
import { Worker,isMainThread,parentPort,workerData } from 'node:worker_threads';
import assert from 'node:assert/strict';
if (!isMainThread) {
  const counts={};
  for (let i=0;i<workerData.rounds;i+=1) {
    for (const word of workerData.words) counts[word]=(counts[word]??0)+1;
  }
  parentPort.postMessage(counts);
} else {
  const worker=new Worker(new URL(import.meta.url),{
    workerData:{words:['document','task','document'],rounds:200000},
  });
  const heartbeat=setInterval(()=>process.stdout.write('.'),20);
  try {
    const result=await new Promise((resolve,reject)=>{
      let received=false;
      worker.once('message',value=>{received=true;resolve(value);});
      worker.once('error',reject);
      worker.once('exit',code=>{if(!received)reject(new Error(`工作线程提前退出：${code}`));});
    });
    assert.deepEqual(result,{document:400000,task:200000});
    console.log('\n统计正确：document 400000，task 200000');
  } finally {
    clearInterval(heartbeat);
    await worker.terminate();
  }
}
```

这里把数据显式传给 worker，而不是试图让它读取主线程的 Map。真实文档很大时，克隆本身也会花费时间与内存；可以评估传递文件标识、可转移缓冲区或独立服务，但相应引入了文件权限、所有权转移与失败清理。优化必须包含数据搬运成本，不能只比较计算函数运行了几毫秒。

## 原练习的完整参考：严格检查模式

保存为 `runtime-check.mjs`，无依赖。运行 `node runtime-check.mjs --check` 只输出计划，不执行任务；运行 `node runtime-check.mjs --typo` 应返回非零退出码。该实现把参数解析与配置解析放在实际工作之前，并拒绝重复或未知选项。

完整代码已收录在本章末尾的练习参考答案中；可先阅读说明，再展开复制运行。


## 进程退出应该是有顺序的状态转换

服务停止可以先进入 draining 状态，健康检查不再把新流量分配过来，再停止领取新作业，等待在途请求或任务达到明确边界，最后关闭连接池和日志资源。这个顺序不是固定模板：交互式请求通常可以短暂等待，后台任务可能要释放租约让其他实例接手。重要的是每种资源都有停止策略，而不是收到信号就直接 process.exit。

收尾也需要截止时间。一个永远不结束的请求不能无限阻止部署；超时以后应记录未完成工作并执行明确的终止策略。幂等任务可以稍后重试，临时文件可以由清理器回收，尚未确认的数据库提交需要按请求键恢复。关机设计与事务、文件状态、任务租约因此相互连接，而不是单独写一个 signal 回调就完成。

开发工具自动重启只是在文件变化后重新启动进程，不会自动迁移内存状态。出现“保存代码后任务消失”时，应检查任务是否只存在 Map；出现“重启后端口占用”时，应确认旧进程是否仍在监听，而不是盲目换端口。排查应识别进程号、启动命令和资源所有者，避免误停止另一个项目的服务。

## 验收如何从脚本提升到实际服务

本章离线实验验证了可解释的执行顺序、配置分支与工作线程结果；真实服务还需验证启动依赖失败时不会宣告就绪、关闭时不再接收新工作、在途请求有明确结局。记录进程启动与停止的原因、版本和请求标识，可以帮助滚动部署时追踪一条任务在哪个实例执行。

CPU、内存、连接数和事件循环延迟应结合观察。单次 CPU 高可能是预期解析工作；内存增长后稳定可能是缓存；连接数持续增加且不下降可能是泄漏。先构造可重复的小负载，再比较运行前后资源，远比一上来修改所有性能参数更容易找出因果。性能目标也应以用户任务为单位，例如上传后多久出现可查询状态，而不仅是一个无负载函数的耗时。

## 配置文件与秘密的发布边界

开发环境可以用 Node 的环境文件能力或项目已有配置加载器注入变量，但环境文件不是浏览器构建配置，也不应成为源码仓库里的密钥清单。团队文档助手的模型密钥只由服务端读取，前端只知道受控业务接口。需要排查配置时，输出变量是否存在、采用哪个配置来源和格式是否有效，比直接打印真实值更合适。

配置变更也不一定即时生效。启动时读取并固定的配置通常要重启进程，动态开关则需要明确刷新时机与失败回退。把 process.env 当成跨实例共享配置数据库，会造成只有某个实例变化的错觉。先决定配置的生命周期，再选择文件、环境注入或配置服务，可以避免用户请求处理中突然出现半新半旧的运行状态。


## 练习：加入只检查配置的模式

要求支持 `node runtime.mjs --check`，只验证配置并输出计划任务数，不执行任何等待。提示：先解析配置，再判断 `process.argv.slice(2)`；检查模式也必须对非法配置返回非零退出码。

<details><summary>参考答案（含完整可运行实现）</summary>

在 try 中读取配置后计算 `const checkOnly = process.argv.slice(2).includes('--check')`，将循环和“全部完成”日志放进 `if (!checkOnly)`；检查模式打印“配置有效”。不要在参数判断之前提前返回成功，否则非法 JOB_COUNT 会被跳过。扩展要求是拒绝未知参数，让拼错的 `--chekc` 不会悄悄启动真实任务。下面给出可直接运行的完整参考文件。





```js runtime-check.mjs
// runtime-check.mjs
import { setTimeout as delay } from 'node:timers/promises';
function parseConfig(argv,env) {
  if (argv.some(arg=>arg!=='--check') || argv.length>1) throw new Error('只支持一次 --check');
  const raw=env.JOB_COUNT??'3';
  if (!/^[1-9]\d*$/.test(raw)) throw new Error('JOB_COUNT 必须是整数文本');
  const count=Number(raw);
  if (!Number.isSafeInteger(count) || count>20) throw new Error('JOB_COUNT 必须为 1 到 20');
  return {count,checkOnly:argv.includes('--check')};
}
try {
  const config=parseConfig(process.argv.slice(2),process.env);
  console.log(`配置有效：${config.count} 个任务`);
  if (!config.checkOnly) {
    for(let i=1;i<=config.count;i+=1) { await delay(20); console.log(`完成 ${i}`); }
  }
} catch(error) {
  console.error(error.message);process.exitCode=1;
}
```


</details>

## 可验证的验收标准

默认运行完成三项任务且退出码为零；非法配置在第一项任务开始前失败；中断长任务后不会继续打印全部完成；收尾日志无论成功、配置失败或取消都恰好一次。PowerShell 用 `$LASTEXITCODE` 检查退出码。能解释主线程为何不会被定时器等待占住，同时指出把大循环写成 async 仍可能阻塞。

## 三个自测问题与答案

1. 为什么模块顶层 Map 不能直接保存生产会话？答案：数据只属于当前实例，重启丢失，多实例不共享，还必须解决过期和容量。
2. 把密码写进 Vite 的公开环境变量再发给 Node 是否安全？答案：公开变量会进入客户端产物，不能承担服务端秘密配置职责。
3. 为什么不在 exit 监听里 await 数据库关闭？答案：退出阶段不会等待这样的异步收尾，应在显式停止流程中提前完成。


## 本章验证记录

编写时使用 Node 22.22.0 对本章全部 4 个 JavaScript 完整文件执行了语法检查。已在本机实际运行通过：`runtime.mjs`、`event-loop-lab.mjs`、`worker-document.mjs`、`runtime-check.mjs`。

## 本章官方参考

- [Node ECMAScript modules](https://nodejs.org/api/esm.html)：模块格式、相对导入与模块地址。
- [Node Process](https://nodejs.org/docs/latest-v24.x/api/process.html)：参数、环境变量、信号和退出。
- [Node 不阻塞事件循环](https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop)：区分 CPU 工作与异步等待。
- [Node Worker threads](https://nodejs.org/api/worker_threads.html)：工作线程、workerData 与消息传递。
