前一篇文章写得实在太挫,重新来一篇。

多线程环境下生命周期的管理

多线程环境下,跨线程对象的生命周期管理会有什么挑战?我们拿生产者消费者模型来讨论这个问题。

实现一个简单的用于生产者消费者模型的队列

生产者消费者模型的基本结构如下图所示:

如果我们要实现这个队列该怎么写?首先我们先简单挖掘下这个队列的一些基本需求。

显而易见,这个队列需要支持多线程并发读写。

我们知道,多线程并发读写同一个对象,需要对读写操作进行同步以避免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 ( == 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呢?

所有的官方解释(POSIXMSDNWiki)都集中到了一个名词:spurious wakeup。但是具体是什么导致的spurious wakeup,都没有挑明。我第一次看到这个while的时候,当时分析的结果是,这个过程存在竞态。

我们假设有两个消费者(C1、C2)一个生产者(P)。并且此时队列已空。接下来:

  1. C1执行pop,因为队列为空,所以线程在cv_.wait处挂起
  2. P开始执行push,进入临近区并且还未退出
  3. C2执行pop。因为P还没有退出临近区,所以C2在进入临界区处挂起
  4. P插入数据后,退出临界区并通知cv_
  5. C2先被唤醒,进入临近区(可能性很大,因为push操作先退出临近区,再通知cv_)
  6. 此时C1无法从cv_.wait中退出,因为无法成功锁住mtx_
  7. C2消耗了P插入的数据,并从临界区中退出
  8. C1从cv_.wait中返回
  9. 此时,队列中已无数据

从这个角度分析同样需要条件判断为循环形式。当然,也不止我一个人这么认为

多线程共享对象生命周期管理的挑战

我们假定生产者对应的实现类叫做producer,消费者类叫consumer。那么producer和consumer类都应该有一个指向blocking_queue的指针(或者引用),知道该往哪读写数据。

接下来就有几个问题需要我们考虑了:

  1. producer、consumer和blocking_queue之间是什么关系?
  2. 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 ;
}

sp1和sp2虽然都指向e,但是他们相互之间并不知道对方。如果要让shared_ptr相互了解对方,那么除了第一个shared_ptr对象是从raw指针创建除来的之外,其他shared_ptr都必须是从和这个shared_ptr对象相关的shared_ptr或者weak_ptr创建出来的。这其中的本质原因就是他们使用的不是同一份引用计数对象。

shared_ptr(this),遇到的问题是一样的。

如果确定要将this指针能够跨线程传递,那么必须(以example为例):

  1. example对象必须是一个在堆上的对象
  2. example对象被shared_ptr管理
  3. example类必须继承std::enable_shared_from_this
  4. 使用enable_shard_from_this::shared_from_this将this指针传递到其他线程中的对象

== 完 ==

