并发编程
并发的本质
并发系统模型
并发真正要解决的问题是:多个执行主体在不确定时间顺序下,对状态进行协作的问题。
并发系统 = 执行单元(谁在运行) + 状态(谁被修改) + 时间交错(顺序是否确定) + 协调机制(如何避免冲突)并发的核心矛盾
共享(协同)vs 隔离(避免冲突)所有并发模型都在这两个极端之间权衡:
| 模型 | 倾向 |
|---|---|
| Actor | 隔离 |
| CSP | 隔离 |
| STM | 共享 |
| 线程+锁 | 共享 |
并发问题分析
充分必要条件
并发问题必须同时满足三个条件:多个执行主体 + 共享可变状态 + 时间交错。
消除任意一个条件:
| 消除项 | 结果 |
|---|---|
| 不共享 | 无竞争 |
| 不可变 | 无修改 |
| 无时间交错 | 无并发 |
并发问题不在于线程,而是共享。
错误统一抽象
并发错误本质上是协调失效:
| 类型 | 含义 | 本质 |
|---|---|---|
| 安全性 | 不做错事 | 状态协调失效 |
| 活跃性 | 系统持续推进 | 进度协调失效 |
状态协调失效(安全性)
三个永恒问题:
| 问题 | 本质 | 根源 |
|---|---|---|
| 可见性 | 修改是否被观察到 | 缓存 / 重排 |
| 原子性 | 操作是否被打断 | 时间交错 |
| 有序性 | 执行顺序是否一致 | 编译器 / CPU 优化 |
错误本质:基于已经失效的观察结果做出了决策。
| 问题 | 本质 | 具体含义 |
|---|---|---|
| 竞态条件 | 观察失效 | 观察到状态为 X,基于它做决策,但在决策提交前另一个线程已把状态改成 Y,导致决策基于失效前提 |
| 脏读 | 观察滞后 | 读到另一个线程正在修改但尚未提交的值,这个值可能回滚或被覆盖 |
| 双重创建 | 状态失真 | 对象构造期间(构造函数未执行完),状态还不完整就被其他线程看到 |
| ABA 问题 | 时间错觉 | 观察到的值与之前相同(A),但中间经历了 A→B→A 的完整迁移,掩盖了状态变化的历史 |
不变式(Invariant)是对象在生命周期中必须始终保持的业务条件,例如账户余额 >= 0、订单状态不可逆。所谓的线程安全是:无论执行顺序如何,对象始终维持其不变式(Invariant)。
线程安全 = 不变式始终成立线程安全分类
| 分类 | 含义 | 风险来源 | 需要同步 | 代表 |
|---|---|---|---|---|
| 不可变安全 | 天然线程安全 | 无(状态不可变) | 不需要 | String、final 字段 |
| 封闭安全 | 不共享 | 无(执行单元私有) | 不需要 | 栈变量、ThreadLocal |
| 相对安全 | 单操作安全 | 复合操作被打断 | 需要(针对单操作) | AtomicInteger |
| 组合不安全 | 多步骤破坏不变式 | 业务不变式未被保护 | 需要(业务层面) | 多字段组合的业务对象 |
锁、CAS、事务都只是维护不变式的手段。
共享 + 可变 + 未受控访问 = 不安全设计优先级:不共享 > 不可变 > 消息通信 > 乐观协调 > 互斥锁
进度协调失效(活跃性)
活跃性问题的本质是:协调结构失效导致无法推进,各方对"对方会让步"的假设循环依赖。
| 问题 | 本质 | 具体表现 |
|---|---|---|
| 死锁 | 循环资源等待 | 线程互相持有对方需要的锁,都阻塞等待 |
| 活锁 | 持续协商但永不成功 | CPU 繁忙但系统无进展,始终在响应但无法推进 |
| 饥饿 | 长期得不到调度机会 | 某些线程永远无法获得所需资源 |
死锁:循环资源等待。Coffman 条件:互斥、占有等待、不可剥夺、循环等待。
活锁:持续协商但永不成功。特点:CPU 很高,系统无进展。
饥饿:长期得不到调度机会。典型:优先级反转、非公平锁。
内存模型:并发世界的抽象规范
内存模型是什么
内存模型是多线程环境下内存访问行为的抽象规范,回答的核心问题是:
一个线程的内存操作,何时对另一个线程可见?
它是硬件能力与编程语言之间的契约层——约定了你能期望什么,而不需要关心硬件怎么实现。
为什么需要内存模型
现代硬件为了性能做的事:
| 优化 | 导致的问题 |
|---|---|
| CPU 缓存 | 线程看不到其他线程的修改(可见性问题) |
| 指令重排 | 程序顺序与执行顺序不符(有序性问题) |
| 异步写入 | 复合操作被打断(原子性问题) |
内存模型定义:当程序员写了 X 代码,真实执行时 Y 行为是否可以接受。
内存模型定义三个保证
| 保证 | 解决什么问题 |
|---|---|
| 可见性保证 | 写入何时对线程可见(为什么他看不到我的修改) |
| 有序性保证 | 哪些操作顺序是强制的(重排到什么时候是错的) |
| 原子性保证 | 哪些操作是不可分割的(复合操作怎么保证完整) |
happens-before:跨线程的保证
happens-before 是可见性 + 有序性的组合保证:
A happens-before B=B 能看到 A 的结果且A 在 B 之前排序它不是时间概念,而是语言层(Java JMM / C++ Memory Model / Rust Send/Sync)共同遵守的理论基础。
内存模型的光谱
不同硬件的"一致性代价"不同:
| 类型 | 特点 | 代表 |
|---|---|---|
| 强顺序(TSO) | 代价内化,始终慢 | x86 |
| 弱顺序 | 代价显式,平时快,需要时贵 | ARM, PowerPC |
| 顺序一致 | 所有线程看到一致顺序 | 默认模型 |
强顺序模型:硬件帮你做所有屏障,编程简单但始终有开销弱顺序模型:需要时手动加屏障,性能更好但编程复杂硬件到语言的链路
硬件(CPU 缓存 / 重排 / 原子指令) ↓ 提供能力内存模型(定义契约) ↓ 提供语法语言(Java volatile / C++ memory_order / Rust Send/Sync) ↓程序员(使用 API)硬件负责"能做到什么",内存模型负责"做到了什么效果"——这是必要的抽象层,让软件在不同硬件上有一致行为。
并发实践
并发控制哲学
并发控制不是"保护代码",而是协调状态访问顺序——即并发系统四要素中"协调机制"的落地。所有控制手段按是否保留冲突分为两层:
| 层级 | 思路 | 策略 | 回扣 |
|---|---|---|---|
| 消除型 | 让并发问题不成立 | 隔离(消共享)、不可变(消可变) | 充要条件表、线程安全分类 |
| 协调型 | 冲突存在,管理它 | 乐观(假设冲突少)、悲观/互斥(假设冲突多) | 悲观与乐观差异 |
统一优先级:消除 > 协调,即 不共享 > 不可变 > 消息通信 > 乐观 > 互斥。共享越少,复杂度越低。
消除型:从根源让问题不存在
锁是共享之后的补救,隔离与不变则从根源消灭竞争。
最好的锁 = 不存在的锁| 手段 | 本质 | 条件 |
|---|---|---|
| 隔离(线程封闭) | 对象仅属单执行单元(栈变量、协程局部、Actor 内部状态) | 不逸出执行单元 |
| 不可变 | 共享不可避免时,共享不可变状态 | 状态不可改 + final(禁重排)+ 构造期不逸出 |
二者共同点:无需同步、无需可见性保证、无时间问题。
协调型:冲突存在时如何管理
二者分歧在对冲突概率的假设:
| 维度 | 悲观/互斥 | 乐观 |
|---|---|---|
| 假设 | 冲突一定发生 | 冲突是小概率 |
| 策略 | 先限制,再执行 | 先执行,冲突再处理 |
| 典型 | Mutex、synchronized、数据库锁 | CAS(先执行失败重试)、版本(MVCC、COW 快照读) |
| 代价 | 阻塞、上下文切换、死锁风险 | 重试、ABA、自旋消耗(CAS 系) |
安全发布
并发问题很多不是"锁问题",而是发布时机问题:对象在构造完成前就进入了共享域,再完美的锁保护的也是半成品。
发布:对象从私有域进入共享域(放入缓存、注册监听器、返回给其他线程)。
逸出 = 失控的发布:对象在未准备好前被其他执行单元访问。最危险情况是构造期间 this 逸出(注册监听器、启动内部线程),导致 final 语义失效、对方读到不完整对象。
安全发布的本质不是"有没有加锁",而是是否建立了 happens-before。因为 happens-before 同时保证:
- **可见性**——构造期的所有写入都已刷出,对方不会读到 null 或旧值
- **有序性**——构造完成排在发布之前,对方看到的必然是完整对象
四种安全发布手段,本质都是在"发布"这一时间点建立 happens-before,区别只在途径:
| 手段 | 建立 happens-before 的途径 |
|---|---|
| 静态初始化 | JVM 类加载锁保证初始化先于任何使用 |
| final 字段 | 构造结束的 freeze 屏障,对正确发布的对象可见 |
| volatile | 写-读屏障传递可见性与有序性 |
| 锁保护发布 | 解锁 happens-before 后续加锁 |
性能与伸缩性的本质
并发不是免费的。其代价与上限由两条根本定律决定,"减少共享"等优化口诀都是它们的推论。
伸缩性的两个天花板
| 定律 | 结论 | 含义 |
|---|---|---|
| Amdahl 定律 | 加速比上限 = 1 / 串行占比 | 串行部分决定天花板,再多核也无法突破 |
| USL(通用伸缩性定律) | 在 Amdahl 上叠加一致性开销项 | 该项随并发数二次增长,故加核到一定程度反而变慢 |
真正限制伸缩性的不是 CPU,而是共享热点——共享导致的一致性流量正是 USL 里那个二次恶化项。所以现代系统的演化方向是:共享最小化 + 局部性最大化。局部性之所以关键:共享的真实粒度是物理缓存行而非逻辑变量——逻辑上不相干的数据若落在同一缓存行,仍构成共享热点(即伪共享)。
代价的三个来源
并发损耗并非单一,分属不同抽象层:
| 来源 | 成本 | 抽象层 |
|---|---|---|
| 调度 | 上下文切换 | OS |
| 一致性 | 缓存失效、内存屏障(Fence) | 硬件 / 内存模型 |
| 串行化 | 锁竞争 | 算法 / Amdahl 串行占比 |
后两类直接回扣内存模型章——缓存与屏障的代价,在伸缩性层面显形为一致性开销。
锁竞争的量化
临界区的串行化程度近似排队论的利用率:
ρ ≈ 到达率 × 持锁时间ρ 越接近 1,等待越剧烈。优化顺序据此推出:减少共享(降到达率)> 缩短临界区(降服务时间)> 优化锁(降单次开销)。
并发目标不是线程更多,而是等待更少。
并发模型的统一分类
并发模型看似繁多,实则由两条正交的设计轴决定:怎么通信与安全从哪来。两轴定义一个坐标平面,所有模型都是其中的坐标点。
第一维:通信机制
| 模型 | 协作方式 | 特点 | 代表 |
|---|---|---|---|
| 共享内存 | 我修改,你直接看到 | 灵活、高性能、容易失控 | Java Threads、pthreads |
| 消息传递 | 我修改,告诉你,你再处理 | 隔离性强、易扩展、延迟更高 | Actor、CSP |
第二维:安全保证来源
| 约束 | 时机 | 特点 | 代表 |
|---|---|---|---|
| 动态约束 | 运行时发现错误 | 灵活、容易遗漏 | 锁、CAS、STM |
| 静态约束 | 编译期证明安全 | 强约束、更安全 | Rust Ownership、线性类型系统 |
两维坐标:模型的统一定位
把典型模型放进 (通信 × 安全) 平面,分类才真正"统一":
| 动态约束(运行时) | 静态约束(编译期) | |
|---|---|---|
| 共享内存 | 锁、STM | Ownership |
| 消息传递 | Actor、CSP | 会话类型(Session Types,前沿) |
坐标暴露两条规律:
- **同一通信方式下,安全轴决定代价时机**:共享内存里,锁/STM 把检查推迟到运行时,Ownership 提前到编译期——同源不同时。
- **空格即演进方向**:消息传递 + 静态约束 长期是空白,会话类型正是在填补它(编译期证明通道协议不出错)。
典型并发模型:各自的取舍
定位之后看细节——每个模型都不消灭复杂度,只转移复杂度:
| 模型 | 坐标 | 本质 | 消除的问题 | 转移出的复杂度 |
|---|---|---|---|---|
| 锁模型 | 共享 / 动态 | 控制共享 | 数据竞争 | 死锁 |
| Actor | 消息 / 动态 | 封装状态 | 共享问题 | 消息一致性 |
| CSP | 消息 / 动态 | Channel 协作 | 显式锁 | 通道阻塞 |
| STM | 共享 / 动态 | 内存事务 | 锁管理 | 回滚成本 |
| Ownership | 共享 / 静态 | 类型隔离 | 数据竞争 | 生命周期复杂度 |
"转移出的复杂度"列揭示一条守恒律:选型不是挑"最好的模型",而是选愿意承受哪一类复杂度。
现代并发演化
并发的根本困难是共享状态 + 时间不确定性。看似纷繁的现代技术,实则沿四条同向矢量演进——每条都在削弱这两个困难之一:
| 矢量 | 演进 | 攻击的困难 | 实例 | 消除的痛点 |
|---|---|---|---|---|
| 结构化 | 隐式 → 显式 | 时间不确定性 | Async/Await(控制流)、Structured Concurrency(生命周期) | 回调地狱、孤儿任务、资源泄漏 |
| 去共享 | 共享 → 隔离/不可变 | 共享状态 | Actor、不可变数据(见核心矛盾) | 数据竞争 |
| 静态化 | 运行时 → 编译期 | 时间不确定性 | Rust Ownership、会话类型(见两维坐标的右移) | 错误发现太晚 |
| 无阻塞 | 挂起等待 → 流式推进 | 时间不确定性 | Lock-Free、Reactive 背压(见性能与伸缩性章) | 线程挂起传播、伸缩性瓶颈 |
四条矢量殊途同归:要么消除共享,要么把时间交错变得显式、可证、不阻塞——与开篇"共享 vs 隔离"的核心矛盾首尾呼应。
并发设计模式
设计模式是前述原理的落地实例——"问题 → 解法 → 代价"的复用模板。三类模式分别对应三种诉求:安全(去共享,消除型控制)、协调(共享受限下的协作协议)、伸缩(突破单点容量)。每个模式都在转移而非消灭复杂度。
安全性模式
去共享 / 去可变,从根源消除竞争——"消除型"控制的实例。
| 模式 | 解决的问题 | 机制 | 转移出的代价 |
|---|---|---|---|
| Immutable | 共享可变状态引发竞争 | 状态不可改,构造后永不迁移 | 每次"修改"需新建对象(内存 / GC 压力) |
| Thread Confinement | 对象被多线程共享 | 对象限定在单一执行单元内,不发布 | 跨线程传递需显式拷贝或移交 |
| Copy-On-Write | 读多写少时读写互斥开销大 | 写时复制副本,读旧本无锁 | 写放大、内存翻倍、读可能拿到旧快照 |
| ThreadLocal | 全局状态被并发访问 | 每个执行单元持有私有副本 | 内存随线程数增长,线程池下需清理(泄漏 / 串数据) |
协调性模式
共享不可避免时,用结构化协议管理访问顺序——"协调型"控制的实例。
| 模式 | 解决的问题 | 机制 | 转移出的代价 |
|---|---|---|---|
| Producer-Consumer | 生产 / 消费速率不匹配 | 队列缓冲,解耦两端节奏 | 队列容量与背压(满 / 空时阻塞) |
| Guarded Suspension | 前置条件未满足时如何等待 | 条件不成立则挂起,成立再唤醒 | 虚假唤醒,须 while 循环复检 |
| Reader-Writer | 读多写少时统一锁粒度过粗 | 读共享、写独占 | 写饥饿(读不断则写等不到) |
| Two-Phase Termination | 如何安全停止运行中的任务 | 先发停止信号,再等清理完成 | 需可中断点,清理顺序敏感 |
可伸缩模式
突破单点容量与速率瓶颈——多在分布式层落地。
| 模式 | 解决的问题 | 机制 | 转移出的代价 |
|---|---|---|---|
| Worker Pool | 无界任务并发导致资源耗尽 | 固定 worker 数 + 任务队列,复用执行单元 | 队列积压、拒绝策略,任务间不可相互依赖 |
| MQ | 瞬时流量超过处理能力,上下游强耦合 | 异步消息缓冲,削峰填谷 + 解耦 | 延迟增加,消息重复 / 顺序 / 一致性 |
| Sharding | 单点状态成为热点与容量瓶颈 | 按 key 拆分到多分片,并行处理 | 跨分片操作复杂,再平衡成本 |
| Consistent Hash | 分片增减时数据大规模迁移 | 哈希环,节点变动只影响相邻区间 | 负载不均(需虚拟节点),实现复杂 |
并发测试哲学
根因:时间是不可控的隐藏输入
普通测试是"固定输入 → 固定输出";并发的输入除了数据,还有交错顺序,而它由运行时决定、每次不同(即前文的时间不确定性)。于是同样输入这次通过、下次崩溃——并发 Bug 时序相关、概率触发、难以复现。
范式转变:从"验证输出"到"探索交错"
既然时间是输入,测一次只覆盖了一种交错,等于只测了一个输入点。并发测试的核心因此转变:
不是验证"这次跑对了",而是控制、扰动或穷尽交错空间,逼出坏交错或证明其不存在。
所有并发测试手段都是这一目标的不同强度实现。
对策:两条路线
| 路线 | 思路 | 手段 | 边界 |
|---|---|---|---|
| 提高触发概率 | 多跑、乱跑、扰动调度,让罕见坏交错显形 | 压力 / 随机重复、延迟注入(jcstress、CHESS)、竞争检测器(TSan、-race) | 只能"发现存在",不能"证明不存在" |
| 穷尽或证明 | 把交错空间系统化覆盖或形式化推理 | 确定性重放 / 模拟、模型检测(TLA+、SPIN)、不变式 / 属性断言 | 状态爆炸,规模受限 |
竞争检测器是性价比最高的一档:它查的是数据竞争这一根因,不依赖 Bug 是否恰好显形。
测试重点
测什么,对应前文"错误统一抽象"的四类失效——关键是每一维如何在交错中检验:
| 维度 | 核心 | 怎么测 |
|---|---|---|
| 正确性 | 不变式始终成立 | 高并发下持续断言不变式(如余额 >= 0) |
| 安全性 | 状态不被破坏 | 竞争检测器 + 随机交错压力 |
| 活跃性 | 系统持续推进 | 死锁检测、超时探针、活锁观测(CPU 高但无进展) |
| 性能 | 延迟与吞吐 | 递增并发压测,观察 ρ 趋近 1 时的拐点(见性能章) |
并发误区与反模式
认知误区
观念层面的错误判断——多源于把直觉套用到并发:
| 误区 | 真相 |
|---|---|
| 并发 = 并行(多线程必加速) | 并发管等待、并行才加速;CPU 密集的加速受 Amdahl 限制(见性能章) |
| 锁 = 安全 | 锁只护它圈住的范围,跨多步的业务不变式仍可能被破坏(见错误抽象) |
| volatile = 轻量锁 | volatile 只保证可见性 / 有序性,不保证原子性(见内存模型) |
| 响应式无需同步 | 消除了显式回调,但 operator 间状态、订阅生命周期、线程切换边界仍需协调 |
| 协程没有并发问题 | 单线程协程在 await 让出点仍会被交错 / 重入;多线程协程更叠加数据竞争 |
| 死锁是偶然 | 死锁是结构缺陷——满足 Coffman 四条件就必然发生(见活跃性) |
真正的反模式
做法层面的坏实践——看似可行却有害:
| 反模式 | 危害 | 正确做法 |
|---|---|---|
| 忙等待 / 自旋空转 | 烧 CPU 却无进展 | 用条件变量 / 阻塞队列挂起等待 |
| 锁内做 I/O 或调用外部代码 | 持锁时间不可控,利用率 ρ 飙升(见性能章) | I/O 移出临界区,锁内只护状态 |
| 嵌套锁、加锁顺序不一致 | 循环等待 → 死锁 | 统一全局锁序 / 一次性获取 / tryLock 超时 |
| 全局粗粒度锁 | 一切串行化,伸缩性归零 | 锁分段 / 缩小临界区 / 改无锁结构 |
| 用 sleep 凑同步 | 既慢又不可靠,竞态依旧存在 | 显式同步原语(latch、条件变量) |
| 双重检查锁定漏 volatile | 可能读到半初始化对象(见安全发布) | 字段加 volatile,或用静态 holder / 枚举单例 |
并发设计哲学总结
全文可收束为一条因果链:一个矛盾派生两类问题,两类问题对应两条对策,对策落地为各章内容。
一个矛盾 共享状态 × 时间不确定性 ↓ 派生两类问题 安全性(状态协调失效) + 活跃性(进度协调失效) ↓ 对策两条主线 消除共享 驯服时间 (隔离 / 不可变) (结构化 / 静态化 / 无阻塞) ↓ 落地各章展开 控制哲学 → 模型坐标 → 设计模式 → 测试范式两条主线对应全文的两组章节:
| 主线 | 攻击的困难 | 展开于 |
|---|---|---|
| 消除共享 | 共享状态 | 控制哲学(消除型)、安全发布、安全性模式 |
| 驯服时间 | 时间不确定性 | 内存模型(happens-before)、现代演化(结构化/静态化/无阻塞)、测试范式 |
由此得到全文唯一的最高原则——按"消除 > 协调"排序:
不共享 > 不可变 > 消息通信 > 乐观 > 互斥
并发的真正难点不是线程、锁或 API,而是:
如何在不确定时间中维持状态一致性。 并发编程,本质上是"状态协调工程学"。
关联内容
- [/编程语言/并发模型.html](/编程语言/并发模型.html) 不同编程语言的并发模型比较,对应"统一分类"的坐标落地
- [/编程语言/JAVA/JVM/JAVA内存模型.html](/编程语言/JAVA/JVM/JAVA内存模型.html) happens-before 与可见性/有序性/原子性在 JMM 中的具体实现
- [/编程语言/Rust.html](/编程语言/Rust.html) 所有权与线性类型——"静态约束"轴的编译期并发安全
- [/编程语言/编程范式/函数式编程.html](/编程语言/编程范式/函数式编程.html) 不可变性从根源消除并发问题,对应"消除型"控制
- [/中间件/数据库/数据库系统/事务管理/事务.html](/中间件/数据库/数据库系统/事务管理/事务.html) MVCC、隔离级别、两阶段锁——乐观/悲观并发控制的数据库实现
- [/操作系统/进程与线程.html](/操作系统/进程与线程.html) 操作系统层面的执行单元概念
- [/操作系统/死锁.html](/操作系统/死锁.html) 死锁与 Coffman 条件,对应活跃性失效
- [/计算机系统/程序结构和执行/存储器层次结构.html](/计算机系统/程序结构和执行/存储器层次结构.html) 缓存一致性、伪共享、局部性的硬件根因
- [/软件工程/性能工程.html](/软件工程/性能工程.html) Amdahl 定律、USL、排队论——并发性能与伸缩性的理论基础
- [/软件工程/架构/系统设计/高并发.html](/软件工程/架构/系统设计/高并发.html) 伸缩性原理在高并发系统设计中的应用
- [/计算机网络/IO模型.html](/计算机网络/IO模型.html) IO 模型与异步/等待,关联 Async 与事件驱动并发