多线程条件变量通信时需互斥锁保护的变量范围问题
你当前写的示例代码存在明确的数据竞争,属于C标准定义的未定义行为,仅保护flag变量完全不够,n和d的跨线程读写必须纳入互斥锁的保护范围,或者通过符合规范的原子操作建立同步关系。不存在“条件变量自带的屏障能兜住锁外读写”的好事,开高优化等级、换不同架构/编译器时代码随时可能出问题。
很多人对条件变量的内存可见性保证有根本性误解,包括对经典教材描述的断章取义:
所谓“发信号线程可见的内存值,唤醒线程同样可见”的成立有严格前提:可见性保证仅覆盖持锁临界区内的操作,且仅对wait成功拿到锁、从临界区内开始执行的唤醒线程生效。
pthread的互斥锁、条件变量操作本身确实会附带全内存屏障,但这个屏障的作用范围是严格和锁的临界区绑定的:
- 持锁时的所有写操作,会在锁释放前对后续拿到同一把锁的线程可见
- 拿锁时的所有读操作,只会拿到之前释放锁的线程写入的最新值
你把n、d的写放在加锁前,读放在解锁后,相当于把这部分操作完全放在了同步机制的保护范围之外,屏障根本管不到。
1. 编译器的无约束优化
编译器对代码的优化只遵循单线程语义,它完全感知不到锁外的n、d是跨线程共享变量,会做出各种意料之外的优化:
- 指令重排:可以把
n=10、d = malloc(...)的指令重排到pthread_mutex_lock之后,甚至重排到flag=1写入之后,接收线程可能看到flag已经是1,但n还是旧值、d还是野指针 - 寄存器缓存:可以把接收线程对n、d的读取优化成只读一次、把值存在寄存器里,后续处理直接用寄存器里的旧值,根本不回内存读新写入的内容
- 读写消除:如果编译器分析后发现接收线程没有对n、d的写入,甚至可以直接删掉部分看似“无意义”的读写逻辑
这些优化完全符合C语言标准,在-O2/O3优化等级下非常常见,和CPU架构的内存模型无关。
2. 无happens-before关系的未定义行为
从编程语言内存模型的角度,只要两个线程对同一个普通变量存在并发读写(没有同步原语建立明确的先后关系),就是数据竞争,属于未定义行为。这种情况下代码出任何问题都符合标准:可能读到撕裂值、可能读到半写入的指针、可能随机崩溃,甚至可能出现完全不符合逻辑的执行结果。
你觉得“写n、d在设flag之前,读n、d在看到flag之后,时间上是错开的”只是主观逻辑上的顺序,没有同步原语做保证的话,编译器、CPU都不会认这个顺序。
不需要为了保护n、d长时间持锁,只需要保证跨线程共享变量的读写都落在临界区内即可,可以通过拷贝到本地变量的方式缩短持锁时间:
发送线程
所有给接收端准备数据的写入操作,都放在持锁区间内,再设置flag、发信号:
pthread_mutex_lock(&mtx); n = 10; // 注意原示例里的笔误:d是double*,分配内存要对应sizeof(double) d = malloc(n * sizeof(double)); flag = 1; pthread_cond_signal(&cnd); pthread_mutex_unlock(&mtx);
接收线程
在持锁区间内确认flag状态后,把需要的共享变量值拷贝到线程本地变量,再解锁处理本地数据即可:
while (1) { pthread_mutex_lock(&mtx); while (!flag) { pthread_cond_wait(&cnd, &mtx); } // 拷贝到线程本地变量,减少持锁时间 int local_n = n; double *local_d = d; // 如果需要重复使用同步标志,可以在这里把flag重置为0 pthread_mutex_unlock(&mtx); // 无锁处理本地拷贝的local_n、local_d,不存在竞争 }
判断共享变量是否需要锁保护的标准非常简单,没有任何例外:
- 只要变量会被多个线程访问,且存在至少一个线程做写入操作,所有对该变量的读写都必须放在互斥锁临界区内,或者用带正确内存序的C11原子变量修饰
- 只有初始化完成后永远只读的变量,才可以不用锁保护
不要试图靠“逻辑上的先后顺序”“条件变量自带的屏障”去省掉锁保护,这类侥幸写法在调试模式下可能跑通,但在生产环境的高优化等级、不同CPU架构下必然踩坑。
内容的提问来源于stack exchange,提问作者Ralf

