《读书笔记》程序员的自我修养之线程安全问题

场景:因为多线程程序处于一个多变的环境中,可访问的全局变量和堆数据随时均可能被其余线程改变。缓存

一个经典实例来阐述多个线程同时访问一个共享数据所形成的后果。安全

线程1多线程

线程2并发

i=1函数

++i优化

--iui

首先要明白++i的实现步骤以下:spa

(1)    读取i到某个寄存器X;线程

(2)    X++;code

(3)    将X的内容存储回i。

若是线程1和线程2并发执行,则执行顺序以下:

执行序号

执行指令

语句执行后变量值

线程

1

i=1

i=1,X[1]=未知

1

2

X[1]=i

i=1,X[1]=1

1

3

X[2]=i

i=1,X[2]=1

2

4

X[1]++

i=1,X[1]=2

1

5

X[2]--

i=1,X[2]=0

2

6

i=X[1]

i=2,X[1]=2

1

7

i=X[2]

i=0,X[2]=0

2

  • 从程序逻辑来看,两个线程都执行完以后,i的值应该为1,可是实际的状况下,可能为0,1,2;
  • 形成这个问题的缘由是++i这条语句会被编译为3条汇编代码。
  • 可见,两个程序同时读写同一个共享数据时会致使意想不到的后果。

 为了不上述问题,有以下几种方式:

  1. 原子指令:单指令操做,执行时不会被打断(仅适用于简单场合)
  2. 同步:一个线程访问数据未结束时,其余线程禁止对相同数据的访问。
  3. 锁:同步最多见的方法。

注解:锁是一种非强制机制,每个线程在访问数据或资源以前首先试图获取(Acquire)锁,并在访问结束以后释放(Release)锁。当锁已经被占用的时候试图获取锁时,线程会等待,直到锁从新可用。

 

锁的分类:

1)  二元信号量(只有两种状态:占用和非占用,适合只能被惟一线程访问的资源)

2)  多元信号量,也叫信号量(一个初始值为N的信号量容许N个线程并发访问)

获取过程:

将信号量的值减1;

若是信号量的值小于0,则进入等待状态,不然继续执行。

释放过程

将信号量的值加1;

若是信号量的值小于1,唤醒一个等待中的线程。

3)  互斥量(与二元信号量类似,资源仅容许被一个线程访问)

二元信号量与互斥量的区别:二元信号量:在整个系统能够被人以线程获取并释放,即,同一个信号量能够被系统中的一个线程获取后以后,能够由另外一个线程释放;互斥量则要求哪一个线程获取了互斥量,哪一个线程就要负责释放这个锁。

4)  临界区(其做用范围仅限于本进程,其余进程没法获取该锁)

5)  读写锁(适用于读多,写少的场合)

读写锁有两种获取方式(共享的和独占的)

6)  条件变量(可让许多线程一块儿等待某个事件的发生,当事件发生时,全部的线程能够一块儿恢复执行。多元信号量,只能让一个线程恢复执行。)

 

编译器的过分优化也可能形成线程安全的问题,看以下几个例子。

例1:

x = 0;

Thread1 Thread2

lock(); lock();

x++; x++;

unlock(); unlock();

因为有lock和unlock的保护,x++的行为不会被并发所破坏,那么x的值彷佛必然是2了。其实否则,若是编译器为了提升x的访问速度,把x放到了某个寄存器里(不一样线程的寄存器是各自独立的),所以若是Thread1先得到锁,则程序的执行多是以下状况:

  •  [Thread1]读取x的值到某个寄存器R[1](R[1]=0)
  •  [Thread1] R[1]++(因为以后可能还要访问x,所以Thread1暂时不讲R[1]写回x)
  •  [Thread2]读取x的值到某个寄存器R[2](R[2]=0)
  •  [Thread2] R[2]++(R[2]=1)
  •  [Thread2]将R[2]写回至x(x=1)

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

例2:

x = y = 0;

Thread1 Thread2

x = 1; y = 1;

r1 = y; r2 = x;

r1和r2至少有一个为1,逻辑上不可能同时为0.然而,事实上r1 = r2 = 0的状况确实可能发生。编译器在进行优化的时候,可能为了效率而交换绝不相干的两条相邻指令(如x=1和r1=y)的执行顺序。

则变为:

x = y = 0;

Thread1 Thread2

r1 = y; r2 = x;

x = 1; y = 1;

解决方法:

可使用volatile来阻止过分优化,volatile主要作两件事
1)阻止编译器为了提升速度将一个变量缓存到寄存器内而不写回
2)阻止编译器调整操做volatile变量的指令顺序。

这个方法能够解决第一个问题,但不能彻底解决第二个问题,由于volatile可以阻止编译器调整顺序,也没法阻止CPU动态调度换序。

 

例3:

volatile T* pInst = 0;

T* GetInstance()
{
       if (pInst == NULL)
       {
              lock();
              if(pInst == NULL)
                     pInst = new T;
              unlock();
       }
       return pInst;
}

当函数返回时,pInst老是指向一个有效的对象。而lock和unclock防止了多线程竞争致使的麻烦。

然而,实际上这样的代码是有问题的,问题来源于CPU的乱序执行。

C++里的new操做包含两个步骤:

(1)    分配内存

(2)    调用构造函数

因此pInst = new T包含了三个步骤:

(1)    分配内存

(2)    在内存的位置上调用构造函数

(3)    将内存的地址赋给pInst

在这3步中,(2)(3)的顺序是能够颠倒的,也就是说可能出现这种状况:pInst的值已经不是NULL,但对象仍然没有构造完毕。这时候若是出现另一个对GetInstance的并发调用,此时第一个 if内的表达式pInst == NULL为false,因此这个调用会直接返回还没有构造彻底的对象的地址(pInst)以提供给用户使用。

从上面的例子中能够看出,阻止CPU换序是必需的。但目前并不存在可移植的阻止换序的方法。一般状况下是调用CPU提供的一条指令(barrier)。

barrier 指令用于阻止CPU将该指令以前的指令交换到barrier以后,反之亦然。

 

所以,例3能够修改成以下:

#define barrier() _asm_ volatile("lwsync")

volatile T* pInst = 0;

T* GetInstance()

{
       if(!pInst)

       {
              lock();

              if(!pInst)

              {
                     T* temp = new T;

                     barrier();

                     pInst = temp;
              }
              unlock();
       }
       return pInst;
}

因为barrier的存在,对象的构造必定在barrier执行以前完成,所以,当pInst被赋值时,对象老是无缺的。

相关文章
相关标签/搜索