互斥锁和内存可见性

一:引言

         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 Km
arrived = 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 r1
add r1,r1,r2 // r1 = r1+r2
add 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 runs
volatile int Brun=0; // dito for thread B

pthread_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
 */
int
main()
{
   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/

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值