当析构函数遇到多线程─C++ 中线程安全的对象回调
摘要
编写线程安全的类不是难事,用同步原语保护内部状态即可。但是对象的生与死不能由对象自身拥有的互斥器来保护。如何保证即将析构对象 x 的时候,不会有另一个线程正在调用 x 的成员函数?或者说,如何保证在执行 x 的成员函数期间,对象 x 不会在另一个线程被析构?如何避免这种竞态条件是 C++ 多线程编程面临的基本问题,可以借助 boost 的 shared_ptr 和 weak_ptr 完美解决。这也是实现线程安全的 Observer 模式的必备技术。
本文源自我在 2009 年 12 月上海 C++ 技术大会的一场演讲《当析构函数遇到多线程》,内容略有增删。原始 PPT 可从 http://download.csdn.net/source/1982430 下载,或者在 http://www.docin.com/p-41918023.html 直接观看。
本文读者应具有 C++ 多线程编程经验,熟悉互斥器、竞态条件等概念,了解智能指针,知道 Observer 设计模式。
目录
1 多线程下的对象生命期管理 2
线程安全的定义 3
Mutex 与 MutexLock 3
一个线程安全的 Counter 示例 3
2 对象的创建很简单 4
3 销毁太难 5
Mutex 不是办法 5
作为数据成员的 Mutex 6
4 线程安全的 Observer 有多难? 6
5 一些启发 8
原始指针有何不妥? 8
一个“解决办法” 8
一个更好的解决办法 9
一个万能的解决方案 9
6 神器 shared_ptr/weak_ptr 10
7 插曲:系统地避免各种指针错误 10
8 应用到 Observer 上 11
解决了吗? 11
9 再论 shared_ptr 的线程安全 12
10 shared_ptr 技术与陷阱 13
对象池 15
enable_shared_from_this 17
弱回调 17
11 替代方案? 19
其他语言怎么办 19
12 心得与总结 19
总结 20
13 附录:Observer 之谬 20
14 后记 21
1 多线程下的对象生命期管理
与其他面向对象语言不同,C++ 要求程序员自己管理对象的生命期,这在多线程环境下显得尤为困难。当一个对象能被多个线程同时看到,那么对象的销毁时机就会变得模糊不清,可能出现多种竞态条件:
l 在即将析构一个对象时,从何而知是否有另外的线程正在执行该对象的成员函数?l 如何保证在执行成员函数期间,对象不会在另一个线程被析构?
l 在调用某个对象的成员函数之前,如何得知这个对象还活着?
解决这些 race condition 是 C++ 多线程编程面临的基本问题。本文试图以 shared_ptr 一劳永逸地解决这些问题,减轻 C++ 多线程编程的精神负担。
线程安全的定义
依据《Java 并发编程实践》/《Java Concurrency in Practice》一书,一个线程安全的 class 应当满足三个条件:
l 从多个线程访问时,其表现出正确的行为
l 无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织
l 调用端代码无需额外的同步或其他协调动作
依据这个定义,C++ 标准库里的大多数类都不是线程安全的,无论 std::string 还是 std::vector 或 std::map,因为这些类通常需要在外部加锁。
Mutex 与 MutexLock
为了便于后文讨论,先约定两个工具类。我相信每个写C++ 多线程程序的人都实现过或使用过类似功能的类,代码从略。
Mutex 封装临界区(Critical secion),这是一个简单的资源类,用 RAII 手法 [CCS:13]封装互斥器的创建与销毁。临界区在 Windows 上是 CRITICAL_SECTION,是可重入的;在 Linux 下是 pthread_mutex_t,默认是不可重入的。Mutex 一般是别的 class 的数据成员。
MutexLock 封装临界区的进入和退出,即加锁和解锁。MutexLock 一般是个栈上对象,它的作用域刚好等于临界区域。它的构造函数原型为 MutexLock::MutexLock(Mutex& m);
这两个 classes 都不允许拷贝构造和赋值。
一个线程安全的 Counter 示例
编写单个的线程安全的 class 不算太难,只需用同步原语保护其内部状态。例如下面这个简单的计数器类 Counter:
class Counter : boost::noncopyable
{
// copy-ctor and assignment should be private by default for a class.
public:
Counter(): value_(0) {}
int64_t value() const;
int64_t increase();
int64_t decrease();
private:
int64_t value_;
mutable Mutex mutex_;
}
int64_t Counter::value() const
{
MutexLock lock(mutex_);
return value_;
}
int64_t Counter::increase()
{
MutexLock lock(mutex_);
int64_t ret = value_++;
return ret;
}
// In a real world, atomic operations are perferred.
// 当然在实际项目中,这个 class 用原子操作更合理,这里用锁仅仅为了举例。
这个 class 很直白,一看就明白,也容易验证它是线程安全的。注意到它的 mutex_ 成员是 mutable 的,意味着 const 成员函数如 Counter::value() 也能直接使用 non-const 的 mutex_。
尽管这个 Counter 本身毫无疑问是线程安全的,但如果 Counter 是动态创建的并透过指针来访问,前面提到的对象销毁的 race condition 仍然存在。
2 对象的创建很简单
对象构造要做到线程安全,惟一的要求是在构造期间不要泄露 this 指针,即
l 不要在构造函数中注册任何回调
l 也不要在构造函数中把 this 传给跨线程的对象
l 即便在构造函数的最后一行也不行
之所以这样规定,是因为在构造函数执行期间对象还没有完成初始化,如果 this 被泄露 (escape) 给了其他对象(其自身创建的子对象除外),那么别的线程有可能访问这个半成品对象,这会造成难以预料的后果。
// 不要这么做 Dont do this.
class Foo : public Observer
{
public:
Foo(Observable* s) {
s->register(this); // 错误
}
virtual void update();
};
// 要这么做 Do this.
class Foo : public Observer
{
// ...
void observe(Observable* s) { // 另外定义一个函数,在构造之后执行
s->register(this);
}
};
Foo* pFoo = new Foo;
Observable* s = getIt();
pFoo->observe(s); // 二段式构造
这也说明,二段式构造——即构造函数+initialize()——有时会是好办法,这虽然不符合 C++ 教条,但是多线程下别无选择。另外,既然允许二段式构造,那么构造函数不必主动抛异常,调用端靠 initialize() 的返回值来判断对象是否构造成功,这能简化错误处理。
即使构造函数的最后一行也不要泄露 this,因为 Foo 有可能是个基类,基类先于派生类构造,执行完 Foo::Foo() 的最后一行代码会继续执行派生类的构造函数,这时 most-derived class 的对象还处于构造中,仍然不安全。
相对来说,对象的构造做到线程安全还是比较容易的,毕竟曝光少,回头率为 0。而析构的线程安全就不那么简单,这也是本文关注的焦点。
3 销毁太难
对象析构,这在单线程里不会成为问题,最多需要注意避免空悬指针(和野指针)。而在多线程程序中,存在了太多的竞态条件。对一般成员函数而言,做到线程安全的办法是让它们顺次执行,而不要并发执行,也就是让每个函数的临界区不重叠。这是显而易见的,不过有一个隐含条件或许不是每个人都能立刻想到:函数用来保护临界区的互斥器本身必须是有效的。而析构函数破坏了这一假设,它会把互斥器销毁掉。悲剧啊!
Mutex 不是办法
Mutex 只能保证函数一个接一个地执行,考虑下面的代码,它试图用互斥锁来保护析构函数:
Foo::~Foo()
{
MutexLock lock(mutex_);
// free internal state (1)
}
void Foo::update()
{
MutexLock lock(mutex_); // (2)
// make use of internal state
}
extern Foo* x; // visible by all threads
// thread A
delete x;
x = NULL; // helpless
// thread B
if (x) {
x->update();
}
有 A 和 B 两个线程,线程 A 即将销毁对象 x,而线程 B 正准备调用 x->update()。尽
补充:综合编程 , 安全编程 ,