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。