shared_ptr和多线程的更多相关文章

  1. C++之shared_ptr总结

    转自 http://blog.csdn.net/u013696062/article/details/39665247 Share_ptr也是一种智能指针.类比于auto_ptr学习.所以推荐先学习a ...

  2. 智能指针shared_ptr, auto_ptr, scoped_ptr, weak_ptr总结

    看这里: http://blog.csdn.net/lollipop_jin/article/details/8499530 shared_ptr可以多线程同时读,但是涉及到写,需要加锁. share ...

  3. [并发并行]_[C/C++]_[C++标准库里的线程安全问题]

    场景 1.写普通的程序时, 经常会使用cout来做输出, 每个进程只有一个控制台, 如果多线程调用cout时会出状况吗? 2.之所以研究cout会不会在并发下调用有问题, 是因为曾经有一个bug的崩溃 ...

  4. 一些关于VC++开发的笔记

    通常程序卡住了,主要有双方面的可能: (1)死循环了 (2)死锁了 要确定是否是死循环.能够通过调试器(经常使用Windbg)查看线程执行时间,假设隔了一段会儿两次查看的执行时间有非常大区别,那么非常 ...

  5. muduo总结

    总结说的有的过大,算是对自己学习的一个总结.兴许会不断补充. 模型总结 muduo是基于非堵塞的IO和事件驱动的网络库. muduo的总体结构时one loop per thread+threadpo ...

  6. 为什么多线程读写 shared_ptr 要加锁?

    https://www.cnblogs.com/Solstice/archive/2013/01/28/2879366.html 为什么多线程读写 shared_ptr 要加锁? 陈硕(giantch ...

  7. C++11 shared_ptr 智能指针 的使用,避免内存泄露

    多线程程序经常会遇到在某个线程A创建了一个对象,这个对象需要在线程B使用, 在没有shared_ptr时,因为线程A,B结束时间不确定,即在A或B线程先释放这个对象都有可能造成另一个线程崩溃, 所以为 ...

  8. 如何在多线程leader-follower模式下正确的使用boost::asio。

    #include <assert.h> #include <signal.h> #include <unistd.h> #include <iostream& ...

  9. shared_ptr:资源管理利器

    如果你还在使用传统的C++,那么可以肯定堆内存的管理让你头痛过!在传统的C++领域,堆内存管理上我们能借用的现成工具就只有auto_ptr.但是很不幸用auto_ptr管理堆内存简直就是个错误.aut ...

随机推荐

  1. Google Maps地图投影全解析(3):WKT形式表示

    update20090601:EPSG对该投影的编号设定为EPSG:3857,对应的WKT也发生了变化,下文不再修改,相对来说格式都是那样,可以到http://www.epsg-registry.or ...

  2. 基础笔记12(socket,url网络通信)

    进一步深入socket 1.网络通信条件: .IP地址,可用主机名. .传输数据时将不用的应用程序通过数字标识区分开来,这种标识称为逻辑端口,也称端口.(0-65535端口,一般系统预留0-1024) ...

  3. VERY DEEP CONVOLUTIONAL NETWORKS FOR LARGE-SCALE IMAGE RECOGNITION 这篇论文

    由Andrew Zisserman 教授主导的 VGG 的 ILSVRC 的大赛中的卷积神经网络取得了很好的成绩,这篇文章详细说明了网络相关事宜. 文章主要干了点什么事呢?它就是在在用卷积神经网络下, ...

  4. mac 工具集

    1.brew brew 全名叫homebrew,引用官网的一句话就是 "The missing package manager for macOS",意思brew是mac os上不 ...

  5. 转!!windows记事本保存“联通” 编码问题

    原博文网址:http://blog.csdn.net/Zhiyuan_Ma/article/details/51838054 简单分析: 这是微软记事本的一个BUG,准确点就是unicode编码的问题 ...

  6. [分享] 封装工具ES4配置文件解释

    [分享] 封装工具ES4配置文件解释 LiQiang 发表于 2015-2-3 14:41:21 https://www.itsk.com/thread-346132-1-4.html [分享] 封装 ...

  7. spring框架学习(六)AOP

    AOP(Aspect-OrientedProgramming)面向方面编程,与OOP完全不同,使用AOP编程系统被分为方面或关注点,而不是OOP中的对象. AOP的引入 在OOP面向对象的使用中,无可 ...

  8. x-csrf-token

  9. 关于codeblock中一些常用的快捷键(搬运)

    关于codeblock中一些常用的快捷键(搬运) codeblock作为一个常用的C/C++编译器,是我最常用的一款编译器,但也因为常用,所以有时为了更加快速的操作难免会用到一些快捷键,但是因为我本身 ...

  10. Scrapy--1安装和运行

    1.Scrapy安装问题 一开始是按照官方文档上直接用pip安装的,创建项目的时候并没有报错, 然而在运行 scrapy crawl dmoz 的时候错误百粗/(ㄒoㄒ)/~~比如: ImportEr ...