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

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/GugeMichael/article/details/8237891

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

  •       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表的冲突拉链中。所以,如果我们的上下文是多读一写的,就可以通过适当的编程技巧来避免过度的同步。

猜你喜欢

转载自blog.csdn.net/GugeMichael/article/details/8237891