互斥锁和内存可见性

来源:互联网 发布:怎么申请农村淘宝 编辑:程序博客网 时间:2024/05/18 02:44

一:引言

         POSIX线程遵循一种共享状态的并发模型。在这种模型中,若干线程同时访问共享对象时,需要在线程间有合适的协调机制。特别是,需要以下特性来简化这种模型中的编程:

         原子性访问:当某个线程正在修改共享对象时,需要避免另一个线程访问它;

         内存可见性:一旦某个线程修改了共享对象,我们希望当修改发生后,在另一个线程中就能立即得到最新的状态。就像下图中描述的那样。

 

         互斥锁通常被介绍为是一种保证原子访问的机制。但是实际上,锁不仅用于管理共享对象的访问,它还用于处理内存可见性的问题。接下来我们将看到,某些情况下,原子访问不成问题,但是内存可见性却至关重要。这种场景下,没有互斥锁的帮助,将会犯下严重的错误。

         上图中,线程A设置x=6,y=7,接下来,在线程B中设置z=x*y。我们希望z是42.

 

二:Mutexes arefor sissy

         考虑下面的“马拉松”程序。线程A会一直run(),直到它到达线程B设置的“终点线”。所谓“终点线”是通过arrived标志位来表示的。

volatile bool  arrived = false;volatile float miles   = 0.0;/*--- Thread A ----------------------------------------*/while (!arrived){   run();}printf("miles run: %f\n", miles);/*-----------------------------------------------------*//*--- Thread B ----------------------------------------*/miles   = 26.385; // 42.195 Kmarrived = true;/*-----------------------------------------------------*/

         这里对于arrived标志位的访问,并没有使用互斥锁。我曾经多次见到这样的代码,而且听到过下面的理由:

         a:我们不需要互斥锁,因为这里仅有一个线程读,一个线程写;

         b:即使这里我得到的是arrived的脏数据,它也是非零的,因此在C中也是为“真”的,所以循环依然会如期停止。原子性在这里并非一个问题,所以我们不需要互斥锁;

         c:该示例中,互斥锁只是增加了代码的行数,并且减慢了程序的运行速度;

         d:我经过了压力测试,程序如期的工作;

 

         以上所有的理由,尽管在特定平台下可能是正确的。然而上面的代码仍然是错误的,当移植到另一个平台时,程序可能以非常微妙的方式失败。

 

三:硬件优化

         某些平台上,线程A可能会如期停止,但是打印出的miles却是0.0。而在某些平台上,即使线程B中设置了arrived为true,但线程A可能也不会停止循环。

         造成这种奇怪行为的原因是硬件。更确切的说,是因为在处理器访问内存时,硬件进行了某种优化措施。

         通常而言,处理器从内存中取的一条数据,要比执行其他指令更慢。所以这里内存就成了性能瓶颈,因此硬件工程师们想出了聪明的办法来提高速度:首先就是使用缓存(cache)来加快访问。然而,这种优化带来了额外的复杂性:

         a:当缓存不命中时,处理器依然需要访问更慢的内存;

         b:在多核系统中,我们需要一个协议来维持缓存一致性(cache coherency)。

 

1:乱序执行

         我们知道为了提高代码的执行速度,编译器可能会对指令进行重排序。然而你也许不知道,为了应对上面所说的问题,现代的处理器也会随手对指令进行重排序。

         参考下面的伪汇编指令:

mov r1, mem  // load mem cell to register r1add r1,r1,r2 // r1 = r1+r2add r3,r4,r5 // r3 = r4+r5

         可能内存单元mem没有被缓存,因此需要从内存中获取。这种情况下,处理器可以按照下面的方式对指令进行重排序,以优化处理速度:

         处理器执行第(1)条指令,但并不等待它的完成;

         一旦第(1)条指令执行完成,就立即执行第(2)条指令;

         第(3)条指令会立即执行,因为它所涉及的操作数是可得的,并且与前两条指令无关;

 

         所以,处理器可能会按照下面的顺序执行指令:(3)-(1)-(2)。这样做的优势在于:在执行耗时的内存操作之前,处理器可以执行其他更有用的工作,从而提高执行速度。这种优化对于执行指令的线程而言,是完全透明的(也就是乱序对于当前线程而言是没有影响的)。

         然而,这种重排序可能会影响到其他线程。考虑上面的代码,线程B中,因为重排序,arrived可能会在miles变量之前被置为true,这样,线程A停止循环,打印miles的值时,该值可能还没有被设置……

 

