当析构函数遇到多线程 ── C++中线程安全的对象回调

编写线程安全的类不是难事,用同步原语保护内部状态即可。但是对象的生与死不能由对象自身拥有的互斥器来保护如何保证即将析构对象 x 的时候,不会有另一个线程正在调用 x 的成员函数?或者说,如何保证在执行 x 的成员函数期间,对象 x 不会在另一个线程被析构?如何避免这种竞态条件是 C++ 多线程编程面临的基本问题,可以借助 boost的shared_ptrweak_ptr 完美解决。这也是实现线程安全的 Observer 模式的必备技术。

1、多线程下的对象生命期管理

C++ 要求程序员自己管理对象的生命期。当一个对象能被多个线程同时看到,那么对象的销毁时机变得模糊不清,可能出现竞态条件:

1) 在即将析构一个对象时,从何而知是否有另外的线程正在执行该对象的成员
函数?
2)如何保证在执行成员函数期间,对象不会在另一个线程被析构?
3)在调用某个对象的成员函数之前,如何得知这个对象还活着?它的析构函数会
不会刚执行到一半?

本文试图以 shared_ptr一劳永逸地解决这些问题,减轻 C++ 多线程编程的精神负担。

1、线程安全的定义

1)从多个线程访问时,其表现出正确的行为
2)无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织
3)调用端代码无需额外的同步或其他协调动作

依据这个定义,C++ 标准库里的大多数类都不是线程安全的,无论 std::string 还是std::vector 或 std::map,因为这些类通常需要在外部加锁。

2、Mutex 与 MutexLock

Mutex 封装临界区(Critical secion),这是一个简单的资源类,用RAII 手法封装互斥器的创建与销毁

MutexLock 封装临界区的进入和退出,即加锁和解锁。MutexLock一般是个栈上对象,它的作用域刚好等于临界区域。

2、对象的创建很简单

对象构造要做到线程安全,惟一的要求是在构造期间不要泄露 this 指针,即

1)不要在构造函数中注册任何回调
2)也不要在构造函数中把 this 传给跨线程的对象
3)即便在构造函数的最后一行也不行

之所以这样规定,是因为在构造函数执行期间对象还没有完成初始化,如果 this 被泄露 (escape) 给了其他对象(其自身创建的子对象除外),那么别的线程有可能访问这个半成品对象,这会造成难以预料的后果。

// 不要这么做 Don't 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); // 二段式构造

3、销毁太难

对象析构,在多线程程序中,存在了太多的竞态条件。函数用来保护临界区的互斥器本身必须是有效的。而析构函数破坏了这一假设,它会把互斥器销毁掉。悲剧啊!

作为 class 数据成员的 Mutex 只能用于同步本 class 的其他数据成员的读和写,它不能保护安全地析构。因为成员 mutex 的生命期最多与对象一样长,而析构动作可说是发生在对象身故之后(或者身亡之时)。另外,对于基类对象,那么调用到基类析构函数的时候,派生类对象的那部分已经析构了,那么基类对象拥有的 mutex 不能保护整个析构过程。

4、原始指针有何不妥?

当析构函数遇到多线程 ── C++中线程安全的对象回调_第1张图片

5、神器 shared_ptr/weak_ptr

shared_ptr引用计数型智能指针shared_ptr是一个类模板(class template),它只有一个类型参数,使用起来很方便。

引用计数是自动化资源管理的常用手法,当引用计数降为0时,对象(资源)即被销毁!!!weak_ptr也是一个引用计数型智能指针,但是它不增加引用次数,即弱 (weak) 引用。

1shared_ptr 控制对象的生命期。 shared_ptr 是强引用(想象成用铁丝
绑住堆上的对象),只要有一个指向 x 对象的 shared_ptr 存在,该 x 对
象就不会析构。当指向对象 x 的最后一个 shared_ptr 析构或 reset 的时
候,x 保证会被销毁。

2)weak_ptr 不控制对象的生命期,但是它知道对象是否还活着(想象成用棉
线轻轻拴住堆上的对象)。如果对象还活着,那么它可以提升 (promote) 为有
效的 shared_ptr;如果对象已经死了,提升会失败,返回一个空的 
shared_ptr。“提升”行为是线程安全的 。

空悬指针/野指针解决办法: shared_ptr/weak_ptr

6、再论 shared_ptr 的线程安全

虽然我们借 shared_ptr 来实现线程安全的对象释放,但是 shared_ptr 本身不是100% 线程安全的。它的引用计数本身是安全且无锁的,但对象的读写则不是,因为shared_ptr 有两个数据成员,读写操作不能原子化。

7、shared_ptr 技术与陷阱

意外延长对象的生命期 。shared_ptr 是强引用(铁丝绑的),只要有一个指向 x 对象的 shared_ptr 存在,该对象就不会析构。而shared_ptr 又是允许拷贝构造和赋值的(否则引用计数就无意义了),如果不小心遗留了一个拷贝,那么对象就永世长存了。

8、替代方案?

除了使用 shared_ptr/weak_ptr,要想在 C++ 里做到线程安全的对象回调与析构,可能的办法有:
1) 用一个全局的 facade 来代理 Foo 类型对象访问,所有的 Foo 对象回调和析构都通过这个 facade 来做,也就是把指针替换为objId/handle 。这样理论上能避免 race condition,但是代价很大。因为要想把这个 facade 做成线程安全,那么必然要用互斥锁。这样一来,**从两个线程访问两个不同的 Foo 对象也会用到同一个锁,让本来能够并行执行
的函数变成了串行执行,没能发挥多核的优势**。当然,可以像 Java 的 ConcurrentHashMap那样用多个 buckets,每个 bucket 分别加锁,以降低 contention。

2)将来在 C++ 0x 里有 unique_ptr,能避免引用计数的开销,或许能在某些场合替换shared_ptr

9、总结

1)原始指针暴露给多个线程往往会造成 race condition 或额外的簿记负担;
2)统一用 shared_ptr/scoped_ptr 来管理对象的生命期,在多线程中尤其重要;
3)shared_ptr 是值语意,当心意外延长对象的生命期。例如boost::bind 和容器;
4)weak_ptrshared_ptr 的好搭档,可以用作弱回调、对象池等;

你可能感兴趣的:(网络编程与多线程,多线程)