muduo学习笔记1. 第1章 线程安全的对象生命期管理

本章小结:

  本章介绍了多线程编程中,对象析构会导致的race condition,如何使用智能指针正确地管理生命周期,并且实现线程安全的对象回调和析构。篇幅不长,但有大量的编程细节和思考,对于把C++当成带类的C来用的小白我,是非常好的启蒙篇章。

 

1.1 当析构函数遇到多线程

多线程共享的对象析构时会遇到的几个问题:

1、要析构一个对象时,如何得知其他线程是否在调用该对象的成员函数?

2、如何保证调用成员函数时,对象不会被另一个线程析构?

3、调用一个对象的成员函数前,如何得知对象是否还活着?它的析构函数会不会碰巧执行到一半?

 

1.1.1 一个线程安全的class应满足如下条件:
1、多个线程同时访问,其表现出正确的行为;

2、无论OS如何调度这些线程,无论这些线程的执行顺序如何交织;

3、调用端代码无需进行额外的同步动作;

 

1.1.2 MutexLock和MutexLockGuard

两个工具类,不允许复制和拷贝,基本功能分别对应std::mutex, std::lock_guard

MutexLock是不可重入锁,用RAII手法封装互斥器pthread_mutex_t;

MutexLockGuard一般是个栈上对象,用来封装临界区的进入和退出,即构造加锁,析构解锁;

 

1.1.3 一个线程安全的Counter示例

一个简单的类(代码就不贴了),演示如何使用mutex构建线程安全的class,但由于对象是动态创建,原始指针管理,race condition 仍然存在;

思考:如果mutex_是static,是否影响正确性/或性能?

如果mutex_是static修饰,那么所有counter对象共享一个mutex_变量,运行时会同时争抢同一个锁,不会影响正确性,但会严重影响到性能。第一个问题应该是想让读者先评估下自己的基础知识水平。


1.2 对象的创建很简单

构造对象要做到线程安全,唯一要求是在构造期间不要泄露自己的this指针,如果泄露出去,其他对象可能会调用这个半成品对象,导致灾难发生;即使是在构造函数最后一行也不行,因为如果该class是基类,后面还会执行派生类的构造函数,这期间他还是个半成品;

所以应该采用构造函数+initilize()的方式,先创建对象,再把自己的指针注册给其他对象;

 

1.3 销毁太难

对于一般成员函数而言,做到线程安全的方式是让他们顺序执行(关键是不能同时读写共享状态),即加锁让每个成员函数的临界区不重叠。但这么做的前提是锁必须是有效的,而析构函数会把锁销毁掉,悲剧啊!!!

 

1.3.1 mutex 不是办法

 

 

上方代码试图用mutex来保护析构函数,但很容易就会遇到一个问题:

线程A去销毁对象x,它首先持有了mutex,继续往下执行,而此时线程B通过了if(x)的检测,试图去持有mutex时被阻塞,之后线程A销毁对象x完成,x的mutex_也被销毁了,这时线程B咋办?它有可能永远阻塞,也可能进入临界区,然后core dump,整个程序崩溃;

这个例子同时说明了delete对象之后把指针置为null没屌用,去年我在部门主管的代码里经常会看到这样的代码,当时我觉得代码很严谨,但现在看来,他不管音视频流,还是业务逻辑处理,都是单生产者+单消费者模式,所以才不会遇到问题。

 

1.3.2  作为数据成员的mutex不能保护析构

上方例子已经能说明这个问题。只有当任何线程都访问不到这个对象时,析构才是安全的,否则就会发生race condition。

 

另外这里还论述了一种死锁的情景,一个函数需要对两个相同类型入参都加锁时,可能会由于入参顺序不同,导致死锁。

void lockBoth(Lock& a, Lock& b){

  a.lock();

  b.lock();

  ......

}

现在有两个lock1,lock2,线程A调用lockBoth(lock1,lock2),线程B调用lockBoth(lock2,lock1),很可能发生死锁;


1.4 ~ 1.7 原始指针有何不妥,及神器shared_ptr/weak_ptr

一个动态创建的对象是否还活着,光看指针是看不出来的,如何判断一个指针指向的对象是否正常,是C/C++指针问题的根源。

首先了解下空悬指针:p1,p2指向同一个对象,某线程通过p1把对象销毁了,这时p2就变成了空悬指针;

解决空悬指针的一个办法是,引入一层间接性,p1,p2都指向对象的代理指针proxy,当对象被销毁时,p2可以查看这个proxy是否为0判断对象是否还活着,那么问题来了,proxy何时销毁呢?而且还会有race condition;

一个更好的解决办法是,引入引用计数,即用count记录当前有多少指针指向该对象,当count为0时,自动销毁对象,这就是智能指针shared_ptr。这也是垃圾回收机制的原理,没有人引用的不就是垃圾嘛。不过shared_ptr是立即回收,GC似乎是定时做;

 

