并发编程

并发的本质

并发系统模型

并发真正要解决的问题是:多个执行主体在不确定时间顺序下,对状态进行协作的问题。

并发系统 = 执行单元(谁在运行) + 状态(谁被修改) + 时间交错(顺序是否确定) + 协调机制(如何避免冲突)

并发的核心矛盾

共享(协同)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 同时保证:

四种安全发布手段,本质都是在"发布"这一时间点建立 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,前沿)

坐标暴露两条规律:

典型并发模型:各自的取舍

定位之后看细节——每个模型都不消灭复杂度,只转移复杂度:

模型 坐标 本质 消除的问题 转移出的复杂度
锁模型 共享 / 动态 控制共享 数据竞争 死锁
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,而是:

如何在不确定时间中维持状态一致性。 并发编程,本质上是"状态协调工程学"。

关联内容