多线程程序常见Bug剖析(上)
编写多线程程序的第一准则是先保证正确性,再考虑优化性能。本文重点分析多线程编程中除死锁之外的另两种常见Bug:违反原子性(Atomicity Violation)和违反执行顺序(Ordering Violation)。现在已经有很多检测多线程Bug的工具,但是这两种Bug还没有工具能完美地帮你检测出来,所以到目前为止最好的办法还是程序员自己有意识的避免这两种Bug。本文的目的就是帮助程序员了解这两种Bug的常见形式和常见解决办法。
1. 多线程程序执行模型
在剖析Bug之前,我们先来简单回顾一下多线程程序是怎么执行的。从程序员的角度来看,一个多线程程序的执行可以看成是每个子线程的指令交错在一起共同执行的,即Sequential Consistency模型。它有两个属性:每个线程内部的指令是按照代码指定的顺序执行的(Program Order),但是线程之间的交错顺序是任意的、不确定的(Non deterministic)。
我原来举过一个形象的例子。伸出你的双手,掌心面向你,两个手分别代表两个线程,从食指到小拇指的四根手指头分别代表每个线程要依次执行的四条指令。
(1)对每个手来说,它的四条指令的执行顺序必须是从食指执行到小拇指
(2)你两个手的八条指令(八个手指头)可以在满足(1)的条件下任意交错执行(例如可以是左1,左2,右1,右2,右3,左3,左4,右4,也可以是左1,左2,左3,左4,右1,右2,右3,右4,也可以是右1,右2,右3,左1,左2,右4,左3,左4等等等等)
好了,现在让我们来看看程序员在写多线程程序时是怎么犯错的。
2. 违反原子性(Atomicity Violation)
何谓原子性?简单的说就是不可被其他线程分割的操作。大部分程序员在编写多线程程序员时仍然是按照串行思维来思考,他们习惯性的认为一些简单的代码肯定是原子的。
例如:
以下是代码片段: Thread 1 Thread 2 S1: if (thd->proc_info) ... { S3: thd->proc_info=NULL; S2: fputs(thd->proc_info,...) } |
这个例子的对象是两条语句,所以很容易看出来它们的组合不是原子的。事实上,有些看起来像是原子操作的代码其实也不是原子的。最著名的莫过于多个线程执行类似“x++”这样的操作了。这条语句本身不是原子的,因为它在大部分硬件平台上其实是由三条语句实现的:
以下是代码片段: mov eax,dword ptr [x] add eax,1 mov dword ptr [x],eax |
以下是代码片段: struct RoomPoint { public int X; public int Y; } RoomPoint p = new RoomPoint(2,3); r.Location = p; |
有时候数据竞争可是隐藏的很深的,例如下面的Parallel.For看似很正常:
以下是代码片段: Parallel.For(0, 10000, i => {a[i] = new Foo();}) |
以下是代码片段: class Foo { private static int counter; private int unique_id; public Foo() { unique_id = counter++; } } |
3. Atomicity Violation的解决方案
解决方案大致有三(可结合使用):
(1)把变量隔离起来:只有一个线程可以访问它(isolation)
(2)把变量的属性定义为immutable的:这样它就是只读的了(immutability)
(3)同步对这个变量的读写:比如用锁把它锁起来(synchronization)
例如下面这个例子里面x是immutable的;而a[]则通过index i隔离起来了,即不同线程处理a[]中不同的元素;
以下是代码片段: Parallel.For(1,1000, i => { a[i] = x; }); |
以下是代码片段: public class Coordinate { private double x, y; public Coordinate(double a, double b) { x = a; y = b; } public void GetX() { return x; } public void GetY() { return y; } } |
以下是代码片段: Thread 1 Thread 2 LOCK(&lock) S1: if (thd->proc_info) LOCK(&lock); { S3: thd->proc_info=NULL; S2: fputs(thd->proc_info,...) UNLOCK(&lock); } UNLOCK(&lock) |
“Java 5 中提供了 ConcurrentLinkedQueue 来简化并发操作。但是有一个问题:使用了这个类之后是否意味着我们不需要自己进行任何同步或加锁操作了呢?
也就是说,如果直接使用它提供的函数,比如:queue.add(obj); 或者 queue.poll(obj);,这样我们自己不需要做任何同步。”但是,两个原子操作合起来可就不一定是原子操作了(Atomic + Atomic != Atomic),例如:
以下是代码片段: if(!queue.isEmpty()) { queue.poll(obj); } |
以下是代码片段: synchronized(queue) { if(!queue.isEmpty()) { queue.poll(obj); } } |
以下是代码片段: int x = 0; pthread_mutex_t lock1; pthread_mutex_t lock2; pthread_mutex_lock(&lock1); x++; pthread_mutex_unlock(&lock1); ... ... pthread_mutex_lock(&lock2); x++; pthread_mutex_unlock(&lock2); |
总结一下,不管是多条语句之间的原子性也好,单个语句(例如x++)的原子性也好都需要大家格外小心,有这种意识之后很多跟Atomicity Violation相关的Bug就可以被避免了。其实归根结底,我们最终是想让多线程程序按照你的意愿正确的执行,所以在清楚什么样的情形可能让你的多线程程序不能按你所想的那样执行之后我们就能有意识的避免它们了(或者更加容易的修复它们)。下一篇文章我们再来仔细分析下Ordering Violation。
[注1] 严格意义上来讲,Data Race只是Atomicity Violation的一个特例,Data Race Free不能保证一定不会出现Atomicity Violation。例如文中Java实现的那个Concurrent Queue的例子,严格意义上来讲它并没有data race,因为isEmpty()和poll()都是线程安全的调用,只不过它们组合起来之后会出现违反程序员本意的Atomicity Violation,所以要用锁保护起来。
P.S. 参考文献中的前两篇是YuanYuan Zhou教授的得意门生Dr. Shan Lu的论文,后者现在已经是Wisconsin-Madison的教授了。
建议继续学习:
- 浅析C++多线程内存模型 (阅读:7127)
- C++ 多线程编程总结 (阅读:6810)
- 多线程队列的算法优化 (阅读:6532)
- 程序中的“多线程” (阅读:5680)
- php多线程扩展 (阅读:4257)
- 为什么在多线程程序中要慎用volatile关键字? (阅读:4008)
- Ameba , 一个简单的 lua 多线程实现 (阅读:3605)
- 多线程程序中操作的原子性 (阅读:3021)
- 极不和谐的 fork 多线程程序 (阅读:2705)
- linux下多线程的创建与等待详解 (阅读:2690)
扫一扫订阅我的微信号:IT技术博客大学习
- 作者:冠诚 来源: 并行实验室 | Parallel Labs
- 标签: 多线程
- 发布时间:2010-12-07 02:44:58
- [51] Oracle MTS模式下 进程地址与会话信
- [47] android 开发入门
- [47] Go Reflect 性能
- [47] 如何拿下简短的域名
- [47] IOS安全–浅谈关于IOS加固的几种方法
- [43] 读书笔记-壹百度:百度十年千倍的29条法则
- [43] 图书馆的世界纪录
- [41] 【社会化设计】自我(self)部分――欢迎区
- [28] 视觉调整-设计师 vs. 逻辑
- [26] 程序员技术练级攻略