Linux(muduo网络库):02---线程安全的对象生命期管理之(以智能指针替换原始指针:内含Observer模式、对象池(弱回调)等演示案例)

一、线程安全的Observer有多难

  • 一个动态创建的对象是否还活着,光看指针是看不出来的(引用也一样看不出来):
    • 指针就是指向了一块内存,这块内存上的对象如果已经销毁,那么就根本不能访问(《C++编程规范》条款99)(就像free之后的地址不能访问一样),既然不能访问又如何知道对象的状态呢?
    • 换句话说,判断一个指针是不是合法指针没有高效的办法,这是C/C++指针问题的根源。(万一原址又创建了一个新的对象呢?再万一这个新的对象的类型异于老的对象呢?)

对象的三种关系

  • 在面向对象程序设计中,对象的关系主要有三种:composition、 aggregation、association
    • ①composition(组合/复合)关系:在多线程里不会遇到什么麻烦,因为对象x的生命期由其唯一的拥有者owner控制, owner析构的时候会把x也析构掉。从形式上看,x是owner的直接数据成员,或者scoped_ptr成员,抑或owner持有的容器的元素
    • 后两种关系在C++里比较难办,处理不好就会造成内存泄漏或重复释放:
      • ②association(关联/联系):是一种很宽泛的关系,它表示一个对象a用到了另一个对象b,调用了后者的成员函数。从代码形式上看,a 持有b的指针(或引用),但是b的生命期不由a单独控制
      • ③aggregation(聚合)关系:从形式上看与association相同,除了a和b有逻辑上的整体与部分关系。如果b是动态创建的并在整个程序结束前有可能被释放,那么就会出现“前一篇文章最开始”谈到的竞态条件
  • 那么似乎一个简单的解决办法是:
    • 只创建不销毁
    • 程序使用一个对象池来暂存用过的对象,下次申请新对象时,如果对象池里有存货,就重复利用现有的对象,否则就新建一个。对象用完了,不是直接释放 掉,而是放回池子里。这个办法当然有其自身的很多缺点,但至少能避免访问失效对象的情况发生
  • 这种山寨办法的问题有:
    • 对象池的线程安全,如何安全地、完整地把对象放回池子里,防止出现“部分放回”的竞态?(线程A认为对象x已经放回了,线程B认为 对象x还活着。)
    • 全局共享数据引发的lock contention,这个集中化的对象池会不会 把多线程并发的操作串行化?
    • 如果共享对象的类型不止一种,那么是重复实现对象池还是使用类模板?
    • 会不会造成内存泄漏与分片?因为对象池占用的内存只增不减, 而且多个对象池不能共享内存(想想为何)。
  • 回到正题上来,如果对象x注册了任何非静态成员函数回调,那么必然在某处持有了指向x的指针,这就暴露在了race condition之下

一个典型的场景是Observer模式

  • 代码如下:
class Observer //:boost::noncopyable
{
public:
    virtual ~Observer();
    virtual void update() = 0;
    //...
};

class Observable //:boost::noncopyable
{
public:
    void register_(Observer* x);
    void unregister(Observer* x);

    void notifyObservers() {
        for (Observer* x : observers_) {
            x->update(); //1
        }
    }
private:
    std::vector<Observer*> observers_;
};
  • 当Observerable通知每一个Observer时(代码段标记1处)它从何处得知Observer对象x还活着?
  • 要不试试在Observer的析构函数里调用unregister()来解注册?(代码如下) 恐难奏效
class Observer //:boost::noncopyable
{
public:
    //其他代码同上
    void observe(Observable* s) {
        s->register_(this);
        subject_ = s;
    }

    virtual ~Observer() {
        subject_->unregister(this); //2
    }
	
    Observable* subject_;
};
  • 我们试着让Observer的析构函数去调用unregister(this),这里有两个race conditions:
    • 其一:“代码段标记2处”如何得知subject_还活着?
    • 其二:就算subject_指向某个永久存在的对象,那么还是险象环生:
      • 1.线程A执行到“代码段标记2处”之前,还没有来得及unregister本对象
      • 2.线程B执行到“代码段标记1处”,x正好指向是“代码段标记2处”正在析构的对象
      • 这时悲剧又发生了:
        • 既然x所指的Observer对象正在析构,调用它的任何非静态成员函数都是不安全的,何况是虚函数(C++标准对在构造函数和析构函数中调用虚函数的行为有明确规定,但是没有考虑并发调用的情况)
        • 更糟糕的是, Observer是个基类,执行到“代码段标记2处”时,派生类对象已经析构掉了,这时候整个对象处于将死未死的状态,core dump恐怕是最幸运的结果
  • 这些race condition似乎可以通过加锁来解决,但在哪儿加锁,谁持有这些互斥锁,又似乎不是那么显而易见的。要是有什么活着的对象能帮帮我们就好了,它提供一个isAlive()之类的程序函数,告诉我们那个对象还在不在。可惜指针和引用都不是对象,它们是内建类型

