Chinaunix首页 | 论坛 | 博客
  • 博客访问: 92017
  • 博文数量: 44
  • 博客积分: 2525
  • 博客等级: 少校
  • 技术积分: 316
  • 用 户 组: 普通用户
  • 注册时间: 2010-04-25 17:01
文章分类

全部博文(44)

文章存档

2010年(44)

我的朋友

分类:

2010-04-27 10:47:30

 当析构函数遇到多线程
── C++ 中线程安全的对象回调

 

陈硕 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

请尽量阅读本文 PDF 版:http://www.cppblog.com/Files/Solstice/dtor_meets_mt.pdf 

豆丁亦可,内容略微滞后: 

这里是从 word 直接粘贴过来,脚注链接都丢失了。

 

摘要

编写线程安全的类不是难事,用同步原语保护内部状态即 可。但是对象的生与死不能由对象自身拥有的互斥器来保护。如何保证即将析构对象 x  的 时候,不会有另一个线程正在调用 的成员函数?或者说,如何保证在执行 的成 员函数期间,对象 不会在另一个线程被析构?如何避免这种竞态条件是 C++ 多 线程编程面临的基本问题,可以借助 boost 的 shared_ptr 和 weak_ptr 完美解决。这也是实现线程安全的 Observer 模式的必备技术。


本文源自我在 2009  12 月上海 C++ 技术大会的一场演讲《当析构函数遇到多线程》,内容略有 增删。原始 PPT 可 从  下载,或者在  直 接观看。

 

本文读者应具有 C++ 多 线程编程经验,熟悉互斥器、竞态条件等概念,了解智能指针,知道 Observer 设计模式。

目录

 

1 多线程下的对象生命期管理 2

线程安全的定义 3

Mutex 与 MutexLock 3

一个线程安全的 Counter 示例 3

2 对象的创建很简单 4

3 销毁太难 5

Mutex 不是办法 5

作为数据成员的 Mutex 6

4 线程安全的 Observer 有多难? 6

5 一些启发 8

原始指针有何不妥? 8

一个“解决办法” 8

一个更好的解决办法 9

一个万能的解决方案 9

6 神器 shared_ptr/weak_ptr 10

7 插曲:系统地避免各种指针错误 10

8 应用到 Observer 11

解决了吗? 11

9 再论 shared_ptr 的线程安全 12

10 shared_ptr 技术与陷阱 13

对象池 15

enable_shared_from_this 17

弱回调 17

11 替代方案? 19

其他语言怎么办 19

12 心得与总结 19

总结 20

13 附录:Observer 之谬 20

14 后记 21

 

多线程下的对象生命期管理

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


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

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

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

 

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


线程安全的定义

 

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

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

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

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

 

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


Mutex 与 MutexLock

 

为了便于后文讨论,先约定两个工具类。我相信每个写C++ 多 线程程序的人都实现过或使用过类似功能的类,代码从略。

 

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

 

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

 

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


一个线程安全的 Counter 示例

 

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

 

class Counter : boost::noncopyable

{

  // copy-ctor and assignment should be private by default for a class.

 public:

  Counter(): value_(0) {}

  int64_t value() const;

  int64_t increase();

  int64_t decrease();

 private:

  int64_t value_;

  mutable Mutex mutex_;

}

 

int64_t Counter::value() const

{

  MutexLock lock(mutex_);

  return value_;

}

 

int64_t Counter::increase() 

{

  MutexLock lock(mutex_);

  int64_t ret = value_++;

  return ret;

}

// In a real world, atomic operations are perferred. 

// 当然在实际项目中,这个 class 用原子操作更合理,这里用锁仅仅为了举例。

 

这个 class 很直白,一看就明白,也容易验证它是线程安全的。注意到它的 mutex_ 成员是 mutable 的,意味着 const 成员函数如 Counter::value() 也能直接使用 non-const 的 mutex_

 

尽管这个 Counter 本身毫无疑问是线程安全的,但如果 Counter 是动态创建的并透过指针来访问,前面提到的对象销毁的 race condition 仍然存在。


对象的创建很简单

 

对象构造要做到线程安全,惟一的要求是在构造期间不要泄 露 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 的对象还处于构造中,仍然不安全。

 

相对来说,对象的构造做到线程安全还是比较容易的,毕竟 曝光少,回头率为 0。而析构的线程安全就不那么简单,这也是本文关注的焦点。


销毁太难

 

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


Mutex 不是办法

 

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();

}

 

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

 

1. 线程 执行 到了 (1) 处,已经持有了互斥锁

2. 线程 通过 了 if (x) 检测,阻塞在 (2) 处

 

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

 

这个例子至少说明 delete 对象之后把指针置为 NULL 根本没用,如果一个程序要靠这个来防止二次释放,说明代码逻辑出了问题。


