Life is Good.

Enhance Tech and English
随笔 - 65, 文章 - 20, 评论 - 21, 引用 - 0
数据加载中……

【转】C++多线程入门(二)

第三节 线程互斥


本节介绍如下内容

1.    主动对象

2.    调度与原子操作

3.    竞争条件和数据一致性

4.    为何需要互斥

5.    互斥类接口定义

6.    示例程序

7.    互斥类的Unix和Windows实现

1.     主动对象(Active Object)

第二节介绍Thread类的时候曾经提到,每个子线程一旦被创建,就被赋予了自己的生命。当一个线程类创建并启动之后,它将会以自己的步调主动执行其独立线程,它和其他线程(包括主线程)的执行是并行关系。

为了详细说明这个问题,先介绍一下什么是控制流(control flow)。计算机科学中,控制流指指令式(imperative)或函数式(functional)编程语言中语句、指令、函
数调用的执行(或求值)序列。

单线程程序中,控制流始终是一线串珠,一旦控制流达到某个对象,由程序主动调用对象函数,在函数执行完毕后,控制流返回主程序继续执行。对于被访问对象来 说,访问、修改、成员函数调用都是一个被动的过程,此过程受控于该程序的控制流。所以,称单线程程序的对象为被动对象(Passive Object)。

与被动对象相对应的是主动对象。主动对象和被动对象的区别在于,主动对象可以发起自己的线程,创建新的执行序列,产生独立于主线程的控制流分支。简而言 之,主动对象可以独立于主线程,自主制定自己的控制流。如果愿意,你可以实现一个和主线程没有任何协调关系,天马行空式的独立线程。当然,这样的线程往往 也意味着毫无用处(娱乐作用除外)。

2.     调度和原子操作

从理论模型上说,多线程环境中,线程之间是独立、对等关系。一个线程完全可以无视其他线程的存在。但实际多线程执行环境中,线程数往往远大于CPU数量。 为了共享和分配资源,线程必需遵守一定规则,进行必要的协调。操作系统中具体的规则执行者是调度程序。因此,要想掌握多线程编程,就必需了解线程调度的基 本概念和特点。在此基础上,能了解为什么需要在线程之间进行协调,进而才能透彻理解如何协调多线程的并发执行。

现代操作系统中,存在许多不同的线程调度模型,这些调度模型的基本共同点就是线程执行顺序具有随机性和不确定性。调度模型既无法事先知道某一时刻会存在多 少线程,也无法知道哪个线程会正在运行,甚至也不知道某个线程确切会到什么时刻执行结束,更不用说预先安排在特定时刻执行特定线程的特定语句。

前面提到,控制流就是语句,指令或函数调用的顺序序列。反映到时间轴上,就是一系列离散分布的执行序列。线程调度作用于多个控制流的客观效果,就是多个控 制流按同一条时间轴排列时,是一个近似随机的执行序列;按每个控制流各自的时间轴排列时,则是一个具有先后顺序的执行序列。

在具体的程序执行上,这个特点就表现为线程A的一个语句执行完毕后,紧接着执行的可能是另一个线程B的一个语句。甚至可能是线程A的一个语句还没有执行完毕,就接着执行线程B的语句。

对于后一点不用感到奇怪。因为操作系统中,调度程序作为系统底层实现的一部分,参与调度的操作指令可能比高级编程语言的基本语句要底层的多。同一个调度程 序,其调度的众多线程可能由多种高级语言编写,所以调度程序基本上不可能以某种高级编程语言的单条语句为单位安排执行序列。

通常,一条高级语言语句可能对应多条汇编指令,而一条汇编指令可能对应多条CPU微码指令。而一条微码指令,则可能对应逻辑电路的一个具体电路逻辑。顺便 提一下,这也是Verilog, VHDL等高级语言能够综合出具体的逻辑电路的基本原理。所不同的是,高级语言编译器编译的最终单位是汇编,而硬件描述语言综合的最终单位是和微码对应的 电路器件单元(集成电路前端设计的内容。记得以前暑期实习,我窝在学校做的就是这么一个元件库,做的很不像样子居然还得到了一定的肯定,多年后想起来还是 汗颜)。