shared_ptr是强引用,控制对象生命周期,只要有一个指向对象x的shared_ptr存在,对象x就不会析构;相反,没有的话对象x会立即析构。

weak_ptr是弱引用,不控制对象生命周期,但它知道对象是否还活着;如果还活着,它可以提升为一个shared_ptr,否则会返回一个空的shared_ptr。提升/lock()是线程安全的。

插曲:如何系统地避免各种指针错误

C++的内存问题大致有这么几种,正确使用只能指针可以轻易解决前5个问题

1、缓冲区溢出;使用std::vector<char>/std::string或自己编写Buffer class来管理缓冲区,通过成员函数,而非裸指针来修改缓冲区。

2、空悬指针/野指针;用shared_ptr/weak_ptr。

3、重复释放;用scoped_ptr,只在对象析构时释放一次。

4、内存泄漏;用scoped_ptr,对象析构时自动释放内存。

5、不配对的new[]/delete;把new[]统统替换为std::vector/scoped_array。

6、内存碎片;


1.8 应用到Observer上

class Observable
{
public:
    void register(const weak_ptr<Observer>& x);
    // void unregister(const weak_ptr<Observer>& x); // 不需要它
    void notifyObservers();

private:
    mutable MutexLock mutex_;
    std::vector<weak_ptr<Observer>> observers_;
}

void Observable:notifyObservers()
{
    MutexLock lock(mutex_);
    auto it = observers_.begin();
    while(it!=observers_.end())
    {
        shared_ptr<Observer> obj(it->lock());
        if(obj)
        {
            // 提升成功,引用计数+1,如果不加1,observer有可能在调用过程中被析构
            obj->update();
            ++it;
        }
        else
        {
            // 对象已销毁,指针无意义,从容易中移除
            it = observers_.erase(it);
        }
    }
}

思考:如果vector中保存的是shared_ptr,会有什么后果?

Observer的生命周期会被延长,直至Observable析构或者调用unregister

 

解决了吗?

当试图使用一种较新的解决方法时,同时又会引入新的问题需要去解决,虽然这是对的

几个问题:

1、侵入性:要求Observer必须用shared_ptr管理;

2、不是完全线程安全:Observer析构函数调用observable->unregister(this),那么为确保安全性,要求observable也用shared_ptr管理,持有weak_ptr引用;

3、锁争用:三个成员函数都使用了互斥来同步,而nofityObservers的执行时间是无上限的,会造成register和unregister一直等待nofityObservers

4、死锁:万一update调用了(un)register,如果mutex不可重入,会死锁;可重入,则会修改vector,导致迭代器失效,core dump。陈硕倾向于使用不可重入的mutex,要求mutex可重入往往意味着设计上出了问题。

 

1.9 再论shared_ptr的线程安全

shared_ptr本身不是线程安全的,它的引用计数是安全无锁的,但对它管理的对象读写则不是。

//读取的时候需要加锁
void read()
{
    shared_ptr<Foo> localPtr;
    {
        MutexLockGuard lock(mutex);
        localPtr = globalPtr;
    }
    doit(localPtr);
}
//写入的时候也需要加锁
void write()
{
    shared_ptr<Foo> newPtr(new Foo);
    shared_ptr<Foo> oldPtr;
    {
        MutexLockGuard lock(mutex);
        globalPtr.swap(oldPtr);   //使用swap,让原对象在临界区外析构
        localPtr = newPtr;
    }
    doit(newPtr);
}

临界区要尽可能短,而构造和析构的时间消耗容易被忽视,在write里把new Foo放在外面构造,并使用swap使对象在临界区外析构;

 

1.10 shared_ptr计数与陷阱

1、意外延长的生命周期。

由于shared_ptr是强引用,只要有一个指向对象x的shared_ptr存在,x就不会析构;这也是java常见的内存泄漏原因。

另外这里还提到了boost:bind,也就是std::bind,会把实参中的shared_ptr拷贝一份,导致该对象生命周期不会短于function对象。这里可能会在不经意间延长对象生命周期。

2、函数参数。

由于要修改引用计数,shared_ptr的拷贝开销比原始指针高。不过只需要确保最外层函数有一个实体对象,之后都可以用const reference的方式来使用这个shared_ptr,无需拷贝。

3、析构所在的线程。

对象的析构是同步的,当最后一个指向对象x的shared_ptr离开其作用域时,x会同时在同一个线程析构。如果析构比较耗时,那可能会拖慢关键线程的性能,因此有必要的话可以用一个单独线程专门做析构。

4、现成的RAII handle

RAII 资源获取即初始化,初学C++会告诉我们,new了之后要delete。如果使用RAII,每一个new出来的资源都应该立刻交由handle管理(如shared_ptr),一般不去delete。shared_ptr要避免循环使用,通常做法时owner持有child的shared_ptr,child持有owner的weak_ptr。

 

1.11 定制析构函数,enable_shared_from_this,弱回调

这段通过一个对象池StockFactory引出了shared_ptr的定制析构功能。

