跳转到内容

并发与同步

多个执行流共享状态且至少一个是写、没有正确同步——竞态条件,结果是"依赖于不可控的执行顺序"。本章讲临界区与互斥锁的实现、条件变量与信号量、死锁的预防、读写锁与 RCU,以及原子操作与内存序(→ 进程、线程与上下文切换)。

竞态条件(race condition)的经典例子:两个线程执行 counter++。它编译成三条指令(读、加、写回),交错执行会丢失更新——结果依赖于调度,且难复现(调度不确定,测试跑通的代码上线炸)。

临界区:访问共享状态的代码段。互斥(mutual exclusion)= 同一时间最多一个线程在临界区内。

spinlock.c
while (test_and_set(&lock) == 1) ; /* 原子交换:拿到 1 说明别人持有 */
/* 临界区 */
lock = 0;

test_and_set 是原子指令(x86 的 lock xchg)。自旋锁不进内核、切换零成本,但等待时烧 CPU——适合临界区极短的场景;临界区长时,轮询的浪费超过切换成本。

futex(fast userspace mutex):无竞争时在用户态完成(一条原子指令),有竞争时才进内核挂起(futex 系统调用)。快路径用户态、慢路径内核——pthread_mutex 的基础,兼得两者的优点。

锁保护的是数据,不是代码:临界区只是手段,正确的心智模型是"同一时刻只有一个执行流能碰这份数据"——锁加在哪里,取决于数据被谁共享,而不是代码长什么样。

条件变量解决"等条件成立":wait(释放锁并睡眠)与 signal/broadcast(唤醒)。必须配合锁,且用谓词循环(防虚假唤醒):

condvar.c
pthread_mutex_lock(&m);
while (!ready) /* while 而非 if:防虚假唤醒 */
pthread_cond_wait(&cv, &m); /* 原子地释放锁并睡眠 */
/* 使用 ready */
pthread_mutex_unlock(&m);

信号量(semaphore):计数器 + 两个原子操作(P 计数减一,为负则睡;V 计数加一,唤醒等待者)。互斥锁是初值 1 的特例;计数信号量管理资源池。生产者-消费者、限流是标准应用。

死锁的四个条件:互斥、持有并等待、不可剥夺、循环等待。全部成立才会死锁,打破任何一个都行:

  • 最实用的预防:固定加锁顺序(打破循环等待)——全局约定锁的全序,所有代码按序加锁;
  • 一次拿全所有锁(打破持有并等待);
  • trylock + 回退(打破不可剥夺)。

死锁检测(资源分配图找环)在数据库里常用(回滚一个事务),在操作系统里少用(代价高)。

  • 读写锁:多个读者并发、写者独占——读多写少的共享结构;写者饥饿与读侧缓存失效(伪共享,→ 存储层次与缓存)是两个坑;
  • RCU(read-copy-update):读者零开销(几乎无同步),写者复制后原子替换、等旧读者退出再回收——Linux 内核路由表、dentry 缓存的做法,读侧极致但写侧复杂。

原子操作(CAS、fetch-add)是无锁编程的原料:CAS(compare-and-swap)"期望值匹配才写入",是无锁结构的核心循环。但原子不只是原子——还有顺序:

ordering.c
/* 线程 1 */ /* 线程 2 */
data = 42; while (!flag) ;
flag.store(1, release); data.load(acquire);

编译器与 CPU 会重排指令(→ 流水线、冒险与乱序执行)。内存序(acquire/release)是无锁编程的基础:release 保证"此前的写对 acquire 之后可见"——上面的配对保证线程 2 看到 data == 42。松散序(relaxed)只保证原子性不保证顺序,适合计数器;顺序一致(seq_cst)最直观也最贵。

无锁编程的正确性论证极其困难(ABA 问题、内存回收),工程结论:先用锁, profiler 证明锁是瓶颈再考虑无锁;无锁结构用经过验证的库。

调度不确定 + 缓存层次 + 编译器重排,使 bug 依赖不可控的交错——测试环境跑不出来。对策:压测 + 随机延迟注入、线程 sanitizer(TSan,检测数据竞争)、模型检查;以及最重要的——缩小共享状态(能不共享就不共享,消息传递代替共享内存,Go 的口号:"不要通过共享内存来通信,而要通过通信来共享内存")。

  • 竞态条件 = 共享 + 写 + 无同步;结果依赖不可控的交错,难复现
  • 自旋锁适合极短临界区;futex 快路径用户态、慢路径内核
  • 条件变量配谓词循环防虚假唤醒;锁保护的是数据不是代码
  • 死锁预防最实用的办法是固定加锁顺序(打破循环等待)
  • 内存序(acquire/release)是无锁编程的基础;先用锁, profiler 证明瓶颈再无锁