至于系统调度程序具体会定义怎样的原子操作集合,会以什么粒度的指令为调度基本单位,这就是系统设计者各显神通的地方了。个人认为,作为高级语言的编程 者,记住什么操作是原子操作意义并不明显。大多数场合,只要认为,多线程的控制流在同一条时间轴上看来是完全随机的就可以了。要记住,墨菲定律生效的时 候,看似不可能的事情都能成为现实。

多线程程序设计中,一种朴素的设计思想是将线程具体化为主动对象,主动对象之间通过共享环境(全局资源)来维护当前应用的运行状态;主动对象间通过一套约 定的互斥和同步规则来实现通信;线程的具体执行时序则由调度程序安排。主动对象之间,应尽量避免一个主动对象直接调用另一个主动对象的内部方法(例如 suspend, stop, exit)直接控制另一个线程的执行步调。主动对象的行为应该完全由其本身控制,和其他主动对象的交互尽量只通过环境以及同步语句来实现。

实际实现中,如果多个主动对象都可以直接调用某个主动对象的stop方法终止其运行,这个程序是非常脆弱的,因为在调度程序之外,不借助同步机制,主观假 定线程执行时序,人为更改线程执行状态是非常不明智的。即使这样的程序可用,对于维护者来说其难度也不亚于维护goto语句。

这也就是前一篇文章所定义线程类中detach, start, stop没有加锁的原因,因为并不希望在多个线程中调用同一个线程对象的这些方法。

3.     竞争条件和数据一致性

共享环境是进程全局资源的同义词。在多线程并发执行中,最常遇到的问题就是共享环境被污染。具体体现就是全局数据被破坏,全局文件内容被破坏 … 。

例如:
有一个64位全局变量long globalVar = 0;主动对象A想把它置为0x000A000B000C000D;假设这个操作分两步执行,先将高32位置为000A000B,再把低32位置为 000C000D。但不巧的是,对象A刚刚将高位置位,调度程序安排另一主动对象B执行。这时,全局变量globalVar内部的值是一个非法值,它是无 意义的。在B拿着这个值做进一步处理时,得出的也是错误结果。这时,称为数据的一致性被破坏。

线程调度的不确定性也可能导致程序执行结果的不确定性。有时候这种不确定性会导致程序得到错误的运行结果。
例如:
为了对Thread类所生成的对象总数计数,定义一个全局变量Unsigned int counter = 0; 在Thread类的构造函数中,执行++counter。现在假设有2个Thread对象objA和objB并发运行,考虑如下两个场景:
Scenario1.操作序列如下:
1.      counter = 0;
2.      objA将counter值0从内存读入寄存器A;
3.      objA将寄存器A值加1;
4.      objA将寄存器A值写回内存,counter值为1;
5.      objB将counter值1从内存读入寄存器B;
6.      objB将寄存器B值加1;
7.      objA将寄存器B值写回内存,counter值为2;
8.      最终counter值为2。
Scenario2.操作序列如下:
1.      counter = 0;
2.      objA将counter值0从内存读入寄存器A;
3.      objB将counter值0从内存读入寄存器B
4.      objA将寄存器A值加1;
5.      objB将寄存器B值加1;
6.      objA将寄存器A值写回内存,counter值为1;
7.      objA将寄存器B值写回内存,counter值为1;
8.      最终counter值为1。
场景1的结果是设计的本意,场景2的结果对我们而言是错误的。

一个线程的执行结果正确性取决于于其他线程执行时序的条件,称为竞争条件。中文这样翻译不伦不类,但从英文字面理解非常容易。Race一般是计时赛,某位选手跑得快一点,竞赛结果就有变化,最终结果由race condition决定,还是非常形象的。

4.     为何需要互斥

