概述

当对一个复杂对象进行某种操作时,从操作开始到操作结束,被操作的对象往往会经历若干非法的中间状态。这跟外科医生做手术有点象,尽管手术的目的是改善患者的健康,但医生把手术过程分成了几个步骤,每个步骤如果不是完全结束的话,都会严重损害患者的健康。想想看,如果一个医生切开患者的胸腔后要休三周假会怎么样?与此类似,调用一个函数(假设该函数是正确的)操作某对象常常会使该对象暂时陷入不可用的状态(通常称为不稳定状态),等到操作完全结束,该对象才会重新回到完全可用的状态。如果其他线程企图访问一个处于不可用状态的对象,该对象将不能正确响应从而产生无法预料的结果,如何避免这种情况发生是线程安全性的核心问题。单线程的程序中是不存在这种问题的,因为在一个线程更新某对象的时候不会有其他线程也去操作同一个对象。(除非其中有异常,异常是可能导致上述问题的。当一个正在更新某对象的线程因异常而中断更新过程后,再去访问没有完全更新的对象,会出现同样的问题)

线程安全性的级别

就线程安全性进行讨论的时候存在这样一个问题:线程的安全性是存在多种级别的,每个人谈论的级别其实并不相同,仅仅说某段代码不具备线程安全性并不能说明全部问题。然而许多人对线程的安全性有一些想当然的预期,有些时候这些预期是合理而合法的,但有些时候不是。下面给出一些此类的预期: - 通常认为多个线程读某对象时不会产生问题,问题只会在更新对象的时候出现,因为只有那时对象才会被修改, 从而有进入不稳定状态的危险。然而,某些对象具有内部状态,即使在读的时候内部状态也会被改变(比如某些对象有内部缓冲)。假如两个线程去读取这种对象,问题仍然会产生,除非该对象的读操作设计已经采用了合适的多线程处理方法。 - 通常认为更新两个相互独立的对象时,即使它们的类型相同也不会有问题。一般假设相互独立的对象之间是互不相关的,一个对象的不稳定状态并不会对另一个对象产生影响。然而,一些对象在内部是存在数据共享的(如静态的类数据,全局数据等),这使它们即使看上去没有什么逻辑上的关系,内部却依然存在联系。这种情况下,修改两个“相互独立”的对象怎么都会产生问题。

考虑下面的情况:

 
	void f( ) 
	{ 
			std::string x;
	  		// Modify x.
	}
	
	void g( )
	{
			std::string y;
	  		// Modify y.
	}
如果一个线程运行函数f()修改x,另一个线程运行函数g()修改y,这种情况下会出现问题吗?大部分人认为这是两个明显独立的对象,可以被同时改动而不会导致任何问题。但是,有一种可能性就是,两个对象内部存在共享数据,这完全依赖于 std::string的实现,如果那样的话,同时改动便是有问题的了。实际上,如果两个对象内部存在共享数据的话,即使一个函数仅仅是读取对象,问题依然存在,因为改动另一个对象的函数可能触及内部的共享数据。 - 通常认为即便使用一个通用的资源池,获取资源的函数也不存在线程安全性的问题。

例如:

 
	void f()
	{
			char *p = new char[512];
			// use the array p
	}
 
	void g()
	{
			char *p = new char[512];
			// use the array p
	}
如果一个线程执行函数f(),另一个线程执行函数g(),两个线程可能同时使用操作符new去分配内存。在多线程环境中,只有假设new操作符的实现已经考虑到这种情况,从同一个内存池中获取内存也设计了正确的处理方法,才可以认为安全性得到保证。实际中,new操作符在内部确实会同步这些线程,因而每次调用都能够得到独立的内存分配而不损坏共享的内存池。与此类似的操作还有文件的打开、网络连接的发起以及其他资源的分配。 - 人们一般认为会引起问题的情形是一个线程要访问(读或更新)某对象时另一个线程正在更新它。全局对象尤其易于出现这种问题,局部对象出现问题的情况则少的多。

