文章目录
一、当析构函数遇到多线程
当一个对象能被多个线程同时看到时,那么对象的销毁时机就会变得模糊不清,可能出现多种竞态条件(race condition):
-
在即将析构一个对象时,从何而知此刻是否有别的线程正在执行该对象的成员函数?
-
如何保证在执行成员函数期间,对象不会在另一个线程被析构?
-
在调用某个对象的成员函数之前,如何得知这个对象还活着?它的析构函数会不会碰巧执行到一半?
解决这些 race condition 是 C++ 多线程编程面临的基本问题。本文试图以shared_ptr 一劳永逸地解决这些问题。
【线程安全的定义】:
一个线程安全的 class 应当满足以下三个条件:
-
多个线程同时访问时,其表现出正确的行为。
-
无论操作系统如何调度这些线程, 无论这些线程的执行顺序如何交织(interleaving)。
-
调用端代码无须额外的同步或其他协调动作。
二、对象的创建很简单
对象构造要做到线程安全,唯一的要求是在构造期间不要泄露 this 指针,即:
-
不要在构造函数中注册任何回调。
-
也不要在构造函数中把 this 传给跨线程的对象。
-
即便在构造函数的最后一行也不行。
// 不要这么做( Don't do this.)
class Foo : public Observer
{
public:
Foo(Observable* s)
{
s->register_(this); // 错误,非线程安全
}
virtual void update();
};
// 对象构造的正确方法:
// 要这么做(Do this)
class Foo : public Observer
{
public:
Foo();
virtual void update();
// 另外定义一个函数,在构造之后执行回调函数的注册工作
void observe(Observable* s)
{
s->register_(this);
}
};
Foo* pFoo = new Foo;
Observable* s = getSubject();
pFoo->observe(s); // 二段式构造,或者直接写 s->register_(pFoo);
三、销毁太难
mutex 只能保证函数一个接一个地执行,考虑下面的代码,它试图用互斥锁来保护析构函数:(注意代码中的 (1) 和 (2) 两处标记。)
尽管线程 A 在销毁对象之后把指针置为了 NULL,尽管线程 B 在调用 x 的成员函数之前检查了指针 x 的值,但还是无法避免一种 race condition:
-
线程 A 执行到了析构函数的 (1) 处,已经持有了互斥锁,即将继续往下执行。
-
线程 B 通过了 if (x) 检测,阻塞在 (2) 处。
四、原始指针有何不妥
假如线程A通过p1将object对象销毁了,这时候p2就变成了野指针或者叫空悬指针,这是一种典型的内存错误:
【Note】:
-
指向对象的原始指针( raw pointer)是坏的,尤其当暴露给别的线程时。
-
要想安全地销毁对象,最好在别人(线程)都看不到的情况下,偷偷地做。
五、神器 shared_ptr/weak_ptr
shared_ptr是引用计数型智能指针,当引用计数变为0时,对象被销毁。weak_ptr(主要是解决shared_ptr循环引用的问题,将其中一个shared_ptr换成weak_ptr即可)也是引用计数型智能指针,但是它不增加对象的引用次数,即弱引用。
1、shared_ptr的关键点
-
shared_ptr 控制对象的生命期。 shared_ptr 是强引用(想象成用铁丝绑住堆上的对象),只要有一个指向 x 对象的 shared_ptr 存在,该 x 对象就不会析构。当指向对象 x 的最后一个 shared_ptr 析构或 reset() 的时候, x 保证会被销毁。
-
weak_ptr 不控制对象的生命期,但是它知道对象是否还活着(想象成用棉线轻轻拴住堆上的对象)。如果对象还活着,那么它可以提升( promote)为有效的shared_ptr;如果对象已经死了,提升会失败,返回一个空的shared_ptr。“提升/lock()”行为是线程安全的。
-
shared_ptr/weak_ptr 的“计数”在主流平台上是原子操作,没有用锁,性能不俗。
-
shared_ptr/weak_ptr 的线程安全级别与 std::string 和 STL 容器一样。
2、shared_ptr的线程安全
虽然我们借 shared_ptr 来实现线程安全的对象释放,但是 shared_ptr 本身不是100% 线程安全的。它的引用计数本身是安全且无锁的,但对象的读写则不是,因为shared_ptr 有两个数据成员,读写操作不能原子化。根据文档 11,shared_ptr 的线程安全级别和内建类型、标准库容器、 std::string 一样,即:
-
一个 shared_ptr 对象实体可被多个线程同时读取;
-
两个 shared_ptr 对象实体可以被两个线程同时写入,“析构”算写操作;
-
如果要从多个线程读写同一个 shared_ptr 对象,那么需要加锁。
请注意,以上是 shared_ptr 对象本身的线程安全级别,不是它管理的对象的线程安全级别。
六、系统地避免各种指针错误
-
缓冲区溢出:用 std::vector/std::string 或自己编写 Buffer class 来管理缓冲区,自动记住用缓冲区的长度,并通过成员函数而不是裸指针来修改缓冲区。
-
空悬指针/野指针:用 shared_ptr/weak_ptr,这正是本章的主题。
-
重复释放:用 unique_ptr,只在对象析构的时候释放一次。
-
内存泄漏:用 unique_ptr,对象析构的时候自动释放内存。
-
不配对的 new[]/delete:把 new[] 统统替换为 std::vector/scoped_array。
七、shared_ptr 技术与陷阱
-
意外延长对象的生命期:shared_ptr 是强引用(“铁丝”绑的),只要有一个指向 x 对象的 shared_ptr 存在,该对象就不会析构。
-
函数参数:因为要修改引用计数(而且拷贝的时候通常要加锁), shared_ptr 的拷贝开销比拷贝原始指针要高,但是需要拷贝的时候并不多。
-
析构动作在创建时被捕获:这意味着析构函数可以定制,虚析构函数不是必须的。
-
循环引用:可以用weak_ptr解决。
八、小结
-
原始指针暴露给多个线程往往会造成 race condition 或额外的簿记负担。
-
统一用 shared_ptr/unique_ptr来管理对象的生命期,在多线程中尤其重要。
-
shared_ptr 是值语意,当心意外延长对象的生命期。例如 boost::bind 和容器都可能拷贝 shared_ptr。
-
weak_ptr 是 shared_ptr 的好搭档,可以用作弱回调、对象池、解决循环引用等。