线程间协作问题,通常可以归为互斥和同步两类。其中互斥又主要解决两类问题:维护数据一致性、避免竞争条件的出现。

解决一致性问题,通俗说就是,修改数据的线程通告其他线程“我正在修改你要访问的对象X,操作过程中不能保证这个数据的有效性,请不要使用此对象”。

避免竞争条件,通俗说就是,某线程通告其他线程“我将要进行涉及某对象的一系列操作A,在我未完成这一系列操作之前,如果有人和我同时执行涉及此对象的操作序列B(B也可能就是A),将会影响我执行结果的正确性,请不要进行涉及此对象的操作”。

这种操作序列A有时候也被称为“原子性操作”,因为它不允许操作序列B在时间轴上和它交叉分布,必需保证在时间轴上看来,操作序列A是一个不可分割的整体。(物理学早期,原子也被认为是不可分割的)。

以上冗长的解释,精简成一句话,就是“线程间需要互斥执行”。需要互斥的操作对应的代码也有一个很学术的名称-“关键域(或关键区)”。

那么如何实现互斥呢?一个简单的思路就是,设立一个权威仲裁者,给那些需要互斥执行的线程颁发一个共用的通行证。某个线程想要进入一个关键域执行,需要先 申请可以进入该关键域的通行证,如果别的线程已经拿走了该通行证,则本线程等待,进入休眠状态,退出调度。如果本线程的通行证使用完毕,则应该将它归还给 仲裁者,重新唤醒等待线程,参与调度,竞争此通行证。

比如,下列伪码中,threadFuncA和threadFuncB就需要申请同一张通行证:
例一:
int globalCounter = 0;

void threadFuncA (通行证类型 * 通行证)
{
  获取通行证;
  globalCounter++;
  归还通行证;
}

Void threadFuncB (通行证类型 * 通行证)
{
  获取通行证;
  globalCounter *= 2;
  归还通行证;
}

又比如,下列伪码中,需要为ResourceClass类的对象引用计数器制定一张通行证

例二:
class ResourceClass
{
public:
  resource & reference()
  {
    获取通行证;
++refcounter;
printf(“当前对象被引用了%u次”, refCounter);
释放通行证;
  }
private:
  通行证类型 通行证;
  unsigned int refCounter;
};

ResourceClass rescObj
Void threadFuncA()
{
  rescObj-> reference();
}

Void threadFuncB()
{
  rescObj-> reference();
}

最后一个例子,是为ResourceClass类的对象计数器制定一张通行证。
例三:
class ResourceClass
{
public:
  ResourceClass ()
  {
    获取通行证;
++objcounter;
printf(“当前类创建了%u个对象”, objCounter);
释放通行证;
  }
private:
  static通行证类型 通行证;
  unsigned int objCounter;
};

Void threadFuncA()
{
  ResourceClass * rescObj = new ResourceClass ();
}

Void threadFuncB()
{
  ResourceClass * rescObj = new ResourceClass ();
}
这三个例子中,例一是不同函数之间互斥,所以通行证的作用域要对两个函数都可见。例二是同一个对象的内部函数多次调用之间的互斥,所以只要保证该函数多次 调用时共用的都是当前对象内部同一份通行证即可。例三是同一个类的所有对象在创建时都要互斥,所以必需保证这个类的所有对象构造时共用的时同一份通行证, 从而通行证被声明为静态成员。

这里所说的“通行证”在多线程编程中对应一个专门的术语mutex,由“mutual exclusion”拼接而来。为什么不直接用“锁”的概念呢?因为“锁”并不能很好的表达互斥的含义。锁是指一定条件下不允许当前代码段执行的概念。如 上述例二或例三,不允许多个线程同时执行同一函数,这时说这个函数被锁定是很形象。但在例一中,A函数被锁定,为什么B函数不能执行呢?这就较难理解了。

