muduo网络库:01---线程安全的对象生命期管理之(多线程中对象的构造与析构)

  • 编写线程安全的类不是难事,用同步原语保护内部状态即可。但是对象的生与死不能由对象自身拥有与的mutex(互斥器)来保护
  • 如何避免对象析构时可能存在的race condition(竞态条件)是C++多线程编程面临的基本问题,可以借助Boost库中的shared_ptr和weak_ptr完美解决。这也是实现线程安全的Observer模式的必备技术

一、当析构函数遇到多线程

  • 与其他面向对象语言不同,C++要求程序员自己管理对象的生命期,这在多线程环境下显得尤为困难。当一个对象能被多个线程同时看 到时,那么对象的销毁时机就会变得模糊不清,可能出现多种竞态条件

    • 在即将析构一个对象时,从何而知此刻是否有别的线程正在执行该对象的成员函数?
    • 如何保证在执行成员函数期间,对象不会在另一个线程被析构?
    • 在调用某个对象的成员函数之前,如何得知这个对象还活着?它的析构函数会不会碰巧执行到一半?
  • 解决这些race condition是C++多线程编程面临的基本问题。本文及后面几篇文章试图以shared_ptr一劳永逸地解决这些问题,减轻C++多线程编程的精神负担

线程安全的定义

  • 依据[JCP],一个线程安全的class应当满足以下三个条件:
    • 多个线程同时访问时,其表现出正确的行为
    • 无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织(interleaving)
    • 调用端代码无须额外的同步或其他协调动作
  • 依据这个定义,C++标准库里的大多数class都不是线程安全的,包括std:: string、std::vector、std::map等,因为这些class通常需要在外部加锁才能供多个线程同时访问

