11.5 共享有限资源
可以认为单线程处理程序就像围绕问题空间求解的一个实体,在某一时刻只做一件事情。因为只有一个实体,根本无须考虑在同一时刻两个实体试图使用同一资源的问题:比如两个人试图在同一车位停车,或两个人同时走过同一扇门,甚至两个人同时讲话这样的问题。
有了多线程处理,可以同时做很多事情,但是现在可能有两个或更多的线程试图在同一时刻使用同一个资源。这就可能引起两种不同的问题。首先,必需的资源可能不存在。在C++中,程序员在对象的生存期内对其有完全的控制权。创建线程来使用这些对象是很容易的,这些对象在线程完成之前被销毁。
第2个问题是,两个或更多的线程在其试图同时访问同一个资源时可能会发生冲突。如果不去防止这样的冲突,就会有两个线程试图同时访问同一银行账号、在同一打印机上打印、调整同一个变量的值等等问题。
本节介绍当任务仍然在使用某个对象时,而这个对象却突然消失了的问题,以及任务发生冲突时结束共享资源的问题。读者将会学到用来解决这些问题的有关工具。
11.5.1 保证对象的存在
在C++中,对内存和资源管理是主要的关注点。在创建任何C++程序时,可以选择在栈上或者在堆(使用new)上创建对象。在一个单线程处理的程序中,通常很容易保持对对象生存期的跟踪,所以不要尝试使用已经销毁的对象。
本章中的示例显示在堆上使用new创建了Runnable对象,但请注意这些对象从来都不是被显式删除的。然而,当运行程序时,可以从输出中看到,线程库保持跟踪每个任务并最后删除它,这是因为调用了任务的析构函数。这是在Runnable:run()成员函数完成时发生的—从run()返回就显示任务已经完成。
让线程来负担删除任务是个问题。因为线程不用必须知道是否有另一个线程仍然需要获得对那个Runnable的引用,所以可能会提早销毁该Runnable。为了处理这个问题,ZThread中的任务被ZThread库机制自动地进行了引用计数(reference-counted)。任务一直维持到该任务的引用计数归零,此时才能够删除该任务。这就意味着,必须总是动态删除任务,所以它们不能在栈上创建。取而代之,任务必须总是用new来创建,就像在本章所有例子中看到的那样。
通常必须确保非任务对象在任务需要它们的时候长期保留在活动状态。否则,容易导致那些被任务使用的对象在任务完成之前离开作用域。如果这种情况发生,任务将尝试访问非法的存储单元,并将引起程序错误。这里有一个简单的例子:
Count类初看上去似乎有点功能过强,但是如果n只是一个int型变量(不如是一个数组),编译器会把它放在寄存器中,那个存储单元在Count对象离开作用域后仍保持可用(虽然从技术上来说这是不合法的)。在这种情况下发现内存违例(violation)是困难的。最终结果依赖使用的编译器和操作系统的不同而有所不同,可以试着使n成为一个int型变量看看会发生什么。在任何事件中,如果Count包含如上的一个int数组,编译器被迫要将它放在栈上而非寄存器中。
Incrementer是一个使用Count对象的简单任务。在main()函数中,可以看到Incrementer任务运行了足够长的时间,Count对象离开了作用域,所以该任务尝试访问一个非长期存在的对象。这就会产生一个程序错误。
为了解决这个问题,必须保证在这些任务之间任何被共享的对象要长期存在,只要这些任务需要它们(如果对象没有被共享,可以把它们直接组成到任务类中,如此一来,使它们的生存期与那个任务捆绑在一起)。既然不希望静态的程序作用域控制对象的生存期,那么就可以把对象放置在堆上。并且确保直到没有其他对象(在此情况下指任务)使用它时才被销毁,这里使用了引用计数。
引用计数在本教材第1卷中有过透彻的讲解,本卷中更进一步地复习它。ZThread库包括一个名叫CountedPtr的模板,它自动执行引用计数并在引用计数归零时用delete删除一个对象。这里有一个使用CountedPtr对上面程序进行了修改的新程序,以防发生这类错误:
Incrementer现在包含一个CountedPtr对象,由它管理Count。在main()函数中,将CountedPtr对象以值传递方式传递给两个Incrementer对象,所以调用了拷贝构造函数,引用计数增1。只要任务仍然在运行,引用计数值就为非零,所以就不会销毁CountedPtr管理的Count对象。仅当所有使用Count的任务都完成时,CountedPtr才会调用(自动地)Count对象上的delete执行删除操作。
每当有对象被多于一个任务使用时,几乎总是需要使用CountedPtr模板来管理那些对象,以防由对象生存期争端而产生的问题。
11.5.2 不恰当地访问资源
考虑下面的例子,其中一个任务产生偶数,另外的任务来消费这些数。在这里,消费者线程的惟一工作就是检查偶数的有效性。
首先定义消费者线程EvenChecker,因为它在所有后续的例子中会被重复使用。为了使EvenChecker与进行试验的各种类型的发生器解除耦合,将创建一个名叫Generator的接口,它包含最少量且必需的函数,这些有关的函数是EvenChecker必须知道的:它有一个可以取消的nextValue()函数。
Generator类引入了抽象类Cancelable,它是ZThread库的一部分。Cancelable的目的是提供一个一致的接口,以便通过cancel()函数来改变对象的状态,用isCanceled()函数来检查对象是否已被取消。在这里,使用一个简单的bool型取消标志,类似于以前在ResponsiveUI.cpp中看到的quitFlag。注意,本例中的类是Cancelable而不是Runnable。另外,依赖于Cancelable对象(Generator)的所有EvenChecker任务都要测试这个标志,看它是否已被取消,就像在run()函数中所看到的那样。这种办法,由共享公共资源(Cancelable Generator)的任务监视着该资源,以便根据标志来结束监视。这就消除了所谓的竞争条件(race condition),即两个或更多的任务竞争着响应同一个条件,因此发生冲突,否则(没有发生冲突但却)产生不一致的结果。必须仔细考虑以防所有可能会使并发系统崩溃的情形发生。例如,一个任务不能依赖于其他任务,因为不能保证任务停止的顺序。在这里,使任务依赖于非任务对象(使用CountedPtr引用计数),消除了潜在的竞争条件。
在稍后各节中,将会看到ZThread库包含与线程结束有关的更通用的机制。
既然多个EvenChecker对象可以结束共享一个Generator,所以CountedPtr模板用于对Generator对象进行引用计数。
EvenChecker类中的最后一个成员函数是一个static静态成员模板。该模板在CountedPtr内部创建一个Generator,设置和进行对任何类型的Generator对象的测试,然后启动若干个使用那个Generator的EvenChecker。如果Generator失败了,test_()将会报告它并返回;否则,必须按Control-C键来结束它。
EvenChecker任务不断地从与其发生联系的Generator中读取和测试值。注意,如果generator->isCanceled()为真,run()函数就返回,它告诉EvenChecker:test_()中的Executor,任务已经完成。任何EvenChecker任务可以在与其发生联系的Generator上调用cancel()函数,这会导致其他所有使用Generator的EvenChecker顺畅地关闭。
EvenGenerator很简单—由nextValue()产生下一个偶数值:
currentEvenValue的值在第1次增1之后与第2次增1之前的这段时间,可能会有一个线程调用nextValue()(代码中注释着“Danger point here!”之处),其放进变量的值会处于一个“不正确的”状态。为了证明这种情况可能会发生,EvenChecker:test_()创建了一组EvenChecker对象,不断读取一个EvenGenerator的输出,并测试是否每个都为偶数。如果不是,会报告出错并关闭程序。
直到EvenGenerator完成多次循环,这个程序可能也不会发现问题,这依赖于你使用的操作系统的特性以及其他实现细节。如果要想尽快地看到它失败,可以尝试把一个yield()调用放在第1次与第2次增1操作之间。在任何事件中,当EvenGenerator处于“不正确”状态时,因为EvenChecker线程仍能够访问EvenGenerator里的信息,所以EvenChecker最终将会失败。
11.5.3 访问控制
前面的例子显示了使用线程时会遇到的一个基本问题:你永远不会知道一个线程何时可能运行。想像一下,你坐在桌子前拿着一把叉子,打算叉盘中最后一块食物。当叉子碰到食物时,它却突然消失了(因为你的线程被挂起,另一个用餐者进来吃掉了食物)。这就是在编写并发程序时要处理的问题。
有时候,在试图使用某一资源时,并不关心它在同一时刻是否正在被访问。但是在大多数情况下还是要关心这个问题。对于多线程处理的工作,需要一些方法来防止两个线程同时访问同一个资源,至少要防止两个线程在临界期(critical period)内访问同一资源。
防止这种冲突的一个简单方法,就是在线程正在使用一个资源时,给该资源加一把锁。访问该资源的第1个线程给资源加上锁,然后其他线程在该资源未被解锁时不能访问它。解锁的同时,另一个要使用它的线程就可以对该资源加锁并且使用它,依此类推。假设汽车的前排座位是有限的资源,那个大喊“我要坐”的小孩就类似于声明获得该锁。
因此,在某个存储单元处于不适当的状态时,需要能够防止任何其他任务访问该存储单元。也就是说,需要有一个机制,当第1个任务已经在使用某个存储单元时,该机制用来排除(exclude)第2个任务对该存储单元的访问。这个想法对所有多线程处理系统来说是基本的,它被称为相互排斥(mutual exclusion);该机制被简写为互斥(mutex)。ZThread库包含互斥机制,这在Mutex.h头文件中进行了声明。
在以上程序中解决这个问题,首先要能够识别临界区(critical section),在临界区中必须应用相互排斥机制;然后,在进入临界区之前获得互斥锁,并在临界区的终点释放(release)它。在任何时刻仅有一个线程可以获得该互斥锁,因此,相互排斥完成。
在MutexEvenGenerator中增加了一个叫做lock的Mutex型变量,并且在nextValue()函数中使用acquire()和release()创建了临界区。另外,为了在currentEvenValue处于奇数状态时提高语境切换的可能性,一个Thread:yield()调用被插入到两个增1语句之间。因为互斥机制防止了多个线程在同一时刻出现在同一个临界区中的情况,所以不会失败。但是如果有可能发生失败,调用yield()是促使失败提早发生的很有用的方法。
注意,nextValue()函数必须在临界区内部获得返回值,因为如果从临界区中返回,没有释放这个锁,因此将阻止其再次从临界区获得该锁。(这通常会导致死锁(deadlock),在本章的末尾将学到有关这方面的内容。)
第1个进入nextValue()的线程获得了锁,那些试图获得该锁的其他任何线程都被阻塞在那里等待,直到第1个线程释放了该锁。这时候,系统的调度机制选择另一个正在等待得到该锁的线程进入nextValue()。以这种方法,在同一时刻只有一个线程能通过被互斥锁保护的代码。
11.5.4 使用保护简化编码
当引入异常时,互斥锁的使用就迅速变得复杂起来。为确保互斥锁总能被释放,就必须保证每条可能的异常路径都包含一个对release()函数的调用。另外,任何有多条返回路径的函数都必须小心,以保证在合适的地点调用release()。
利用下述事实,可以很容易地解决这些问题:基于栈的(自动)对象有一个析构函数,不管是怎样从函数的作用域中退出的,该析构函数总会被调用。在ZThread库中,这个功能以Guard模板的方式实现。Guard模板创建对象,当这些对象被创建时用acquire()函数获得一个Lockable对象;当这些Guard对象被销毁时,用release()函数释放该锁。Guard对象创建于本地栈上,不管函数是如何退出的,它都将会被自动销毁,并且总能将Lockable对象解锁。在这里,把上面的例子用Guard重新实现:
注意,在nextValue()函数中,临时返回值不是必须的。一般情况下,要编写的代码较少,因而用户出错的机会大大减少。
Guard模板的一个有意思的特征,就是它可以被安全地用于操纵其他保护(guard)。比如,下面程序中的第2个Guard可以用于临时解锁一个保护:
Guard也可以尝试在一个确定的时间内获得某个锁,然后放弃:
在这个例子中,如果在500毫秒内不能获得锁,则抛出一个Timeout Exception异常。
同步整个类
ZThread库还提供了一个GuardedClass模板来自动地为整个类创建同步封装器(wrapper)。这意味着该类中的每个成员函数都将自动被保护。
对象a是非同步的,所以func1()和func2()能被任意个线程在任何时刻调用。对象b被GuardedClass封装器保护了起来,所以每个成员函数都被自动同步,在任意时刻每个对象仅有一个函数能被调用。
封装器在类一级的粒度上加锁,这也许会影响到它性能。[1]如果一个类包含某些互不相关的函数,也许用两种不同的锁在内部同步这些函数会更好一些。然而如果这样做了,则意味着该类也许包含非强相关(strongly associated)的数据组。应该考虑把这个类分解成两个类。
用一个互斥锁保护一个类的所有成员函数并不能自动保证那个类是线程安全(thread-safe)的。必须小心考虑所有的线程处理问题,以便保证线程的安全性。
11.5.5 线程本地存储
消除任务在共享资源上发生冲突问题的第2种办法是消除共享变量,对使用同一对象的各个不同线程,可以为同一个变量创建不同的存储单元。因此,如果有5个线程使用一个含有变量x的对象,线程本地存储(thread local storage)会自动为变量x产生5个不同的存储片段(单元)。幸运的是,线程本地存储的创建和管理由ZThread库的ThreadLocal模板自动管理,如下所示:
当通过实例化该模板来创建ThreadLocal对象时,只能用get()和set()成员函数访问该对象的内容。get()函数返回一份与那个线程相关联的对象的拷贝,而set()则将其参数插入到与那个线程相关的对象中存储,并返回存储单元中原来所保存的对象。可以看到,这种方法用在了ThreadLocalVariables里的increment()和get()函数中。
由于tlv被多个Accessor对象共享,它被写成像Cancelable一样,以便在想要停止系统运行时,让Accessor可以收到信号。
在运行该程序时,将看到各个线程分配有自己的存储单元的证据。
[1]这可能很重要。通常函数只有小部分需要被保护。把这些保护放在函数入口点常常可以使临界区比它实际需要的要长。