前一篇文章写得实在太挫,重新来一篇。
多线程环境下生命周期的管理
多线程环境下,跨线程对象的生命周期管理会有什么挑战?我们拿生产者消费者模型来讨论这个问题。
实现一个简单的用于生产者消费者模型的队列
生产者消费者模型的基本结构如下图所示:
如果我们要实现这个队列该怎么写?首先我们先简单挖掘下这个队列的一些基本需求。
显而易见,这个队列需要支持多线程并发读写。
我们知道,多线程并发读写同一个对象,需要对读写操作进行同步以避免data race[1]。在C++11里,我们可以借助mutex。
另外当队列为空时,消费者来读取数据时,期望的结果应该是消费者线程被挂起,而不是不停地进行重试看队列是否非空。当生产者插入数据后,唤醒消费者,数据已经生成了。这个唤醒的机制可以通过条件变量来实现,condition_variable。
在分析基本的需求和了解了相关的技术支持后,我们可以着手设计这个队列的基本接口了。它应该至少包含下面三个对外接口:
- push
- pop
- size
我们也可以考虑基于模板的方式来实现这个类。因此,程序看起来会是这样:
template <typename T, typename CONTAINER_TYPE = std::queue<T>> class blocking_queue { public: blocking_queue(); ~blocking_queue(); void push(const T&); T pop(); size_t size() const; private: std::mutex mtx_; std::condition_variable cv_; CONTAINER_TYPE queue_; blocking_queue(const blocking_queue&) = delete; blocking_queue& operator =(const blocking_queue&) = delete; };
这里我特意屏蔽了拷贝构造和赋值操作。咱的这个队列从语义上不应该支持copy这件事。我们接下来看如何实现其中最主要的push和pop函数。
push操作相对简单些,使用mtx_进行操作同步,然后插入数据。数据插入后进行通知。
void push(const T& element) { { std::lock_guard<std::mutex> lock(mtx_); queue_.push(element); } cv_.notify_once(); }
pop函数会稍微复杂点。
T pop() { std::unique_lock lock(mtx_); while (0 == queue_.size()) { cv_.wait(lock); } T ret = queue_.front(); queue_.pop(); return ret; }
另外,condition_variable::wait有两个重载函数,这里的while循环还可以写成:
cv_.wait(lock, [this]() -> bool { return queue_.size() > 0; }
这里我们岔开下话题稍微多说一下pop函数。主要是pop函数中的那个while。
while loop associated with the condition variable
条件变量的应用中,这个while语句已经是一个标配了。有人说条件变量的使用是最不容易出错的,因为正确的使用方式就这么一种,必须得配while。
那为什么一定要用while呢?
所有的官方解释(POSIX,MSDN,Wiki)都集中到了一个名词:spurious wakeup。但是具体是什么导致的spurious wakeup,都没有挑明。我第一次看到这个while的时候,当时分析的结果是,这个过程存在竞态。
我们假设有两个消费者(C1、C2)一个生产者(P)。并且此时队列已空。接下来:
- C1执行pop,因为队列为空,所以线程在cv_.wait处挂起
- P开始执行push,进入临近区并且还未退出
- C2执行pop。因为P还没有退出临近区,所以C2在进入临界区处挂起
- P插入数据后,退出临界区并通知cv_
- C2先被唤醒,进入临近区(可能性很大,因为push操作先退出临近区,再通知cv_)
- 此时C1无法从cv_.wait中退出,因为无法成功锁住mtx_
- C2消耗了P插入的数据,并从临界区中退出
- C1从cv_.wait中返回
- 此时,队列中已无数据
从这个角度分析同样需要条件判断为循环形式。当然,也不止我一个人这么认为。
多线程共享对象生命周期管理的挑战
我们假定生产者对应的实现类叫做producer,消费者类叫consumer。那么producer和consumer类都应该有一个指向blocking_queue的指针(或者引用),知道该往哪读写数据。
接下来就有几个问题需要我们考虑了:
- producer、consumer和blocking_queue之间是什么关系?
- producer、consumer中的blocking_queue指针是raw指针么?
我们先来思考第一问题。可以确定的一点是,blocking_queue不会同时被producer和consumer管理整个生命周期,这样没法管。同时producer和consumer并不需要知道对方的存在。所以势必有一方和blocking_queue之间是关联关系。我们就假定producer和blocking_queue之间是关联关系。
再来思考第二个问题。简单起见,先假定producer保存的是指向blocking_queue的指针,类型为blocking_queue *。
现在我们回到多线程环境里来思考producer对象的处境。
多个producer线程写一个共享的blocking_queue对象。producer通过blocking_queue *指针如何知道这个blocking_queue对象是有效的?这个问题产生的本源就是这两者之间是关联关系,相互之间的耦合并不十分强烈。blocking_queue对象的创建和销毁对于producer来说都是透明的。这个问题也可以简单归结为通过一个指针,如何判断指向的内存是否有效?
很不幸,这个问题在C/C++里是无解的(这里夸大了,事实上应该是可以使用二级指针来解决这个问题的)。这种有效性无法通过if语句判断。指针非空并不意味着指向的内存块保存的是有效的对象。既然如此,我们就需要使用新的解决方案。
既然指针不行,那我们是不是可以实现一个对象管理类,专门用于管理blocking_queue对象,并且提供一个queue_is_valid()成员函数来判断blocking_queue对象的合法性。要实现这个方案,必须保该这个对象的生命周期比blocking_queue长。我们暂且把这个类称为manager。通过manager来管理这个blocking_queue对象指针的生命周期。
那么,producer就需要有一份manager对象的拷贝(why? 如果是指针,问题是不是又回来了?)。既然如此,那么有多少个producer对象,就有多少个manager对象的拷贝。所以就引入了新的问题,这些manager拷贝如何共享同一个blocking_queue指针的相关信息?当其中一个manager对象释放了这个blocking_queue,其他manager对象如何知道呢?
如何做好信息的同步是解决这个问题的手段。从这个角度出发,我们希望看到的情况应该是,当有人在用它,那么它就应该是活的;如果已经没有人用它了,那么它就没有必要存在了。类似于GC。所有人都不使用的东西,肯定是垃圾了。那么比较自然的解决方案就是引用计数。
这就是C++11中引入的shared_ptr。
我们用shared_ptr管理blocking_queue对象,并且将该shared_ptr对象保存到每一个producer对象中。多线程共享对象的生命周期问题完美解决。producer类看起来可能是这样的:
class producer { public: // constructor & destructor … // other public interfaces … private: std::shared_ptr<blocking_queue> product_queue_; // other stuff … };
等等,这里应该还有个问题。之前我们明明说好了producer不参与blocking_queue对象的生命周期管理。但是现在来看,似乎producer会对blocking_queue对象的生命周期产生非常大的影响。即便某一时刻我们认为blocking_queue对象需要被终结,但是因为producer对象的存在,这个blocking_queue始终无法被销毁。
shared_ptr带来的新问题
通过刚才的分析我们已经知道shared_ptr如何帮助我们解决线程共享对象的生命周期管理问题。但是问题解决的同时也引入了副作用,刻意延长了对象的生命周期。按照之前我的设计想法,显然在这里出现了一些出入。这里,我们更期望的结果是,如果这个队列对象还活着,那么producer可以向队列插入数据,如果队列已经死亡,那么producer啥事都不做。简单地说,就是shared_ptr提供了除检测对象有效性的功能外,还提供了生命周期的管理功能(生命周期的管理使得有效性的判断变得比较隐含)。但我们仅需要有效性的判断即可。
这需要借助weak_ptr。
使用weak_ptr检测对象的有效性
weak_ptr如何检查对象的有效性?
作为和shared_ptr一起被引入的智能指针,weak_ptr和shared_ptr可以说是一对搭档。shared_ptr专职提供生命周期管理,weak_ptr专职提供对象有效性判断。
weak_ptr的接口等基本信息和用法可以参考这里。
从weak_ptr的构造函数可以知道,weak_ptr需要借力shared_ptr。它需要和一个shared_ptr对象关联,检测这个shared_ptr管理的对象是否还存活。
对象有效性的检测可以通过weak_ptr::expired或者weak_ptr::lock的返回值来看。一般来说,使用lock的情况更普遍,因为对象有效,我们常常需要更进一步的操作。lock可以直接返回给我们一个shared_ptr对象。通过判断这个shared_ptr对象我们可以知道被管理的内存对象是否还存在。
那么shared_ptr和weak_ptr该如何配合使用,这其中的基本原则是怎样的呢?
一般来说,父对象持有子对象的shared_ptr,子对象持有父对象的weak_ptr(Wiki)。
this指针的跨线程传递
我们吧问题再说得广一点。前面说到的都是普通的指针,在C++里还有一个特殊的指针this。如果我们要将this跨线程传递怎么办?根据前面的分析,我们已经知道raw指针的跨线程传递是非常危险的。除此以外,this指针的跨线程传递还有跟多要考虑的东西。
构造函数中,能否将this指针传递出去?
不可以!因为对象还没有创建完成!你无法预知其他线程中的对象会在什么样的情况下使用这个this指针。
既然不能传递this指针,那么我们就需要将this指针shared_ptr化。但是直接shared_ptr(this)又是不对的。举个例子:
class example; int main() { example *e = new example; std::shared_ptr<example> sp1(e); std::shared_ptr<example> sp2(e); return 0; }
sp1和sp2虽然都指向e,但是他们相互之间并不知道对方。如果要让shared_ptr相互了解对方,那么除了第一个shared_ptr对象是从raw指针创建除来的之外,其他shared_ptr都必须是从和这个shared_ptr对象相关的shared_ptr或者weak_ptr创建出来的。这其中的本质原因就是他们使用的不是同一份引用计数对象。
shared_ptr(this),遇到的问题是一样的。
如果确定要将this指针能够跨线程传递,那么必须(以example为例):
- example对象必须是一个在堆上的对象
- example对象被shared_ptr管理
- example类必须继承std::enable_shared_from_this
- 使用enable_shard_from_this::shared_from_this将this指针传递到其他线程中的对象
== 完 ==