ctor/dtor 与线程安全

ctor/dtor 与线程安全

当一个对象能被多个线程同时看到,那么对象的销毁时机就会变得模糊不清,可能出现多种竞态条件:

  • 在即将析构一个对象时,从何而知是否有另外的线程正在执行该对象的成员函数?

  • 如何保证在执行成员函数期间,对象不会在另一个线程被析构?

  • 在调用某个对象的成员函数之前,如何得知这个对象还活着?

现在以 shared_ptr 一劳永逸地解决这些问题

线程安全的定义

依据《Java 并发编程实践》/《Java Concurrency in Practice》一书,一个线程安全的 class 应当满足三个条件:

- 从多个线程访问时,其表现出正确的行为

- 无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织

- 调用端代码无需额外的同步或其他协调动作

依据这个定义,C++ 标准库里的大多数类都不是线程安全的,无论 std::string 还是 std::vectorstd::map,因为这些类通常需要在外部加锁。

Mutex 与 MutexLock

为了便于后文讨论,先约定两个工具类。

Mutex 封装临界区(Critical secion),这是一个简单的资源类,用 RAII 手法 [CCS:13]封装互斥器的创建与销毁。临界区在 Windows 上是 CRITICAL_SECTION,是可重入的;在 Linux 下是 pthread_mutex_t,默认是不可重入的。Mutex 一般是别的 class 的数据成员。

MutexLock 封装临界区的进入和退出,即加锁和解锁。MutexLock 一般是个栈上对象,它的作用域刚好等于临界区域。它的构造函数原型为 MutexLock::MutexLock(Mutex& m);

这两个 classes 都不允许拷贝构造和赋值。

ctor的线程安全

对象构造要做到线程安全,惟一的要求是在构造期间不要泄露 this 指针,即

  • 不要在构造函数中注册任何回调

  • 也不要在构造函数中把 this 传给跨线程的对象

  • 即便在构造函数的最后一行也不行

之所以这样规定,是因为在构造函数执行期间对象还没有完成初始化,如果 this 被泄露 (escape) 给了其他对象(其自身创建的子对象除外),那么别的线程有可能访问这个半成品对象,这会造成难以预料的后果。

// 不要这么做 Don't do this.
class Foo : public Observer
{
public:
  Foo(Observable* s) {
    s->register(this);  // 错误
  }
  virtual void update();
};

 

// 要这么做 Do this.
class Foo : public Observer
{
  // ...
  void observe(Observable* s) {  // 另外定义一个函数,在构造之后执行
    s->register(this);
  }
};
Foo* pFoo = new Foo;
Observable* s = getIt();
pFoo->observe(s);  // 二段式构造

这也说明,二段式构造——即构造函数+initialize()——有时会是好办法,这虽然不符合 C++ 教条,但是多线程下别无选择。另外,既然允许二段式构造,那么构造函数不必主动抛异常,调用端靠 initialize() 的返回值来判断对象是否构造成功,这能简化错误处理。

即使构造函数的最后一行也不要泄露 this,因为 Foo 有可能是个基类,基类先于派生类构造,执行完 Foo::Foo() 的最后一行代码会继续执行派生类的构造函数,这时 most-derived class 的对象还处于构造中,仍然不安全。

dtor与线程安全

对一般成员函数而言,做到线程安全的办法是让它们顺次执行,而不要并发执行,也就是让每个函数的临界区不重叠。这是显而易见的,不过有一个隐含条件或许不是每个人都能立刻想到:函数用来保护临界区的互斥器本身必须是有效的。而析构函数破坏了这一假设,它会把互斥器销毁掉。这是析构销毁对于线程安全的难点。

Mutex 只能保证函数一个接一个地执行,考虑下面的代码,它试图用互斥锁来保护析构函数:

Foo::~Foo()
{
  MutexLock lock(mutex_);
  // free internal state  (1)
}

void Foo::update()
{
  MutexLock lock(mutex_);  // (2)
  // make use of internal state
}
extern Foo* x;  // visible by all threads

// thread A
delete x;
x = NULL;  // helpless

// thread B
if (x) {
  x->update();
}

有 A 和 B 两个线程,线程 A 即将销毁对象 x,而线程 B 正准备调用 x->update()。尽管线程 A 在销毁对象之后把指针置为了 NULL,尽管线程 B 在调用 x 的成员函数之前检查了指针 x 的值,还是无法避免一种 race condition:

  1. 线程 A 执行到了 (1) 处,已经持有了互斥锁, 即将继续往下执行
  2. 线程 B 通过了 if (x) 检测,阻塞在 (2) 处

接下来会发生未定义行为。因为析构函数会把 mutex_ 销毁,那么 (2) 处有可能永远阻塞下去,有可能进入“临界区”然后 core dump,或者发生其他更糟糕的情况。

前面的例子说明,作为 class 数据成员的 Mutex 只能用于同步本 class 的其他数据成员的读和写,它不能保护安全地析构。因为成员 mutex 的生命期最多与对象一样长,而析构动作可说是发生在对象身故之后(或者身亡之时)。另外,对于基类对象,那么调用到基类析构函数的时候,派生类对象的那部分已经析构了,那么基类对象拥有的 mutex 不能保护整个析构过程。再说,析构过程本来也不需要保护,因为只有别的线程都访问不到这个对象时,析构才是安全的,否则会有前面谈到的竞态条件发生。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值