Skip to content

并发编程常见问题

一、引言

本章探讨了并发缺陷的类型(死锁与非死锁缺陷),分析其成因、修复方法及预防策略,旨在帮助开发者编写健壮、正确的并发程序。关键问题在于如何识别和处理常见的并发缺陷。


二、并发缺陷的分类

根据Lu等人的研究[L+08],通过分析MySQL、Apache、Mozilla和OpenOffice等开源应用的并发缺陷,得出以下结论: - 总计105个缺陷: - 非死锁缺陷:74个(占70.5%) - 死锁缺陷:31个(占29.5%) - 应用缺陷分布: - MySQL:14非死锁,9死锁 - Apache:13非死锁,4死锁 - Mozilla:41非死锁,16死锁 - OpenOffice:6非死锁,2死锁

非死锁缺陷占比更高,且主要集中在违反原子性违反顺序两种类型,而死锁问题则涉及复杂的资源竞争。


三、非死锁缺陷

非死锁缺陷占并发问题的大多数,修复相对简单。以下是两种主要类型及其成因与修复方法:

1. 违反原子性(Atomicity Violation)
  • 定义:多次内存访问预期的可串行性被破坏,即代码段本应原子执行,但实际未强制实现。
  • 示例(MySQL): c Thread 1: if (thd->proc_info) { fputs(thd->proc_info, ...); } Thread 2: thd->proc_info = NULL;
  • 问题:线程1检查proc_info非空后,若被线程2中断并将proc_info置空,线程1恢复执行时可能因引用空指针崩溃。
  • 成因:缺乏对共享变量proc_info的原子性保护。
  • 修复
  • 使用互斥锁确保访问的原子性: c pthread_mutex_t proc_info_lock = PTHREAD_MUTEX_INITIALIZER; Thread 1: pthread_mutex_lock(&proc_info_lock); if (thd->proc_info) { fputs(thd->proc_info, ...); } pthread_mutex_unlock(&proc_info_lock); Thread 2: pthread_mutex_lock(&proc_info_lock); thd->proc_info = NULL; pthread_mutex_unlock(&proc_info_lock);
  • 关键:所有访问proc_info的代码均需加锁。
2. 违反顺序(Order Violation)
  • 定义:两个内存访问的预期顺序被打破(即A应在B前执行,但实际顺序相反)。
  • 示例c Thread 1: void init() { mThread = PR_CreateThread(mMain, ...); } Thread 2: void mMain(...) { mState = mThread->State; }
  • 问题:线程2假定mThread已初始化,但若线程1未执行,线程2可能因引用空指针崩溃。
  • 成因:线程间缺乏明确的执行顺序控制。
  • 修复
  • 使用条件变量强制执行顺序: c pthread_mutex_t mtLock = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t mtCond = PTHREAD_COND_INITIALIZER; int mtInit = 0; Thread 1: void init() { mThread = PR_CreateThread(mMain, ...); pthread_mutex_lock(&mtLock); mtInit = 1; pthread_cond_signal(&mtCond); pthread_mutex_unlock(&mtLock); } Thread 2: void mMain(...) { pthread_mutex_lock(&mtLock); while (mtInit == 0) pthread_cond_wait(&mtCond, &mtLock); pthread_mutex_unlock(&mtLock); mState = mThread->State; }
  • 关键:条件变量确保线程2在mThread初始化后再执行。
非死锁缺陷小结
  • 占比:97%的非死锁缺陷为违反原子性或违反顺序。
  • 修复策略:加锁(确保原子性)或条件变量/信号量(强制顺序)。
  • 挑战:复杂场景可能需要深入理解程序逻辑及大规模代码调整。
  • 建议:开发自动化代码检查工具,重点检测这两种错误。

四、死锁缺陷

死锁是并发编程中的经典问题,涉及线程间资源竞争导致的循环等待。

1. 死锁示例
Thread 1:           Thread 2:
lock(L1);           lock(L2);
lock(L2);           lock(L1);
  • 问题:线程1持有L1等待L2,线程2持有L2等待L1,形成循环等待,导致死锁。
  • 依赖图:资源依赖形成闭环。
2. 死锁发生的原因
  • 复杂依赖:大型系统中,组件间依赖(如虚拟内存与文件系统)可能导致循环。
  • 封装问题:模块化设计隐藏锁细节,可能引发意外死锁(如Java Vector的AddAll方法)。
  • 锁顺序不一致:不同线程以不同顺序获取锁。
3. 死锁的四个必要条件[C+71]
  1. 互斥:资源(如锁)被独占。
  2. 持有并等待:线程持有资源同时等待其他资源。
  3. 非抢占:资源不可被强制剥夺。
  4. 循环等待:线程间形成资源请求的闭环。
  5. 预防策略:打破任一条件即可避免死锁。
4. 死锁预防
(1)打破循环等待
  • 方法:规定锁获取的全序(total ordering)或偏序(partial ordering)。 -全序示例:总是先锁L1再锁L2。 -偏序示例:Linux内存映射代码中的锁顺序(如i_mutex早于i_mmap_mutex)。
  • 技巧:按锁地址顺序加锁,避免因参数顺序不同导致死锁: c if (m1 > m2) { pthread_mutex_lock(m1); pthread_mutex_lock(m2); } else { pthread_mutex_lock(m2); pthread_mutex_lock(m1); }
  • 挑战:需要深入理解代码库,粗心可能导致死锁。