二、原始指针有何不妥

  • 指向对象的原始指针(raw pointer)是坏的,尤其当暴露给别的线程时:
    • Observable应当保存的不是原始的Observer*,而是别的什么东西,能分辨Observer对象是否存活
    • 类似地,如果Observer要在析构函数里解注册(这虽然不能解决前面提到的race condition,但是在析构函数里打扫战场还是应该的),那么subject_的类型也不能是原始的Observable*
  • 有经验的C++程序员或许会想到用智能指针。没错,这是正道,但也没那么简单,有些关窍需要注意。这两处直接使用shared_ptr是不行 的,会形成循环引用,直接造成资源泄漏。别着急,后文会一一讲到

空悬指针

  • 这是一种典型的C/C++内存错误:
    • 有两个指针p1和p2,指向堆上的同一个对象Object,p1和p2位于不同的线程中(左图)
    • 假设线程A通过p1指针将对象销毁了(尽管把p1置为了NULL),那p2就成了空悬指针(右图)

  • 要想安全地销毁对象,最好在别人(线程)都看不到的情况下,偷偷地做。(这正是垃圾回收的原理,所有人都用不到的东西一定是垃圾)

一个解决办法

  • 一个解决空悬指针的办法是,引入一层间接性,让p1和p2所指的对象永久有效。比如下图中的proxy对象,这个对象,持有一个指向Object的指针。(从C语言的角度,p1和p2都是二级指针)

  • 当销毁Object之后,proxy对象继续存在,其值变为0(见下图)。 而p2也没有变成空悬指针,它可以通过查看proxy的内容来判断Object是否还活着

  • 要线程安全地释放Object也不是那么容易,race condition依旧存在。比如p2看第一眼的时候proxy不是零,正准备去调用Object的成员函数,期间对象已经被p1给销毁了
  • 问题在于,何时释放proxy指针呢?

一个更好的解决办法

  • 为了安全地释放proxy,我们可以引入引用计数(reference counting),再把p1和p2都从指针变成对象sp1和sp2。proxy现在有两个成员,指针和计数器
  • ①一开始,有两个引用,计数值为2(见下图)

  • ②sp1析构了,引用计数的值减为1(见下图)

  • ③sp2也析构了,引用计数降为0,可以安全地销毁proxy和Object 了(见下图)

  • 慢着!这不正是引用计数型智能指针吗?

一个万能的解决方案

  • 引入另外一层间接性(another layer of indirection),用对象来管理共享资源(如果把Object看作资源的话),亦即handle/body惯用技法 (idiom)
  • 当然,编写线程安全、高效的引用计数handle的难度非凡, 作为一名谦卑的程序员,用现成的库就行。万幸,C++的TR1标准库里提供了一对“神兵利器”,可助我们完美解决这个头疼的问题

三、神器shared_ptr/weak_ptr

  • shared_ptr:
    • 语法参阅:https://blog.csdn.net/qq_41453285/article/details/95196634
    • shared_ptr是引用计数型智能指针在Boost和std::tr1里均提供,也被纳入C++11标准库,现代主流的C++编译器都能很好地支持。 shared_ptr是一个类模板(class template),它只有一个类型参数, 使用起来很方便。引用计数是自动化资源管理的常用手法,当引用计数降为0时,对象(资源)即被销毁
  • weak_ptr:
  • 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容器一样, 后面还会讲