而且经常有人感到疑惑,为什么“加锁后,被锁定的关键域只能串行执行”。这个其实是指在各自的时间轴上,并行的控制流在经过互斥执行的代码段时,必需以先 后顺序串行执行。在今后的介绍中,mutex的申请,用acquire()操作表示,mutex的归还,用release()表示。舍弃lock(), unlock()的表示。

为了深入理解,先来看一段使用忙等待实现互斥的代码,用的是系统内核中使用较多的“spin lock”互斥方法。

例4.忙等待实现互斥
//声明为volatile,防止被编译器优化。
volatile bool dataProcessNotDone = true;
int criticalData = 0;

unsigned threadFuncA( void* para )
{
   //如果编译器不支持volatile关键字,
//打开优化选项时,此句可能直接变成死循环。
   while (dataProcessNotDone);   // spin lock,锁定的是后续数据

   //被锁定的代码区
   printf("critical data is %d\n", CriticalData);
   return 0;
}

unsigned threadFuncB( void* para )
{
   sleep(1000);
   criticalData++;
   dataProcessNotDone = false; //修改互斥变量
   return 0;
}
在高级语言中,利用spin lock实现复杂互斥条件非常困难,单单处理竞争条件就令人望而生畏。Spin lock在每次等待解锁的时间都很短时,具有无需线程切换,无需再调度等独特优势。但是在绝大多数应用中,由于互斥等待时间不确定(可能很长),多个线程等待spin lock解锁的过程中,spinning的行为可能导致系统处于半瘫痪状态,会严重影响程序性能。

除了忙等待之外,很多操作系统或线程库都提供了互斥原语来实现互斥。如果有可能,应当尽量使用系统提供的接口实现互斥。否则,要考虑编译器优化中的常量代入,语句执行顺序重排,cpu指令序列优化等依赖于具体软硬件环境的复杂问题(关键是这样的付出没有太大意义)。

下面根据上述概念,抽象出互斥类的概念,定义如下Mutex类接口

5.     互斥类接口定义

文件mutex.h
#ifndef __MUTEX_H__
#define __MUTEX_H__
// C++标准中以_或__开头的名字不符合标准,
// 这一特点可以让这样定义的宏不会错误覆盖其他名字。

class Mutex
{
public:
  Mutex();
  ~Mutex();
  bool acquire (bool block = true);
  void release();
private:
  //依赖于具体实现,后面再说。
};
#endif
其中,
Mutex::acquire(),获取互斥量,有阻塞和非阻塞两种调用方式,阻塞方式,获取互斥量失败时线程休眠。非阻塞方式下,获取失败时直接返回。

Mutex::release(),释放互斥量。

6.     示例程序


下面的例子说明了如何实现多线程环境下的Singleton模式。
文件Singleton.h:
#ifndef __SINGLETON_H__
#define __SINGLETON_H__

#include <stdio.h>
#include "thread.h"
#include "mutex.h"

// Dummy class.
class Helper {};

// A wrapper class for Mutex class
// It is exception safe.
class Guard
{
public:
  Guard(Mutex & lock):mutex(lock)
  {
    mutex.acquire();
  }

  ~Guard()
  {
    mutex.release();
  }

private:
  Mutex & mutex;
};

// Correct but possibly expensive multithreaded version
class Singleton1
{
public:
  Helper * getInstance() {
    Guard guard(mutex);
    if (helper == NULL)
    {
      helper = new Helper();
    }
    return helper;
  }

private:
  static Mutex mutex;
  static Helper * helper;
};

// Broken multithreaded version
// "Double-Checked Locking" idiom
class Singleton2
{
public:
  Helper * getInstance() {
    
    if (helper == NULL)
    {
      Guard guard(mutex);
      if (helper == NULL)
      {
        helper = new Helper();
      }
    }
    
    return helper;
  }

private:
  static Mutex mutex;
  static Helper * helper;
};

