条款12:对STL容器线程安全性的期待现实一些
标准C++的世界是相当保守和陈旧的。在这个纯洁的世界,所有可执行文件都是静态链接的。不存在内存映射文件和共享内存。没有窗口系统,没有网络,没有数据库,没有其他进程。在这种情况下,当发现标准没有提到任何关于线程的东西时你不该感到惊讶。你对STL的线程安全有的第一个想法应该是它将因实现而不同。
当然,多线程程序是很普遍的,所以大部分STL厂商努力使他们的实现在线程环境中可以正常工作。但是,即使他们做得很好,大部分负担仍在你肩上,而理解为什么会这样是很重要的。STL厂商只能为你做一些可以减少你多线程的痛苦的事情,你需要知道他们做了什么。
在STL容器(和大多数厂商的愿望)里对多线程支持的黄金规则已经由SGI定义,并且在它们的STL网站[21]上发布。大体上说,你能从实现里确定的最多是下列内容:
- 多个读取者是安全的。多线程可能同时读取一个容器的内容,这将正确地执行。当然,在读取时不能有任何写入者操作这个容器。
- 对不同容器的多个写入者是安全的。多线程可以同时写不同的容器。
就这些了,那么让我解释你可以期望的是什么,而不是你可以确定的。有些实现提供这些保证,但是有些不。
写多线程的代码很难,很多程序员希望STL实现是完全线程安全的。如果是那样,程序员可以不再需要自己做并行控制。毫无疑问这将带来很多方便,但这也非常难实现。一个库可能试图以下列方式实现这样完全线程安全的容器:
- 在每次调用容器的成员函数期间都要锁定该容器。
- 在每个容器返回的迭代器(例如通过调用begin或end)的生存期之内都要锁定该容器。
- 在每个在容器上调用的算法执行期间锁定该容器。(这事实上没有意义,因为,正如条款32所解释的,算法没有办法识别出它们正在操作的容器。不过,我们将在这里检验这个选项,因为它的教育意义在于看看为什么即使是可能的它也不能工作。)
现在考虑下列代码。它搜寻一个vector<int>中第一次出现5这个值的地方,而且,如果它找到了,就把这个值改为0。
vector<int> v; vector<int>::iterator first5(find(v.begin(), v.end(), 5)); // 行1 if (first5 != v.end()){ // 行2 *first5 = 0; // 行3 }
在多线程环境里,另一个线程可能在行1完成之后立刻修改v中的数据。如果是那样,行2对first5和v.end的检测将是无意义的,因为v的值可能和它们在行1结束时的值不同。实际上,这样的检测会产生未定义的结果,因为另一线程可能插在行1和行2之间,使first5失效,或许通过进行一次插入操作造成vector重新分配它的内在内存。(那将使vector全部的迭代器失效。关于重新分配行为的细节,参见条款14。)类似的,行3中对*first5的赋值是不安全的,因为另一个线程可能在行2和行3之间执行,并以某种方式使first5失效,可能通过删除它指向(或至少曾经指向)的元素。
在上面列举的锁定方法都不能防止这些问题。行1中begin和end调用都返回得很快,以至于不能提供任何帮助,它们产生的迭代器只持续到这行的结束,而且find也在那行返回。
要让上面的代码成为线程安全的,v必须从行1到行3保持锁定,很难想象STL实现怎么能自动推断出这个。记住同步原语(例如,信号灯,互斥量,等等)通常开销很大,更难想象实现怎么在程序没有明显性能损失的情况下做到前面所说的——以这样的一种方式设计——让最多一个线程在1-3行的过程中能访问v。
这样的考虑解释了为什么你不能期望任何STL实现让你的线程悲痛消失。取而代之的是,你必须手工对付这些情况中的同步控制。 在这个例子里,你可以像这样做:
vector<int> v; ... getMutexFor(v); vector<int>::iterator first5(find(v.begin(), v.end(), 5)); if (first5 != v.end()) { // 这里现在安全了 *first5 = 0; // 这里也是 } releaseMutexFor(v);
一个更面向对象的解决方案是创建一个Lock类,在它的构造函数里获得互斥量并在它的析构函数里释放它,这样使getMutexFor和releaseMutexFor的调用不匹配的机会减到最小。这样的一个类(其实是一个类模板)基本是这样的:
template<typename Container> // 获取和释放容器的互斥量 class Lock { // 的类的模板核心; public: // 忽略了很多细节 Lock(const Containers container) : c(container) { getMutexFor(c); // 在构造函数获取互斥量 } ~Lock() { releaseMutexFor(c); // 在析构函数里释放它 } private: const Container& c; };
使用一个类(像Lock)来管理资源的生存期(例如互斥量)的办法通常称为资源获得即初始化,你应该能在任何全面的C++教材里读到它。一个好的开端是Stroustrup的《The C++ Programming Language》[7],因为Stroustrup普及了这个惯用法,但你也可以转到《More Effective C++》的条款9。不管你参考了什么来源,记住上述Lock是被剥离到最原始的本质的。一个工业强度的版本需要很多改进,但是那样的扩充与STL无关。而且这个最小化的Lock已经足够看出我们可以怎么把它用于我们一直考虑的例子:
vector<int> v; ... { // 建立新块; Lock<vector<int> > lock(v); // 获取互斥量 vector<int>::iterator first5(find(v.begin(), v.end(), 5)); if (first5 != v.end()) { *first5 = 0; } } // 关闭块,自动 // 释放互斥量
因为Lock对象在Lock的析构函数里释放容器的的互斥量,所以在互斥量需要释放是就销毁Lock是很重要的。为了让这件事发生,我们建立一个里面定义了Lock的新块,而且当我们不再需要互斥量时就关闭那个块。这听上去像我们只是用关闭新块的需要换取了调用releaseMutexFor的需要,但是这是错误的评价。如果我们忘记为Lock建立一个新块,互斥量一样会释放,但是它可能发生得比它应该的更晚——当控制到达封闭块的末端。如果我们忘记调用releaseMutexFor,我们将不会释放互斥量。
而且,这种基于Lock的方法在有异常的情况下是稳健的。C++保证如果抛出了异常,局部对象就会被销毁,所以即使当我们正在使用Lock对象时有异常抛出,Lock也将释放它的互斥量。如果我们依赖手工调用getMutexFor和releaseMutexFor,那么在调用getMutexFor之后releaseMutexFor之前如果有异常抛出,我们将不会释放互斥量。
异常和资源管理是重要的,但是它们不是本条款的主题。本条款是关于STL里的线程安全。当涉及到线程安全和STL容器时,你可以确定库实现允许在一个容器上的多读取者和不同容器上的多写入者。你不能希望库消除对手工并行控制的需要,而且你完全不能依赖于任何线程支持。