(2)打破持有并等待
  • 方法:原子地获取所有锁,使用全局预防锁: c lock(prevention); lock(L1); lock(L2); unlock(prevention);
  • 问题:不适用于封装场景,可能降低并发性。
(3)打破非抢占
  • 方法:使用trylock尝试获取锁,失败则释放已持锁并重试: c top: lock(L1); if (trylock(L2) == -1) { unlock(L1); goto top; }
  • 问题: -可能引发活锁(livelock):线程反复尝试但无进展。 -封装场景难以实现。 -需确保释放中间分配的资源。
  • 解决活锁:重试前随机等待,降低竞争。
(4)打破互斥
  • 方法:使用无等待(wait-free)数据结构,避免锁。
  • 示例:基于比较并交换(compare-and-swap, CAS)指令的原子操作: c int CompareAndSwap(int *address, int expected, int new) { if (*address == expected) { *address = new; return 1; } return 0; } void AtomicIncrement(int *value, int amount) { do { int old = *value; } while (CompareAndSwap(value, old, old + amount) == 0); }
  • 复杂示例:无锁链表插入: c void insert(int value) { node_t *n = malloc(sizeof(node_t)); n->value = value; do { n->next = head; } while (CompareAndSwap(&head, n->next, n) == 0); }
  • 优点:避免死锁。
  • 挑战:设计复杂,适用场景有限,可能产生活锁。
5. 死锁避免(Avoidance)
  • 方法:通过调度避免死锁,需了解线程的锁需求。
  • 示例
    • 表32.2:T1、T2需L1、L2,T3需L2,T4无需锁。
    • 调度策略:T1、T2不同时运行,避免死锁。
  • 复杂场景(表32.3):T1、T2、T3均需L1、L2,保守调度(如单处理器顺序执行)避免死锁,但降低并发。
  • 经典算法:Dijkstra的银行家算法[D64]。
  • 局限:需全局信息,适用场景有限(如嵌入式系统),并发性受限。
6. 死锁检测与恢复
  • 方法:允许死锁发生,检测后恢复。
  • 检测:定期检查资源依赖图,寻找循环。
  • 恢复:重启系统或线程,复杂场景需人工干预。
  • 适用场景:死锁罕见且影响小(如数据库系统)。
  • Tom West定律:若问题罕见且影响小,不必追求完美预防。
7. 死锁小结
  • 常见策略:锁顺序设计(预防循环等待)、无等待数据结构。
  • 挑战:大型系统复杂依赖和封装增加死锁风险。
  • 未来方向:开发无锁编程模型(如MapReduce),减少锁使用。

五、工具

1. lockdep

  • 功能:Linux内核的锁依赖检查工具,追踪锁的获取顺序,检测潜在死锁。
  • 优化策略
    • 将同一行代码初始化的锁视为“同一锁”,减少追踪开销。
    • 代价:牺牲检测精度。例如,哲学家就餐问题中,若所有锁在同一行初始化,lockdep无法区分锁顺序,可能漏报死锁。
  • 适用场景:内核开发中检测复杂的锁依赖问题。

2. AddressSanitizer (ASan)

  • 功能:通过编译器插入内存访问断言,检测内存错误(如越界访问、释放后使用)。
  • 优势
    • 自动化检查内存正确性,适用于复杂系统(如操作系统内核)。
    • 相比人工调试,显著提高效率。
  • 局限
    • 仅针对内存相关错误,无法直接检测并发缺陷(如死锁或数据竞争)。
    • 运行时开销较高,可能影响性能。
  • 与并发缺陷的关系:可间接辅助发现并发导致的内存错误(如违反原子性导致的空指针引用)。

3. ThreadSanitizer (TSan)

  • 功能:检测数据竞争(data race),即不同线程对同一变量的无序访问(至少一个为写,且无happens-before关系)。
  • 工作原理
    • 基于happens-before关系分析,识别潜在竞争。
    • 通过插桩监控内存访问,捕获竞争场景。
  • 局限
    • 检测依赖特定线程调度,可能漏报(未触发的数据竞争)。
    • 无法直接发现死锁或违反顺序问题。
  • 适用场景:用户态程序(如C/C++应用)的并发调试,特别适合检测违反原子性导致的竞争。

五、总结与延伸

  1. 并发缺陷的核心
  2. 非死锁缺陷(违反原子性、违反顺序):占比高,修复较简单,需关注锁和条件变量。
  3. 死锁缺陷:涉及复杂资源竞争,需通过预防、避免或检测恢复解决。
  4. 开发建议
  5. 代码设计:明确锁顺序,避免循环等待;优先考虑无锁数据结构。
  6. 工具支持:开发自动化检测工具,重点检查原子性和顺序问题。
  7. 编程模型:探索无锁模型(如MapReduce),减少并发复杂性。
  8. 延伸思考
  9. 现代并发框架:如Rust的内存安全模型、Go的goroutine和通道,是否能有效减少传统并发缺陷?
  10. 硬件支持:随着多核CPU和原子指令(如CAS)的普及,无等待数据结构是否会成为主流?