几年前排查一个多线程下载程序的问题,折腾了很久。现象很经典:线程都成功启动了,任务也都派发出去了,但最终合并数据时总是缺一块或者多一块。当时的第一反应是网络问题,后来才发现问题出在更基础的环节——线程同步没做好。这种问题在Linux下非常典型:线程本身是轻量级的,但一旦涉及共享资源的并发访问,缺少同步机制就会产生数据竞争,而数据竞争在C语言里属于未定义行为,表现往往千奇百怪。
这篇文章就把Linux下线程同步的常见手段系统梳理一遍,包括互斥锁、条件变量、读写锁、信号量、屏障以及原子操作和自旋锁。我会尽量把底层原理讲明白,同时给出可直接参考的代码片段和实操经验,适合正在写多线程程序、或者被并发问题折腾得焦头烂额的同学。不管是刚接触pthread,还是已经在生产环境里维护高并发服务,这篇内容都能提供一些有用的视角。
1. 线程同步的核心问题与底层逻辑
1.1 临界区与数据竞争:为什么多线程会“打架”
线程同步要解决的核心问题,本质上只有两个:互斥和通信。互斥说的是多个线程不能同时操作同一份共享数据,通信说的是线程之间需要协调执行顺序。这两个问题都围绕着一个概念——临界区(Critical Section)。
临界区可以理解为代码里访问共享资源的那一段区域。比如一个全局计数器,多个线程同时对它执行count++。表面上看这是一条语句,但在机器指令层面它至少被拆成三步:从内存读取值到寄存器、对寄存器加一、把结果写回内存。如果两个线程同时执行这三步,就可能出现“读取同一个旧值、各自加一、分别写回”的情况,结果只增加了一次而不是两次。
我用一个厨房的类比来解释:两个人同时用同一个锅炒菜,如果没有规则,A把菜放进去,B也把菜放进去,最后锅里一团浆糊。临界区就是“用锅”的那段操作,线程同步就是规定“一个人用完锅,另一个人才能用”。
数据竞争(Data Race)就是指多个线程同时访问同一块内存,且至少有一个是写操作,而又没有任何同步机制保证顺序。C11标准明确说这是未定义行为,编译器完全可能针对这种代码做激进优化,比如把读操作提到循环外面,或者重排指令顺序。这也是为什么很多人调试并发程序时,感叹“代码单独跑没问题,一上多线程就疯掉”。
1.2 原子性、内存可见性与内存屏障
要理解锁是怎么解决上述问题的,就得往底层多看一层。现代CPU是多核架构,每个核心都有自己的缓存(L1/L2),共享最后一级缓存和主存。当一个线程修改了某个变量,如果没有同步机制,这个修改可能暂时只停留在它的缓存里,其他核心上的线程读到的还是旧值。这就是内存可见性问题。
锁要保证的两件事是:互斥(同一时刻只有一个线程进入临界区)和内存屏障(Memory Barrier)。内存屏障的作用是约束编译器和CPU的指令重排序,同时确保临界区内的读写操作不会“逃逸”到加锁/解锁边界之外。比如某个线程在锁内修改了一个全局变量,解锁时必须保证这个修改对下一个获取锁的线程可见。
pthread互斥锁在底层通常依赖原子操作 + 用户态/内核态的等待机制。加锁时先通过原子指令(比如cmpxchg)尝试获取锁,如果获取失败,就进入等待队列并陷入内核睡眠。这个细节后面讲futex的时候再展开。简单来说,锁本身不是一个“魔法”,它是建立在硬件原子指令和内核调度基础上的封装,目的是往临界区外面加一道栅栏,保证变量访问的顺序和可见性符合我们的直觉。
理解这一层之后,很多问题就通了:为什么用volatile不能解决并发?因为volatile只告诉编译器不要优化对这个变量的访问,但它既不保证原子性,也不提供内存屏障,更没有互斥语义。它更适合用来修饰被信号处理函数或内存映射硬件寄存器访问的变量,而不是用来做线程同步。
2. 互斥锁:Linux线程同步的基石
2.1 pthread_mutex 的正确使用方式
互斥锁(Mutex)是Linux下最基础的同步原语。POSIX线程库提供了pthread_mutex_t类型和一组配套函数。使用套路非常固定:定义锁变量、初始化、加锁、操作共享资源、解锁、销毁。我先把标准姿势写出来。
#include <pthread.h> pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; int shared_counter = 0; void *worker(void *arg) { for (int i = 0; i < 100000; i++) { pthread_mutex_lock(&mutex); shared_counter++; pthread_mutex_unlock(&mutex); } return NULL; } int main(void) { pthread_t t1, t2; pthread_create(&t1, NULL, worker, NULL); pthread_create(&t2, NULL, worker, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); // 此时 shared_counter 应为 200000 pthread_mutex_destroy(&mutex); return 0; }这里有几个关键点。第一,PTHREAD_MUTEX_INITIALIZER是静态初始化,不需要调用pthread_mutex_init,也不需要pthread_mutex_destroy,进程退出时资源由系统回收。动态初始化适用于运行时才能确定属性的情况,比如设置递归锁或错误检测锁,这时候记得在不再使用后调用pthread_mutex_destroy。
第二,加锁的粒度要合适。理论上临界区越小越好,因为锁竞争的概率和等待时间都会降低。但锁粒度也不是越小越好,如果拆得太细,可能出现多个锁之间的顺序依赖,反而引入死锁风险。工程上常见做法是“先粗后细”:先保证正确性,再通过性能剖析决定要不要拆锁。
第三,加锁和解锁必须成对出现。很多新手会在某个分支里提前返回,忘了调用pthread_mutex_unlock,结果就是其他线程永久阻塞。如果代码路径比较复杂,可以考虑用C++的std::lock_guard或std::unique_lock做RAII管理,让析构函数保证解锁。C语言里没有这个便利,只能靠细心,或者用宏封装lock/unlock对。
2.2 锁的类型:普通锁、递归锁与错误检测锁
pthread_mutex_t可以配置成几种不同的行为,通过pthread_mutexattr_settype设置。最常见的是这三种:
PTHREAD_MUTEX_NORMAL:普通锁,不检测死锁,也不允许同一线程重复加锁。重复加锁会导致行为未定义,多数实现下会直接卡死。PTHREAD_MUTEX_RECURSIVE:递归锁,允许同一线程多次加锁,但必须对应相同次数的解锁。适合在某些递归函数里加锁的场景,比如递归遍历树的同时对节点结构加保护。PTHREAD_MUTEX_ERRORCHECK:错误检测锁,遇到重复加锁或解锁未加锁等情况会返回错误码,而不是产生未定义行为。调试阶段建议使用。
递归锁看着方便,但我不建议轻易用。原因在于递归锁容易掩盖设计问题——如果一个函数可以被同一个线程重入加锁,说明临界区的边界其实不清楚,背后可能隐藏着“锁内调用另一个加锁函数”的耦合问题。而且递归锁性能也比普通锁差一些,因为它要记录线程ID和加锁次数。
动态初始化的代码大致是这样:
pthread_mutex_t mutex; pthread_mutexattr_t attr; pthread_mutexattr_init(&attr); pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_ERRORCHECK); pthread_mutex_init(&mutex, &attr); pthread_mutexattr_destroy(&attr);调试并发问题时,我习惯先把所有锁设置成ERRORCHECK类型,跑一轮测试,如果某个地方有重复加锁或错误解锁,函数会返回EPERM或EDEADLK,而不是让程序莫名其妙地挂死。定位到问题后再改回普通的NORMAL类型。
注意:不要用
memcpy或赋值操作复制一个正在使用的pthread_mutex_t,锁的内部状态可能包含内核等待队列信息,复制只会复制一个空壳,正确行为完全无法保证。
3. 条件变量:事件驱动下的等待与唤醒
3.1 轮询的痛点和条件变量的设计思路
互斥锁解决的是“同时访问”的问题,但很多时候线程需要等待某个条件成立,比如生产者线程往队列里放数据,消费者线程必须等到队列非空才能取数据。如果只用互斥锁,消费者只能不停地轮询:
while (1) { pthread_mutex_lock(&mutex); if (queue_not_empty()) { process(); pthread_mutex_unlock(&mutex); break; } pthread_mutex_unlock(&mutex); usleep(1000); }这种写法能跑,但问题很明显。每次轮询都要加锁、检查、解锁,CPU白白消耗在无意义的循环上。usleep虽然降低了CPU占用,但引入了延迟:生产者刚放入数据,消费者可能还在sleep,最长要等1毫秒才能处理,吞吐量上不去。
条件变量(Condition Variable)就是专门解决“等待某个条件变成真”这个问题的。它让线程在条件不满足时睡眠,直到另一个线程通知“条件可能已经满足”,等待的线程才会被唤醒并重新检查条件。这比轮询高效得多,也避免了忙等浪费CPU。
条件变量和互斥锁总是配合使用。它本身没有“条件”的概念,所谓条件(比如“队列非空”)其实是程序员自己维护的共享变量的某种判断。条件变量做的只是三件事:让线程带着锁进入睡眠、被通知后醒来、重新获取锁。
3.2 生产者消费者模型的完整实现与伪唤醒问题
我直接给一个典型的生产者消费者实现,这是理解条件变量最好的场景。
#include <pthread.h> #include <stdio.h> #include <stdlib.h> #define BUFFER_SIZE 8 typedef struct { int buffer[BUFFER_SIZE]; int head; int tail; int count; pthread_mutex_t mutex; pthread_cond_t not_full; pthread_cond_t not_empty; } queue_t; void queue_init(queue_t *q) { q->head = 0; q->tail = 0; q->count = 0; pthread_mutex_init(&q->mutex, NULL); pthread_cond_init(&q->not_full, NULL); pthread_cond_init(&q->not_empty, NULL); } void queue_push(queue_t *q, int val) { pthread_mutex_lock(&q->mutex); while (q->count == BUFFER_SIZE) { pthread_cond_wait(&q->not_full, &q->mutex); } q->buffer[q->tail] = val; q->tail = (q->tail + 1) % BUFFER_SIZE; q->count++; pthread_cond_signal(&q->not_empty); pthread_mutex_unlock(&q->mutex); } int queue_pop(queue_t *q) { pthread_mutex_lock(&q->mutex); while (q->count == 0) { pthread_cond_wait(&q->not_empty, &q->mutex); } int val = q->buffer[q->head]; q->head = (q->head + 1) % BUFFER_SIZE; q->count--; pthread_cond_signal(&q->not_full); pthread_mutex_unlock(&q->mutex); return val; }这段代码里有几个细节是教科书里反复强调但不一定讲透的。
pthread_cond_wait做的事是一体的:它原子地释放互斥锁,然后让当前线程睡眠。为什么必须这么设计?因为如果它不是原子操作,就存在一个窗口:线程检查条件发现不满足,正要睡眠,但还没来得及释放锁,另一个线程已经修改了条件并发出signal。如果signal发生在线程睡眠之前,这个唤醒信号就丢失了,等待线程将永远睡下去。pthread_cond_wait把“释放锁+挂起等待”绑定成一个动作,就是为了消除这个竞态窗口。
当线程被唤醒后,pthread_cond_wait返回前会重新获取互斥锁,然后继续执行。也就是说,从wait返回开始,线程又处于持锁状态。正因如此,检查条件必须用while而不是if。原因有两个:一是伪唤醒(spurious wakeup)——POSIX标准允许wait在没有信号的情况下直接返回;二是即使在有信号的情况下,唤醒线程拿到锁后条件也可能已经被另一个线程改变了。用while重新检查一遍条件,是防御这两种情况的标准做法。我见过有人用if写了生产者消费者,线上跑几天偶发一次数据错乱,最后查了一个星期,罪魁祸首就是这里。
pthread_cond_signal和pthread_cond_broadcast的选择也要注意。signal只唤醒一个等待线程,适合“当前条件只能被一个线程消费”的场景;broadcast唤醒所有等待线程,适合多个条件同时成立的场景,但因为需要重新竞争锁,激烈程度会更高。不确定时用broadcast更容易保证正确性,代价是性能略低。比如读者写者问题中,写完成后应该用broadcast唤醒所有读者。
4. 读写锁、信号量与屏障:不同场景的选择
4.1 读写锁:读多写少时的性能优化
互斥锁是“全或无”的排他锁:不管是读还是写,同一时间只能有一个线程持锁。但实际很多场景是读操作远多于写操作,比如配置表、路由表、缓存。所有读操作本来就可以并行,没有必要互相排斥。读写锁(pthread_rwlock_t)解决的问题就是把这个并行度释放出来。
读写锁的语义是:多个读者可以同时持有锁;写者必须独占。也就是说,只要没有写者,所有读者都能通过;一旦有写者申请锁,后续的读者会被挡住,直到写者完成。实现时一般分为读者优先和写者优先两种策略。Linux glibc里的默认实现偏向写者优先,防止“写者饥饿”——如果读者持续不断到来,写者可能永远得不到锁。
使用读写锁的代码改动很小:
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER; void read_data() { pthread_rwlock_rdlock(&rwlock); // 读取共享数据 pthread_rwlock_unlock(&rwlock); } void write_data() { pthread_rwlock_wrlock(&rwlock); // 修改共享数据 pthread_rwlock_unlock(&rwlock); }读锁用pthread_rwlock_rdlock,写锁用pthread_rwlock_wrlock,释放都调用pthread_rwlock_unlock。
需要警惕的一点是:读写锁不一定比互斥锁快。因为读写锁内部要维护读者计数、写者等待队列等状态,原子操作和内存屏障更多,锁本身的成本更高。如果临界区很短,读写比例又不是特别悬殊(比如几十比一以下),互斥锁可能反而更快。所以对性能敏感的地方,不要凭感觉换锁,要拿数据说话——用perf或自检程序测一下临界区竞争时间,再决定用哪种锁。
4.2 信号量:计数语义与进程间同步
信号量(Semaphore)是另一种同步原语,本质是一个计数器,支持两种操作:sem_wait将计数器减一,若为负则阻塞;sem_post将计数器加一,并唤醒等待线程。它和互斥锁最大的区别在于:互斥锁必须由同一个线程加锁和解锁,信号量不要求这一点——线程A可以sem_post,线程B可以sem_wait,这正好对应“发送一个信号,另一个线程接收”的语义。
Linux下有两种信号量:POSIX无名信号量(sem_t,可用于线程或进程间同步)和System V信号量(semget/semop,偏老旧,不建议在新代码中使用)。无名信号量使用套路如下:
#include <semaphore.h> sem_t sem; sem_init(&sem, 0, 1); // pshared=0,仅用于线程间;初值=1 sem_wait(&sem); // P操作 // 临界区 sem_post(&sem); // V操作如果把信号量初值设为1,它就退化成互斥锁。但反过来,互斥锁并不具备信号量的高级用法,比如控制资源池的并发数量——连接池最多允许10个连接同时被使用,这个“限流”场景用信号量实现非常自然:初始化sem_init(&sem, 0, 10),每次获取连接前sem_wait,释放连接后sem_post。
屏障(Barrier)则是另一类同步工具:它让一组线程同时到达某个汇合点后再一起继续执行。pthread_barrier_t的用法很简单:
pthread_barrier_t barrier; pthread_barrier_init(&barrier, NULL, 4); // 等待4个线程 // 每个线程执行完某阶段工作后: pthread_barrier_wait(&barrier);典型场景是并行计算:数据集分给4个线程处理,每个线程算完自己那一份后必须等其他人也算完,才能进入下一步合并操作。如果没有屏障,就得用计数器+条件变量自己实现,代码很啰嗦。pthread_barrier_wait在每个参与线程中调用一次,当最后一个线程到达时,所有线程一起返回。返回值有个细节:其中一个线程会返回PTHREAD_BARRIER_SERIAL_THREAD,其余返回0,可以利用这个返回值指定某个线程做汇总工作。
下面这个表格是我在不同场景下选择同步原语的经验总结:
| 同步原语 | 核心语义 | 适用场景 | 不适用场景 |
|---|---|---|---|
| 互斥锁 | 独占访问 | 临界区短、写多或读写比例不悬殊 | 读多写少且读操作耗时长 |
| 读写锁 | 共享读、独占写 | 读多写少、读操作较重 | 写操作频繁、临界区过短 |
| 条件变量 | 等待条件成立 | 需要等待事件/队列非空等 | 需要限流或计数控制的场景 |
| 信号量 | 计数资源 | 资源池限流、进程同步 | 单纯的互斥访问(用mutex更直接) |
| 屏障 | 多线程汇合 | 并行阶段计算 | 单个线程内部逻辑 |
| 原子操作 | 单个变量的无锁更新 | 计数器、标记位 | 复杂数据结构的并发修改 |
5. 原子操作与自旋锁:无锁思路与底层机制
5.1 原子操作:从计数器到CAS
原子操作(Atomic Operation)是比锁更底层的同步手段。它的含义是:某个操作在硬件级别上要么完整执行,要么完全不执行,中间不会被其他线程打断。对于“单个变量加一”这种场景,用原子操作比用互斥锁还要快,因为它不需要进入内核睡眠等待,也没有复杂的锁状态维护。
GCC和Clang提供了一系列内置原子操作函数,比如__atomic_fetch_add、__atomic_load、__atomic_store。C11标准也引入了<stdatomic.h>,语法更规范:
#include <stdatomic.h> atomic_int counter = ATOMIC_VAR_INIT(0); // 线程安全的自增 atomic_fetch_add(&counter, 1); // 读取当前值 int val = atomic_load(&counter);使用原子操作时要注意内存序(Memory Order)的选择。memory_order_relaxed只保证原子性,不保证顺序;memory_order_seq_cst提供全局顺序一致性,也是默认选项,性能略慢。对于纯计数器累加,用relaxed就够了;但如果原子变量还承担“发布-消费”的作用,就要用到acquire和release语义。
CAS(Compare-And-Swap)是另一个重要工具:__atomic_compare_exchange或C11的atomic_compare_exchange_strong。它的语义是,如果当前值等于预期值,就把它改成新值,并返回成功;否则什么都不做。CAS是很多无锁算法的基础,比如实现无锁栈、无锁队列。但它有一个经典陷阱叫ABA问题:一个值从A变成B又变回A,CAS会认为“值没变过”,从而误判。解决ABA问题通常需要引入版本号,或者用带标签的指针。
我的建议是:能用原子操作解决的单一变量更新,就别上锁;需要维护复杂不变量(比如“多个变量同时满足某种关系”)时,老老实实用锁。无锁编程的调试难度非常高,单步调试经常不生效,因为时序取决于缓存和调度,很难复现。
5.2 自旋锁与futex:锁内部发生了什么
自旋锁(Spinlock)是一种忙等待锁:加锁失败时,线程不会睡眠,而是一直在循环检查锁状态,直到获取成功。pthread_spinlock_t是POSIX提供的接口:
pthread_spinlock_t spin; pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE); pthread_spin_lock(&spin); // 极短的临界区 pthread_spin_unlock(&spin);自旋锁的优点是省掉了线程睡眠和唤醒的开销,适合临界区特别短、且持有锁的线程很快就能释放的场景,比如内核里修改某个链表节点。但它有个致命缺点:如果临界区较长,或者多个线程频繁竞争,自旋会白白烧掉CPU,反而拖垮系统。单核CPU上自旋锁完全没有意义——持有锁的线程如果被中断,等待的线程只会空转,谁也等不到。
现代Linux上pthread互斥锁的性能之所以不像想象中那么差,是因为它内部揉了自旋和睡眠两级策略:加锁时先在用户态用原子指令尝试几次(类似自旋),如果没抢到,再通过futex系统调用进入睡眠。futex(Fast User-space muTEX)是Linux内核提供的一个基础机制:用户态原子操作决定是否竞争;竞争失败时通过FUTEX_WAIT挂起,其他线程释放锁时通过FUTEX_WAKE唤醒。这个设计让大部分无竞争的加锁/解锁流程完全发生在用户态,成本极低;只有真正发生竞争时才陷入内核。
理解这层之后,你就明白为什么我不建议在生产代码里自己封装锁了。pthread互斥锁是经过大量优化和压力测试的,内部已经处理了竞争升级、优先级继承等复杂问题。自己用原子操作或者自旋锁去实现所谓的高性能锁,九成概率会在边界条件下出问题。
6. 死锁排查与同步性能调优
6.1 死锁的四个条件与实用排查手段
死锁是多线程编程中最常见又最难排查的问题。它需要同时满足四个条件:互斥条件(资源只能被一个线程持有)、占有并等待(线程持有一个资源又去申请另一个)、不可抢占(资源不能被强制拿走)、循环等待(多个线程形成等待环)。
在Linux上排查死锁,我一般按这个顺序来:
先看现象是否“卡死”。如果程序所有线程都阻塞在某次加锁上,那么用gdb附加到进程,执行thread apply all bt打印所有线程的堆栈,能看到每个线程阻塞在哪个函数。多执行几次,如果阻塞位置不变,基本就是死锁。
pstack命令也是必备工具,它能直接输出进程所有线程的调用栈,省去gdb交互的麻烦。结合strace -p PID观察线程是否卡在futex系统调用上,可以确认线程正在等待锁。
工程上避免死锁的手段,最有效的是固定锁的获取顺序。比如两个锁A和B,所有代码都先锁A再锁B,就永远不会形成等待环。如果实在无法统一顺序,可以尝试用pthread_mutex_trylock获取锁,失败时释放已经持有的锁,退避后重试,但这样要处理“部分成功”的补偿逻辑,复杂度和出错率都上升了。
我分享一个简单但容易忽略的经验:加锁后要尽早调用解锁,不要在持锁状态下做耗时的IO操作或让出CPU的操作。一方面这会放大锁竞争,另一方面也增加了死锁的形成空间——别把锁当“保护伞”,以为持锁时间越长越安全,恰恰相反。
6.2 锁粒度、伪共享与性能调优
线程同步引入的性能开销,主要体现在三个方面:加锁/解锁本身的原子操作和屏障成本、锁竞争时睡眠唤醒的调度成本、以及缓存一致性失效的成本。三个里面,锁竞争往往是最大的瓶颈。
减小锁竞争的第一思路是减小锁粒度。把一个大临界区拆成多个小临界区,或者用“读写锁分离”的方式,把纯读路径从锁中解放出来。另一个思路是锁拆分(Lock Stripe):把单一的大锁拆成若干小锁,每个小锁保护一部分数据,比如哈希表的每个桶一把锁,这样不同线程操作不同桶时互不干扰。
伪共享(False Sharing)是个更隐蔽的问题。它发生在多个线程访问不同的变量,但这些变量在内存中恰好位于同一个缓存行(Cache Line,通常64字节)内。CPU缓存一致性协议是以缓存行为单位维护的,所以一个线程修改变量A会导致包含变量B的整个缓存行在其他核心上失效,另一个线程再读变量B就得重新从内存加载,性能骤降。
避免伪共享的办法很简单:把热点变量按缓存行对齐。GCC给属性写法示例:
struct alignas(64) hot_data { int thread_local_counter; char padding[60]; // 补齐到64字节缓存行 };我在一个压测项目里遇到过这种情况:8个线程各维护一个计数变量,放在一个数组里,每分钟互相独立地更新。结果并发性能比单线程还差,因为8个变量落在相邻缓存行里,每次更新都让其他核心的缓存失效。改成每个变量按64字节对齐后,性能直接翻了近十倍。调同步问题时,如果锁竞争已经很低但多核扩展性还是很差,首先怀疑伪共享。
调优时我常用的工具组合:perf lock可以报告锁竞争事件;perf stat -e context-switches, migrations能看到线程切换是否频繁;valgrind --tool=helgrind和clang的线程安全分析(Thread Safety Analysis)可以在开发阶段静态检测数据竞争。这些工具组合起来,绝大多数同步问题都能在测试环境复现出来。
7. 高频问题速查表与一点心得
7.1 开发中最常遇到的同步问题
下面这张表整理了我这几年遇到比较高频的问题现象和对应排查方向:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 加锁后共享数据仍然错乱 | 部分代码路径没有走同一把锁 | 查找所有对共享变量的访问点,确认统一加锁 |
| 线程永久阻塞在wait | signal发生在wait之前丢掉了 | 检查是否有条件变量信号丢失,条件判断是否和锁配套 |
| 程序偶发卡死 | 死锁或递归加锁未解锁 | gdb打印线程堆栈,检查锁顺序和递归锁使用 |
| 性能随核数增加反而下降 | 伪共享、锁竞争激烈 | 检查变量布局和锁粒度,用perf锁事件统计 |
| 偶发段错误 | 解锁后继续访问共享数据 | 检查临界区边界是否向后溢出 |
| 条件变量信号丢失导致卡顿 | 用if判断条件,存在伪唤醒覆盖不足 | 改成while循环重新检查条件 |
7.2 选择同步方案的个人经验顺序
我实际写代码时,选择同步方案的思考顺序是这样的:先看能否用原子操作解决单变量的更新需求;再看能否通过拆分数据结构,让每个线程只操作自己私有的数据,从根上避免共享;如果必须共享,临界区短且竞争不激烈用互斥锁,读多写少用读写锁;需要等待某个事件成立再继续,就用条件变量;需要限制并发数量,用信号量。
一个容易忽略的经验是:在写代码之前先想清楚“谁负责触发条件、谁负责消费条件”,把这两个角色的边界划分清楚。很多同步问题不是技术手段不够,而是设计阶段就没有定义清楚资源所有权和状态转换的语义。比如生产者消费者模型里,队列的归属权是谁、条件由谁更新、唤醒信号何时发送,这些想明白了,写代码时几乎不会有歧义。
最后再分享一个调试技巧:程序表现出并发问题时,不要急着在代码里加更多锁。先试着让崩溃/异常更容易复现,比如增大线程数、缩短临界区里的sleep、用-fsanitize=thread重新编译。ThreadySanitizer能在运行时报出精确的数据竞争位置,比对着代码猜快太多。我在一个图像处理项目里就靠它定位了一个隐藏很久的竞态:某个缓存对象在释放后仍被读线程引用,加锁都救不了,只能从生命周期管理上修。同步只是工具,真正的目标是设计出不需要过度同步也能保持正确的数据流。