Linux多线程编程-线程安全的对象生命期管理(2)

    废话不多说,接着上篇文档继续。

3.对象析构

        针对对象析构的话,分两种情况。在单线程模式下,对象析构显然不会造成问题,最多需要注意一下对象指针是否为空的情况。但是,在多线程模式下,对象析构则会是我们需要重点关注的地方,因为存在太多的竞态条件。对于一般的成员函数,我们只要做到不并发执行(读写不要同时),保证每个成员函数的临界区不重叠,这样就能做到线程安全。但是,保护成员函数临界区的互斥量必须要有效,但是析构函数又能将互斥量析构,所以析构函数则成了多线程编程的大问题。

3.1 mutex能否解决

        我们知道,如果使用了mutex(互斥量),我们只能保证函数一个接着一个执行,但是它能完全保证吗?好不多说,上代码:

        

Process::~Process(){
   MutexLockGuard lock(mutex_);
   do...//(1)
}
void Process::setNumber()
{
   MutexLockGuard lock(mutex_);
   do ...//(2)
}

      假如,此时有两个线程(1,2),线程1正在调用析构函数,线程2在调用setNumber成员函数,尽管线程1在销毁对象之后将对象指针置为空,但是还是无法避免我们之前将的竞态条件,因为:

    * 线程1执行到1处时,已经持有了互斥锁,继续往下执行

    * 线程2同检测,发现指针存在,则执行到2处

      程序接下来会执行什么,无法预知,可能永远阻塞在2处,也可能沿2处继续执行,然后“core dump”...

   以上可以看出,析构给多线程造成的问题。

3.2 类的数据成员“mutex”并不能保护析构函数

    通多1的例子可以看出,作为class的数据成员的互斥量(mutex)只能用来同步本class的其他数据成员的读和写,它并不能用来保护多线程情况下对象安全的析构,因为类的数据成员的声明周期最多与当前对象一样长,但是析构的动作发生在对象销毁之后。另外对于基类对象,当调用的基类机构函数的时候,派生类的那部分已经析构完成了,那么基类对象拥有的mutex并不能保护整个析构过程。其实说白了也就是:对象的析构过程根本就不应该需要保护,因为只有别的线程访问不到当前对象时,对象析构才是最安全的。

    还有一种情况,如果多个线程同时读写一个class的两个对象,则可能出现死锁的可能:

void swap(Process& proce1,Process& proce2){
    MutexLockGuard aLock(proce1.mutex_);
    MutexLockGuard bLock(procel2.mutex_);
    .....
}

    假如线程1执行了swap函数swap(a,b),线程2执行了swap(b,a),就有可能产生死锁。

    一个函数如果要锁住相同类型的多个对象,为了保证始终按相同的顺序加锁,我们可以比较mutex对象的地址,始终遵循一个规则(地址大、小)来进行顺序加锁。

 1.4 线程安全到底有多难

      针对一个动态创建出来的对象是否被析构,只通过判断指针或者引用是看不出的。指针就是指向了一块内存,至于其指向的内存空间是否被释放或者根本就不能访问,既然都不能访问又如何知道对象的状态呢 。

        面向对象的程序设计,主要有组合、关联、聚合三种关系。组合在多线程情况下不会遇到什么麻烦,因为对象x的声明周期由其唯一的拥有者控制,拥有者析构的时候也会把其拥有的其他对象析构,从形式上来看,可以把其看成一个容器。

      那么继承和多态就相对比较麻烦了,如果处理不恰当,则可能会造成内存泄漏或者对象重复释放。“关联”是一个概念比较广的关系,它表示一个对象用到了另一个对象的成员函数,也可以这么理解,a对象持有b对象的指针,但是b的生命周期并不受a单独控制。聚合从形式上看与关联相似,但它属于整体与部分的关系,那么可能出现前面谈到的竞态条件。

      那么,我们首先想到的一个简单的解决方式是:只创建不销毁。程序使用一个对象池来暂时存放用过的对象。下次申请新对象时,如果对象池中有,则重复利用现有的对象,否则则新建一个,对象用完了,不是直接释放,而是放回对象池中,类似于内存池的思想。当然,虽然方法有点挫,但是确实可以避免失效的情况发生。但这种方法又引入了其他问题:

        * 线程怎么安全的将对象完整的放回对象池,也就是线程1认为对象已经放回了,线程2则认为对象还没有放回;

        * 全局共享数据引发的同步问题,这个集中化的对象池会不会把本来的并发操作串行化,如果那样,可以直接使用单线程了;

        * 如果共享对象的类型不止一种,那么重复实现对象池还是类模板呢?

       * 这样会不会造成大量内存碎片的产生,造成内存泄漏呢,因为对象池占用的内存只增不减,多个对象不能共享内存。

       显然咱们立马就想到的方法并不适用。我们继续说。

       如果对象注册了一个类的非静态成员函数,那么必然在程序某处存在指向当前对象的指针,这是对象就暴露出来。请看下面代码:

