【muduo】线程安全的对象生命期管理

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/daaikuaichuan/article/details/85331394

一、当析构函数遇到多线程

  当一个对象能被多个线程同时看到时,那么对象的销毁时机就会变得模糊不清,可能出现多种竞态条件(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 的好搭档,可以用作弱回调、对象池、解决循环引用等。

猜你喜欢

转载自blog.csdn.net/daaikuaichuan/article/details/85331394
今日推荐