四、插曲:系统地避免各种指针错误

  • 孟岩曾说过:“大部分用C写的上规模的软件都存在一些内存方面的错误,需要花费大量的精力和时间把产品稳定下来”。举例来说, 就像Nginx这样成熟且广泛使用的C语言产品都会不时暴露出低级的内存错误
  • 内存方面的问题在C++里很容易解决。C++里可能出现的内存问题大致有这么几个方面:
    • 1.缓冲区溢出(buffer overrun)
    • 2.空悬指针/野指针
    • 3.重复释放(double delete)
    • 4.内存泄漏(memory leak)
    • 5.不配对的new[]/delete
    • 6.内存碎片(memory fragmentation)
  • 正确使用智能指针能很轻易地解决前面5个问题,解决第6个问题需要别的思路:(会在“后面分布式系统的软件不要求7*24”中介绍)
    • 1.缓冲区溢出:用std::vector/std::string或自己编写Buffer class来管理缓冲区,自动记住用缓冲区的长度,并通过成员函数而不是 裸指针来修改缓冲区
    • 2.空悬指针/野指针:用shared_ptr/weak_ptr,这正是本章的主 题
    • 3.重复释放:用scoped_ptr,只在对象析构的时候释放一次
    • 4.内存泄漏:用scoped_ptr,对象析构的时候自动释放内存
    • 5.不配对的new[]/delete:把new[]统统替换为 std::vector/scoped_array
  • 在现代的C++程序中一般不会出现delete语句,资源(包括复杂对象本身)都是通过对象 (智能指针或容器)来管理的,不需要程序员还为此操心
  •  在这几种错误里边,内存泄漏相对危害性较小,因为它只是借了东西不归还,程序功能在一段时间内还算正常。其他如缓冲区溢出或重复释放等致命错误可能会造成安全性(security和data safety)方面的严重后果
  •  需要注意一点:scoped_ptr/shared_ptr/weak_ptr都是值语意,要么是栈上对象,或是其他对象的直接数据成员,或是标准库容器里的元素。 几乎不会有下面这种用法:
std::shared_ptr<Foo>* pFoo = new std::shared_ptr<Foo>(new Foo); //错误的语法
  • 还要注意:如果这几种智能指针是对象x的数据成员,而它的模板参数T是个incomplete类型,那么x的析构函数不能是默认的或内联的, 必须在.cpp文件里边显式定义,否则会有编译错或运行错(原因在后文C++链接中介绍)

五、将智能指针应用到Observer上

  • 既然通过weak_ptr能探查对象的生死(通过lock()函数),那么Observer模式的竞态条件就很容易解决,只要让Observable保存weak_ptr即可
  • 代码如下:
    • 就这么简单。“一”中的代码(“代码段标记1处”)的竞态条件已经弥补了
    • 思考:如果把上面“代码段标记3处”改为vector<shared_ptr<Observer> > observers_;,会有什么后果?
class Observable //:boost::noncopyable
{
public:
    void register_(std::weak_ptr<Observer> x); //参数可以使用const xxx&代替
    //void unregister(weak_ptr<Observer> x);不需要了

    void notifyObservers();
private:
    mutable MutexLock mutex_;
    std::vector<std::weak_ptr<Observer>> observers_;  //3
    typedef std::vector<std::weak_ptr<Observer>>::iterator Iterator;
};

void Observable::notifyObservers()
{
    MytexLockGuard lock(mutex_);
    Iterator it = observers_.begin();
    while (it != observers_.end())
    {
        //lock():如果it迭代器所指之处的waek_ptr对象存在,返回一个it对象的shared_ptr
        std::shared_ptr<Observer> obj(it->lock());
        if (obj)
        {
            //引用计数值变为2
            //没有竞态条件,因为obj在栈上,对象不可能在本作用域内销毁
            obj->update(); //3
            ++it;
        }
        else //对象已经销毁,从容器中删除即可
        {
            it = observers_.erase(it);
        }
    }
}

还有一些问题没有解决

  • 把Observer*替换为weak_ptr部分解决了Observer模式的线程安全,但还有以下几个疑点:
    • ①侵入性:强制要求Observer必须以shared_ptr来管理
    • ②不是完全线程安全:Observer的析构函数会调用subject_- >unregister(this),万一subject_已经不复存在了呢?为了解决它,又要求Observable本身是用shared_ptr管理的,并且subject_多半是个weak_ptr
    • ③锁争用(lock contention):即Observable的三个成员函数都用了互斥器来同步,这会造成register_()和unregister()等待notifyObservers(),而后者的执行时间是无上限的,因为它同步回调了用户提供的update()函数。我们希望register_()和unregister()的执行时间不会超过某个固定的上限,以免殃及无辜群众
    • ④死锁:万一上面的“代码段标记3处”的update()虚函数中调用了register()或unregister()呢?
      • 如果mutex_是不可重入的,那么会死锁
      • 如果mutex_是可重入的,程序会面临迭代器失效(core dump是最好的结果),因为vector observers_在遍历期间被意外地修改了
      • 这个问题乍看起来似乎没有解决办法,除非在文档里做要求。(一种办法是:用可重入的mutex_,把容器换为 std::list,并把++it往前挪一行)
    • 这些问题留到本文最后“Observer之谬”去探讨, 每个都是能解决的
  • 我个人倾向于使用不可重入的mutex,例如Pthreads默认提供的那个,因为“要求mutex可重入”本身往往意味着设计上出了问题 (见后文的“互斥器”介绍)。Java的intrinsic lock是可重入的,因为要允许synchronized方法相互调用(派生类调用基类的同名synchronized方法),我觉得这也是无奈之举

