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

共享变量与数据竞争

2019年04月28日 ⁄ 综合 ⁄ 共 4089字 ⁄ 字号 评论关闭
共享变量与数据竞争

         在多线程程序线程间交互最方便莫过于利用共享变量,但它是一把双刃剑,大部分并发错误都是由它引起。它引起的并发错误不仅难以调试和检测,就连偶尔出现一次再现都是一件很难的事。并发错误主要有两种,死锁和数据竞争,而共享变量是引发数据竞争主要原因。共享变量引发的数据竞争又可以分为两种,顺序冲突和原子违反。

原子违反

原子违反是数据竞争中最常见的并发错误,由共享变量引起的原子违反的原因是一个线程在对共享变量原子性操作时没有进行系列化保护而另一个线程对该共享变量进行访问。来看一个简单的例子:

 

void IncX()

{

         x++;

}

 

该函数的功能是将共享变量x加1,在一个顺序的程序中不管你调用多少次结果你都能保证,但在多线程程序中多个线程对他进行调用你就不能保证它的结果是否正确。x++等价于x=x+1,它的操作可以分为三步:(1)首先从内存中取出x;(2)然后将x加1;(3)最后将结果存入内存中。当有两个线程同时调用该函数时,它们同时取出x,然后同时将x加1,再同时将结果存入内存,虽然调用了两次该函数,但x的结果只加1。对共享变量x没有进行系列化保护,才会出现该结果。

为了对共享变量进行系列化保护可以利用锁机制来保护每次只有一个线程对共享变量进行操作。因此该函数可以这样:

 

void IncX()

{

         mutex.lock();

         x++;

         mutex.unlock();

}

 

在java中可以这样:

 

void IncX()

{

         synchronized(mutex){

                   x++;

         }

}

 

或者可以这样:

 

synchronized void IncX()

{

         x++;

}

 

如果我们对计数要求不是很高,不要锁保护,也仅仅是造成如上所示少加1,使计数不准确。但会不会发生更严重的事情呢?

两个线程同时对IncX()调用,最严重的结果是x少加一半。但现在的多核多处理器非常普遍,不同的处理器又有单独的缓存。当一个线程仅仅调用IncX()一次,从内存中取得x的值为0到缓存中,然后被其他的事情挂起,当挂起结束后将结果存入内存中,则不管其它线程对x进行了怎样的操作,x的结果都是1。

顺序冲突

         顺序冲突发生的原因是一个操作A必须在另一个操作B之前进行,而程序没有对其进行保证。首先来看一个简单的例子:

 

         typedef struct str{

                   char name[30];

                   …

         }StrName,*PStrName;

         PStrName p;

         //Thread 1                             //Thread 2

         strcpy(p->name,”Time”);//B           //p is uninitialized

         …                                     //until here

         printf(“Name:%s\n”,p->name);         p=(PStrName)malloc(sizeof(StrName)); //A

 

         当线程2在未初始化p之前,线程1对p->name进行操作时程序会直接崩溃。程序正常运行时线程2对p进行初始化总是会在线程1对p进行引用之前,但是在特殊情况时,比如在线程2对p进行初始化之前线程2由于某种情况进行挂起则线程1对p进行引用会先进行,或者在特殊的机器上线程2运行的处理器运行比线程1运行的处理器慢,线程1对p的引用会比线程2对p进行初始化早。

         我们再来看看另一个例子:

 

         //Thread 1                             //Thread2

         while(…){                                                                                          
tmp=buffer[i];//A free(buffer);//B

}

 

         当线程2对buffer释放在线程1对buffer引用之前,就会出现对野指针引用。不知道大家注意到这里的例子和前一个例子有什么区别?

         前一个例子只要线程2初始化p,线程1就可以对p进行引用,也就是执行一次A后面所有B都可以执行的顺序关系,可以表示为firstA-B的关系。而本次的例子是,只有当所有的tmp=buffer[i]操作(该操作可能不只是在线程1中)都执行完,线程2释放buffer的操作才可以操作,也就是只有当所有的A都执行,后面的B才可以执行的顺序关系,可以表示为allA-B的关系。

         对顺序冲突的修复方法可以根据不同程序的语义进行修复,但还有一种普遍的方法就是利用信号等待来实现。

         对于firstA-B方式的顺序冲突的修复相对比较简单,需要一个布尔变量和一个条件变量,A操作的线程进行发信号和设置布尔变量的操作,B操作的线程进行接受等待信号的操作,第一个A的操作进行后就设置布尔变量并发信号,其他线程的B操作就可以进行。其代码如下:

 

         if(!alreadyBroadcast){

                   alreadyBroadcast=true;

                   mutex.lock();                         mutex.lock();

                   cond.broadcast(con);                  if(!alreadyBroadcast)

                   mutex.unlock();                       
cond.timedwait(con,mutex,t);          
}                                                 mutex.unlock();