MutexLock与MutexLockGuard类

  • 为了便于后文讨论,先约定两个工具类(实现代码参阅:https://blog.csdn.net/qq_41453285/article/details/104875213
    • MutexLock:封装临界区(critical section),这是一个简单的资源类,用RAII手法封装互斥器的创建与销毁。MutexLock一般是别的class的数据成员
      • 临界区在Windows上是struct CRITICAL_SECTION,是可重入的
      • 在Linux下是pthread_mutex_t,默认是不可重入的
    • MutexLockGuard:封装临界区的进入和退出,即加锁和解锁。 MutexLockGuard一般是个栈上对象,它的作用域刚好等于临界区域
  •  这两个class都不允许拷贝构造和赋值,它们的使用原则参阅互斥器的介绍(https://blog.csdn.net/qq_41453285/article/details/104859230

单线程安全的Counter示例

  • 编写单个的线程安全的class不算太难,只需用同步原语保护其内部状态
  • 例如下面这个简单的计数器类Counter:
class Counter :boost::noncopyable
{
public:
    Counter() :value_(0) {}
    int64_t value()const;
    int64_t getAndIncrease();
private:
    int64_t value_;
    mutable MutexLock mutex_;
};

int64_t Counter::value()const
{
    MutexLockGuard lock(mutex_); //lock的析构会晚于返回对象的构造,因此有效地保护了这个共享数据
    return value_;
}

int64_t Counter::getAndIncrease()
{
    MutexLockGuard lock(mutex_);
    int64_t ret = value_++;
    return ret;
}

//实际项目中,这个class用原子操作更合理,这里用锁仅仅为了举例
  • 这个class很直白,一看就明白,也容易验证它是线程安全的。每个Counter对象有自己的mutex_,因此不同对象之间不构成锁争用(lock contention)。即两个线程有可能同时执行getAndIncrease()函数中的value_++,前提是它们访问的不是同一个Counter对象
  • 注意到其mutex_成员是mutable的,意味着const成员函数如Counter::value()也能直接使用non-const的mutex_。思考:如果mutex_是static,是否影响正确性和/或性能?
  • 尽管这个Counter本身毫无疑问是线程安全的,但如果Counter是动态创建的并通过指针来访问,前面提到的对象销毁的race condition仍然存在

二、对象的创建很简单

  • 对象构造要做到线程安全,唯一的要求是在构造期间不要泄露this 指针,即:
    • 不要在构造函数中注册任何回调
    • 也不要在构造函数中把this传给跨线程的对象
    • 即便在构造函数的最后一行也不行
  • 之所以这样规定,是因为在构造函数执行期间对象还没有完成初始化,如果this被泄露(escape)给了其他对象(其自身创建的子对象除外),那么别的线程有可能访问这个半成品对象,这会造成难以预料的后果
  • 下面的构造函数不是线程安全的:
class Foo :public Observer //Observer的定义见下文
{
public:
    Foo(Observable* s)
    {
        s->register_(this); //此处非线程安全
    }

    virtual void update();
};
  • 对象构造的正确方法是:
class Foo :public Observer
{
public:
    Foo();
    virtual void update();

    //另外定义一个函数,在构造之后执行回调函数的注册工作
    void observer(Observable* s)
    {
        s->register_(this);
    }
};
  • 这也说明,二段式构造——即构造函数+initialize()——有时会是好办法,这虽然不符合C++教条,但是多线程下别无选择。另外,既然允许二段式构造,那么构造函数不必主动抛异常,调用方靠initialize()的返回值来判断对象是否构造成功,这能简化错误处理
  • 即使构造函数的最后一行也不要泄露this:
    • 因为Foo有可能是个基类,基类先于派生类构造,执行完Foo::Foo()的最后一行代码还会继续执行派生类的构造函数,这时most-derived class的对象还处于构造中, 仍然不安全
  • 相对来说,对象的构造做到线程安全还是比较容易的,毕竟曝光少,回头率为零。而析构的线程安全就不那么简单,这也是本文及后面文章所关注的焦点

三、销毁太难

  • 对象析构,这在单线程里不构成问题,最多需要注意避免空悬指针和野指针
  • 在多线程程序中,存在了太多的竞态条件。对一般成员函数而言,做到线程安全的办法是让它们顺次执行,而不要并发执行 (关键是不要同时读写共享状态),也就是让每个成员函数的临界区不 重叠。这是显而易见的,不过有一个隐含条件或许不是每个人都能立刻想到:成员函数用来保护临界区的互斥器本身必须是有效的。而析构函数破坏了这一假设,它会把mutex成员变量销毁掉

mutex不是办法

  • mutex只能保证函数一个接一个地执行
  • 考虑下面的代码:
    • 试图用互斥锁来保护析构函数
    • 有一个函数也会使用到互斥锁

  • 此时,假设有A、B两个线程都能看到Foo对象x:
    • 线程A即将销毁x
    • 而线程B正准备调用x->update()

  • 尽管线程A在销毁对象之后把指针置为了NULL,尽管线程B在调用x的成员函数之前检查了指针x的值,但还是无法避免一种race condition:
    • 线程A执行到了析构函数的(1)处,已经持有了互斥锁,即将继续往下执行
    • 线程B通过了if (x)检测,阻塞在(2)处
  • 接下来会发生什么,只有天晓得。因为析构函数会把mutex_销毁, 那么(2)处有可能永远阻塞下去,有可能进入“临界区”,然后core dump,或者发生其他更糟糕的情况
  •  这个例子至少说明delete对象之后把指针置为NULL根本没用,如果 一个程序要靠这个来防止二次释放,说明代码逻辑出了问题

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

  • 前面的例子说明,作为class数据成员的MutexLock只能用于同步本class的其他数据成员的读和写,它不能保护安全地析构。因为:
    • MutexLock成员的生命期最多与对象一样长,而析构动作可说是发生在对象身故之后(或者身亡之时)
    • 另外,对于基类对象,那么调用到基类析构函数的时候,派生类对象的那部分已经析构了,那么基类对象拥有的MutexLock不能保护整个析构过程
    • 再说,析构过程本来也不需要保护,因为只有别的线程都访问不到这个对象时,析构才是安全的,否则会有文章最开始谈到的竞态条件发生
  • 另外如果要同时读写一个class的两个对象,有潜在的死锁可能。比方说:
    • 有swap()这个函数
    • 如果线程A执行swap(a, b);而同时线程B执行swap(b, a);,就有可能死锁
void swap(Counter& a, Counter& b)
{
    //potential dead lock(潜在的死锁)
    MutexLockGuard aLock(a.mutex_);
    MutexLockGuard bLock(b.mutex_);

    //swap
    int64_t value = a.value_;
    a.value_ = b.value_;
    b.value_ = value;
}
  • operator=()也是类似的道理,也可能产生死锁。例如:
Counter& Counter::operator=(const Counter& rhs)
{
    if (&rhs == this)
        return *this;

    //potential dead lock(潜在的死锁)
    MutexLockGuard myLock(mutex_);
    MutexLockGuard itsLock(rhs.mutex_);
    value_ = rhs.value_; //如果该位value_=rhs.value()就会产生死锁,因为value()函数也请求锁住互斥量
    return *this; 
}
  • 一个函数如果要锁住相同类型的多个对象,为了保证始终按相同的顺序加锁,我们可以比较mutex对象的地址,始终先加锁地址较小的mutex

四、附加

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

董哥的黑板报

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值