现在的位置: 首页 > 综合 > 正文

C/C++ — 编程中的内存屏障(Memory Barriers) (2)

2014年02月01日 ⁄ 综合 ⁄ 共 3039字 ⁄ 字号 评论关闭

在前面的文章里,主要介绍了一下内存屏障的基本认识,和基本原理。本文针对之前的思路继续聊一聊该如何处理相应的问题,以及一些多线程程序编程的技巧。

  •       1.  Volatile关键字
  •       2.  Linux pthread线程锁
  •       3.  Linux gcc 4.2之后的__sync_fetch_and_add
  •       4.  双Buffer实现Lock free无锁
  •       4.  多读一写数据结构实现Lock free无锁
       1. 在上篇文章中,提到了c/c++里的volatitle关键字,这个关键字的官方解释如下 :
     "就象大家更熟悉的const一样,volatile是一个类型修饰符(type
specifier)。它是被设计用来修饰被不同线程访问和修改的
变量。如果没有volatile,基本上会导致这样的结果:要么无法编写多线程程序,要么编译器失去大量优化的机会。"
      --- 摘自《百度百科》
       
       其核心旨意其实就是防止编译器对其进行优化(可以预防上文中提到的编译器导致的内存屏障),也就是每次cpu要获取一个变量,都要去内存中重新读取,而不会还缓存在自己的Cache中。应用的场景就例如while(!stop)这种大量执行判断的时候,每次都会去内存读取真实的值,而写入变量值的时候,也会写到内存地址中。但这样会组织编译器的优化,对这个变量的读取写入会比较慢(相对CPU的ns级别来说)。
      2. 如果想彻底避免多线程之间的同步问题,或者临界区(多线程同时访问的集合)中不一定是一个变量,可能是一段代码一些逻辑,那么这时候,“锁”就会派上用场了:
      “锁”的理念就是对临界区上一把“排他性”的标记,其他线程运行到这里时候,如果想获取同一把锁就要阻塞等待。Linux中的锁主要分为:pthread_mutex互斥锁/pthread_wrlock读写锁/pthread_spinlock自旋锁,几个的异同可以参考本人另外一篇文章《Linux锁的适用场景》。这样就可以对其间的代码访问进行同步,如:
pthread_mutex_t lock;
pthread_mutex_init(&lock,NULL);

pthread_mutex_lock(&lock);
g_count++;
pthread_mutex_unlock(&lock);

      其中的g_count++并不是原子操作,可以拆分成三步,即:取值->+1->写回值。经过加锁以后即可实现同步。

      Linux的“锁”,是一种强制性质“悲观锁”,也就是锁实现强制的,很容易导致“死锁”。并且性能不好,如果多个线程里访问临界区过多,锁过多的情况下,反而不如单线程的性能(此结论因项目环境而言)。所以单线程环境中必须去锁,在多线程环境下要尽量少锁,要尽量缩短lock->unlock中间的代码和时间
     3. 如果项目使用的编译器是gcc 4.2以上(可通过gcc -v查看),那么编译器内置了一种对atomic_t原子指令的支持:
type __sync_fetch_and_add (type *ptr, type value);
type __sync_fetch_and_sub (type *ptr, type value);
type __sync_fetch_and_or (type *ptr, type value);
type __sync_fetch_and_and (type *ptr, type value);
type __sync_fetch_and_xor (type *ptr, type value);
type __sync_fetch_and_nand (type *ptr, type value);
type __sync_add_and_fetch (type *ptr, type value);
type __sync_sub_and_fetch (type *ptr, type value);
type __sync_or_and_fetch (type *ptr, type value);
type __sync_and_and_fetch (type *ptr, type value);
type __sync_xor_and_fetch (type *ptr, type value);
type __sync_nand_and_fetch (type *ptr, type value);

     这些Gcc内置函数可以实现对int,long,long long基本类型的 + / - / 前置++ / 后置++ / & / ^ / | / -- 等基本操作,比如操作一个全局计数器递增,就可以用:

__sync_fetch_and_add (&globel,1);

     实现原子操作,其内部不是用pthread等锁来实现的,而是直接用了lock的汇编指令来实现,汇编了一下上面的代码:

    leal    40(%esp), %eax 
    lock addl   $1, (%eax)
    .loc 3 353 0
    movl    40(%esp), %edx 
    movl    $.LC7, %eax 

     可以看出,这是靠底层CPU指令来支持的(有文章称intel对此支持比较好,AMD不好)。有资料现实,同样递增计数器,使用这种内置的支持比pthread_mutex的锁支持要快5~6倍(出于篇幅,这里本人就不做测试了)。

     写多线程的程序其实有的时候,可以通过代码技巧和算法来避免“锁”和“同步”,下面就来介绍一个lock free(无锁)的技巧。
     4.  双Buffer切换实现Lock Free :
     试想一个这样的场景,有两个线程,线程A负责处理用户的请求,相应用户请求,其间要从线程B的缓冲区获取一些数据,线程B的缓冲区是一块内存,这块内存定时或者不间断会更新一次,这块内存就成为了两个线程的临界区(线程B刷新此内存时,线程A可能正在读取)。
     按照常规的做法,我们可能通过加锁来避免同时访问,但这样的话,因为这块内存可能没秒被访问100次,而30s更新一次,这样的话,每次访问都加锁和解锁,会带来很多无用功,性能也会下降。所以我们这里采用一种优化策略:双Buffer切换顾名思义,就是有两个同样结构的内存块,同一时刻只有一个内存块提供服务,如果发生了更新或写入,则把新数据放在另一个内存块做,等到做完了,通过"指针"(此处的指针泛指指向资源的句柄)改变指向,指向新的内存块,即完成了切换,原内存块在新内存块完之前,仍然提供服务。

        5. 对于一些多读一写的数据结构,这里用LinkLisk链表来举例:
         多个线程读一个LinkList,后台有个线程会根据数据更新来写这个LinkList,也可以做到lock free。首先,拿插入来举例,插入的顺序决定了是否lock free。有两种情况:
         一、先修改前面节点的next指针,再把新节点的next指针指向原来节点的下一个
node_t* tmp = pre_node->next;
pre_node->next = new_node;
new_node->next = tmp;

                这里存在的问题就是,修改完前驱节点时,另一个线程介入了,会根据新的链接关系,走到这个新节点,而新节点的next却是NULL,导致了不同步。


        二,可以先修改新节点的next,然后修改原节点的next即可,不会出现访问异常。
     
       类比这种方法,也可以推广到Hash表的冲突拉链中。所以,如果我们的上下文是多读一写的,就可以通过适当的编程技巧来避免过度的同步。

抱歉!评论已关闭.