wpcockroach

导航

shared_ptr和多线程

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

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

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

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

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

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

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

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

所有的官方解释(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 0;
}

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指针传递到其他线程中的对象

== 完 ==

posted on 2014-03-19 16:39  wpcockroach  阅读(8267)  评论(1编辑  收藏  举报