2:存储缓冲(Store Buffer)

         在一个多核系统中,当CPU写入共享内存单元时,事情可能变得更加微妙。为了保证所有核对于该内存单元维护同一个值,必须要有一个协议,保证其他CPU核缓存的该内存单元的值成为无效值。这种协议会使得CPU写数据变得更慢。

         此时,硬件工程师们又站了出来,提出了一个聪明的想法:将写请求缓存到一个被称为存储缓冲(store buffer)的硬件队列中。缓存的所有请求,将会在之后某个方便的时间点,一下子全部(in one fell swoop)应用到内存上。

         这个所谓的“方便的时间点”,对于我们开发软件的人而言就带来了问题。比如上面的马拉松程序,有可能将arrived=true在存储缓冲中排队之后,并没有应用到内存上,从而线程A不能得到其最新的值,导致其一直运行下去……

 

四:内存屏障

         以上我们看到了,在现代硬件系统上可能会出现的奇怪的事情,这不是我们所期望的好的内存可见性。在这样的系统上如何能编写稳健的程序呢?

         这就是内存屏障(memorybarriers, membars, memory fences, mfences)的作用所在。所谓内存屏障,是一种特殊的处理器指令,作用在于:

         a:flush存储缓冲;

         b:内存屏障之前所有挂起的操作都会执行完成;

         c:内存屏障之后的指令不会进行重排序;

 

         通过使用内存屏障,可以保证所有的重排序都已完成,所有挂起的写操作都已执行。因此其他线程的数据一致性得以保证,从而保证了良好的内存可见性。而且:互斥锁的实现中,使用了我们所需要的内存屏障……

         如果你对内存屏障和硬件优化有兴趣的话,建议读一下Paul McKenny的论文(http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2009.04.05a.pdf)。

 

五:实际的例子

         之前的讨论较为理论,本节中我们来看一个具体的,因为没有正确的内存可见性而导致奇怪结果的例子。

         下面的代码中,创建了2个线程。使用了全局变量Arun和Brun,使用Pthreadbarrier(不是内存屏障!,其实不用也可以)保证两个线程同时启动。一旦两个线程都结束后,应该能保证条件(Astate==1 || Bstate==1)为真。如果该表达式为假,则打印出一条信息。

