导读:
作为开发人员,我们知道编写一个线程安全的类不是难事,只要针对类内部状态信息进行同步保护即可。但是,当前类创造出的对象的声明周期却不能用当前对象自身的同步措施进行保护(常用的mutex)。那么我们应该如何避免在多线程情况下对象析构时所面临的竞态条件,这是我们作为C++程序员需要认真考虑的基本问题。当然,我们可以用标准库(C++11)或者Boost库提供的shared_ptr和weak_ptr完美解决。下面让我们逐步进行剖析。
1.多线程与析构函数
与其他开发语言不通,作为C/C++程序员,我们要自己管理对象的声明周期,这在多线程环境下容易出现问题。当一个对象能被多个线程访问到时,那么这时,对象的析构就会变得模糊不清,此时可能出现多种竞态条件:
* 当析构一个对象时,怎么才能知道当前有没有别的线程正在执行该对象的成员函数?
* 如何保证在执行当前对象的成员函数期间,对象不会被另一个线程析构?
* 当我们准备调用当前对象的某个成员函数时,怎么样才知道当前对象是否还存在,或者是当前对象的析构函数正好执行到一半?
解决以上问题,是我们要面临的基本问题。
1.1 线程安全的定义
一个线程安全的类应该满足以下三个条件:
* 多个线程同时访问,其表现出相同的行为
* 无论操作系统如何调度这些线程,无论这些线程的执行顺序如何,其都能正确执行
* 调用端代码无需额外的同步或者其他协调动作
依据以上定义,在C++中很多class都不是线程安全的,包括std::string、std::vector、std::map等,因为这些class通常需要在外部加锁才能供多个线程同时访问。
1.2 MutexLock与MutexLockGuard
为了便于解释和讲解,我们先约定两个工具类。
MutexLock封装临界区,这是一个简单的资源类,用RAII手法封装互斥器的创建于销毁。
MutexLockGuard封装临界区的进入和退出,即加锁和解锁。MutexLockGuard一般是个栈上的对象,它的作用域刚好等于临界区域。这两个class都允许拷贝构造和赋值。
1.3一个线程安全的实例
class Counter : boost::noncopyable{
public:
Counter():value_(0){}
int64_t value(){
MutexLockGuard lock(mutex_);
return value_;
}
int64_t getAndIncrease(){
MutexLockGuard lock(mutex_);
int64_t ret=value_++;
return value_;
}
private:
int64_t value_;
mutable MutexLock mutex_;
};
以上代码定义,每个Counter对象有自己的mutex_,因此不同对象之间不构成锁争用,即两个线程有可能同时执行某一行代码。前提是它们访问的是同一个对象。我们把mutex_定义为mutable,意味着const的成员函数也能直接使用mutex_。
请考虑,如果当前类的对象时动态创建的并通过指针来访问,前面提到的多线程情况下的对象销毁的静态仍然存在。
2.对象的创建很简单
对象构造要做到线程安全,唯一的要求就是要在对象构造期间不要泄露this指针,即:
* 不要在构造函数注册任何回调函数
* 也不要在构造函数中把this指针传给跨线程的对象
* 即便在构造函数的最后一行也不行
之所以这样规定,是因为在构造函数执行期间,对象还没有完全初始化,如果this被传递给了其他对象,那么别的线程有可能访问当前还没有创建完成的对象,会造成难以预测的后果。
请看以下代码:
class Foo : public Observer{
public:
void Foo(Observer * s){
s->register(this);//另外定义一个成员函数,在构造执行之后执行回调函数的注册工作
}
virtual void update();
}
可以看出在构造函数中将this指针过早的暴露出去,存在潜在的BUG。
正确的写法:
class Foo : public Observer{
public:
Foo();
void observer(Observer * s){
s->register(this);//另外定义一个成员函数,在构造执行之后执行回调函数的注册工作
}
virtual void update();
}
Foo pFoo= new Foo;
Observer * s=getSub();
pFoo->observer(s);//二段式构造
我们可以看出,二段式构造也是一种解决问题的办法,虽然这有些不符合C++中的某些教条,但是在多线程环境下别无选择。
即使是构造函数最后一行也不要泄露this指针,因为如果当前Foo是基类,基类先于派生类构造,执行完Foo::Foo的最后一行代码还会继续执行派生类的构造函数,此时当前对象还处于构造中,不安全。
相对来说,在多线程环境下,对象的构造安全还是比较容易地,毕竟只是在初始化的时候使用,但是析构函数的线程安全却没有这么简单。怎么样解决呢,我们下篇文章再探讨。