class StockFactory : public enable_shared_from_this<SocketFactory>
{
public:
    shared_ptr<Stock> get(const string& key);
private:
    static void weakDeleteCallback(const weak_ptr<StockFactory>& wkFactory, Stock*stock);
    void removeStock(Stock* stock);

    mutable MutexLock mutex_;
    map<string,weak_ptr<Stock>> stocks_;
};

shared_ptr<Stock> StockFactory:get(const string& key)
{
    shared_ptr<Stock> stock;
    MutexLockGuard lock(mutex_);
    weak_ptr<Stock>& wkStock = stocks_[key];
    stock = wkStock.lock();
    if(!stock)
    {
     // 这里通过定制析构函数的方式从map中移除Stock
     // 为避免StockFactory的生命周期被意外延长,即不短于function对象
     // 这里传入StockFacotry的weak_ptr,同时StockFactory需要继承enable_shared_from_this使this变身成shared_ptr
        stock.reset(new Stock(key),
                    bind(&StockFactory::weakDeleteCallback,weak_ptr<StockFactory>(shared_from_this()),_1));
        wkStock = stock;
    }
    return stock;
}

void StockFactory::weakDeleteCallback(const weak_ptr<StockFactory>&wkFactory,Stock* stock)
{
    shared_ptr<Factory> factory(wkFactory.lock());
    if(factory)
    {
        factory->removeStock(stock);
    }
    delete stock;
}


void StockFactory::removeStock(Stock* stock)
{
    if(stock)
    {
        MutexLockGuard lock(mutex_);
        stocks_.erase(stock->key());
    }
}

 

如果对象池以shared_ptr的方式保存对象,那对象永远不会被销毁,如果改成weak_ptr的方式,虽然对象会销毁,但保存对象的容器大小只增不减,仍会导致内存泄露。

解决方法是,使用shared_ptr的定制析构功能。额外传入一个函数指针或仿函数d,在析构对象时执行d(ptr),但这里如果把SocketFactory this指针保存在了function里,假如StockFactory先于Stock析构,就core dump了;那么如果让SocketFactory继承enable_shared_from_this,传入shared_ptr呢,这样又会使得其生命周期不短于function函数(虽然大多数时候无关紧要),所以要传入weak_ptr。

 

1.12 替代方案

除了智能指针外,要想做到线程安全的对象回调与析构,还有下列方法:

1、用一个全局factory代理Foo类型对象访问,用之前先check-out,用完之后check-in,但这样代价很大,并且会导致原本能并行的调用变成串行。当然可以使用ConcurrentHashMap那样用多个buckets,每个buckets分别加锁,以降低contention。ConcurrentHashMap是Java面试必问题,它的底层实现原理后面我要专门写一个笔记。

2、只创建不销毁,^_^无奈之举。

3、重新造一套智能指针的轮子,不考虑。

4、使用unique_ptr,避免引用计数的开销,在某些场合可替换shared_ptr。

5、使用Java、Go等带垃圾回收的语言,彻底脱离苦海(尤其是工作写业务逻辑);Java是目前支持并发编程最好的主流语言,它的的util.conrrent和内存模型是C++11效仿的对象;

 

1.13 心得与小结

  学习多线程程序设计远远不是看看教程了解API怎么用那么简单,这最多是为了读懂别人的代码,如果自己要写这类代码,必须专门花时间严肃、认真、系统地学习,严禁半桶水上阵。

  本章陈硕推荐了两类书,一类是编程书,《Java Concurrency in Practice》可读性和可操作性俱佳。另一类是操作系统教材,诸如《操作系统设计与实现》、《现代操作系统》、《操作系统概念》,了解同步原语,临界区,竞态条件、死锁、典型的IPC(进程间通信)问题等等,防止闭门造车。

  分析可能出现的race condition不仅是多线程编程系统的基本功,也是设计分布式系统的基本功,需要反复历练,形成一定的思考范式,并基类一些经验教训,才能少犯错误。

  尽管本章通篇在讲如何安全地使用跨线程的对象,但陈硕建议使用跨线程的对象,”用流水,生产者消费者,任务队列这些有规律的机制,最低限度地共享数据。这是我所知最好的多线程编程建议了。“

 

1.4 Observer之谬

我之前一直使用的都是C的函数回调,后面改用bind了,没用过Observer模式,所以对这个模式的缺陷完全不熟悉。

Observer模式的本质问题在于面向对象的设计,Observer是基类,这代了非常强的耦合,仅次于友元,这种耦合不仅限制了成员函数的名字,参数,返回值,还限制了成员函数所属的类型。而且如果要观察多个类型的事件,还需要使用多继承。。如果要重复观察同一事件也很麻烦。。

替换Observer,可以使用Signal/Slots,不是只QT那种靠语言拓展的实现,而是完全靠标准库实现的thread safe, race condition free,thread contention free 的Signal/Slots。

posted @ 2020-07-20 15:37  onlyandonly  阅读(580)  评论(1编辑  收藏  举报