Chinaunix首页 | 论坛 | 博客
  • 博客访问: 534278
  • 博文数量: 103
  • 博客积分: 2024
  • 博客等级: 上尉
  • 技术积分: 1294
  • 用 户 组: 普通用户
  • 注册时间: 2010-01-08 21:17
文章分类

全部博文(103)

文章存档

2012年(2)

2011年(21)

2010年(80)

分类: LINUX

2011-04-13 20:37:28

在《程序员的自我修养 — 链接装载与库》一书第28页“过度优化”这一节中,作者提到了编译器优化可能造成多线程bug的情况(我手中的是09年6月第二次印刷那版)。原文如下:

线程安全是一个非常烫手的山芋,因为即使合理的使用了锁,也不一定能保证线程安全,这是源于落后的编译器技术已经无法满足日益增长的并发需求。很多看似无错的代码在优化和并发前又产生了麻烦。最简单的例子,让我们看看如下代码:

x = 0;
Thread 1 Thread 2
lock(); lock();
x++; x++;
unlock(); unlock();

由于有lock和unlock的保护,x++的行为不会被并发所破坏,那么x的值似乎必然是2了。然后,如果编译器为了提高x的访问速度,把x放到了某个寄存器里,那么我们知道不同线程的寄存器是各自独立的,因此如果Thread 1先获得锁,则程序的执行可能会呈现如下的执行情况:

*1 Thread 1:读取x的值到某个寄存器R[1] (R[1]=0)
*2 Thread 1:R[1]++
*3 Thread 2:读取x的值到某个寄存器R[2] (R[2]=0)
*4 Thread 2:R[2]++
*5 Thread 2:将R[2]写回至x(x=1)
*6 Thread 1:(很久以后)将R[1]写回至x(x=1)

可见在这样的情况下即使正确的加锁,也不能保证多线程安全。

这个“加锁后仍不能保证线程安全”的结论其实是错误的。在对一段代码进行加锁操作之后,被锁保护起来的代码就形成了一个临界区,在任何时刻最多只能有一个线程运行这个临界区中的代码,而其他的线程必须等待(例如)。给临界区加锁之后相当于给临界区内的代码添加了原子性的语义。

既然加锁之后临界区内的代码是原子操作的,那么就不可能出现《程》中描述的那种执行顺序,因为Thread 2必须要等到Thread 1执行完x++和unlock()之后才能获得锁并随即进行x++操作。即如下所述的执行顺序:

*1 Thread 1:lock()
*2 Thread 1:读取x的值到某个寄存器R[1] (R[1]=0)
*3 Thread 1:R[1]++
*4 Thread 1:将R[1]写回至x(x=1)
*5 Thread 1:unlock()
*6 Thread 2:lock() //得到锁
*7 Thread 2:读取x的值到某个寄存器R[2] (R[2]=1)
*8 Thread 2:R[2]++
*9 Thread 2:将R[2]写回至x(x=2)
*10 Thread 2:unlock()

其实,这里更值得讨论的一个问题是memory visibility(内存可见性)。例如,在Thread 1将R[1]的值写回至x的这一步中,如果Thread 1只是将值放到了这个CPU核的write buffer(write buffer是多核CPU中为于优化写性能的一种硬件)里,而未将最新值直接更新至内存,那么处在另一个CPU核上的Thread 2真的有可能在第7步时读到的是x的旧值0,这下该怎么办?这个问题其实就是共享变量的值何时能被其他线程可见的问题。

好在正是因为内存可见性在共享内存的并行编程中如此的重要,所以以pthread为代表的线程库早就规定好了自己的内存模型,其中就包括了memory visibility的定义:

Memory Visibility
- When will changes of shared data be visible to other threads?
- Pthreads standard guarantees basic memory visibility rules
» thread creation
• memory state before calling pthread_create(…) is visible to created thread
» mutex unlocking (also combined with condition variables)
• memory state before unlocking a mutex is visible to thread which locks same mutex
» thread termination (i.e. entering state “terminated”)
• memory state before termination is visible to thread which joins with terminated thread
» condition variables
• memory state before notifying waiting threads is visible to woke up threads

说简单点,Pthreads线程库帮程序员保证了pthread mutex(spin lock也一样)所保护的临界区内共享变量的可见性:即Thread 1一执行完unlock(),x的最新值1一定能被Thread 2看见。(为了实现这一点,Pthreads线程库在实现的时候都会根据相应的硬件平台调用相应的memory barrier来保证内存可见性,感兴趣的同学可以看看nptl的实现)

所以,只要正确的用锁保护好你的共享变量,你的程序就会是线程安全的。《程》中所给出的上述例子其实是错误的。

PS.《程》确实是本好书,作者作为我的同龄人功力还是令人钦佩的。但是这个例子也反映了一个现实:写书最怕的就是出现重大的原则性错误,而博客作为互联网上的公开资源,能更容易的吸收大家的修改意见,保证文章的正确性。

原文连接  

参考文献:

[2] Mutex and Memory Visibility

阅读(2700) | 评论(4) | 转发(0) |
给主人留下些什么吧!~~

sandflee2011-04-19 09:38:39

GFree_Wind: 其实我认为原书并没有写错。如果不考虑memory barrior的问题,当thread1执行x++后,并不需要立刻从寄存器写回内存。原作者的说法并没有错误。当然因为pthread的.....
同意。但原文里的lock容易让人联想到POSIX LOCK。至少我只接触到这种lock....

GFree_Wind2011-04-17 23:59:00

sandflee: POSIX如果连这个都不能保证的话,锁还有什么意义。这在底层应该都处理好的。
这篇文章适从http://www.parallellabs.com/2011/04/09/pthread-mutex-lock-and-thr.....
个人感觉,原书作者中的lock只是一种单一lock功能。他引用寄存器的说明,应该就是为了说明在使用寄存器的情况下,会有这种不一致的行为。而这种行为产生的条件,一个是使用了寄存器,二是内存屏障问题。

sandflee2011-04-17 23:52:11

GFree_Wind: 其实我认为原书并没有写错。如果不考虑memory barrior的问题,当thread1执行x++后,并不需要立刻从寄存器写回内存。原作者的说法并没有错误。当然因为pthread的.....
POSIX如果连这个都不能保证的话,锁还有什么意义。这在底层应该都处理好的。
这篇文章适从http://www.parallellabs.com/2011/04/09/pthread-mutex-lock-and-thread-safety/ 这里转的。

GFree_Wind2011-04-14 12:29:34

其实我认为原书并没有写错。如果不考虑memory barrior的问题,当thread1执行x++后,并不需要立刻从寄存器写回内存。原作者的说法并没有错误。当然因为pthread的锁,保证了memory barrior,所以原作者提出的问题,在使用pthread锁时,并不存在。当我相信,原书作者提出这个问题时,就是为了说明这个memory barrior的问题。