并发编程手册
速查向 · 概念辨析与场景设计的思路要点(不附代码,重在设计思想与解题方向)
1. 基础概念辨析
1.1 进程 / 线程 / 协程
Q:说说三者的区别与适用场景。
进程是资源分配的基本单位,有独立地址空间,切换开销最大(涉及页表切换、TLB 失效)。
线程是 CPU 调度的基本单位,同进程线程共享地址空间,切换只保存寄存器,开销较小,但共享内存需同步。
协程是用户态的轻量执行体,由语言运行时/库调度,不经过内核,切换开销最小(纳秒级),适合高并发 IO 密集。
要点:进程隔离性最好、线程共享方便但需锁、协程最轻量但需语言支持。
线程是 CPU 调度的基本单位,同进程线程共享地址空间,切换只保存寄存器,开销较小,但共享内存需同步。
协程是用户态的轻量执行体,由语言运行时/库调度,不经过内核,切换开销最小(纳秒级),适合高并发 IO 密集。
要点:进程隔离性最好、线程共享方便但需锁、协程最轻量但需语言支持。
1.2 并行 vs 并发
Q:并发和并行的区别?
并发(Concurrency)是逻辑上同时处理多个任务,通过时间片轮转,可以是单核。
并行(Parallelism)是物理上同时执行,必须多核。
比喻:并发是一个人交替做多件事,并行是多人同时各做一件事。并发是结构,并行是执行。
并行(Parallelism)是物理上同时执行,必须多核。
比喻:并发是一个人交替做多件事,并行是多人同时各做一件事。并发是结构,并行是执行。
1.3 上下文切换
Q:什么是上下文切换?开销来自哪里?
保存当前任务的执行状态(寄存器、程序计数器、栈指针),恢复下一个任务的状态。
开销来源:① 寄存器/栈的保存恢复;② 进程切换还需切换页表导致 TLB 失效;③ 缓存局部性被破坏(cache 失效)。
如何减少:减少线程数、用协程、无锁编程、批量处理、绑核(CPU affinity)。
开销来源:① 寄存器/栈的保存恢复;② 进程切换还需切换页表导致 TLB 失效;③ 缓存局部性被破坏(cache 失效)。
如何减少:减少线程数、用协程、无锁编程、批量处理、绑核(CPU affinity)。
1.4 死锁
Q:死锁的四个必要条件?如何预防?
四个必要条件(缺一不可):
① 互斥:资源同一时刻只能被一个持有;
② 持有并等待:持有资源同时等待其他资源;
③ 不可剥夺:资源不能被强行抢走;
④ 循环等待:存在等待环路。
预防(破坏任一条件):① 尽量用共享/无锁结构;② 一次性申请全部资源;③ 允许超时抢占/释放;④ 统一加锁顺序(最实用)。
① 互斥:资源同一时刻只能被一个持有;
② 持有并等待:持有资源同时等待其他资源;
③ 不可剥夺:资源不能被强行抢走;
④ 循环等待:存在等待环路。
预防(破坏任一条件):① 尽量用共享/无锁结构;② 一次性申请全部资源;③ 允许超时抢占/释放;④ 统一加锁顺序(最实用)。
避免 vs 检测:银行家算法(避免)、资源分配图找环(检测)。工程上更常用加锁顺序 + 超时。
1.5 活锁与饥饿
Q:活锁和饥饿是什么?
活锁:线程没阻塞,一直在"礼让/重试",但始终无法推进(如两个线程同时退让)。
饥饿:低优先级线程长期拿不到资源,被高优先级线程持续抢占。
解决:活锁用随机退避;饥饿用公平锁(FIFO)或优先级老化。
饥饿:低优先级线程长期拿不到资源,被高优先级线程持续抢占。
解决:活锁用随机退避;饥饿用公平锁(FIFO)或优先级老化。
2. 锁与同步机制
2.1 乐观锁 vs 悲观锁
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 思想 | 假设一定会冲突,先加锁 | 假设不冲突,提交时校验 |
| 实现 | 互斥锁、行锁、SELECT FOR UPDATE | 版本号、CAS、时间戳 |
| 适用 | 写多读少、冲突激烈 | 读多写少、冲突少 |
| 代价 | 加锁开销、可能阻塞 | 失败需重试、ABA 问题 |
ABA 问题:值从 A→B→A,CAS 认为没变。解决:加版本号或时间戳。
2.2 CAS 与原子操作
Q:CAS 的原理和缺点?
原理:比较内存值与期望值,相等则更新为新值,整个操作由 CPU 指令(如
缺点:① ABA 问题;② 自旋开销大(高竞争下空转浪费 CPU);③ 只能保证一个变量的原子性(多个可用 AtomicReference 打包或加锁)。
CMPXCHG)保证原子。缺点:① ABA 问题;② 自旋开销大(高竞争下空转浪费 CPU);③ 只能保证一个变量的原子性(多个可用 AtomicReference 打包或加锁)。
2.3 自旋锁 / 读写锁 / 可重入锁
- 自旋锁:不睡眠、忙等待,适合临界区极短的场景;长临界区浪费 CPU。
- 读写锁:读读并行、读写/写写互斥;适合读多写少;注意可能写饥饿。
- 可重入锁:同一线程可重复获取,内部记录持有计数;避免递归调用自锁死。
- 公平锁 vs 非公平锁:公平按 FIFO(无饥饿但吞吐低);非公平允许插队(吞吐高但可能饥饿)。
2.4 无锁编程
Q:如何实现一个无锁队列?思路是什么?
核心:用 CAS 操作头尾指针代替加锁。
单生产者-单消费者(SPSC):最简单,生产者和消费者各操作一端,无需 CAS,只需保证读写指针的可见性(内存屏障)。
多生产者:入队需 CAS 竞争尾指针,失败则重试;难点是安全的回收节点(否则 ABA / 悬空指针)。
关键技术:CAS、内存屏障(acquire/release)、内存回收(引用计数、Hazard Pointer、RCU)。
单生产者-单消费者(SPSC):最简单,生产者和消费者各操作一端,无需 CAS,只需保证读写指针的可见性(内存屏障)。
多生产者:入队需 CAS 竞争尾指针,失败则重试;难点是安全的回收节点(否则 ABA / 悬空指针)。
关键技术:CAS、内存屏障(acquire/release)、内存回收(引用计数、Hazard Pointer、RCU)。
陷阱:"无锁"不等于"无等待",多生产者下仍可能自旋重试(lock-free 但非 wait-free)。
3. 线程池与并发控制
3.1 线程池设计
Q:设计一个线程池需要考虑哪些点?
① 核心/最大线程数:IO 密集型可设大(2×核数以上),CPU 密集型设核数+1;
② 任务队列:有界(防 OOM)还是无界(可能积压);
③ 拒绝策略:抛异常 / 丢弃 / 丢弃最旧 / 由提交者执行(降级);
④ 线程复用与保活:空闲超时回收;
⑤ 优雅关闭:停止接收新任务 + 等待已提交任务完成;
⑥ 监控:队列长度、活跃线程数、拒绝次数;
⑦ 命名与隔离:不同业务用不同池,避免互相拖垮。
② 任务队列:有界(防 OOM)还是无界(可能积压);
③ 拒绝策略:抛异常 / 丢弃 / 丢弃最旧 / 由提交者执行(降级);
④ 线程复用与保活:空闲超时回收;
⑤ 优雅关闭:停止接收新任务 + 等待已提交任务完成;
⑥ 监控:队列长度、活跃线程数、拒绝次数;
⑦ 命名与隔离:不同业务用不同池,避免互相拖垮。
3.2 限流算法
| 算法 | 思路 | 特点 |
|---|---|---|
| 固定窗口 | 每秒计数,超限拒绝 | 简单,但有临界突刺问题 |
| 滑动窗口 | 按时间粒度细分统计 | 平滑,内存开销略大 |
| 漏桶 | 请求入桶,匀速流出 | 绝对平滑,不能应对突发 |
| 令牌桶 | 匀速生成令牌,拿牌才放行 | 允许一定突发,最常用 |
Q:分布式限流怎么做?
用 Redis + Lua 保证"读计数 + 判断 + 自增"的原子性;或基于令牌桶的 Redis 实现。
注意时间同步和 Redis 单点压力,可本地限流 + 集群限流两级配合。
注意时间同步和 Redis 单点压力,可本地限流 + 集群限流两级配合。
3.3 生产者-消费者
Q:实现生产者消费者模型,关键点是什么?
三要素:① 缓冲区(有界队列,满则阻塞生产者,空则阻塞消费者);② 同步机制(互斥锁 + 条件变量,或用阻塞队列);③ 停止信号(关闭通知消费者退出,避免永久阻塞)。
要点:用 while 而非 if 判断条件(防虚假唤醒);先获取锁再判断条件;停产后要唤醒所有等待者。
要点:用 while 而非 if 判断条件(防虚假唤醒);先获取锁再判断条件;停产后要唤醒所有等待者。
3.4 多线程按序执行
Q:如何让多个线程按指定顺序执行?
思路一(信号量):每个线程配一个信号量,A 完成后 release B 的信号量,依次传递。
思路二(条件变量):共享一个"当前轮到谁"的变量,不满足则 wait,满足则唤醒下一个。
思路三(管道/channel):用阻塞队列串联,前一个的输出作为后一个的输入(流水线)。
扩展:交替打印——两个线程分别 wait/notify,或用两个 channel 互相传递"令牌"。
思路二(条件变量):共享一个"当前轮到谁"的变量,不满足则 wait,满足则唤醒下一个。
思路三(管道/channel):用阻塞队列串联,前一个的输出作为后一个的输入(流水线)。
扩展:交替打印——两个线程分别 wait/notify,或用两个 channel 互相传递"令牌"。
3.5 超时与取消
Q:如何实现任务超时控制与取消?
核心思路:传递一个"取消信号"向下传播。
① Go:
② Java:
③ 通用:用
要点:取消要逐层传递(父取消则子全取消);资源要及时释放(defer/cancel);不可取消的阻塞(如某些系统调用)需配合强制中断或监控。
① Go:
context.WithTimeout 派生子 context,内部通过 Done() channel 通知;② Java:
Future.get(timeout) 或 CompletableFuture.orTimeout;③ 通用:用
select/poll 配合超时时间等待结果 channel。要点:取消要逐层传递(父取消则子全取消);资源要及时释放(defer/cancel);不可取消的阻塞(如某些系统调用)需配合强制中断或监控。
3.6 单例与初始化
Q:如何实现线程安全的单例?
① 饿汉式:类加载即创建,简单但可能浪费(不用也建);
② 懒汉 + 双检锁(DCL):注意实例字段要 volatile,防止指令重排导致拿到半初始化对象;
③ 静态内部类:利用类加载机制保证线程安全与延迟加载(推荐);
④ 枚举:天然防反射与序列化破坏(Java 最佳实践);
⑤ Go 用
② 懒汉 + 双检锁(DCL):注意实例字段要 volatile,防止指令重排导致拿到半初始化对象;
③ 静态内部类:利用类加载机制保证线程安全与延迟加载(推荐);
④ 枚举:天然防反射与序列化破坏(Java 最佳实践);
⑤ Go 用
sync.Once,简洁且保证只执行一次。
4. 内存模型与可见性
4.1 内存可见性
Q:为什么多线程会看到"过期"的变量值?
每个 CPU 有缓存,写操作可能只更新到本地缓存,其他核看不到;编译器/CPU 还会重排序指令。
解决:① 内存屏障/volatile(强制刷新到主存、禁止重排);② 加锁(锁的释放-获取本身带屏障语义);③ 原子操作。
解决:① 内存屏障/volatile(强制刷新到主存、禁止重排);② 加锁(锁的释放-获取本身带屏障语义);③ 原子操作。
4.2 happens-before
Q:什么是 happens-before 原则?
定义可见性的偏序关系:如果 A happens-before B,那么 A 的结果对 B 可见。
主要规则:① 程序顺序(单线程内);② 监视器锁(解锁 hb 后续加锁);③ volatile 写 hb 后续读;④ 线程启动/终止(start hb 线程内所有操作);⑤ 传递性。
主要规则:① 程序顺序(单线程内);② 监视器锁(解锁 hb 后续加锁);③ volatile 写 hb 后续读;④ 线程启动/终止(start hb 线程内所有操作);⑤ 传递性。
4.3 伪共享(False Sharing)
Q:什么是伪共享?如何避免?
多个线程修改同一缓存行(通常 64 字节)中的不同变量,导致缓存行频繁失效、性能暴跌。
解决:缓存行填充(padding,把变量间隔到不同缓存行);Java 用
场景:高并发计数器数组、无锁队列的 head/tail 指针。
解决:缓存行填充(padding,把变量间隔到不同缓存行);Java 用
@Contended 注解;Go 中手动填充。场景:高并发计数器数组、无锁队列的 head/tail 指针。
5. 场景设计题
5.1 设计题:并发安全的高性能缓存
Q:如何设计一个支持高并发的本地缓存?
① 分片(Sharding):按 key 哈希拆成 N 个 map,各自加锁,降低锁竞争;
② 读写锁:读多写少用 RWMutex;
③ 淘汰策略:LRU/LFU/TTL,注意淘汰也要加锁,可做惰性删除;
④ 单飞(SingleFlight):同一 key 的并发回源请求合并成一次;
⑤ 防缓存击穿:热点 key 不过期 + 异步刷新;
⑥ 统计:命中率、淘汰次数,用原子计数避免锁。
② 读写锁:读多写少用 RWMutex;
③ 淘汰策略:LRU/LFU/TTL,注意淘汰也要加锁,可做惰性删除;
④ 单飞(SingleFlight):同一 key 的并发回源请求合并成一次;
⑤ 防缓存击穿:热点 key 不过期 + 异步刷新;
⑥ 统计:命中率、淘汰次数,用原子计数避免锁。
5.2 设计题:多线程统计 / 计数
Q:如何高效统计海量请求的计数?
① 不要所有线程竞争一个原子变量(伪共享 + 单点竞争);
② 分段计数(LongAdder 思想):每个线程/分片维护独立计数,读取时求和;
③ ThreadLocal 累加:线程本地累加,定期汇总;
④ 注意缓存行填充,避免伪共享。
② 分段计数(LongAdder 思想):每个线程/分片维护独立计数,读取时求和;
③ ThreadLocal 累加:线程本地累加,定期汇总;
④ 注意缓存行填充,避免伪共享。
5.3 设计题:限流 + 熔断 + 降级
Q:如何设计一个服务的高可用防护体系?
分层:
① 限流:入口挡掉超量请求(令牌桶/滑动窗口),保护自身;
② 熔断:调用下游持续失败时快速失败,不再打过去(三态:关闭/打开/半开);
③ 降级:核心功能受损时返回兜底数据(缓存旧值、默认值);
④ 隔离:线程池/信号量隔离,避免一个依赖拖垮全部;
⑤ 超时:所有远程调用必须设超时,防线程耗尽;
⑥ 重试:注意幂等与退避,避免重试风暴。
① 限流:入口挡掉超量请求(令牌桶/滑动窗口),保护自身;
② 熔断:调用下游持续失败时快速失败,不再打过去(三态:关闭/打开/半开);
③ 降级:核心功能受损时返回兜底数据(缓存旧值、默认值);
④ 隔离:线程池/信号量隔离,避免一个依赖拖垮全部;
⑤ 超时:所有远程调用必须设超时,防线程耗尽;
⑥ 重试:注意幂等与退避,避免重试风暴。
5.4 高频手写题思路
| 题目 | 核心思路 |
|---|---|
| 两个线程交替打印 1-100 | 两个信号量/条件变量互相唤醒 |
| 三个线程循环打印 ABC | 三个信号量构成环形令牌传递 |
| 生产者消费者 | 有界队列 + 互斥锁 + 非空/非满条件变量 |
| 多线程累加求和的正确写法 | 分段计算 + 结果汇总,避免共享变量竞争 |
| 实现一个阻塞队列 | 环形数组/链表 + 锁 + 两个条件变量 |
| 实现读写锁 | 读计数 + 写标志 + 条件变量 |
| 限流器(令牌桶) | 记录上次补充时间,按时间差补充令牌 |
| 并发安全的单例 | 双检锁 + volatile,或 Once/静态内部类 |
| 定时任务调度器 | 最小堆存任务 + 等待唤醒 + 时间轮加速 |
5.5 如何排查并发问题
Q:线上出现并发 bug,你怎么定位?
① 看现象:是数据错乱(竞态)、卡死(死锁)、还是性能骤降(锁竞争)?
② 抓栈:死锁看线程栈找相互等待的锁;CPU 高看热点方法;
③ 竞态检测:Go 用
④ 复现:加大并发、拉长临界区、调整时序;
⑤ 日志:打印关键状态与线程标识,注意日志本身的线程安全;
⑥ 修复后回归:压测验证,确认无锁竞争与死锁。
② 抓栈:死锁看线程栈找相互等待的锁;CPU 高看热点方法;
③ 竞态检测:Go 用
-race,Java 用压力测试 + 静态分析;④ 复现:加大并发、拉长临界区、调整时序;
⑤ 日志:打印关键状态与线程标识,注意日志本身的线程安全;
⑥ 修复后回归:压测验证,确认无锁竞争与死锁。
6. 速查清单
| 主题 | 一句话答案 |
|---|---|
| 进程/线程/协程 | 独立地址空间 / 共享内存 / 用户态最轻量 |
| 并发 vs 并行 | 逻辑同时 vs 物理同时(需多核) |
| 死锁四条件 | 互斥、持有等待、不可剥夺、循环等待 |
| 预防死锁 | 统一加锁顺序、一次性申请、超时释放 |
| 悲观 vs 乐观 | 先加锁写多 / CAS 校验读多 |
| ABA 问题 | 加版本号或时间戳 |
| CAS 缺点 | ABA、自旋开销、只能保证单变量 |
| 线程池要点 | 核心/最大线程、队列、拒绝策略、优雅关闭 |
| CPU 密集线程数 | 核数 + 1 |
| IO 密集线程数 | 核数 × 2 以上,视阻塞比例 |
| 限流算法 | 固定/滑动窗口、漏桶、令牌桶 |
| 生产者消费者 | 有界队列 + 锁 + 非空非满条件变量,while 判断 |
| 超时取消 | context/Future + 逐层传递取消信号 |
| 线程安全单例 | DCL + volatile / 静态内部类 / 枚举 / Once |
| 可见性 | volatile / 内存屏障 / 锁 |
| happens-before | 锁释放-获取、volatile 写-读、start/join |
| 伪共享 | 同缓存行不同变量互相失效 → 缓存行填充 |
| 分段计数 | LongAdder 思想,避免单点竞争 |
| 熔断三态 | 关闭 → 打开 → 半开 |