C/C++编程:当析构函数遇上多线程

1059 篇文章 286 订阅

当析构函数遇上多线程

C++要求程序员自己管理对象的声明周期,这在多线程情况下可能会造成麻烦。当一个对象能被多个线程同时看到时,那么对象的销毁时机就会变得模糊不清,可能会出现多竞态条件:

  • 当即将析构一个对象时,从何而知此刻是否有别的线程正在执行该对象的成员函数
  • 如何保证在执行成员函数期间对象不会被另一个线程被析构
  • 在调用某个对象的成员函数之前,如何得知这个对象还活着?它的析构函数会不会碰巧执行到一半

解决这些race condition是C++编程面临的基本问题。本文试图以shared_ptr一劳永逸的解决这些问题,减轻C++编程的精神负担

线程安全的定义

一个线程安全的class应当满足一下三个条件

  • 多个线程同时访问时,其表现出正确的行为
  • 无需操作系统如何调度这些线程,无论这些线程的执行顺序如何交织
  • 调用端代码无须额外的同步或者其他协调动作

依据这个定义,C++标准库中大多数类都不是线程安全的,包括std::string、std::vector、std::map等,因为这些类需要在外部加锁才能供多个线程同时访问

MutexLock和MutexLockGuard

为了便于后文讨论,先约定两个工具类。代码见$2.4

  • MutextLock封装临界区,这是一个简单的资源量,用RAII手法(在类的构造函数中申请资源,然后使用,最后在析构函数中释放资源)封装互斥器的创建和消耗。临界区在windows上是struct CRITICAL_SECTION,是可重入的;在Linux上是pthread_mutex_t。默认是不可重入的。MutexLock一般是别的class的数据成员
  • MutexLockGuard封装临界区的进入和退出,即加锁和解释。MutexLockGuard一般是个栈上对象,它的作用域刚好等于临界区域

这两个类都不允许拷贝构造和赋值,它们的使用原则见$2.1

一个线程安全的Counter示例

编写单个的线程安全的class不难,只需要用同步原语保护其内部状态。比如下面的计数器类Counter

class Counter : boost::noncopyable{
	// copy-ctor and assignment sholud be private by defaulte for a class
public:
	Counter() : value_(0) {}
	int64_t  value() const;
	int64_t getAndIncrease();
private:
	int64_t value_;  // 实际中,这里用原子操作更加合理,这里用锁仅仅为了举例
	mutable MutexLock mutex_; 
	 // mutable 意味着即使在const成员函数Counter::value()也能字节使用non-const的mutex
	 // 每个Counter 实例都有自己的mutex_,因此不同对象之间不构成锁争用
}

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

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

这个类是线程安全的,因为满足如下三个条件:

  • 多个线程同时访问时,其表现出正确的行为
  • 无需操作系统如何调度这些线程,无论这些线程的执行顺序如何交织
  • 调用端代码无须额外的同步或者其他协调动作

但如果Counter是动态创建的并通过指针来访问,前面提到的对象销毁的race condation依然存在

销毁真难

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

空悬指针:执行已经销毁的对象和已经回收的地址
野指针:未经初始化的指针

mutex不是办法

mutext只能保证函数一个接一个的地址,考虑如下代码,它试图用互斥锁来保护析构函数:(注意代码中的(1)和(2)两处标记)
在这里插入图片描述
此时,由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的其他数据成员的读写,但不能用来保护安全的析构。因为MutexLockc成员的声明周期最多和对象一样长,而析构动作可以发生在对象身故之后(或者身亡之时)。另外,对于基类对象,那么调用到基类析构函数的时候,派生类对象的那部分已经析构了,那么基类对象拥有的MutexLock不能保护整个析构过程。再说,析构过程本来也不需要保护,因为只有别的线程都访问不到这个对象,析构才是安全的

另外,如果要同时读写一个class的两个对象,有潜在的死锁可能。比如说由swap这个函数:

在这里插入图片描述
如果线程A执行swap(a, b);而同时线程B执行swap(b, a);,就有可能死锁。

一个函数如果要锁住相同类型的多个对象,为了保证时钟按照相同的顺序加锁,我们可以比较mutex对象的地址,始终先加锁地址较小的mutex

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值