当析构函数遇上多线程
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