(a)     Signal operation                            (b) Wait operation

 

对于allA-B方式的顺序冲突的修复则相对比较复杂,其需要一个整数变量和一个条件变量,首先要先得到有多少个具有A操作的线程,将该线程数赋给其整数变量,然后每一个具有A操作的线程执行完A操作就将其整数变量减1,当所有的A操作执行完其整数变量就减为0,然后就发出信号,B操作的线程接受到该信号后就可以执行B操作。其代码如下:

 

mutex.lock();                                          mutex.lock();

if(--C==0)                                             if(C>0)

         cond.broadcast(con);                              cond.timedwait(con,mutex,t);

mutex.unlock();                                         mutex.unlock();

(a)     Signal operation                                (b) Wait operation

 

前提到的共享变量引发的数据竞争都是由于程序员没有正确操作好共享变量引起的,但是有时候尽管你的程序在语义和语法上一点问题都没有,然而当编译链接后执行的时候发现结果和你的程序语义完全不一样。造成该结果可能由以下两个原因引起:编译器优化和硬件系统。

编译器优化

         首先来看一个简单的例子:

 

         //Thread 1                                 //Thread 2                 

x=…;                                       while(!done){}

        done=true;                                  …=x;

 

         该例子讲述的是一个线程等待一个布尔变量,线程2等待布尔变量done变为true,然后再继续执行。

         当一个 ”笨” 编译器编译链接后执行完全没有问题,但是当一个 “聪明” 编译器编译链接后执行就不能保证了。编译器发现done在循环中并没有修改,因此它会自作聪明的进行优化。它优化的结果可能如下:

         tmp=done; while (!tmp) {}

         甚至会优化成:

         tmp=done; if (!tmp) while (true) {}

         对我们的例子编译器优化肯定是错误的,但对于编译器如此优化却有两个原因:(1)传统的编译器被设计为编译顺序程序而不是多线程程序;(2)甚至现代的语言任然允许编译器这样做。

         编译器还有一个典型的优化会导致并发程序出现数据竞争,那就是编译器优化改变程序中的顺序,这里就不举例子了。

硬件系统

         硬件系统导致程序出现数据竞争在以前是比较严重的,现在由于并发程序的特点被研究的比较透彻,硬件开发商也对硬件做了相应改变。硬件系统导致数据竞争有很多原因,以下举一个神奇的例子。在一个连续存储的内存块中a和b相邻,每次对a或者b存取操作时都会同时将a和b从内存中取出然后操作再存入内存。假如有两个线程同时分别对a和b操作,一个线程执行a=1,另一个线程执行b=1,则会出现一个很神奇的结果,如果a最后存入则只执行了a=1,否则只执行了b=1。

         共享变量是一个非常难于操作但又非常重要的东西,在不知不觉的编程中就悄悄留下一个以后难于发现的并发错误,因此使用它需要非常小心。当然编程语言和硬件都在优化以适应多核多线程发展趋势,现在编程不需要去考虑编译器和硬件的影响,而只需要考虑程序本身语法和语义问题。C++11已经发布,其对并发编程做了很大的调整和优化,其内存模型更适合于并发编程(其并发内存模型参考了java的内存模型)。

抱歉!评论已关闭.