/*------------------------------- mutex_01.c --------------------------------*On Linux, compile with: cc -std=c99 -pthread mutex_01.c -o mutex_01  Check your system documentation how to enable C99 and POSIX threads on other Un*x systems.Copyright Loic Domaigne. Licensed under the Apache License, Version 2.0.*--------------------------------------------------------------------------*/#define _POSIX_C_SOURCE 200112L // use IEEE 1003.1-2004#include <unistd.h>  // sleep()#include <pthread.h> #include <stdio.h>   #include <stdlib.h>  // EXIT_SUCCESS#include <string.h>  // strerror() #include <errno.h>/***************************************************************************//* our macro for errors checking                                           *//***************************************************************************/#define COND_CHECK(func, cond, retv, errv) \if ( (cond) ) \{ \   fprintf(stderr, "\n[CHECK FAILED at %s:%d]\n| %s(...)=%d (%s)\n\n",\              __FILE__,__LINE__,func,retv,strerror(errv)); \   exit(EXIT_FAILURE); \} #define ErrnoCheck(func,cond,retv)  COND_CHECK(func, cond, retv, errno)#define PthreadCheck(func,rc) COND_CHECK(func,(rc!=0), rc, rc)/*****************************************************************************//* real work starts here                                                     *//*****************************************************************************//* * Accordingly to the Intel Spec, the following situation * *    thread A:         thread B: *    mov [_x],1        mov [_y],1 *    mov r1,[_y]       mov r2,[_x] * * can lead to r1==r2==0.  * * We use this fact to illustrate what bad surprise can happen, if we don't * use mutex to ensure appropriate memory visibility.  * */volatile int Arun=0; // to mark if thread A runsvolatile int Brun=0; // dito for thread Bpthread_barrier_t barrier; // to synchronize start of thread A and B./*****************************************************************************//* threadA- wait at the barrier, set Arun to 1 and return Brun                *//*****************************************************************************/void*threadA(void* arg){    pthread_barrier_wait(&barrier);    Arun=1;    return (void*) Brun;}/*****************************************************************************//* threadB- wait at the barrier, set Brun to 1 and return Arun                *//*****************************************************************************/void*threadB(void* arg){    pthread_barrier_wait(&barrier);    Brun=1;    return (void*) Arun;}/*****************************************************************************//* main- main thread                                                         *//*****************************************************************************//* * Note: we don't check the pthread_* function, because this program is very * timing sensitive. Doing so remove the effect we want to show */intmain(){   pthread_t thrA, thrB;   void *Aval, *Bval;   int Astate, Bstate;   for (int count=0; ; count++)    {      // init       //      Arun = Brun = 0;      pthread_barrier_init(&barrier, NULL, 2);      // create thread A and B      //      pthread_create(&thrA, NULL, threadA, NULL);      pthread_create(&thrB, NULL, threadB, NULL);      // fetch returned value       //      pthread_join(thrA, &Aval);      pthread_join(thrB, &Bval);      // check result       //      Astate = (int) Aval; Bstate = (int) Bval;      if ( (Astate == 0) && (Bstate == 0) )  // should never happen       {         printf("%7u> Astate=%d, Bstate=%d (Arun=%d, Brun=%d)\n",                count, Astate, Bstate, Arun, Brun );      }    } // forever   // never reached   //   return EXIT_SUCCESS;}

         运行代码,得到的结果如下:

  61586> Astate=0, Bstate=0 (Arun=1, Brun=1) 670781> Astate=0, Bstate=0 (Arun=1, Brun=1) 824820> Astate=0, Bstate=0 (Arun=1, Brun=1)1222761> Astate=0, Bstate=0 (Arun=1, Brun=1)1337091> Astate=0, Bstate=0 (Arun=1, Brun=1)1523985> Astate=0, Bstate=0 (Arun=1, Brun=1)2340428> Astate=0, Bstate=0 (Arun=1, Brun=1)2400663> Astate=0, Bstate=0 (Arun=1, Brun=1)

         导致这种结果的,只能是内存可见性问题。gcc生成的线程A的汇编代码如下,可见对于Arun和Brun的访问都是原子的:

threadA:.LFB2:        pushq   %rbp.LCFI0:        movq    %rsp, %rbp.LCFI1:        subq    $16, %rsp.LCFI2:        movq    %rdi, -8(%rbp)        movl    $barrier, %edi        call    pthread_barrier_wait        movl    $1, Arun(%rip)        movl    Brun(%rip), %eax        cltq        leave        ret

 

六:POSIX内存可见性规则

         IEEE1003.1-2008在XBD 4.11 Memory Synchronization(http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_11)中,定义了内存可见性的规则。正确的POSIX实现能够保证:

         a: pthread_create,调用pthread_create之前设置的变量,在新创建的线程中是可见的。但是在pthread_create调用之后设置的变量不保证新线程中的可见性,即使该操作在线程启动之前执行;

         b:pthread_join:在线程终止之前设置的变量,在其他线程成功调用pthread_join之后就是可见的;

         c:某个线程在解锁之前设置的变量,其他线程同一个锁加锁之后就是可见的。如果是不同的锁,或者根本不使用锁,或者在pthread_unlock之后设置的变量,则不保证可见性。如下图:

 

七:结论

         读完本文后,你就可以理解在CertPOS03-C(https://www.securecoding.cert.org/confluence/display/c/CON02-C.+Do+not+use+volatile+as+a+synchronization+primitive)中指出的:不要用volatile作为同步原语的意思了。

         因此,只要遵守POSIX内存可见性规则,就能保证程序的正确性。一个线程写,另一个线程读时,即使能保证原子访问的情况下,也需要一个互斥锁来正确的同步内存访问,。

 

原文:

http://www.domaigne.com/blog/computing/mutex-and-memory-visibility/

更多关于内存可见性的讨论,可以参考《Programming With Posix Threads》第3.4节。

 

有关内存屏障的概念,可以参考文章《内存屏障什么的》:

http://www.spongeliu.com/233.html

 

关于多线程中要慎用volatile,参考:

http://hedengcheng.com/?p=725

 

http://www.parallellabs.com/2010/12/04/why-should-we-be-care-of-volatile-keyword-in-multithreaded-applications/

 

关于顺序一致性和cache一致性,参考:

http://www.parallellabs.com/2010/03/06/why-should-programmer-care-about-sequential-consistency-rather-than-cache-coherence/

0 0
原创粉丝点击