六、再论shared_ptr的线程安全

  • 虽然我们借shared_ptr来实现线程安全的对象释放,但是shared_ptr本身不是100%线程安全的。它的引用计数本身是安全且无锁的,但对象的读写则不是,因为shared_ptr有两个数据成员,读写操作不能原子化
  • 根据文档,shared_ptr的线程安全级别和内建类型、标准库容器、 std::string一样,即:
    • 一个shared_ptr对象实体可被多个线程同时读取
    • 两个shared_ptr对象实体可被两个线程同时写入(“析构”也算写操作)
    • 如果要从多个线程读写同一个shared_ptr对象,那么需要加锁
  • 请注意,以上是shared_ptr对象本身的线程安全级别,不是它管理的对象的线程安全级别。
  • 要在多个线程中同时访问同一个shared_ptr,正确的做法是用mutex保护:
    • globalPtr能被多个线程看到,那么它的读写需要加锁
    • 注意我们不必用读写锁,而只用最简单的互斥锁,这是为了性能考虑。因为临界区非常小,用互斥锁也不会阻塞并发读
MutexLock mutex;  //for ReaderWriterLock
std::shared_ptr<Foo> globalPtr;

//将globalPtr传递给这个函数使用
void doit(const std::shared_ptr<Foo>& pFoo);
  • 为了拷贝globalPtr,需要在读取它的时候加锁,即:
void read()
{
    std::shared_ptr<Foo> localPtr;
    
    {
        MutexLockGuard lock(mutex); //加锁
        localPtr = globalPtr; //read globalPtr
    } //作用域结束后,mutex自动解锁释放
    
    //在此处使用localPtr(函数局部的),因此读写localPtr也无须加锁
    doit(localPtr);
}
  • 写入的时候也要加锁:
void write()
{
    std::shared_ptr<Foo> newPtr(new Foo); //对象的创建在临界区之外
    
    {
        MutexLockGuard lock(mutex); //加锁
        globalPtr = newPtr; //write to globalPtr
    } //作用域结束后,mutex自动解锁释放

    //在此处使用newPtr(函数局部的),因此读写newPtr也无须加锁
    doit(newPtr);
}
  • 注意事项:
    • 上面的read()和write()在临界区之外都没有再访问globalPtr, 而是用了一个指向同一Foo对象的栈上shared_ptr local copy。下面会谈到,只要有这样的local copy存在,shared_ptr作为函数参数传递时不必复制,用reference to const作为参数类型即可
    • 另外注意到上面的new Foo是在临界区之外执行的,这种写法通常比在临界区内写globalPtr.reset(new Foo)要好,因为缩短了临界区长度
    • 如果要销毁对象,我们固然可以在临界区内执行globalPtr.reset(),但是这样往往会让对象析构发生在临界区以内,增加了临界区的长度。一种改进办法是像上面一样定义一个localPtr,用它在临界区内与globalPtr交换(swap()),这样能保证把对象的销毁推迟到临界区之外(自己思考:在write()函数中,globalPtr=newPtr;这一句有可能会在临界区内销毁原来globalPtr指向的Foo对象,设法将销毁行为移出临界区)

七、shared_ptr技术与陷阱

①以外延长对象的生命期

  • shared_ptr是强引用,只 要有一个指向x对象的shared_ptr存在,该对象就不会析构
  • 而shared_ptr又是允许拷贝构造和赋值的,如果不小心遗留了一个拷贝,那么对象就永世长存了。例如:
    • ①前面提到如果把“五”的代码中把obervers_的类型改为vector<shared_ptr<Observer>>,那么除非手动调用unregister(),否则Observer对象永远不会析构。即便它的析构函 数会调用unregister(),但是不去unregister()就不会调用Observer的析构(函数。这也是Java内存泄漏的常见原因。)
    • 另外一个出错的可能是boost::bind,因为std::bind会把实参拷贝一份,如果参数是个shared_ptr,那么对象的生命期就不会短于std::function对象:
#include <functional>;

class Foo
{
public:
    void doit();
};

shared_ptr<Foo> pFoo(new Foo);
std::function<void()> func = std::bind(&Foo::doit, pFoo);
  • 这里func对象持有了shared_ptr的一份拷贝,有可能会在不经意间延长倒数第二行创建的Foo对象的生命期