//Thread class for test.
template <typename T>
class TestThread: public Thread
{
public:
  TestThread<typename T>(T & resource,  Helper *& res, Helper *&
res2Cmp)
    :singleton(resource), instance(res), instance2Cmp(res2Cmp) {}

protected:
  void * run (void *)
  {
    for (int i=0; i<100000; i++)
    {
      instance = singleton.getInstance();
      if (instance != instance2Cmp  
          && instance != NULL
          &&instance2Cmp != NULL
         )
      {
        printf("Fail! %p <> %p.\n", instance, instance2Cmp);
      }
    }
    return NULL;
  }
private:
  T & singleton;
   Helper * & instance;
   Helper * & instance2Cmp;
};

#endif

文件main.cpp
#include <stdio.h>
#include "singleton.h"

#define SINGLETON Singleton1

Mutex SINGLETON::mutex;
Helper * SINGLETON::helper = NULL;

int main(int argc, char** argv)
{
  Helper * instance1= NULL;
  Helper * instance2 = NULL;
  SINGLETON singleton;

  TestThread<SINGLETON> thread1(singleton, instance1, instance2);
  TestThread<SINGLETON> thread2(singleton, instance2, instance1);
  thread1.start();
  thread2.start();
  thread1.wait();
  thread2.wait();
  printf("Finished!\n");
  return 0;
}

对此示例程序,说明如下几点。
1.定义了一个新的Guard类,这样做的好处是做到异常安全。比如:
try
{
  Mutex mutex;
  mutex.acquire();
  
// Some operations
  if (errorHappened)
    throw Exception();
  
mutex.release();
}
catch (Exception & e)
{
  // Print error message;
}
这段代码中,抛出异常时,互斥量不能释放,容易造成死锁。使用Guard类重写,
可以实现异常安全:
try
{
  Mutex mutex;
  Guard guard(mutex);
  // Some operations
  if (errorHappened)
    throw Exception();
}
catch (Exception & e)
{
  // Print error message;
}

2.Singleton1的实现可以确保多线程安全。但它是一个低效实现。假如有100次访问,只有1次会修改instance指针,其余99次都是只读操作。但是每次访问都需要进行互斥量的获取和释放操作。

取决于系统实现方式,互斥操作可能比整型变量的++操作慢一个数量级。有的实现提供的mutex其实是进程级别的互斥,一次互斥操作,会进入内核态,然后再返回用户态。而有的线程库提供的是Process local mutex,要稍快一些。但无论那种实现,代价都较大。

因此,为了改进Singleton1的实现效率,"Double-Checked Locking" idiom被提了出来。其思路是如果instance指针为空再进入互斥操作。由于获取互斥量过程中,可能别的线程已经将instance指针赋值,所以需要在获得互斥量所有权之后,再次检查instance指针值。这就是所谓”double check”中double的来历。

Double check的设计很聪明,但可惜无论在C++中还是在Java中,这么做其实都不能保证线程安全。考虑如下序列:
Step1. 线程A检查instance指针,发现其为空。获取互斥量成功,运行至语句helper = new Helper();
在打开优化选项时,这个语句在优化后可能变成2步子操作, 而且编译器自动调整了原语句的执行顺序(reordering):
1)      分配内存,将地址赋值给helper变量。此时helper值非空。
2)      开始初始化此内存。
在运行完子语句1后,线程发生切换,此时内存尚未初始化。
Step2. 线程B检查instance指针,发现其非空。对instance所指对象进行进一步操作,由于此时对象是初始化还未完成无效数据,程序崩溃。

那么如何实现安全的double check呢?vc2005以后的版本,以及java 1.6以后的版本中,可以通过为helper加上volatile限定符,防止编译优化时调整指令执行顺序。最新的g++对volatile如何处理,没 有查到相关资料。不过在C++中,只要本地汇编支持memoryBarrier指令,也可以通过在C++代码中内嵌汇编指令实现线程安全。
在此不再详细讨论。

除此之外,instance类型是否是基本类型,是否多核环境,都对不安全的double check版本运行结果有微妙影响。

