当对一个复杂对象进行某种操作时,从操作开始到操作结束,被操作的对象往往会经历若干非法的中间状态。这跟外科医生做手术有点象,尽管手术的目的是改善患者的健康,但医生把手术过程分成了几个步骤,每个步骤如果不是完全结束的话,都会严重损害患者的健康。想想看,如果一个医生切开患者的胸腔后要休三周假会怎么样?与此类似,调用一个函数(假设该函数是正确的)操作某对象常常会使该对象暂时陷入不可用的状态(通常称为不稳定状态),等到操作完全结束,该对象才会重新回到完全可用的状态。如果其他线程企图访问一个处于不可用状态的对象,该对象将不能正确响应从而产生无法预料的结果,如何避免这种情况发生是线程安全性的核心问题。单线程的程序中是不存在这种问题的,因为在一个线程更新某对象的时候不会有其他线程也去操作同一个对象。(除非其中有异常,异常是可能导致上述问题的。当一个正在更新某对象的线程因异常而中断更新过程后,再去访问没有完全更新的对象,会出现同样的问题)
就线程安全性进行讨论的时候存在这样一个问题:线程的安全性是存在多种级别的,每个人谈论的级别其实并不相同,仅仅说某段代码不具备线程安全性并不能说明全部问题。然而许多人对线程的安全性有一些想当然的预期,有些时候这些预期是合理而合法的,但有些时候不是。下面给出一些此类的预期: - 通常认为多个线程读某对象时不会产生问题,问题只会在更新对象的时候出现,因为只有那时对象才会被修改, 从而有进入不稳定状态的危险。然而,某些对象具有内部状态,即使在读的时候内部状态也会被改变(比如某些对象有内部缓冲)。假如两个线程去读取这种对象,问题仍然会产生,除非该对象的读操作设计已经采用了合适的多线程处理方法。 - 通常认为更新两个相互独立的对象时,即使它们的类型相同也不会有问题。一般假设相互独立的对象之间是互不相关的,一个对象的不稳定状态并不会对另一个对象产生影响。然而,一些对象在内部是存在数据共享的(如静态的类数据,全局数据等),这使它们即使看上去没有什么逻辑上的关系,内部却依然存在联系。这种情况下,修改两个“相互独立”的对象怎么都会产生问题。
考虑下面的情况:
void f( )
{
std::string x;
// Modify x.
}
void g( )
{
std::string y;
// Modify y.
}
例如:
void f()
{
char *p = new char[512];
// use the array p
}
void g()
{
char *p = new char[512];
// use the array p
}
比如:
std::string x;
void f()
{
std::string y;
// modify x and y.
}
void f ( )
{
std : : string x ;
startthread (somefunction , &x);
startthread (somefunction , &x);
}
理论上讲,控制线程行为的唯一办法是使用同步原语,如互斥量和信号量。在某些语言中,线程属于语言本身的一部分,特定的同步原语也由语言本身来提供;此外还有其他的情形,比如在C语言中,同步原语是象POSIX API那样的库函数,和操作系统相关联。
一般,所写的代码应该满足人们对线程安全性最通常的考量。要实现一个类,就要确保能够安全地同时对一个对象进行读操作,如果打算在调用者不可见的情况下更新内部数据,则可能要自己来保护这种更新行为。另外,要保证对相互独立的对象同时进行写操作时的安全性,若使用了共享数据,则可能要自己来保护对共享数据的更新。最后,如果要写一个为多个线程管理一个通用资源池中的共享资源的函数,可能得保护共享资源不会因为多个同时的资源请求而被损坏。但是,总的来说,可能没有什么必要去考虑保护单个对象的同时更新,这方面交给调用者来做好了,因为就运行时效率来讲,达到这种绝对的安全性通常需要付出很高的代价,而且一般没有必要,也不是很恰当。
线程安全性和异常安全性具有大量的共同点:都和处于不稳定状态的对象有关;都必须考虑资源问题(尽管考虑的方面不同,异常的安全与资源泄露有关,线程的安全与资源损坏有关);都有若干安全性级别,每个安全级别对应一种普遍预期,认为某些情况安全而某些情况不安全。
然而,异常的安全和线程的安全有一个重要的不同之处:异常的发生是与程序执行同步的,而线程则是异步的,换句话讲,理论上异常只发生在某些特定的时刻,尽管我们并不是很清楚哪个操作会抛出异常哪个不会,但我们能够精确给出什么时候可能发生异常而什么时候不可能发生。因而常常可以通过找出异常来保证一个函数的异常安全性。相比较而言,却是无法控制两个线程何时发生冲突的,通过重新组织函数来确保它的线程安全性收效甚微。这点不同之处导致线程相关的错误很难再现且尤其不好管理。
这部分打算给出一些构造多线程程序时需要注意的“拇指规则”。尽管使用多线程能够灵活而自然地解决一些程序设计中的问题,但也会引入竞争条件(race conditions)和其他不可思议而难以调试的问题,通过遵循以下一些规则可以避免许多这样的问题。
如前所述,两个线程访问同一个数据对象是有潜在问题的。通常,修改一个对象需要好几个步骤,在执行这些步骤的过程中对象一般会进入不正常的状态,若此时另一个线程来访问对象,它有可能会拿到损坏的信息,然后整个程序可能出现异常行为。这一点是必须避免的。
由于局部变量和函数参数不是共享的,它们不受竞争条件制约,因而尽可能使用局部变量和函数参数,避免使用全局变量。
如果一个函数在其文档中没有特别注明具备线程安全性,则应该认为它不具备。许多库大量使用了内部的静态数据,除非它是为多线程应用所设计,否则要牢记其内部数据可能没有利用互斥量进行适当的保护。类似,如果类的成员函数在其文档中没有特别注明对于多线程应用是安全的话,则认为它不安全。两个线程去操作相同的对象会引起问题,这是显而易见的,然而,即使两个线程去操作不同的物体依然会引起问题。出于多种原因,许多类使用了内部静态数据或者在多个看上去明显不同的对象间共享实现细则,
以下给出几个一般准则:
如果在一个函数中使用了不具备线程安全性的函数,那么这个函数也就跟着不具备线程安全性了,只是,如果确保一个函数不会被两个或更多的线程同时调用的话,也可以在多线程程序中使用不具备线程安全性的函数。我们可以选择只在一个线程中使用不具备线程安全性的函数,也可以选择使用互斥量来保护对这些函数的调用。要记住好多函数与其它函数在内部共享数据,如果要使用互斥量来保护对一个不安全的函数的调用,也必须使用同一个互斥量来保护所有与该函数有关的其它函数,做到这一点往往是很困难的。