②函数参数

  • 因为要修改引用计数(而且拷贝的时候通常要加锁),shared_ptr的拷贝开销比拷贝原始指针要高,但是需要拷贝的时候并不 多。多数情况下它可以以const reference方式传递,一个线程只需要在最外层函数有一个实体对象,之后都可以用const reference来使用这个shared_ptr。例如有几个函数都要用到Foo对象:
void save(const std::shared_ptr<Foo>& pFoo); //pass by const reference
void validateAccount(const Foo& foo);

vool validate(const std::shared_ptr<Foo>& pFoo) //pass by const reference
{
    validateAccount(*pFoo);
}
  • 那么在通常情况下,我们可以传常引用:
void onMessage(const string& msg)
{
    std::tr1::shared_ptr<Foo> pFoo(new Foo(msg)); //只要在最外层持有一个实体,安全就不是问题
    if (validate(pFoo)) { //没有拷贝pFoo
        save(pFoo);       //没有拷贝pFoo
    }
}
  • 遵照这个规则:
    • 基本上不会遇到反复拷贝shared_ptr导致的性能问题
    • 另外由于pFoo是栈上对象,不可能被别的线程看到,那么读取始终是线程安全的

③析构动作在创建时被捕获

  • 这是一个非常有用的特性,这意味着:
    • 虚析构不再是必需的
    • shared_ptr可以持有任何对象,而且能安全地释放
    • shared_ptr对象可以安全地跨越模块边界,比如从DLL里返回,而不会造成从模块A分配的内存在模块B里被释放这种错误
    • 二进制兼容性,即便Foo对象的大小变了,那么旧的客户代码仍然可以使用新的动态库,而无须重新编译。前提是Foo的头文件中不出现访问对象的成员的inline函数,并且Foo对象的由动态库中的Factory构造,返回其shared_ptr
    • 析构动作可以定制(通过shared_ptr的参数2)
  • 最后这个特性的实现比较巧妙,因为shared_ptr只有一个模板参数,而“析构行为”可以是函数指针、仿函数(functor)或者其他什么东西。这是泛型编程和面向对象编程的一次完美结合(这个技术在后面的对象池中还会用到)

④析构所在的线程

  • 析构所在的线程对象的析构是同步的,当最后一个指向x的shared_ptr离开其作用域的时候,x会同时在同一个线程析构。这个线程不一定是对象诞生的线程
  • 这个特性是把双刃剑:
    • 缺点:如果对象的析构比较耗时,那么可能会拖慢关键线程的速度(如果最后一个shared_ptr引发的析构发生在关键线程)
    • 优点:同时,我们可以用一个单独的线程来专门做析构,通过一个BlockingQueue<shared_ptr<void>>把对象的析构都转移到那个专用线程,从而解放关键线程