class Observer{
 public:
    virtual ~Observer();
    virtual void update()=0;
    ...
 private:
};

class Observable{
 public:
    void register(Observer* x);
    void unRegister(Observer* x);
    void notifyObservers(){
       for(Observer* x:observer){
          x->update();
       }
    }
 private:
   std::vector observer_;
}

        当程序执行到Observer通知每一个Observer时,它怎么样知道Observer对象x还活着?那么我们可以尝试在Observer的析构函数里面调用unregister来释放注册。

class Observer{
 void bserve(Observable *s){
    s->register(this);
    subject_=s;
 }
 virtual ~Observer(){
    subject_->unRegister(this);
 }
 Observable* subject_;
};

        我们尝试着让Observer的析构函数去调用unRegister,仔细分析不难看出来,析构函数怎么知道subject_对象是否还存在,假如subject_指向某个永久存在的对象,那么也有问题,假设多线程模式下,线程1执行到析构函数,还没有执行unRegister,此时线程2执行到update,正好指向正在析构的对象,既然x所指向的Observer对象正在析构,调用它的任何非静态成员函数都是不安全的,更何况是虚函数。并且Observer是个虚基类,执行到析构函数时,派生类对象已经析构完成,此时整个对象处于没有完全析构的状态,core dump恐怕是幸运的结果。

        那么有人可能会说以上情况似乎可以通过加锁来解决。那我要问了,在哪加锁呢,谁又是锁的持有者....

       如果要是能够有对象帮我们提供一个检测的方法,来帮助我们记忆对象是否还存在,但是常见的指针和引用都不是对象,他们都是内置类型。

1.5原始指针不行

       指向对象的原始指针不能直接暴露出来,Oberver保存的不应该是原始的Obsever*,而是能分辨Observer对象是否存活。如果Observer要在析构函数里面进行取消注册,那么subject_的类型也不能是原始的Observer*。接触过C++11或者Boost的程序员此时可能立马想到智能指针,没错,确实是通过指针的方式来解决。

1.5.1 悬空指针

    当有两个指针p1和p2,指向堆上的同一个对象Object,p1和p2位于不同的线程中,假设线程A通过p1指针将对象销毁了,将p1置为NULL,那p2就成了悬空指针,这是一种典型的多线程使用错误。

    Linux多线程编程-线程安全的对象生命期管理(2)_第1张图片

    想要安全的销毁对象,最好在其它的线程都看不到的情况下偷偷的做,这就是常说的垃圾处理机制,所有人都用不到的东西一定是垃圾。

    一个解决方法:

        Linux多线程编程-线程安全的对象生命期管理(2)_第2张图片

     我们可以用代理思想,引入一层代理,让p1和p2所指的对象永久有效,当object销毁之后,proxy对象依然存在,其值变为0.而p2也没有编程悬空指针,可以通过查看proxy的内容来判断object是否存在,那么设计proxy又会出现问题,当p1正在准备调用object的成员函数,期间对象已经被p1给销毁了,那么我们又该何时释放object的引用或者指针呢?

  一个更好的解决办法:

        Linux多线程编程-线程安全的对象生命期管理(2)_第3张图片

   为了安全的释放proxy,我们可以引入引用计数,proxy有一个对象指针和当前对象引用次数的计数器,假如p1析构了,引用计数减1,p2如果也析构了,引用计数变为0,那么此时我们可以安全的释放object和proxy对象了。

   引入另外一层proxy用来管理共享资源,可以完美解决,当然编写线程安全、高效地引用计数的proxy还是有一定难度的,C++标准库中实现好的我们拿过来直接用就可以了。

1.6shared_ptr/weak_ptr

     shared_ptr是引用计数型的智能指针,在Boost库和C++11新标准都有实现,现行的编译中都能很好的支持。shared_ptr是一个类模板,它只有一个类型参数,用起来很方便。它用来帮我们管理对象,引用计数是其内部实现,当引用计数为0时,对象立马被销毁,不用我们程序员手动的去销毁对象,这里不多提,感兴趣的小伙伴可以查看相关资料。

1.7shared_ptr的线程安全

         虽然我们用shared_ptr来实现线程安全的对象管理,但是shared_ptr本身不是100%线程安全的。它的引用计数本身是安全且无锁的,但对象的读写则不是,因为shared_ptr有两个数据成员,读写操作都不能原子化,shared_ptr的线程安全级别与标准库容器一样,同样可以被多个线程同时访问,如果有多个线程访问,同样需要加锁,所以要想做到完美的解决方案还得看应用和业务场景。

你可能感兴趣的:(muduo网络库源码剖析)