Node 运行时与进程
把浏览器中的 JavaScript 经验迁移到长期运行的服务进程,理解事件循环、配置、退出与资源生命周期。
本页内容
本章解决什么问题#
前端开发中,刷新页面通常就能恢复状态;后端进程可能连续运行几周,同时服务许多用户。一次同步计算、一个忘记关闭的连接或一次错误退出,会影响所有正在请求的人。本章的目标是建立“代码运行在一个有资源、有生命周期的进程中”的认识,能够写出可配置、可停止、失败时返回正确退出码的 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 官方事件循环说明 给出了两类工作应如何区分。
浏览器页面的状态常以组件生命周期为中心,后端状态则可能属于一次请求、一个进程或一个持久化存储。模块顶层的 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 是这些细节的正式合同。
本例还使用 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 执行器。
// 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 回调需要等待同步代码结束。不要把其中的忙等待放进真实请求路径。
// 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,无依赖。主线程定期输出心跳,工作线程计算一个确定的词频样本,最终断言结果。样本不使用真实私有文档,也不以固定心跳次数作为性能承诺。
// 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);检查模式也必须对非法配置返回非零退出码。
参考答案(含完整可运行实现)
在 try 中读取配置后计算 const checkOnly = process.argv.slice(2).includes('--check'),将循环和“全部完成”日志放进 if (!checkOnly);检查模式打印“配置有效”。不要在参数判断之前提前返回成功,否则非法 JOB_COUNT 会被跳过。扩展要求是拒绝未知参数,让拼错的 --chekc 不会悄悄启动真实任务。下面给出可直接运行的完整参考文件。
// 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;
}
可验证的验收标准#
默认运行完成三项任务且退出码为零;非法配置在第一项任务开始前失败;中断长任务后不会继续打印全部完成;收尾日志无论成功、配置失败或取消都恰好一次。PowerShell 用 $LASTEXITCODE 检查退出码。能解释主线程为何不会被定时器等待占住,同时指出把大循环写成 async 仍可能阻塞。
三个自测问题与答案#
- 为什么模块顶层 Map 不能直接保存生产会话?答案:数据只属于当前实例,重启丢失,多实例不共享,还必须解决过期和容量。
- 把密码写进 Vite 的公开环境变量再发给 Node 是否安全?答案:公开变量会进入客户端产物,不能承担服务端秘密配置职责。
- 为什么不在 exit 监听里 await 数据库关闭?答案:退出阶段不会等待这样的异步收尾,应在显式停止流程中提前完成。
本章验证记录#
编写时使用 Node 22.22.0 对本章全部 4 个 JavaScript 完整文件执行了语法检查。已在本机实际运行通过:runtime.mjs、event-loop-lab.mjs、worker-document.mjs、runtime-check.mjs。
本章官方参考#
- Node ECMAScript modules:模块格式、相对导入与模块地址。
- Node Process:参数、环境变量、信号和退出。
- Node 不阻塞事件循环:区分 CPU 工作与异步等待。
- Node Worker threads:工作线程、workerData 与消息传递。