⑤线程的RAII handle

  • 我认为RAII(资源获取即初始化)是C++语言区别于其他所有编程语言的最重要的特性,一个不懂RAII的C++程序员不是一个合格的C++程序员
  • 初学C++的教条是“new和delete要配对, new了之后要记着delete”
  • 如果使用RAII(参阅Effective C++:https://blog.csdn.net/qq_41453285/article/details/104219176),要改成“每一个明确的资源配置动作(例如new)都应该在单一语句中执行,并在该语句中立刻将配置获得的资源交给handle对象(如shared_ptr),程序中一般不出现delete”
  • shared_ptr是管理共享资源的利器,需要注意避免循环引用,通常的做法是owner持有指向child的shared_ptr,child持有指向 owner的weak_ptr

八、对象池

  • 假设有一个Stock类,代表一只股票的价格:
    • 每一只股票有一个唯一的字符串标识,比如Google的key是“NASDAQ:GOOG”,IBM是“NYSE:IBM”
    • Stock对象是个主动对象,它能不断获取新价格
    • 为了节省系统资源:
      • 同一个程序里边每一只出现的股票只有一个Stock对象,如果多出用到同一只股票,那么Stock对象应该被共享
      • 如果某一只股票没有在任何地方用到,其对应的Stock对象应该析构,以释放资源
  • 为了达到上述要求,我们设计一个对象池StockFactoryrecipes/thread/test/Factory.cc包含这里提到的各个版本。代码如下:
//此代码存在问题
#include <boost/utility.hpp> //for noncopyable
class StockFactory :boost::noncopyable
{
public:
    std::shared_ptr<Stock> get(const std::string& key)
    {
        //如果stock_里找到了key,就返回stock_[key]
        //否则新建一个Stock对象存入stocks[]中
    }
private:
    mutable MutexLock mutex_;
    std::map<string, std::shared_ptr<Stock>> stock_;
};
  • 上面的代码有一个问题:Stock对象永远不会被销毁,因为map里存的是shared_ptr
  • 我们应该仿照前面的Observable那样存一个weak_ptr。例如:
class StockFactory :boost::noncopyable
{
public:
    std::shared_ptr<Stock> get(const std::string& key);
private:
    mutable MutexLock mutex_;
    std::map<string, std::weak_ptr<Stock>> stock_; //map的形参2改为weak_ptr
};

shared_ptr<Stock> StockFactory::get(const std::string& key)
{
    std::shared_ptr<Stock> pStock;

    MutexLockGuard lock(mutex_);
    std::weak_ptr<Stock>& wkStock = stock_[key]; //获取key索引处的weak_ptr(注意,这是一个引用)
    pStock = wkStock.lock();  //调用wkStock的lock()函数

    //如果lock()返回空,那么pStock为空,if成立
    if (!pStock) {
        pStock.reset(new Stock(key)); //创建一个Stock对象,类型为shared_ptr
        wkStock = pStock;  //为wkStock赋值(因为wkStock是一个引用,所以相当为stock_[]中某元素赋值)
    }

    return pStock; //返回shared_ptr
}
  • 这么做固然Stock对象是销毁了,但是程序却出现了轻微的内存泄漏,为什么?
    • 因为stocks_的大小只增不减,stocks_.size()是曾经存活过的Stock对象的总数,即便活的Stock对象数目降为0。或许有人认为这不算泄漏, 因为内存并不是彻底遗失不能访问了,而是被某个标准库容器占用了。 我认为这也算内存泄漏,毕竟是“战场”没有打扫干净。
    • 其实,考虑到世界上的股票数目是有限的,这个内存不会一直泄漏下去,大不了把每只股票的对象都创建一遍,估计泄漏的内存也只有几兆字节
    • 但是如果这是一个其他类型的对象池,对象的key的集合不是封闭的,内存就会一直泄漏下去
  • 解决的办法是,利用shared_ptr的定制析构功能:
    • shared_ptr的构造函数可以有一个额外的模板类型参数,传入一个函数指针或仿函数d, 在析构对象时执行d(ptr),其中ptr是shared_ptr保存的对象指针
    • shared_ptr这么设计并不是多余的,因为反正要在创建对象时捕获释放动作,始终需要一个bridge
//下面是shared_ptr的构造函数与reset()成员函数
template<class Y, class D> shared_ptr::shared_ptr(Y* p, D d);
template<class Y, class D> void shared_ptr::reset(Y* p, D d);
//注意Y的类型可能与T不同,这是合法的,只要Y*能隐式zhuanhuanweiT*
  • 定制shared_ptr的析构功能,我们可以在析构Stock对象的同时清理调用在StockFactory中stocks_中保存的对象。修改代码之后如下所示:
class StockFactory :boost::noncopyable
{
public:
    //只是将pStock.reset()修改了一下而已
    std::shared_ptr<Stock> get(const std::string& key)
    {
        std::shared_ptr<Stock> pStock;
        MutexLockGuard lock(mutex_);
        std::weak_ptr<Stock>& wkStock = stock_[key];
        pStock = wkStock.lock();
        if (!pStock) {
            //我们向reset()传递了第二个参数,一个boost::function
            //让它在析构Stock* p时调用deleteStock()成员函数
            pStock.reset(new Stock(key), boost::bind(&StockFactory::deleteStock, this, _1));
            wkStock = pStock;
        }
        return pStock;
    }
private:
    //删除器
    void deleteStock(Stock* stock) 
    {
        if (stock) {
            MutexLockGuard lock(mutex_);
            stock_.erase(stock->key());
        }
        delete stock;
    }
    //...其余同上
};
  • 上面的代码还存在一处问题:
    • 在boost:bind()那一行,我们把一个原始的StockFactory this指针保存在了boost:bind里,这将会造成线程安全问题
    • 可能会产生core dump:如果这个StockFactory先于Stock对象析构,Stock去析构时回调StockFactory::deleteStock就会core dump。类似于Observer在析构函数里去调用Observable::unregister(),而那时Observable对象可能已经不存在了
    • 这也是可以解决的,可以参阅下面的弱回调技术

①enable_shared_from_this

  • 上面的代码最终还是存在一个线程安全的问题
  • StockFactory::get()本身是一个成员函数,我们可以获得其自身对象的this指针,但是如何获得一个指向当前对象的shared_ptr<StockFactory>对象呢?
  • 有办法,就是利用enable_shared_from_this:这是一个以其派生类为模板类型实参的基类模板,继承它,this指针就能变身为shared_ptr。例如:
#include <memory> //for enable_shared_from_this
class StockFactory :public std::::enable_shared_from_this<StockFactory>,
                    public boost::noncopyable
{
    //...
}
  • enable_shared_from_this的相关注意事项:
    • 不能在构造函数中调用shared_from_this(),因为在构造对象的时候,自己本身还没有被交给shared_ptr保管
    • 继承于enable_shared_from_this之后,类就不能是stack object,必须是heap object且由shared_pre管理其生命周期。例如:
std::shared_ptr<StockFactory> stockFactory(new StockFactory);
  • 现在我们修改get()成员函数的代码:
    • 我们在boost::bind中保存了一份shared_ptr<StockFactory>,可以保证调用StockFactpry::deleteStock的时候那个StockFactory对象还活着
std::shared_ptr<Stock> get(const std::string& key)
{
    //...其余同上

    if (!pStock) {
        pStock.reset(new Stock(key), 
            boost::bind(&StockFactory::deleteStock, shared_from_this(), _1));
        wkStock = pStock;
    }

    //...其余同上
}
  • 此处还存在一个问题:StockFactpry的生命周期似乎被意外延长了

②弱回调

  • 在①中,我们将shared_ptr绑定(boost::bind)到boost::function里,那么回调的时候StockFactory对象始终存在,是安全的。但是同时也延长了对象的生命周期,使之不短于绑定的boost::function对象
  • 弱回调技术:
    • 有时候我们需要“如果对象还活着,就调用它的成员函数,否则忽略之”的语意,就像Observable::notifyObservers()那样,我们称之为“弱回调”
    • 弱回调可以使用weak_ptr实现,我们可以把weak_ptr绑定到boost::function里,这样的对象的生命期就不会被延长。然后再回调的时候先尝试提升为shared_ptr,如果提升成功,说明接受回调的对象还健在,那么就执行回调;如果提升失败,就什么都不做
  • 更改的代码如下:
class Stock {
public:
    Stock(const std::string &s) {}
    std::string key()const;
};

class StockFactory :public std::enable_shared_from_this<StockFactory>,
                    public boost::noncopyable
{
public:
    std::shared_ptr<Stock> get(const std::string& key)
    {
        std::shared_ptr<Stock> pStock;
        MutexLockGuard lock(mutex_);
        std::weak_ptr<Stock>& wkStock = stock_[key]; //注意是引用
        pStock = wkStock.lock();
        if (!pStock) {
            pStock.reset(new Stock(key),
                boost::bind(&StockFactory::weakDeleteCallBack , std::weak_ptr<StockFactory>(shared_from_this()), _1));
            //上面必须强制把shared_from_this()转型为weak_ptr,才不会延长生命期
            //因为boost::bind拷贝的是实参类型,而不是形参类型
            wkStock = pStock;
        }
        return pStock;
    }
private:
    static void weakDeleteCallBack(const std::weak_ptr<StockFactory>& wkFactory,Stock* stock)
    {
        std::shared_ptr<StockFactory> factory(wkFactory.lock()); //提升为shared_ptr
        //如果StockFactory还存活,移除Stock对象,否则什么都不做
        if (factory) { 
            factory->removeStock(stock);
        }
        delete stock;
    }
    void removeStock(Stock* stock) {
        if (stock) {
            MutexLockGuard lock(mutex_);
            stock_.erase(stock->key());
        }
    }
private:
    mutable MutexLock mutex_;
    std::map<std::string, std::weak_ptr<Stock>> stock_;
};
  • 现在我们给出下面的测试代码:

  • 代码修改之后,无论Stock和StockFactory谁先析构都不会影响程序的正常运行。这里我们借助了shared_ptr和weak_ptr完全地解决了两个对象相互引用的问题
  • 当然,通常Factory对象是个singleton,在程序正常运行期间不会析构,此处只是为了展示弱回调技术,这个技术在事件通知中非常有用
  • 本节的StockFactory只有针对单个Stock对象的操作,如果程序需要遍历整个stock_,稍不注意就会造成死锁或数据损坏,后面的文章会给出解决办法(参阅“借助shared_ptr实现copy-on-write”文章

九、智能指针之外的替代方案

  • 除了使用shared_ptr/weak_ptr,要想在C++里做到线程安全的对象回调与析构,可能的办法有以下一些:
    • ①用一个全局的facade来代理Foo类型对象访问:
      • 所有的Foo对象回调和析构都通过这个facade来做,也就是把指针替换为objId/handle, 每次要调用对象的成员函数的时候先check-out,用完之后再check-in
      • 这样理论上能避免race condition,但是代价很大。因为要想把这个façade做成线程安全的,那么必然要用互斥锁。这样一来,从两个线程访问两个不同的Foo对象也会用到同一个锁,让本来能够并行执行的函 数变成了串行执行,没能发挥多核的优势
      • 当然,可以像Java的ConcurrentHashMap那样用多个buckets,每个bucket分别加锁,以降低 contention
    • ②在文章最前面提到的“只创建不销毁”手法,实属无奈之举
    • ③自己编写引用计数的智能指针。本质上是重新发明轮子,把 shared_ptr实现一遍。正确实现线程安全的引用计数智能指针不是一件 容易的事情,而高效的实现就更加困难。既然shared_ptr已经提供了完 整的解决方案,那么似乎没有理由抗拒它
    • ④将来在C++11里有unique_ptr,能避免引用计数的开销,或许能在某些场合替换shared_ptr

十、Observer之谬(取代Observer的一些技术)

  • 在上面的“五”中,我们将shared_pre和weak_ptr应用到Observer模式中,部分解决了其他线程安全问题。此处用Observer举例,因为这是一个广为人知的设计模 式,但是它有本质的问题
  • Observer模式的本质问题在于:其面向对象的设计。换句话说:
    • 我认为正是面向对象(OO)本身造成了Observer的缺点
    • Observer是基类, 这带来了非常强的耦合,强度仅次于友元(friend)。这种耦合不仅限制了成员函数的名字、参数、返回值,还限制了成员函数所属的类型 (必须是Observer的派生类)
  • Observer class是基类,这意味着如果Foo想要观察两个类型的事件 (比如时钟和温度),需要使用多继承。这还不是最糟糕的,如果要重复观察同一类型的事件(比如1秒一次的心跳和30秒一次的自检),就要用到一些伎俩来work around,因为不能从一个Base class继承两次
  • 现在的语言一般可以绕过Observer模式的限制:
    • 比如Java可以用匿名内部类,Java 8用Closure,C#用delegate
    • C++用 boost::function/boost::bind(可以参阅后面的文章“以boost::function和boost::bind取代虚函数”,或者孟岩的博客:https://blog.csdn.net/myan/article/details/5928531
  • 在C++里为了替换Observer,可以用Signal/Slots:
    • 此处指的不是QT那种靠语言扩展的实现,而是完全靠标准库实现的thread safe、race condition free、thread contention free的Signal/Slots,并且不强制要求shared_ptr来管理对象
    • 也就是说完全解决了上面列出的Observer遗留问题。这会用到后面文章介绍的“借shared_ptr实现copy-on-write”技术
  • 在C++11中,借助variadic template,实现最简单(trivial)的一对多回调可谓不费吹灰之力,代码如下:
//摘录于:recipes/thread/SignalSlotTrivial.h
template<typename Signature>
class SignalTrivial;

//NOT thread safe!!!
template<class RET,typename... ARGS>
class SignalTrivial<RET(ARGS...)>
{
public:
    typedef std::function<void(ARGS...)> Functor;

    void connect(Functor&& func)
    {
        functors_.push_back(std::forward<Functor>(func));
    }

    void call(ARGS&&... args)
    {
        for (const Functor& f : functors_)
        {
            f(args...);
        }
    }
private:
    std::vector<Functor> functors_;
};
  • 我们不难把以上基本实现扩展为线程安全的Signal/Slots,并且在Slot析构时自动unregister。有兴趣的读者可仔细阅读完整实现的代码(recipes/thread/SignalSlot.h)

十一、总结

  • 原始指针暴露给多个线程往往会造成race condition或额外的簿记负担
  • 统一用shared_ptr/scoped_ptr来管理对象的生命期,在多线程中尤其重要
  • shared_ptr是值语意,当心意外延长对象的生命期。例如boost::bind 和容器都可能拷贝shared_ptr
  • weak_ptr是shared_ptr的好搭档,可以用作弱回调、对象池等
  • 认真阅读一遍boost::shared_ptr的文档,能学到很多东西:https://www.boost.org/doc/libs/release/libs/smart_ptr/shared_ptr.htm
  • 保持开放心态,留意更好的解决办法,比如C++11引入的 unique_ptr。忘掉已被废弃的auto_ptr
  • 《C++沉思录》这本书详细地介绍了handle/body idiom,这是编写大型C++程序的必备技术,也是实现物理隔离的“法宝”,值得细读
发布了1525 篇原创文章 · 获赞 1085 · 访问量 45万+

猜你喜欢

转载自blog.csdn.net/qq_41453285/article/details/104746195
今日推荐