并发与同步锁、信号量、死锁——每个 C/C 程序员都必须跨过的操作系统门槛并发编程是操作系统课里最考验「硬实力」的部分。这篇文章不给你看教科书上的伪代码而是带你从硬件原子指令出发一路推到实际工程中怎么用锁、怎么避免死锁。一、并发问题的根源竞态条件1.1 两行代码引发的血案// 全局计数器intcounter0;// 线程 A 线程 Bvoidthread_A(){voidthread_B(){counter;counter;}}counter在 C 语言里是一行代码但在 CPU 眼里是三条指令# counter 的汇编x86 mov eax, [counter] # 1. 从内存加载 counter 到寄存器 eax inc eax # 2. 寄存器里的值 1 mov [counter], eax # 3. 写回内存问题如果线程 A 执行到第 2 步eax1还没来得及写回内存线程 B 开始执行第 1 步——线程 B 读到的counter还是 0。最终两个线程都把自己当成「把 0 变成 1」counter 的结果是 1而不是正确的 2。这就是竞态条件race condition多个线程同时访问共享数据最终结果取决于线程执行的精确时序——而这个时序是不可预测和不可控的。1.2 临界区解决竞态条件的关键是识别临界区critical section——访问共享资源的那段代码必须保证同一时刻只有一个线程在执行。临界区的三个要求必须同时满足 1. 互斥Mutual Exclusion——同一时刻最多一个线程在临界区 2. 前进Progress——如果没有线程在临界区想进去的线程能进去 3. 有限等待Bounded Waiting——一个线程不会无限期等不到进入的机会二、互斥的实现从关中断到原子指令2.1 最原始的方法关中断在单核 CPU 时代最简单的互斥是关闭中断cli();// 关闭中断Clear Interrupt flag// 临界区代码sti();// 打开中断Set Interrupt flag但这是内核特权指令ring 0用户态程序不能用。而且多核时代这招无效——关了一个核的中断另一个核照样能访问共享数据。2.2 基于硬件的原子操作Compare-and-Swap (CAS)现代 CPU 提供了原子指令——不可被中断的「读-改-写」操作。最经典的是 CAS# x86: CMPXCHG # 伪代码if (*addr expected) { *addr new; return true; } else { return false; } lock cmpxchg [addr], new_valuelock前缀是关键的——它锁住内存总线或缓存行确保这个读-改-写操作在多核之间是原子的。2.3 用 CAS 实现自旋锁typedefstruct{intlocked;// 0 未锁, 1 已锁}spinlock_t;voidspin_lock(spinlock_t*lock){while(1){// 原子如果 lock-locked 0设为 1 并返回 0获取成功// 如果 lock-locked 1返回 1被占用了if(__sync_bool_compare_and_swap(lock-locked,0,1)){break;// 获取锁成功}// 获取失败自旋等待CPU 空转while(lock-locked){__builtin_ia32_pause();// 提示 CPU 这是在自旋等待降低功耗}}}voidspin_unlock(spinlock_t*lock){lock-locked0;// 简单写入即可但不保证在其他核上的可见性需要屏障__sync_synchronize();// 内存屏障确保 unlock 在其他核上可见}自旋锁的适用场景适合临界区非常短几到几十个 CPU 周期、锁竞争不激烈不适合临界区长毫秒级——CPU 空转浪费太严重Linux 内核大量使用自旋锁因为内核临界区通常极短2.4 互斥锁mutex——当自旋太长时如果临界区可能较长比如涉及磁盘 I/O自旋锁就太浪费 CPU 了。mutex 的做法是拿不到锁就去睡觉等锁释放了再被唤醒。// Linux 的 futexFast Userspace Mutex机制voidmutex_lock(mutex_t*m){// 快速路径用原子操作尝试获取if(atomic_cmpxchg(m-state,UNLOCKED,LOCKED)UNLOCKED){return;// 成功不需要系统调用}// 慢速路径真的需要等待while(1){// 如果已经有人等了确保我们也在等if(atomic_cmpxchg(m-state,LOCKED,LOCKED_WITH_WAITERS)!UNLOCKED){// 系统调用把自己加入等待队列然后休眠futex_wait(m-state,LOCKED_WITH_WAITERS);}// 被唤醒后再次尝试if(atomic_cmpxchg(m-state,UNLOCKED,LOCKED_WITH_WAITERS)UNLOCKED){return;}}}futex是 Linux 的一个系统调用它巧妙地把「快速路径」放在用户态原子操作不需要系统调用开销只在真正需要等待时才陷入内核。三、信号量Semaphore——不止是锁3.1 信号量 vs 互斥锁维度互斥锁Mutex信号量Semaphore谁释放谁加锁谁释放所有权任何线程都能释放计数器只有 0/1二进制可以是任意非负整数典型用途保护临界区资源计数、生产者-消费者3.2 信号量的经典场景有界缓冲生产者-消费者问题是信号量的经典应用sem_tempty;// 缓冲区中的空槽位数量初始 BUFFER_SIZEsem_tfull;// 缓冲区中已填充的槽位数量初始 0mutex_tm;// 保护缓冲区本身的互斥锁// 生产者voidproducer(){while(1){itemproduce_item();sem_wait(empty);// 等一个空槽位如果有空位减 1没有就阻塞mutex_lock(m);// 进入临界区insert_item(item);// 放入缓冲区mutex_unlock(m);// 离开临界区sem_post(full);// 通知多了一个满槽位full 1}}// 消费者voidconsumer(){while(1){sem_wait(full);// 等一个满槽位mutex_lock(m);itemremove_item();mutex_unlock(m);sem_post(empty);// 通知多了一个空槽位consume_item(item);}}关键洞察sem_wait在计数为 0 时会阻塞当前线程sem_post会唤醒一个等待者。信号量既充当了同步机制也充当了计数器——这就是它和互斥锁的本质区别。四、死锁——四个条件缺一不可4.1 产生死锁的四个必要条件条件含义例子互斥Mutual Exclusion资源不能被共享一次只能被一个线程持有打印机持有并等待Hold and Wait已经持有一个资源还在等待另一个线程 A 持有锁 1等待锁 2不可抢占No Preemption资源只能由持有者主动释放你不能强行抢走别人的锁循环等待Circular Wait存在一个等待环A 等 BB 等 CC 等 A四个条件全部满足 必然可能死锁。打破任何一个条件 不会死锁。4.2 经典的死锁场景// 线程 A 线程 Block(mutex_1);lock(mutex_2);lock(mutex_2);← 等 Block(mutex_1);← 等 A// ... // ...时间线T1: A 获取 mutex_1 T2: B 获取 mutex_2 T3: A 尝试获取 mutex_2 → 阻塞被 B 持有 T4: B 尝试获取 mutex_1 → 阻塞被 A 持有 → 死锁。A 和 B 都永远不会醒来。4.3 如何预防——锁定顺序Lock Ordering最简单的预防所有线程以相同的顺序获取锁。// 定义全局锁顺序总是先锁 mutex_1再锁 mutex_2// 线程 A 线程 Block(mutex_1);lock(mutex_1);// B 也先锁 1lock(mutex_2);lock(mutex_2);// 等 A 释放 1 后才拿到// ... // ...如果无法定义全局顺序使用try_lock 回退while(1){lock(mutex_1);if(try_lock(mutex_2)){break;// 两个都拿到了}unlock(mutex_1);// 拿不到就释放已持有的然后重试usleep(random());// 随机延迟避免活锁两个线程同时重试同时失败}4.4 如何检测——线程转储# 查看进程的所有线程和它们的调用栈gdb-ppid-exthread apply all bt-exdetach-exquit# 或者用 pstackLinuxpstackpid# 看锁等待需要 debug 符号cat/proc/pid/stack在生产环境中如果发现大量线程在pthread_mutex_lock或futex_wait上阻塞检查它们各自持有哪些锁、在等哪些锁——如果形成了环就是死锁。五、读写锁与 RCU——读多写少的场景5.1 读写锁RWLock如果 90% 的操作是读10% 是写互斥锁太浪费了——读操作之间完全不需要互斥。pthread_rwlock_trwlock;// 读线程多个可以同时持有pthread_rwlock_rdlock(rwlock);// 读取共享数据pthread_rwlock_unlock(rwlock);// 写线程独占等所有读线程释放后才获得pthread_rwlock_wrlock(rwlock);// 修改共享数据pthread_rwlock_unlock(rwlock);注意如果读操作非常频繁且没有间隙写线程可能饿死永远等不到锁。pthread_rwlockattr_setkind_np可以设置读写锁策略来避免这个问题。5.2 RCURead-Copy-Update——Linux 内核的王牌RCU 是 Linux 内核中最极致的读优化。它的核心思想读者完全不需要锁。// 读者无锁rcu_read_lock();datarcu_dereference(shared_ptr);// 读取共享指针有内存屏障保证// ... 使用 data ...rcu_read_unlock();// 写者new_datacopy_data(old_data);// 1. 复制一份modify(new_data);// 2. 修改副本rcu_assign_pointer(shared_ptr,new_data);// 3. 原子替换指针synchronize_rcu();// 4. 等所有读者退出后释放旧数据原理写者不修改原数据而是复制一份、改副本、然后原子地替换指针。旧数据在所有正在读的线程退出后才释放。读者从头到尾不需要任何锁——只要持有一个引用就可以安全访问。RCU 是 Linux 内核中最广泛使用的同步机制之一尤其适合网络协议栈、文件系统缓存等读极多写极少的场景。六、动手实验检测你程序中的并发问题# 用 ThreadSanitizer 检测数据竞争编译时加 -fsanitizethreadgcc-fsanitizethread-g-omyapp myapp.c ./myapp# TSan 会在检测到竞态时打印详细的冲突位置# 用 Helgrind 检测锁问题valgrind--toolhelgrind ./myapp# 用 perf 查看锁竞争情况perf record-esyscalls:sys_enter_futex-ag--sleep10perf report# 查看内核锁统计cat/proc/lock_stat# 需要 CONFIG_LOCK_STATy七、总结选锁决策树你需要保护共享数据吗 ├── 临界区极短1μs 竞争不激烈 → 自旋锁spinlock ├── 临界区可能较长 → 互斥锁mutex / futex │ ├── 读多写少 → 读写锁RWLock │ └── 读极多写极少 数据量小 → RCU ├── 控制资源数量如线程池→ 信号量semaphore └── 需要跨进程同步 → 文件锁flock或共享内存 信号量核心原则就两条尽量减少锁的范围——锁住的代码越短竞争越少。始终保持锁的获取顺序一致——消除循环等待死锁永远不会发生。参考来源Linux Kernel Documentation: lockingLinux Kernel Documentation: RCUman 7 futex,man 7 pthreads