作为数据成员的 Mutex

 

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

 

另外如果要同时读写本 class 的两个对象,有潜在的死锁可能,见 PPT 第 12 页 的 swap() 和 operator=()


线程安全的 Observer 有多难?

 

一个动态创建的对象是否还活着,光看指针(引用也一样) 是看不出来的。指针就是指向了一块内存,这块内存上的对象如果已经销毁,那么就根本不能访问 [CCS:99](就像 free 之后的地址不能访问一样),既然不能访问又如何知道对象的状态呢?换句话说,判断一个指针是不是野 指针没有高效的办法。(万一原址又创建了一个新的对象呢?再万一这个新的对象的类型异于老的对象呢?)

 

在面向对象程序设计中,对象的关系主要有三种:composition, aggregation 和 associationComposition(组合)关系在多线程里不会遇到什么麻烦,因为对象 的生 命期由其惟一的拥有者 owner 控制,owner 析构的时候,会把 也析构掉。从形式上看,是 owner 的数据成员,或者 scoped_ptr 成员。

 

后两种关系在 C++ 里 比较难办,处理不好就会造成内存泄漏或重复释放。Association(关联/联系)是一种很宽泛的关系,它表示一个对象 用到 了另一个对象 b,调用了后者的成员函数。从代码形式上看,持 有 的指针(或引用),但是 的生命期不由 单独 控制。Aggregation(聚合)关系从形式上看与 association 相同,除了 和 有 逻辑上的整体与部分关系。为了行文方便,下文不加区分地通称为“指涉”关系。如果 是动态创建的并在整个程序结束前有可能被释放, 那么就会出现第 节谈到的竞态条件。

 

那么似乎一个简单的解决办法是:只创建不销毁。程序使用 一个对象池来暂存用过的对象,下次申请新对象时,如果对象池里有存货,就重复利用现有的对象,否则就新建一个。对象用完了,不是直接释放掉,而是放回池子 里。这个办法当然有其自身的很多缺点,但至少能避免访问失效对象的情况发生。

 

这种山寨办法的问题有:

对象池的线程安全,如何安全地完整地把对象放回池子里,不会出现“部分放回” 的竞态?(线程 认为对象 已经放回了,线程 认为对象 还活 着)

thread contention,这个集中化的对象池会不会把多线程并发的操作串行化?

如果共享对象的类型不止一种,那么是重复实现对象池还是使用类模板?

会不会造成内存泄露与分片?因为对象池占用的内存只增不减,而且不能借给别的 对象池使用。

 

回到正题上来,看看正常方式该咋办。如果对象 注 册了任何非静态成员函数回调,那么必然在某处持有了指向 的指针,这就暴露在了 race condition 之下。

 

一个典型的场景是 Observer 模式。

 

class Observer

{

 public:

  virtual ~Observer() { }

  virtual void update() = 0;

};

 

class Observable

{

 public:

  void register(Observer* x);

 

  void unregister(Observer* x);

 

  void notifyObservers() {

    foreach Observer* x {  // 这行是伪代码

      x->update(); // (3)

    }

  }

  // ...

}

 

当 Observable 通知每一个 Observer 时 (3), 它从何得知 Observer 对象 还活着?

要不在 Observer 的析构函数里解注册 (unregister)?恐难奏效。

 

struct Observer

{

  virtual ~Observer() { }

  virtual void update() = 0;

 

  void observe(Observable* s) {

    s->register(this);

    subject_ = s;

  }

 

  virtual ~Observer() {

    // (4)

    subject_->unregister(this);

  }

  Observable* subject_;

};

 

我们试着让 Observer 的析构函数去 unregister(this),这里有两个 race conditions。其一:(4) 如何得知 subject_ 还活着?就算 subject_ 指向某个永久存在的对象,那么还是险象环生:


1. 线程 执行 到 (4) 处,还没有来得及 unregister 本对象

2. 线程 执行到 (3) 处,正好指向是 (4) 处正在析构 的对象

 

那么悲剧又发生了,既然 所指 的 Observer 对象正在析构,调用它的任何非静态成员函数都是不安全的,何况是虚函数(C++ 标 准对在构造函数和析构函数中调用虚函数的行为有明确的规定,但是没有考虑并发调用的情况)。更糟糕的是,Observer 是个基类,执行到 (4) 处时,派生 类对象已经析构掉了,这时候整个对象处于将死未死的状态,core dump 恐怕是最幸运的结果。

 

这些 race condition 似乎可以通过加锁来解决,但在哪儿加锁,谁持有这些互斥锁,又似乎不是那么显而易见的。要是有什么活 着的对象能帮帮我们就好了,它提供一个 isAlive() 之类的程序函数,告诉我们那个对象还在不在。可惜指针和引用都不是对象,它们是内建类型。


阅读(2479) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~