从竞态条件到双重释放:C 并发编程的几个关键问题
从竞态条件到双重释放:C 并发编程的几个关键问题
多线程编程里,真正难处理的往往不是"写不出来",而是写出来了、跑通了、然后在某个不确定的时刻崩溃。
本文讨论四个在实际代码中反复出现的核心问题:
- 竞态条件——
count++为什么不是原子操作,以及四种同步方案的取舍 - 条件变量的正确用法——为什么等待必须包在
while里,为什么执行阶段要放锁 - 内存所有权的归属——双重释放是怎么产生的,如何从设计上规避
- 运行期验证——如何用消毒剂在测试阶段捕捉上述问题
一、竞态条件:count++ 为什么不是原子操作
多线程对同一个变量做自增时,count++ 在高级语言里看是一条语句,编译成机器指令却是三条:
- 从内存读取
count到寄存器 - 寄存器加一
- 把寄存器写回内存
这三步之间存在可被抢占的窗口。假设 count 当前为 5,线程 A 执行完第 1 步读到 5,此时发生线程切换;线程 B 完整执行三步,把 count 变成 6;切回 A,它寄存器里还是 5,加一后写回,count 依然是 6。
两次自增,结果只增加了 1。 这就是竞态条件(race condition)。
注意问题的根源是复合操作的不可分割性被破坏,而不是"读到了脏数据"。即使内存访问本身是原子的(现代 CPU 对对齐的机器字访问确实是原子的),自增这个复合操作仍然不是。
二、四种同步方案的取舍
| 方案 | 实现 | 未获取到时的行为 | 适用场景 |
|---|---|---|---|
| 无同步 | (*pcount)++ | —— | 仅用于验证竞态确实存在 |
| 互斥锁 | pthread_mutex_lock | 线程休眠,让出 CPU | 临界区长、竞争激烈 |
| 自旋锁 | pthread_spin_lock | 线程忙等,持续占用 CPU | 临界区极短,省下切换开销 |
| CPU 原子指令 | lock; xaddl | 无锁,单条指令完成读改写 | 单个变量的简单操作 |
互斥锁与自旋锁的分界线是"临界区长度"。互斥锁在拿不到锁时会把线程挂起,这涉及两次上下文切换(挂起、唤醒),每次切换的开销在微秒量级。如果临界区本身就只需要几十纳秒,那么切换的开销远大于等待本身——这种情况下自旋锁更划算,它让线程原地空转等待,避免了切换代价,代价是空转期间持续占用 CPU 周期。
原子指令方案则是另一个层次:它不依赖锁,而是让 CPU 直接提供一条不可分割的读-改-写指令。
int inc(int *value, int add) {
int old;
__asm__ volatile(
"lock; xaddl %2, %1;" /* lock 前缀保证多核下原子执行 */
: "=a" (old) /* 输出:旧值放入 eax */
: "m" (*value), "a"(add) /* 输入:内存操作数、加数放入 eax */
: "cc", "memory" /* 会修改标志寄存器与内存,阻止编译器重排 */
);
return old;
}几个要点:
xaddl的语义是交换后相加——先交换寄存器值和内存值,再把两者之和写回内存。返回值(old)是交换出来的旧值,这正是自增操作需要的语义。lock前缀不是"加锁"的意思,而是一个指令前缀,作用是让该指令在执行期间锁定对应的缓存行,其他核心无法同时访问同一地址。这是硬件层面的互斥,粒度远小于互斥锁。- 约束列表里的
"cc"表示该指令会修改条件码寄存器(EFLAGS),"memory"表示会读写内存。如果漏掉"memory",编译器可能把其他内存访问重排到这条指令之前或之后,破坏原子性——这是内联汇编里最容易出错的地方。
三、线程池:条件变量的正确用法
线程池的本质是复用。每来一个任务就 pthread_create 有两个代价:创建本身需要分配内核对象和栈空间;任务量突增时线程数失控,CPU 大量时间耗在上下文切换而不是实际计算上。
固定大小的线程池用三个结构体撑起:
| 结构体 | 作用 | 挂在哪条链表 |
|---|---|---|
nTask | 待执行任务:函数指针 + 用户数据 | manager->tasks |
nWorker | 工作线程:线程 ID + 终止标志 | manager->workers |
nManager | 管理器:两条链表头 + 互斥锁 + 条件变量 | 本身 |
任务队列和线程列表都用双向链表实现,LIST_INSERT 采用头插法,LIST_REMOVE 分别处理被删元素位于表头、表中、表尾以及链表只剩一个元素的情形。用宏而不是函数实现,是因为宏不区分类型,一套逻辑可以同时服务于两种节点。
条件变量的语义
条件变量(condition variable,pthread_cond_t)必须与互斥锁配合使用。它的核心 API 是:
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);这个调用做三件事,且整体是原子的:
- 释放传入的互斥锁
- 让当前线程进入睡眠
- 被唤醒时重新获取该互斥锁,然后才返回
"释放锁"和"进入睡眠"必须是原子的,否则会有一个致命窗口:如果先释放锁、还没睡着,此时另一个线程插入任务并发出信号,这个信号就会丢失,而当前线程随后睡下去,永远等不到唤醒。
消费循环的三个细节
pthread_mutex_lock(&manager->mutex);
while (manager->tasks == NULL) { /* 细节 1 */
if (worker->terminate) break;
pthread_cond_wait(&manager->cond, &manager->mutex);
}
if (worker->terminate) { /* 细节 2 */
pthread_mutex_unlock(&manager->mutex);
break;
}
struct nTask *task = manager->tasks;
LIST_REMOVE(task, manager->tasks);
pthread_mutex_unlock(&manager->mutex); /* 细节 3 */
task->task_func(task);细节 1:用 while 而不是 if。 pthread_cond_wait 返回不代表条件一定成立。原因有两类:一是 pthread_cond_broadcast 会唤醒所有等待者,但其中只有一个能抢到任务;二是 POSIX 标准明确允许虚假唤醒(spurious wakeup)——即使没有任何线程发出信号,等待也可能返回。因此被唤醒后必须重新检查条件,if 只检查一次,会漏掉这两种情况。
细节 2:终止检查要在两处都有。 内层循环里的检查处理"线程在等待中被唤醒"的路径;循环外的检查处理"队列非空、直接跳过内层循环"的路径。少任何一处都有线程退不出去或提前退出的问题。
细节 3:执行任务时不持锁。 这是整个设计中最重要的性能决定。task_func 是用户代码,执行时间不可预知。如果在锁内调用,所有工作线程都会被串行化,线程池退化成"单线程加一个队列",完全失去并发意义。
正确的做法是:持锁期间只做"取出任务并摘链"这个 O(1) 的链表操作,拿到任务指针后立即放锁,再执行。
四、内存所有权:双重释放的成因与规避
这个问题在一次线程池销毁逻辑的修正中暴露出来。
原始代码在回调函数结尾有一句 free(worker);——线程退出时释放自己的结构体。而销毁流程缺了 pthread_join 和资源释放,属于资源泄漏。看起来"补上 join 和 free"就能修好,但这样做会踩进一个更严重的坑:
同一个指针被释放两次。
线程退出时 free 一次,销毁流程再 free 一次,构成双重释放(double free)。这是 C 语言中的未定义行为,实际表现通常是堆管理器的内部链表被破坏,然后在完全无关的代码位置崩溃——排查成本极高,因为崩溃点不是案发现场。
问题的根源不是"忘记加判断",而是所有权(ownership)没有定义清楚:这块内存该由谁负责回收?写代码时如果没有明确回答这个问题,释放逻辑迟早会出现重复或遗漏。
可行的所有权模型主要有两种:
模型 A:生产者/管理者持有。 由创建方统一负责释放,使用者只借用。
模型 B:自持有。 谁创建谁释放,或者对象自行负责生命周期。
线程池这里选择模型 A——worker 结构体的所有权归线程池管理器:
- 回调函数不再自释放,改为
return NULL; - 销毁流程统一负责回收
确定所有权之后,销毁顺序也被约束了,每一步的前后关系都不能调换:
置终止标志 → 广播唤醒 → 逐个 join → free(worker) → 释放残留任务 → 销毁同步原语join必须在free之前:线程还在运行时,它的结构体不能被回收,否则线程会访问已释放的内存。terminate的写入必须在持锁状态下:否则与工作线程读取该标志构成数据竞争。虽然int的读写在多数平台上是原子的,但 C 语言的内存模型并不保证这一点,编译器的重排和 CPU 的乱序执行都可能让另一个线程读到意外值。
五、运行验证:用消毒剂捕捉并发缺陷
代码"看起来对"和"确实对"之间差距很大,并发场景下尤其明显——很多缺陷只在特定时序下出现,低负载测试根本触发不到。
GCC 和 Clang 内置的消毒剂(sanitizer)可以在运行时捕捉这类问题,代价只是重新编译一次。
AddressSanitizer:内存错误
gcc -fsanitize=address -g -pthread program.c -o program_asanASan 的原理是在每次内存分配的周围插入不可访问的"红区"(redzone),并记录每个内存块的分配与释放状态。一旦发生越界访问、访问已释放内存、重复释放,它会立刻中止程序并打印完整的调用栈。程序退出时若存在内存泄漏,也会给出泄漏点。
用 ASan 验证线程池的销毁逻辑,可以直接确认双重释放是否真的被消除。
ThreadSanitizer:数据竞争
gcc -fsanitize=thread -g -pthread program.c -o program_tsanTSan 通过记录每个内存访问的线程标识和同步顺序,检测多个线程未经同步地访问同一内存、且至少有一个是写操作的情况。它会输出冲突双方的调用栈以及涉及的内存地址。
一个环境兼容问题
TSan 在部分系统上会直接启动失败:
FATAL: ThreadSanitizer: unexpected memory mapping 0x58de4eb36000-0x58de4eb37000这不是代码缺陷,而是 TSan 对进程的内存布局有特定要求,与系统的 ASLR(Address Space Layout Randomization,地址空间布局随机化)冲突。解决办法是关闭 ASLR 后运行:
setarch $(uname -m) -R ./program_tsan-R 表示禁用地址随机化,只对当前进程生效,不影响系统全局设置。
两项检查全部无报告,才能说明这段并发代码通过了内存安全和数据竞争的基本检验。
六、两个容易被忽略的工程细节
换行符的跨平台差异
三种系统对"换行"的定义不同:
| 名称 | 字节序列 | 使用系统 |
|---|---|---|
| LF | \n(1 字节) | Unix / Linux / macOS |
| CRLF | \r\n(2 字节) | Windows |
| CR | \r | 早期 Mac,已淘汰 |
混用的后果是:Git 会认为整个文件的每一行都被修改过,因为每个换行符的字节序列都不同,diff 显示为全文重写。
处理办法是配置 core.autocrlf:
git config --global core.autocrlf true它在两个方向上自动转换:提交时把工作区的 CRLF 转成 LF 存入仓库,检出时把仓库的 LF 转成 CRLF 写回工作区。结果是仓库里永远只存 LF,换行符差异被完全隔离在本地。
提交时偶尔会看到警告 LF will be replaced by CRLF the next time Git touches it,这是提示而非错误,说明下次检出时会执行转换。
二进制文件与版本控制
编译产物不应纳入版本管理——它们体积大、每次重编译都产生巨大差异、且可以从源码重新生成。
.gitignore 的常规写法能覆盖大部分情况:
build/
bin/
*.o
*.obj
*.exe
*.so但有一个特例:Linux 下的可执行文件通常不带扩展名。gcc lock.c -o lock 生成的文件就叫 lock,无法用 *.xxx 匹配,必须按路径显式列出:
# Linux 可执行文件没有扩展名,无法用通配符匹配
/01-concurrent-lock/lock开头的斜杠表示路径从仓库根目录算起,避免误伤其他同名文件。这也意味着每新增一个可执行文件都要检查是否需要补一行——相比之下,把编译输出统一约定到被忽略的 bin/ 目录下更省心。
七、小结
几个可以直接带走的结论:
- 竞态条件的根源是复合操作不可分割性被破坏,而不只是"内存访问不同步"。解决思路要么是让操作变得原子(
lock前缀),要么是让临界区互斥(锁)。 pthread_cond_wait必须包在while里,因为存在广播唤醒和虚假唤醒两种情况。- 持锁期间只做必要的临界操作——线程池在锁内只摘链,执行任务时放锁。
- 写释放逻辑之前先明确所有权归属,否则双重释放和内存泄漏迟早出现。
join永远在free之前,销毁顺序由所有权模型决定,不能随意调整。- 并发代码要用 ASan 和 TSan 验证,而不是"跑一遍没崩"就认为没问题。