进程(process) 拥有隔离的资源上下文; 线程(thread) 则是其中可被调度的执行路径。
线程能以较低成本共享内存,因此未经同步的先检查后更新可能破坏共享状态,即使每行代码单看都没有问题。
先选择故障与共享边界,再明确规定所有权、等待结束、取消和同步。
是什么,为什么存在
程序是存储介质上的静态代码与数据。进程是程序的一次运行实例,具有操作系统身份、虚拟地址空间、凭据、已打开句柄与生命周期状态。同一个可执行文件启动两次,会产生两个进程,其可变内存通常彼此隔离。
线程是调度器可以在进程内运行的一条指令序列。同一进程中的线程共享代码、堆对象和进程资源,而每个线程都有自己的指令位置、寄存器状态与栈。一个进程启动时至少有一个线程,之后还可以创建更多线程。
这种划分解决两类不同需求。进程边界限制意外内存访问,并能约束许多故障的影响范围;线程边界则让并发工作通过共享内存通信,不必序列化每个值。两种边界本身都不能保证并发正确。
Shell 启动命令、服务管理器监管守护进程,或者工作池把任务交给子程序时,都涉及进程。语言运行时、Web 服务器、数据库客户端、图形界面程序,以及并发执行阻塞或 CPU 密集工作的库中,都能见到线程。
真正有用的问题不是「哪一个更快」,而是哪些状态必须共享、哪些故障必须隔离、哪些资源需要独立所有权,以及工作负载能接受多高的通信成本。这些条件比笼统规则更能决定合适的边界。
实用区别
操作系统与运行时暴露的细节各不相同,但下面的模型适合作为起点:
| 属性 | 独立进程 | 同一进程内的线程 |
|---|---|---|
| 虚拟地址空间 | 通常分离 | 共享 |
| 堆修改 | 无法直接跨边界看到 | 运行时暴露共享内存时可见 |
| 执行状态 | 各自独立 | 每个线程各自独立 |
| 已打开资源 | 资源表分离,有时会继承或传递 | 通常属于整个进程并被共享 |
| 通信 | 管道、套接字、消息、共享映射 | 共享内存、消息、同步原语 |
| 典型崩溃范围 | 通常限于一个进程 | 通常会影响整个进程 |
「共享」不等于「同时访问也安全」。两个线程可以寻址相同字节,但程序仍需规定谁可以在何时读取或修改这些字节。互斥锁、原子操作、不可变快照或单一所有者队列表达了这种规则。
「隔离」也有边界。进程可以主动共享内存、继承文件描述符、操作同一文件,并影响同一个外部服务。隔离缩小了默认内存边界,但不会自动提供授权或事务保证。
工作原理
操作系统使用内核管理的记录表示执行,其中保存身份、调度状态、内存映射和资源引用。不同系统采用不同名称。在 Linux 中,每个线程都是可调度任务,而同一进程中的线程会共享部分内核结构。
调度器选择一个可运行线程,把它分配给处理器,之后可能换成另一个可运行线程。这样的替换称为 上下文切换(context switch) 。切换会保存旧执行状态并恢复新状态,但不会判断应用数据是否一致。
生命周期状态
不同系统的精确状态机并不相同,但以下概念状态足以解释大多数现象:
- 创建阶段分配身份和启动执行所需的资源。
- 可运行线程具备获取处理器时间的资格,但可能仍在运行队列中等待。
- 运行中线程正在某个处理器上执行。
- 阻塞线程等待 I/O、计时器、锁或消息等事件。
- 停止线程被有意暂停,继续执行前不能运行。
- 终止会结束执行,但其他所有者可能仍需收集退出结果。
运行中的线程无法继续取得进展时,就会进入阻塞状态。等待的事件发生后,它通常先变成可运行状态,而不是立即开始运行。调度策略、优先级、处理器可用性与其他可运行工作共同决定它何时再次执行。
终止不等于清理已经完成。父进程可能需要等待子进程,以免留下未收集的退出记录;线程所有者也可能要等待线程结束,之后才能使用最终结果或释放依赖状态。分离线程或调用 unref() 只会改变由谁等待,并不会取消工作。
创建与替换
类 Unix 系统通常用 fork() 创建进程,再通过 exec 操作替换其程序映像。fork() 产生逻辑上独立的子地址空间,具体实现通常会让两方共享写时复制页面,直到任一方修改。文件描述符等资源可以按明确规则继承。
线程在现有进程内创建,并从指定入口函数开始执行。它从一开始就位于进程的资源边界内。运行时抽象还可能增加更强隔离:Node.js 工作线程使用不同的 JavaScript isolate,因此普通 workerData 对象会被克隆,但 SharedArrayBuffer 内存可以共享。
创建操作也有失败路径。进程 ID、线程栈、地址空间映射、句柄与调度容量都是有限资源。代码必须处理创建失败,并限制并发量,不能假定请求的每个工作单元都能启动。
调度不等于顺序
抢占可以在两个源码级操作之间暂停线程。多个处理器还可以同时执行不同线程。某个测试在空闲笔记本上「总是」打印相同顺序,并不能建立顺序契约。
调度器的公平性不保证某个线程接下来一定运行,也不保证它能在应用截止时间内运行。优先级可以影响选择,但优先级反转、CPU 配额、阻塞调用和过载的运行队列仍会产生影响。正确性必须来自同步,而不是对时序的猜测。
因此,睡眠不是协调原语。sleep(10) 只能保证线程至少在一段时间内没有运行资格,不能证明另一个线程已经完成初始化。应等待明确的完成事件、条件、消息或线程结束。
共享状态与同步
如果正确性依赖未受控制的并发事件顺序,就存在 竞态条件(race condition) 。常见的读取、修改、写回序列可能丢失更新:两个线程读到同一个旧计数,各自算出下一个值,再分别把结果写回。
互斥锁(mutex) 让关键区域同一时间只有一个所有者。需要保护的不变量比单个变量更重要:如果余额与账本必须一起变化,两项修改就应遵循同一所有权规则。依赖这个不变量的每次访问都必须遵守该规则。
原子操作让一种受支持的内存操作不可分割,并提供明确的可见性顺序。它适合计数器、标志,以及构建更底层的协议,但一系列原子操作不会自动变成一次原子事务。对于更大的不变量,除非确实需要经过审查的无锁设计,否则应优先使用锁或单一所有者消息循环。
条件变量与事件原语让线程休眠,直到状态可能发生变化。线程醒来后要重新检查谓词,因为通知可能合并、另一个线程可能先消耗条件,或者 API 允许伪唤醒。决定能否继续的是谓词,而不是通知次数。
示例
以下示例在 Linux 上使用 Node 24。子进程展示独立堆与显式消息传递,工作线程展示克隆的 JavaScript 对象与主动共享的字节之间有何不同。下面每段输出都来自所示暂存文件的实际运行。
在子进程中隔离可变状态
父进程通过 IPC 通道发送一条小命令。子进程修改自己的对象并返回结果,而父进程中的对象保持不变。
import { fork } from "node:child_process";
import { fileURLToPath } from "node:url";
if (process.argv[2] === "child") {
const localState = { queue: "billing", pending: 1 };
process.once("message", ({ add }) => {
localState.pending += add;
process.send(localState);
process.disconnect();
});
} else {
const parentState = { queue: "billing", pending: 1 };
const child = fork(fileURLToPath(import.meta.url), ["child"], {
stdio: ["ignore", "inherit", "inherit", "ipc"],
});
console.log(`parent before: ${parentState.pending}`);
child.send({ add: 2 });
child.once("message", (childState) => {
console.log(`child reports: ${childState.pending}`);
console.log(`parent after: ${parentState.pending}`);
});
child.once("exit", (code) => console.log(`child exit: ${code}`));
}parent before: 1
child reports: 3
parent after: 1
child exit: 0消息会按照 IPC 序列化契约传输数据,不会把 parentState 暴露给子进程。message 事件是通信边界,exit 事件是生命周期边界。生产代码还要处理 error、异常退出和响应截止时间。
disconnect() 在回复后关闭 IPC 通道,但不会强制终止子进程。父进程会另外观察退出状态,因此请求完成与进程终止不能合并成同一个事件。
区分克隆对象与共享字节
Node 工作线程确实是线程,但其中的 JavaScript 对象位于单独的 isolate。workerData.copiedState 会被克隆;SharedArrayBuffer 则明确指定两个线程都能访问的内存。
import {
Worker,
isMainThread,
parentPort,
workerData,
} from "node:worker_threads";
if (isMainThread) {
const copiedState = { pending: 1 };
const sharedBytes = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const worker = new Worker(new URL(import.meta.url), {
workerData: { copiedState, sharedBytes },
});
worker.once("message", ({ workerPending }) => {
const sharedState = new Int32Array(sharedBytes);
console.log(`copy in worker: ${workerPending}`);
console.log(`copy in parent: ${copiedState.pending}`);
console.log(`shared in parent: ${Atomics.load(sharedState, 0)}`);
});
} else {
const sharedState = new Int32Array(workerData.sharedBytes);
workerData.copiedState.pending += 1;
Atomics.store(sharedState, 0, 7);
parentPort.postMessage({ workerPending: workerData.copiedState.pending });
}copy in worker: 2
copy in parent: 1
shared in parent: 7普通对象的行为类似消息负载,所以工作线程修改自己的副本不会改变父线程的副本。两个类型化数组视图指向相同的共享字节。Atomics.store() 与 Atomics.load() 明确规定了可见性规则,不依赖偶然时序。
这种运行时设计比「线程共享内存」的一般说法限制更多。务必检查所用抽象。原生线程、Java 线程、浏览器 Worker、Node Worker 与异步任务暴露了不同的共享及调度契约。
等待执行原子更新的工作线程
四个工作线程递增同一个共享计数器。Atomics.add() 防止更新丢失;父线程等待所有 exit 事件后再读取最终值,由此形成汇合点。
import { Worker, isMainThread, workerData } from "node:worker_threads";
const workerCount = 4;
const incrementsPerWorker = 25_000;
if (isMainThread) {
const sharedBytes = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const counter = new Int32Array(sharedBytes);
const workers = Array.from(
{ length: workerCount },
() => new Worker(new URL(import.meta.url), { workerData: sharedBytes }),
);
await Promise.all(
workers.map(
(worker) =>
new Promise((resolve, reject) => {
worker.once("error", reject);
worker.once("exit", (code) =>
code === 0 ? resolve() : reject(new Error(`exit ${code}`)),
);
}),
),
);
console.log(`expected: ${workerCount * incrementsPerWorker}`);
console.log(`actual: ${Atomics.load(counter, 0)}`);
} else {
const counter = new Int32Array(workerData);
for (let index = 0; index < incrementsPerWorker; index += 1) {
Atomics.add(counter, 0, 1);
}
}expected: 100000
actual: 100000如果把 Atomics.add(counter, 0, 1) 换成 counter[0] += 1,一次原子更新就会变成先读取再写入。并发工作线程可能相互覆盖进度。即使不安全的版本某次运行成功,也不能证明它正确。
退出处理器会拒绝异常终止,不会把所有退出都当成成功。在服务中,还应增加有界截止时间,并在失败后终止其余工作线程。如果某个线程阻塞,无截止时间的等待可能永远无法结束。
陷阱
把共享内存当成线程安全
修复方法: 枚举每个共享可变对象及其不变量。指定单一所有者、用同一把互斥锁保护所有相关访问,或者使用契约覆盖完整更新的原子操作。应增加压力测试,但不能把测试通过当成不存在竞态的证明。
用睡眠协调
修复方法: 等待线程结束、消息、条件谓词或完成 Future。为这个明确事件设置截止时间,并区分超时与工作线程失败。测试应控制事件本身,而不是延长等待时间。
忘记生命周期所有权
修复方法: 除非明确移交所有权,否则创建方负责清理。应一起定义正常完成、取消、截止时间、异常退出和关闭行为。通过 finally 路径关闭通道,并终止仍位于边界内的工作。
误把线程当成故障边界
修复方法: 需要隔离故障或信任时,使用受到单独约束的进程或更强沙箱。在边界校验消息,只授予必要资源,并监管退出。进程边界仍需要操作系统权限与资源限制。
为每个元素创建一个工作单元
修复方法: 使用有界工作池或准入队列,并根据实际测量与资源上限设置容量。向生产者传播背压,定义队列溢出行为,分别测量排队延迟与执行时间。初始化成本高于有效工作时,优先考虑批处理。
加锁顺序不统一
修复方法: 定义并记录统一的加锁顺序,限制临界区范围,不要在持锁时调用未知代码。如果难以统一顺序,应采用带回滚的限时获取,或者重新设计所有权以消除嵌套锁。
调度、同步与故障边界
简单的进程与线程对比表隐藏了产生真实缺陷的机制。调度决定执行何时能继续,同步约束哪些观察结果合法,而故障边界决定哪些部分可能一起停止或损坏。每项并发设计都要审查这三个维度。
可调度身份
调度器处理的是可运行执行实体,不是源码中的 Promise 或业务任务。在 Linux 中,线程表示为拥有独立调度属性的任务,而线程组提供进程视图。因此,只监控进程数可能漏掉数百个可运行或阻塞线程。
运行时可以把许多语言级任务多路复用到较少的操作系统线程上。异步函数通常在运行时管理的挂起点暂停,不会各自拥有原生栈。反过来,即使应用代码从未调用线程 API,某个库也可能创建原生辅助线程。
CPU 密集工作只有在能运行于不同处理器,而且运行时允许同时执行时,才会形成并行。对于 I/O 密集工作,如果一条执行路径阻塞时另一条仍可前进,并发仍有帮助。选择原语前,应先明确目标是隐藏延迟、提高吞吐量、并行计算还是隔离。
上下文切换与阻塞
上下文切换会保存足以恢复一个线程的执行状态,再载入另一个线程的状态。它还可能影响缓存与转换后备缓冲区,但成本取决于硬件、内核路径、工作集,以及切换是否跨越地址空间。应测量目标工作负载,不要引用一个通用耗时。
阻塞既可以是主动行为,例如等待管道,也可以由抢占强制发生。线程持有互斥锁时执行阻塞 I/O,会让临界区承受无界外部延迟。如果不变量允许拆分,应在锁内复制所需状态,释放锁后再执行慢操作。
可运行队列很长,表示工作正在争用处理器;阻塞线程很多,表示工作正在等待事件或容量。两种情况都可能增加延迟,但解决方法不同,因此要收集各状态的线程数与等待原因,而不能只报告线程总数。
发布与内存可见性
一个线程构造对象再把它赋给共享位置,并不保证另一个线程在所有语言内存模型中都能看到之前的全部字段写入,除非存在明确发布机制。锁、消息队列、线程汇合与适当原子操作会建立运行时规定的顺序保证。
竞态条件 不只是「两次写入同时发生」。先检查权限有效再打开路径,可能与重命名竞争;先检查队列非空再移除元素,可能与另一个消费者竞争。应把决策与操作作为一个不变量保护,或者使用把两者合并为原子步骤的 API。
原子操作需要明确宽度、对齐、操作类型与内存顺序契约。Node 的 Atomics 方法作用于受支持的共享类型化数组,并提供运行时定义的顺序。它不会让普通对象跨 isolate 可达,也不会保护横跨多个数组元素的业务不变量。
互斥锁、条件与死锁
互斥锁为某个区域建立排他所有权,并在解锁与之后成功加锁之间建立顺序。受保护状态及其不变量应记录在互斥锁旁边。单独设置「锁目录」或为每个字段分配一把锁,往往会掩盖哪些组合必须一起改变。
条件等待通常写成循环:取得互斥锁、测试谓词、谓词为假时等待,并在醒来后重新测试。等待操作会在休眠时释放互斥锁,并在返回前重新取得它。如果不在相关所有权规则下更新谓词就发出信号,唤醒可能丢失,也可能毫无意义。
死锁需要形成等待依赖环。统一加锁顺序能破坏这个环,而单一所有者队列可以彻底移除共享锁依赖。检测与超时能让故障可见,却不能恢复只完成一部分的操作;回滚或幂等重试仍是应用层问题。
多线程程序中的进程创建
多线程进程调用 fork() 时需要特别谨慎。子进程开始时只有发起调用的线程,但内存中可能保留已经不在子进程中的其他线程所持有的锁。在 fork() 与 exec() 之间,只能安全执行平台契约允许的操作。
高级进程 API 通常引导用户采用创建并执行新程序的路径,避免在这种脆弱状态下运行大量子进程代码。如果某个库启动了后台线程,原本无害的手动 fork() 也可能变得不安全。应把进程创建行为视为运行时与库兼容性契约的一部分。
文件描述符继承也是一项边界决定。如果子进程意外保持管道末端打开,读取方可能永远观察不到文件结束。应把无关文件描述符标记为执行时关闭,只传递子进程需要的句柄,并测试子进程在不同阶段失败时的关闭行为。
等待、分离与取消
等待线程结束会形成终止汇合点,之后可以按照 API 的内存模型安全读取最终结果。但等待不会请求终止。取消只会要求工作停止;协作式取消必须等到工作线程检查信号或到达可取消操作,才会取得进展。
分离线程或让工作线程不再被事件循环引用,只会解除某个所有者的义务,或者允许运行时不等待它就退出。这不会消除资源消耗、抑制副作用或保证清理。只有另一个所有者确实会监管生命周期,或者契约明确允许放弃工作时,才能这样做。
进程的优雅关闭是一套协议:停止接收工作、请求取消、让进行中操作到达安全点、关闭通道、在截止时间内等待,必要时再升级终止手段。应记录哪个阶段超时。单条「已终止」日志无法说明数据得到排空还是被丢弃。
选择边界
| 要求 | 通常优先选择 | 仍需验证的原因 |
|---|---|---|
| 隔离崩溃或不可信代码 | 受约束进程 | 仍可能共享文件、凭据与内核攻击面 |
| 共享大型可变工作集 | 线程 | 同步与运行时内存规则可能成为主要成本 |
| 并行执行 CPU 工作 | 取决于运行时的线程或进程 | 全局运行时锁与序列化成本各不相同 |
| 监管独立服务 | 进程 | 启动、健康、重启与版本边界会更明确 |
| 协调大量等待操作 | 异步任务或有界线程池 | 阻塞库仍可能占用线程 |
混合设计很常见。一个服务可以用多个进程隔离故障,用有界线程池执行 CPU 工作,再用异步任务处理网络并发。每层都需要自身的容量上限与关闭路径;多个默认值相乘,可能产生远超预期的总并发量。
选择消息内容时,也要像选择共享状态一样谨慎。复制大型负载可能主导进程通信成本,而共享缓冲区需要版本、所有权与同步规则。分别测量序列化、排队延迟、有效工作和清理,才能看清边界成本。
诊断并发故障
诊断应从时间线开始,不能只看栈追踪。收集进程与线程身份、状态转换、消息标识、锁等待、取消请求、截止时间、退出,以及每项资源的负责方。时长应使用单调时间戳,以免挂钟调整扭曲顺序。
间歇性故障可以按以下顺序处理:
- 写明遭到违反的不变量,以及最小的外部可见错误结果。
- 找出读取或修改相关状态的所有执行路径。
- 标出本应为每组冲突访问建立顺序的同步边或消息边。
- 在每个边界强制触发创建失败、消息延迟、工作线程退出与取消。
- 把追踪缩减成一个可复现调度,再将它保留为回归测试。
线程消毒器、竞态检测器、锁诊断与调度器追踪可以发现普通测试遗漏的证据。它们仍然只能观察实际执行的路径和受支持操作。应把工具输出与书面的所有权及先发生关系论证结合起来。
进程故障也需要边界级证据。应捕获退出状态或信号、标准错误、资源上限事件,并判断最后一个请求是否可能已经向外部提交副作用。只有应用操作可以安全重放或受幂等机制保护时,重试崩溃进程才安全。
延伸阅读
5个问题 · 1 道输出预测题 · 1 道找错题