并发同步原语
多个线程并发访问共享数据时,会出现"读-改-写"被交错执行的竞态问题;同步原语就是用来保证共享资源在同一时刻只被一个执行流安全访问的工具。面试围绕它们的问题是常客:互斥锁与自旋锁的区别、信号量怎么用、条件变量解决什么、CAS 为什么能无锁。
临界区与竞态
并发编程的正确性依赖原子性:一段操作要么全部执行完,要么完全不执行。count += 1 看似一条语句,实际是"读值、加一、写回"三步,两个线程交错执行就会丢失更新:
import threading
count = 0
def add():
global count
for _ in range(1_000_000):
count += 1 # 非原子:三步之间可能被其他线程打断
ts = [threading.Thread(target=add) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print("count =", count) # 输出:count = 1420597(通常小于 2000000,每次不同)
需要保护的那段代码称为临界区,同步原语负责让线程互斥地进入临界区。
互斥锁与自旋锁
互斥锁(mutex):进入前加锁,出临界区解锁;拿不到锁的线程会睡眠并把 CPU 让给别人,适合临界区较长的场景。
import threading
lock, count = threading.Lock(), 0
def add_safe():
global count
for _ in range(1_000_000):
with lock: # 互斥进入临界区
count += 1
# 两个线程都调用 add_safe 后,count 恒为 2000000
自旋锁:拿不到锁时不睡眠,而是原地忙等(自旋)反复尝试,适合临界区极短、持锁时间小于线程切换开销的场景;代价是空转烧 CPU。Java 的 synchronized 会先偏向/轻量级锁自旋,膨胀为重量级才阻塞,即"先自旋、后睡眠"的折中。
信号量
信号量维护一个计数器,P(wait)使计数减一、为负则阻塞,V(signal)使计数加一并唤醒。计数为 1 时退化为互斥锁;计数大于 1 时可限制最多 N 个线程同时访问资源(如连接池)。
信号量 sem = 3(最多 3 个线程同时通过)
线程进入:P → sem=2 → 进入 ... 离开时:V → sem=3
当 sem=0 时再 P 的线程阻塞排队
条件变量
条件变量解决"等待某个条件成立"的问题:线程在条件不满足时释放锁并睡眠,条件满足方调用 notify 唤醒它。经典场景是生产者-消费者队列:
消费者:加锁 → while 队列空: wait(释放锁并睡眠) → 取数据 → 解锁
生产者:加锁 → 放入数据 → notify(唤醒等待者)→ 解锁
必须用 while 而非 if 检查条件,防止虚假唤醒。Java 里 Object.wait/notify 与 Condition、Python 的 Condition 都是这套语义。
原子操作与无锁编程
CAS(比较并交换) 是一条硬件原子指令:仅当内存值等于期望值时才写入新值,否则失败重试。像 AtomicInteger 的自增就是 CAS 循环,避免了加锁的开销。ABA 问题(值 A→B→A 无法察觉变化)可用版本号解决。无锁编程减少了线程阻塞,但正确性更难保证,工程上优先"能用锁就用锁"。
死锁与饥饿
持锁顺序不当会造成死锁(互相等对方释放的锁),经典解法是全局统一加锁顺序与超时重试;饥饿则指某线程长期得不到资源,可用公平锁缓解。锁粒度也要权衡:锁太粗并发低,锁太细易出错——面试常见延伸题。
小结
同步原语的主线是"互斥(锁/信号量)+ 协作(条件变量)+ 无锁替代(CAS)"。能说出每种的阻塞与自旋差异、手写出带锁的生产者-消费者,同步题就基本过关了。