3.无论编译器如何实现,无论硬件环境如何,即使它最慢,也应该尽量使用系统提供的互斥原语。只有它是能够确保安全的。通过系统接口实现互斥,可以避免考虑编译优化等复杂情况。一种观点说volatile可以确保上述double check有效,但是intel有技术人员专门从硬件的角度批驳了这个说法,他告诉大家,即使编译器不做这个reordering, 处理器也可能在指令级别做。唯一能确保安全的,还是由系统实现的互斥接口。(照此说法,MS 和 Intel结成wintel联盟还是必要的,呵呵)双方说法似乎都一样权威时,作为程序开发者,很难取舍,因此在此类问题上还是应该适当保守。

4.Mutex, volatile变量,普通变量在使用中的具体效率对比,是一个非常复杂的问题。涉及到内存,缓存,寄存器之间各种情况的同步问题。不过大家可以针对一些简单的例子,测试一下执行时间上的差异。


7.     Unix实现

下面是借助pthread的Mutex类实现。
文件Mutex.h
#ifndef __MUTEX_H__
#define __MUTEX_H__

#include <pthread.h>

class Mutex
{
public:
  Mutex();
  virtual ~Mutex();
  virtual bool acquire (bool block = true);
  virtual void release();
private:
  pthread_mutex_t handle;
};
#endif

文件 Mutex.cpp
#include "mutex.h"

Mutex::Mutex()
{
  pthread_mutex_init(&handle, NULL);
}

Mutex::~Mutex()
{
  pthread_mutex_destroy(&handle);
}

bool Mutex::acquire(bool block)
{
  if (block)
  {
     return pthread_mutex_lock(&handle) == 0;
  }
  else
  {
    return pthread_mutex_trylock(&handle) == 0;
  }
}

void Mutex::release()
{
  ReleaseMutex(handle);
}


Windows实现
文件mutex.h
#ifndef __MUTEX_H__
#define __MUTEX_H__

#include <windows.h>

class Mutex
{
public:
  Mutex();
  virtual ~Mutex();
  virtual bool acquire (bool block = true);
  virtual void release();
private:
  HANDLE handle;
};
#endif

文件mutex.cpp
#include "mutex.h"

Mutex::Mutex()
{
  handle = CreateMutex(NULL, false, NULL);
}

Mutex::~Mutex()
{
  CloseHandle(handle);
}

bool Mutex::acquire(bool block)
{
  //Use caution when calling the wait functions
  //and code that directly or indirectly creates windows.
  return WaitForSingleObject(handle, block ? INFINITE : 0) ==
WAIT_OBJECT_0;
}

void Mutex::release()
{
  ReleaseMutex(handle);
}


小结
本节从控制流的角度进一步介绍了什么是多线程执行中的并发,在此基础上介绍了主动对象的概念。在多线程编程中,需要考虑线程协作的地方,如果执行顺序理不清,为每一个线程画一条时间轴,标出各自时序,对分析问题往往能有帮助。

本节也介绍了多线程设计的一个基本思想,就是主动对象要具有一定的独立性,和其他线程的交互尽量只通过进程环境、系统或线程库提供的同步原语实现。

为什么要互斥,这个基本问题不能透彻理解的话,锁的概念很容易把自己弄糊涂。互斥的粒度大小也就根本无法谈起。

互斥的效率问题,在实际设计中是一个值得考虑的问题。但是程序的正确性问题是一个更重要的问题。不能保证正确性,就不要用。保证正确性到实现高效性的过程,类似于学会走路到能够飞奔的过程。对于初学者来说,欲速则不达。

为了对互斥量的使用,“通行证”所有权的转换,以及不同系统中Mutex的实现效率等有一个充分的感性认识,大家请多动手实现才能真正有所收益,最终超越我本人的一己知见。

最后,欢迎提出各种意见,以便这个系列能够错误少一些,解释准确一些,帮助也更大一些。

 


posted on 2011-02-22 15:01 Mike Song 阅读(2098) 评论(0)  编辑 收藏 引用


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理