比如:

 
	std::string x;
	 
	void f()
	{
			std::string y;
			// modify x and y.
	}
如果两个线程同时进入函数f(),它们拿到的对象y是不相同的,这是由于不同的线程拥有各自不同的栈,局部变量都在线程自己的栈上分配,因而每个线程都会拿到自己独立的局部变量副本。所以说,在f()中对y进行操作不会产生问题(假定操作独立的对象有安全保证),然而,全局对象x仅有一份两个线程都会触及的拷贝,对x的如上操作便会产生问题。局部变量也不是完全不会产生问题,因为每个函数都能够启动新的线程并且把局部变量的指针作为该线程的一个输入参数,比如:
 
	void f ( )
	{
			std : : string x ;
			startthread (somefunction , &x);
			startthread (somefunction , &x);
	} 
这里假设有一个名为startthread的库函数,它有两个参数,一个是线程入口函数的指针,一个是线程入口函数的参数的指针。在此情况下我们调用 startthread启动两个线程,并且把x对象作为参数同时传给两个线程。如果somefunction()中会对x进行修改,则两个线程可能修改同一个对象,类似的问题便产生了。注意,这种情况是特别隐蔽的,因为somefunction()并没有什么特别的理由去考虑两次调用会传给它同一个对象,因而它不大可能做出应付这种情况的保护措施。

编写具备线程安全性的代码

理论上讲,控制线程行为的唯一办法是使用同步原语,如互斥量和信号量。在某些语言中,线程属于语言本身的一部分,特定的同步原语也由语言本身来提供;此外还有其他的情形,比如在C语言中,同步原语是象POSIX API那样的库函数,和操作系统相关联。

一般,所写的代码应该满足人们对线程安全性最通常的考量。要实现一个类,就要确保能够安全地同时对一个对象进行读操作,如果打算在调用者不可见的情况下更新内部数据,则可能要自己来保护这种更新行为。另外,要保证对相互独立的对象同时进行写操作时的安全性,若使用了共享数据,则可能要自己来保护对共享数据的更新。最后,如果要写一个为多个线程管理一个通用资源池中的共享资源的函数,可能得保护共享资源不会因为多个同时的资源请求而被损坏。但是,总的来说,可能没有什么必要去考虑保护单个对象的同时更新,这方面交给调用者来做好了,因为就运行时效率来讲,达到这种绝对的安全性通常需要付出很高的代价,而且一般没有必要,也不是很恰当。

异常安全性与线程安全性的对比

线程安全性和异常安全性具有大量的共同点:都和处于不稳定状态的对象有关;都必须考虑资源问题(尽管考虑的方面不同,异常的安全与资源泄露有关,线程的安全与资源损坏有关);都有若干安全性级别,每个安全级别对应一种普遍预期,认为某些情况安全而某些情况不安全。

然而,异常的安全和线程的安全有一个重要的不同之处:异常的发生是与程序执行同步的,而线程则是异步的,换句话讲,理论上异常只发生在某些特定的时刻,尽管我们并不是很清楚哪个操作会抛出异常哪个不会,但我们能够精确给出什么时候可能发生异常而什么时候不可能发生。因而常常可以通过找出异常来保证一个函数的异常安全性。相比较而言,却是无法控制两个线程何时发生冲突的,通过重新组织函数来确保它的线程安全性收效甚微。这点不同之处导致线程相关的错误很难再现且尤其不好管理。

多线程程序设计原则

这部分打算给出一些构造多线程程序时需要注意的“拇指规则”。尽管使用多线程能够灵活而自然地解决一些程序设计中的问题,但也会引入竞争条件(race conditions)和其他不可思议而难以调试的问题,通过遵循以下一些规则可以避免许多这样的问题。

共享数据

如前所述,两个线程访问同一个数据对象是有潜在问题的。通常,修改一个对象需要好几个步骤,在执行这些步骤的过程中对象一般会进入不正常的状态,若此时另一个线程来访问对象,它有可能会拿到损坏的信息,然后整个程序可能出现异常行为。这一点是必须避免的。

