线程安全的对象生命期的管理

编写线程安全的类不是难事,用同步原语保护内部状态即可。但是对象的生与死不能由对象自身拥有的mutex(互斥器)来保护。如何避免对象析构时可能 存在的race condition(竞态条件)是C++多线程编程面临的基本问题,可以借助Boost库 中的shared_ptr和weak_ptr完美解决、这也是实现线程安全的Observer模式的必备技术。
1.1当析构函数遇到多线程
与其他的面向对象 语言不同,C++要求程序员自己管理对象的生命周期,这在多线程环境下显得尤为困难。当一个对象能被多个线程同时看到时,那么对象的销毁时机就会变得模糊不清,可能出现多种竞态条件:
1、在即将析构一个对象时,从何而知此刻是否有别的线程正在执行该对象的成员函数?
2、如何保证在执行成员函数期间,对象不会在另一个线程被析构?
3、在调用某个对象的成员函数之前,如何得知这个对象还活着?它的析构函数会不会碰巧执行到一半?
解决这些竞态条件是C++多线程编程面临的基本问题。本文视图以shared_ptr一劳永逸 的解决这些问题,减轻C++多线程编程的精神负担。
1.1.1线程安全的定义
一个线程安全的class应当满足一下三个条件:
1.多个线程同时访问时,其表现出正确的行为。
2、无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织。
3.调用端代码无须额外的同步或者其他的协调动作。
1.1.2 MutexLock与MutexLockGuard
为了便于后文讨论,先约定两个工具类。我们相信每个写C++多线程程序的人都实现过或使用过类似功能的类
MutexLock封装临界区,这是一个简单的资源类,用RAiI手法封装互斥器的创建和销毁。临界区在windows上是一个structCRITII-CAL_SECTIONf,是可重入的;在linux下的pthread_mutex_t,默认是不可重入的,MutexLock一般是别的class的数据成员。
MutexLockGuard封装的临界区的进入和退出,即加锁和解锁。MutexLockGuard是一个栈上的对象,它的作用域刚好等于临界区域。
这两个class不允许拷贝构造和赋值
1.1.3一个线程安全的Counter示例
编写单个的线程安全的class不算太难,只需用同步原语保护其内部状态。例如下面这个简单的计数器Counter:
1.2对象的创建很简单
对象构造要做到线程安全,唯一的要求是在构造期间不要泄漏this指针,即
1.不要在构造函数中注册回调函数
2、不要在构造函数中把this指针传给跨线程的对象
3、即便在构造函数的最后一行也不行
之所以这样规定,是因为在构造函数执行期间对象还没有完成初始化,如果this被泄露给了其他对象(其自身创建的子对象除外),那么别的线程有可能访问这个半成品对象,这会造成难以预料的后果。
1.3销毁太难
对象析构,这在单线程里不构成问题,最多需要注意避免空悬指针和野指针。而在多线程程序中,存在了太多的竞态条件。对于一般的成员函数而言,做到线程安全的办法是让它们顺次执行,而不要并发执行(关键是不要同时读写共享状态),也就是让每个成员函数的临界区不重叠。这是显而易见的,不过有一个隐含条件或许每个人都能立刻想到;成员函数用来保护临界区的互斥器本身必须是有效的。而析构函数破坏了这一个假设,它会把Mutex成员变量销毁掉。
1.3.1 mutex不是办法
Mutex只能保证函数一个接一个地执行,考虑下面的代码
1.3.2作为数据成员的mutex不能保护析构
前面的例子说明,作为class数据成员的mutexLock只能用于同步本class的其他数据成员的读和写,它不能保护安全的析构。因为mutexLock成员的生命期最多与对象一样长,而析构动作可说是发生在对象 身故之后(或者身亡之时)。另外,对于基类对象,那么调用到基类析构函数的过程。再说,析构过程本来 也不需要保护,因为只有别的线程都访问不到这个对象时,析构才是安全的,否则就会有竞态条件发生。
1.4线程安全的Observer有多难
一个动态创建的对象是否还活着,光看指针是看不出来的(引用也一样看不出来)。指针是指向一块内存空间,这块内存空间对象如果已经销毁,那么就根本不能访问,既然不能访问又如何知道对象的状态呢?换句话说,判断一个指针是不是合法指针没有高效的办法,这是C/C++指针问题的根源。(万一源地址又创建 一个新对象呢?再万一这个新的对象异于老的对象呢?)
在面向对象程序设计中,对象的关系主要有三种:composition、aggregation、association。composition(组合/复合)关系在多线程里不会遇到什么麻烦,因为对象x的生命期 由其唯一的拥有者owner控制,owner析构的时候会把 x也析构掉。从形式上看,x是owner的直接数据成员,或者scoped_ptr成员,抑或owner持有的容器的元素。
后两种关系在C++里比较难办,处理不好就是造成内存泄露或重复释放。association(关联/联系)是一种很宽泛的关系,它表示一个对象a用到了另一个对象b,调用了后者的成员函数。从代码形式上看,a持有b的指针(或引用),但是b的生命期不由a单独控制。aggregation(聚合)关系从形式上看与association相同,除了a和b有逻辑上的整体与部分关系。如果b是动态创建的并在整个程序结束前有可能被释放,那么就会出现竞态条件。
那么似乎一个简单的解决办法是:只创建不销毁。程序使用一个对象池来暂存用过的对象,下次申请新对象时,如果对象池里有存货,就重复利用现有的对象,否则就创建一个。对象用完了,不是直接释放掉,而是放回池子里。这个办法当然有其自身的很多缺点,但至少能避免访问失效对象的情况发生。
这种山寨办法的问题有:
对象池的线程安全,如何安全地、完整地把对象放入池子里,防止 出现“部分放回”的竞态?(线程A认为对象x已经放回了,线程B认为对象x已经放回了,线程B认为对象x还活着。)
全局共享数据引发的lock cention,这个集中化的对象池会不会把多线程并发的操作串行化?
如果共享对象的类型不止一种,那么是重复实现对象池还是使用类模版?
会不会造成内存泄漏与分片?因为对象池占用的内存只增不减,而且多个 对象池不能共享内存(想想为何)。
回到正题上来,如果对象x注册了任何非静态成员函数回调,那么必然在某处持有了指向x的指针,这就暴露在了竞态条件。
1.13心得与小结
学习多线程程序设计远远不是看看教程了解API怎么用那么简单,这最多“主要是为了看懂别人的代码,如果自己要写这类代码,必须专门话时间严肃、认真、系统地学习、”。一般的多线程教程上都会提到要让加锁的区域足够小,这没错,问题是如何找出这样的区域并加锁,本章的安全读写shared_ptr可算是一个例子。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值