并发编程——为什么你的库存总是扣成负数 / Concurrency Control and Inventory Consistency
📅 创建时间:2026-05-08 🏷️ 标签:#线程池 #并发 #死锁 #CAS #协程 #竞态条件 📚 前置知识:[[00-backend-overview]] [[06-network]](连接与线程) 📚 相关知识:[[01-redis-deep]](Redis 分布式锁) [[09-mysql-optimization]](乐观锁)
场景:1000 人抢 10 部手机,库存变成了 -990
┌─────────────────────────────────────────────────────────────┐
│ │
│ 双十一,1000 人同时抢购 10 部手机。 │
│ │
│ 查询库存:10 部 │
│ 扣减库存:10 - 1 = 9 │
│ 1000 人并发 → 每个都查到 10 → 每个都扣 1 │
│ 结果:库存 = 10 - 1000 = -990 │
│ │
│ 10 部手机,卖了 1000 部。 │
│ │
│ 你被叫去赔钱。 │
│ │
└─────────────────────────────────────────────────────────────┘这一章,我们理解并发编程的三大问题:竞态条件、死锁、线程池调参。
第1节:竞态条件——并发编程的第一杀手
问题:为什么并发下数字会错
python
import threading
# 库存(共享变量)
stock = 10
def buy():
global stock
# 读取 → 检查 → 写入(不是原子操作!)
if stock > 0:
# 这个间隙内,其他线程可能也在检查
stock -= 1 # 写入
print(f"购票成功,剩余 {stock}")
# 1000 个线程同时抢
threads = [threading.Thread(target=buy) for _ in range(1000)]
for t in threads: t.start()
for t in threads: t.join()
print(f"最终库存:{stock}") # 大概率不是 0!推演:问题出在哪里
┌─────────────────────────────────────────────────────────────┐
│ 竞态条件图解 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 线程 A 线程 B │
│ │ │ │
│ │ ── read stock=10 ────▶ │ │
│ │ ── check stock>0 ── YES ──▶│ │
│ │ │ │
│ │ │ ── read stock=10 ──▶ │
│ │ │ ── check stock>0 ──▶ │
│ │ │ │
│ │ ── write stock=9 ───────▶ │ │
│ │ │ │
│ │ │ ── write stock=9 ──▶ │
│ │ │ │
│ │ 问题:两个线程都买到了,但库存只扣了 1! │
│ │
└─────────────────────────────────────────────────────────────┘解法一:互斥锁(最简单)
python
import threading
stock = 10
lock = threading.Lock()
def buy():
global stock
with lock: # 互斥锁:同时只有一个线程能进入
if stock > 0:
stock -= 1
print(f"购票成功")
threads = [threading.Thread(target=buy) for _ in range(1000)]
for t in threads: t.start()
for t in threads: t.join()
print(f"最终库存:{stock}") # 一定是 0 或负数(取决于检查位置)解法二:CAS(Compare-And-Swap,无锁并发)
python
import threading
from concurrent.futures import ThreadPoolExecutor
# Python 的 CAS:atomic 操作
stock = 10
def buy():
global stock
while True:
current = stock
if current <= 0:
break
# CAS:如果 stock == current,就写成 current - 1
if threading.local():
pass # Python GIL 限制,真正的 CAS 需要 atomic
# Java 的 CAS(无锁,性能更好):
# public class StockService {
# private AtomicInteger stock = new AtomicInteger(10);
#
# public boolean buy() {
# // compareAndSet:原子操作
# return stock.compareAndSet(current, current - 1);
# }
# }第2节:死锁——两个线程互相等待对方
场景:转账场景
python
import threading
class Account:
def __init__(self, balance):
self.balance = balance
self.lock = threading.Lock()
a = Account(1000)
b = Account(2000)
def transfer(from_acc, to_acc, amount):
# 线程 A: 先锁 a,再锁 b
# 线程 B: 先锁 b,再锁 a
with from_acc.lock:
with to_acc.lock:
if from_acc.balance >= amount:
from_acc.balance -= amount
to_acc.balance += amount
# 线程 A: transfer(a, b, 100)
# 线程 B: transfer(b, a, 200)
# → A 锁住了 a,等待 b
# → B 锁住了 b,等待 a
# → 死锁!死锁的四个必要条件
┌─────────────────────────────────────────────────────────────┐
│ 死锁的必要条件(必须同时满足) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 互斥:资源只能被一个线程持有 │
│ 2. 持有并等待:线程持有资源 A,同时等待资源 B │
│ 3. 不可抢占:资源不能被强制夺取 │
│ 4. 循环等待:线程 T1 等 T2,T2 等 T1 │
│ │
│ 破坏任意一个条件 → 打破死锁 │
│ │
└─────────────────────────────────────────────────────────────┘打破死锁:按固定顺序加锁
python
def transfer(from_acc, to_acc, amount):
# 固定顺序:总是先锁 ID 小的账户
first, second = sorted([from_acc, to_acc], key=lambda x: id(x))
with first.lock:
with second.lock:
if from_acc.balance >= amount:
from_acc.balance -= amount
to_acc.balance += amount第3节:线程池——参数怎么调
问题:线程池参数设置不当
┌─────────────────────────────────────────────────────────────┐
│ │
│ 场景:秒杀系统,1000 QPS │
│ │
│ 方案 A:每请求一个线程 │
│ → 1000 个线程 → 线程创建/销毁开销 → 系统卡死 │
│ │
│ 方案 B:线程池过大 │
│ → 1000 个线程 → 上下文切换 → CPU 利用率下降 │
│ │
│ 方案 C:线程池过小 │
│ → 10 个线程 → 1000 QPS → 队列积压 → 超时 │
│ │
│ 问题:线程池到底应该设多大? │
│ │
└─────────────────────────────────────────────────────────────┘线程数公式
┌─────────────────────────────────────────────────────────────┐
│ 线程池大小计算公式 │
├─────────────────────────────────────────────────────────────┤
│ │
│ CPU 密集型(计算为主): │
│ 线程数 = CPU 核心数 + 1 │
│ → CPU 核心数 = 8 → 线程数 = 9 │
│ → 理由:线程可能在等待 CPU(上下文切换),多 1 个备用 │
│ │
│ IO 密集型(网络/磁盘为主): │
│ 线程数 = CPU 核心数 × (1 + IO 时间 / CPU 时间) │
│ → CPU = 8 核心,IO/CPU = 4 → 线程数 = 8 × 5 = 40 │
│ → 理由:线程等待 IO 时不占 CPU,CPU 可以切换到其他线程 │
│ │
│ 实际经验: │
│ IO 密集型线程数 = 2 × CPU 核心数 ~ 100 × CPU 核心数 │
│ │
│ 秒杀场景(实际是 IO + 锁竞争): │
│ → 线程池 + Redis 分布式锁配合 │
│ → 线程数 = 100-200 足够 │
│ │
└─────────────────────────────────────────────────────────────┘Java 线程池参数
java
// Java 线程池:7 个参数
ExecutorService executor = new ThreadPoolExecutor(
10, // corePoolSize:核心线程数(最小保留)
50, // maximumPoolSize:最大线程数
60L, // keepAliveTime:空闲线程存活时间
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000), // 队列容量
Executors.defaultThreadFactory(), // 线程工厂
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);线程池的四种拒绝策略
┌─────────────────────────────────────────────────────────────┐
│ 线程池拒绝策略 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. AbortPolicy(默认): │
│ → 抛出 RejectedExecutionException │
│ → 适用:需要严格控制并发数的场景 │
│ │
│ 2. CallerRunsPolicy: │
│ → 由调用方线程执行任务 │
│ → 适用:需要任务不被丢弃的场景(但可能拖慢调用方) │
│ │
│ 3. DiscardPolicy: │
│ → 直接丢弃任务 │
│ → 适用:不重要的任务 │
│ │
│ 4. DiscardOldestPolicy: │
│ → 丢弃队列中最老的任务,执行新任务 │
│ → 适用:需要优先处理最新任务的场景 │
│ │
└─────────────────────────────────────────────────────────────┘队列的选择
┌─────────────────────────────────────────────────────────────┐
│ 队列选择对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ LinkedBlockingQueue(无界): │
│ → 队列无限增长,可能 OOM │
│ → 适用:不希望任务被拒绝的场景 │
│ │
│ LinkedBlockingQueue(有界): │
│ → 队列满了,新任务被拒绝 │
│ → 适用:需要控制内存的场景 │
│ │
│ SynchronousQueue(同步队列): │
│ → 不存储任务,每个任务必须立即被取走 │
│ → 适用:高并发 + 拒绝策略配合(触发快速失败) │
│ │
│ ArrayBlockingQueue(有界,数组): │
│ → 有界队列,性能更好 │
│ → 适用:固定并发数的场景 │
│ │
└─────────────────────────────────────────────────────────────┘第4节:协程——为什么 Go 能轻松处理百万并发
问题:线程太重了
线程:
• 创建成本高(1-2MB 栈空间)
• 上下文切换慢(操作系统调度)
• 10000 个并发 = 10000 个线程 = 内存爆炸
协程:
• 创建成本低(几 KB 栈空间)
• 用户态调度(不涉及系统调用)
• 10000 个并发 = 10000 个协程 = 轻量级Go 协程 vs Java 线程
┌─────────────────────────────────────────────────────────────┐
│ 协程 vs 线程对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Java 线程: │
│ → 1:1 模型:1 个线程 = 1 个 OS 线程 │
│ → 上下文切换:用户态 ↔ 内核态,代价高 │
│ → 栈大小:默认 1MB │
│ │
│ Go 协程: │
│ → M:N 模型:M 个协程 → N 个 OS 线程 │
│ → GMP 调度器:Goroutine → M 个 Machine → P 个 Processor │
│ → 栈大小:初始 2KB,按需增长 │
│ → 上下文切换:纯用户态,代价极低 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Go 调度器(GMP 模型): │ │
│ │ │ │
│ │ G1 ──▶ P1 ──▶ M1 │ │
│ │ G2 ──▶ P1 ──▶ M1 │ │
│ │ G3 ──▶ P2 ──▶ M2 │ │
│ │ │ │
│ │ G = Goroutine P = Processor M = Machine (OS线程)│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘升华:并发问题的本质
┌─────────────────────────────────────────────────────────────┐
│ 并发问题的分类 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 竞态条件(Race Condition) │
│ → 多个线程同时读/写同一个变量 │
│ → 解法:互斥锁 / CAS / 不可变对象 │
│ │
│ 2. 死锁(Deadlock) │
│ → 多个线程互相等待对方持有的锁 │
│ → 解法:固定加锁顺序 / 锁超时 / 死锁检测 │
│ │
│ 3. 活锁(Livelock) │
│ → 线程不断尝试但始终无法前进 │
│ → 解法:随机退让 │
│ │
│ 4. 饥饿(Starvation) │
│ → 某些线程始终得不到资源 │
│ → 解法:公平锁 │
│ │
│ 核心原则: │
│ → 能用不可变对象就不用锁 │
│ → 能用乐观锁(CAS)就不用悲观锁(互斥) │
│ → 必须用锁时,遵循固定顺序 │
│ │
└─────────────────────────────────────────────────────────────┘"AI 可查 vs 必须理解"清单
AI 可查:
✅ Java / Python 线程池 API 的具体参数
✅ ThreadPoolExecutor / ForkJoinPool 的使用场景
✅ Go GMP 调度器的详细配置
必须理解:
🔴 线程池队列满了,新任务怎么处理
🔴 CPU 密集 vs IO 密集的线程数计算公式
🔴 死锁的四个必要条件
🔴 竞态条件的本质(读-检查-写不是原子操作)
🔴 为什么 Go 协程比 Java 线程更轻量学习状态:🟡 开始学习