什么样的数据是共享的

  • 静态的数据(与程序生存期相同的数据)。包括全局数据和局部静态数据,其中局部静态数据的情况仅仅指在两个(或多个)线程同时执行一个含有局部静态数据的函数时。
  • 地址由静态变量保存的动态分配数据。例如, 一个函数使用malloc()或new分配了一个对象并将其地址置于一个可以为多个线程访问的变量中,则这个动态分配的对象也可以被多个线程访问了。
  • 类的成员变量。当一个类的实例的成员函数被不同的线程调用时需要考虑这一情况。

什么样的数据不是共享的

  • 局部变量。即使两个线程调用同一个函数,它们拿到的该函数的局部变量也是不同的拷贝,因为局部变量保存在栈上,而每个线程拥有独立的栈。
  • 函数参数。像C这样的编程语言,函数参数也是放在栈上的,每个线程同样拿到的是属于它自己的拷贝。

由于局部变量和函数参数不是共享的,它们不受竞争条件制约,因而尽可能使用局部变量和函数参数,避免使用全局变量。

哪种并发访问会导致问题

  • 如果多个线程仅仅读取某个对象的话应该是没有问题的。但是,需要注意的是,对于某些复杂的对象,常常尽管从外部看来只是读取,但也同时对内部信息进行了更新。某些对象即使针对读操作也会维护一个缓冲或在内部跟踪统计使用率。对于此类对象的并发读取是不安全的。
  • 如果在一个线程写某对象的同时另一个线程访问一个完全独立的对象,这种情况下不会出现问题。然而需要提醒的是:许多函数和对象会在内部共享数据,表面上完全独立的两个对象实际可能使用一个相同的数据结构,这对外部却是不可见的。
  • 某些类型的对象是以不允许中断的方式更新的,对于此类对象的并发读和写都是安全的,因为在更新时此类对象是不可能进入不稳定状态的。这样的更新称作原子操作,可惜支持原子操作的数据类型通常非常简单(如int),而且没有什么好的办法可以确切的知道究竟什么类型支持原子操作。为此C标准提供了一个sig_atomic_t的类型,定义在中,是某种整型。对声明为volatile sig_atomic_t的对象进行并发访问是安全的,这种情况下不需要互斥量。

一般准则

如果一个函数在其文档中没有特别注明具备线程安全性,则应该认为它不具备。许多库大量使用了内部的静态数据,除非它是为多线程应用所设计,否则要牢记其内部数据可能没有利用互斥量进行适当的保护。类似,如果类的成员函数在其文档中没有特别注明对于多线程应用是安全的话,则认为它不安全。两个线程去操作相同的对象会引起问题,这是显而易见的,然而,即使两个线程去操作不同的物体依然会引起问题。出于多种原因,许多类使用了内部静态数据或者在多个看上去明显不同的对象间共享实现细则,

以下给出几个一般准则:

  • 操作系统提供的API具备线程安全性
  • POSIX线程标准要求C标准库中的大多数函数具备线程安全性,少数例外会在C标准中注明。
  • 对于Windows提供的C标准库,如果所使用的版本没有问题,而且进行了正确的初始化,他们都是安全的。
  • C++标准库的线程安全性不是很明确,它在很大程度上依赖于使用的编译器。标准模板库线程安全性的SGI准则作为实际中的标准取得很大进展,但并不是统一的标准。

如果在一个函数中使用了不具备线程安全性的函数,那么这个函数也就跟着不具备线程安全性了,只是,如果确保一个函数不会被两个或更多的线程同时调用的话,也可以在多线程程序中使用不具备线程安全性的函数。我们可以选择只在一个线程中使用不具备线程安全性的函数,也可以选择使用互斥量来保护对这些函数的调用。要记住好多函数与其它函数在内部共享数据,如果要使用互斥量来保护对一个不安全的函数的调用,也必须使用同一个互斥量来保护所有与该函数有关的其它函数,做到这一点往往是很困难的。