线程安全
一个线程安全的类应该满足下面三个条件
- 多个线程同时访问,其表现出正确的行为
- 无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织
- 调用端代码无需额外的同步或其他协调动作
对象的线程安全
对象构造要做到线程安全,唯一的要求是在构造期间不要泄露this指针,即
- 不要在构造函数中注册任何回调
- 不要在构造函数中把this传给跨线程的对象:如果在构造期间将指针泄露出去,那么别的线程可能会访问到一个没有构造完成的对象,这样可能会造成不可预知的后果。
- 即使在构造函数的最后一行也不安全:该类可能是一个基类,基类的构造函数先于派生类构造;创建派生类对象是执行完基类构造的最后一行还会去接着执行子类的构造函数,这样执行完该行后此对象还是处于构造过程,所以依旧不安全。
// 错误的做法
class Foo : public Observer
{
public:
Foo(Observable* s)
{
s->register_(this);
}
virtual void update();
};
正确的做法如下
class Foo : public Observer
{
public:
Foo();
virtual void update();
// 定义另外一个函数用来进行注册
void observe(Observable* s)
{
s->register_(this);
}
};
// 声明对象
Foo* foo = new Foo;
Observable*s = getSubject();
foo->observe(s);
这样使用两段式的构造:即构造器+初始化器的组合,就能避免上面的问题。
但是在多线程条件下,由于析构函数造成的竞态条件有很多:可能出现在析构一个对象的过程中,有可能另一个线程还在执行成员函数。
用互斥锁也无法解决上面这种问题:对于一般的成员函数,使用互斥锁保护临界区就能保证程序运行正确,但正确的前提是互斥锁必须是正常工作的;析构函数会把对象的mutex成员变量销毁,这就破坏了上面的条件。例如下面的代码
Foo::~Foo()
{
std::lock_guard<std::mutex> lock(mutex_);
// (1)
}
void Foo::update()
{
std::lock_guard<std::mutex> lock(mutex_); // (2)
}
假如有A、B两个线程都能看到Foo对象x,线程A即将销毁x,线程B正准备调用x->update()
// thread A
delete x;
x = NULL;
// thread B
if (x) {
x->update();
}
此时线程A执行到析构函数的(1)
处,已经持有了互斥锁;线程B通过了if(x)
的判断,并且阻塞在(2)
处。这时就会发生不可预料的事情了。
原始指针的问题
有两个指针A、B指向同一个Object对象,当一个线程通过指针A销毁对象的时候,B就变成了空悬指针。
一个解决空悬指针的办法是加入一层代理,让A、B指针都指向代理对象,代理对象持有一个指向Object对象的指针,当Object被销毁的时候,代理对象依旧存在,另一个线程也可以通过访问代理对象查看Object对象是否存活。但是这样竞态条件依旧存在,当B线程查看了对象存活后,将要调用对象的成员函数,但是此时A线程销毁了该对象,就会造成不可知的后果。
另一个更好的解决方法是引入引用计数(reference counting):这时我们给代理对象增加一个成员count,用于记录实际对象被引用的次数;每当对象执行析构函数时,让代理对象的count自减,当count归零时,我们就可以非常安全的销毁代理对象和Object对象了,因为此时不可能再有任何线程访问到代理对象了(因为没有引用)。
其实最后一种解决方法就是智能指针。
智能指针shared_ptr/weak_ptr
shared_ptr
控制对象的生命周期。shared_ptr
是强引用,只要有一个指向x对象的shared_ptr
存在,该对象就不会被析构。当指向对象的最后一个shared_ptr
析构或reset
时,对象保证会被析构。weak_ptr
不控制对象的生命周期,但是它知道对象是否存活。如果对象还活着,那么它可以提升(promote)为有效的shared_ptr
;如果对象已经死了,提升就会失败,返回一个空的shared_ptr
。“提升”行为是线程安全的。shared_ptr/weak_ptr
的“计数”在主流平台上是原子操作,没有用锁,效率很高
这样还是会有一定问题产生
- 侵入性:强制要求Observer必须以shared_ptr来管理
- 不完全线程安全:Observer的析构函数会调用
subject_->unregister(this)
,为了得知subject_是否存活,又要在Observer中使用智能指针来管理Observable - 锁争用:Observable的三个成员函数都使用了互斥锁来进行同步,这就会导致
register_(), unregister()
会无休止的等待notifyObservers()
,而因为它同步调用了用户的update()
函数,造成notifyObservers()
的执行时间过长,而我们希望register_(), unregister()
的执行时间不会超过某个上限 - 死锁:如果
update()
中调用了(un)register()
,如果mutex_是不可重入的,那么就会造成死锁;如果是可重入的,那么就有可能造成迭代器失效,因为vector在遍历期间被修改了。
shared_ptr
的线程安全
shared_ptr
本身不是线程安全的,它的引用计数本身是安全无锁的,但是它本身作为一个对象不是线程安全的,因为shared_ptr
有两个数据成员(一个指向对象的指针和一个ref_count
对象,在复制一个shared_ptr
的过程中就可能会产生问题),读写操作不能原子化。shared_ptr
的线程安全级别和内建类型、标准库容器、std::string
一样:
- 一个
shared_ptr
对象实体可以被多个线程同时读取 - 两个
shared_ptr
对象可以被两个线程分别同时写入 - 如果多个线程要同时读写一个
shared_ptr
对象,那么就需要加锁
多个线程要同时访问一个shared_ptr,则我们需要使用mutex保护:
std::mutex mutex; // 不需要使用读写锁,因为临界区很小
shared_ptr<Foo> globalPtr;
// 任务是把globalPtr安全地传递给doit()
void doit(const shared_ptr<Foo>& pFoo);
void read()
{
shared_ptr<Foo> localPtr;
{
std::lock_guard<std::mutex> lock(mutex);
localPtr = globalPtr; // 读操作
}
// 这里读写本地变量localPtr无需加锁了
doit(localPtr);
}
void write()
{
shared_ptr<Foo> newPtr(new Foo); // 对象创建写在临界区外,减小临界区,效率更好
{
std::lock_guard<std::mutex> lock(mutex);
globalPtr = newPtr; // 把内容写入到globalPtr中
}
doit(newPtr);
}
shared_ptr
技术与陷阱
-
意外延长对象的生命期:由于
shared_ptr
是允许拷贝构造和赋值的,所以如果不小心遗漏了一个拷贝,那么这个对象将永远存活,这也是Java内存泄漏的常见原因。
另外一个可能出错的地方是boost::bind
,因为boost::bind
会把实参拷贝一分,如果参数是一个shared_ptr
,那么对象的生命期就不会短于boost::function
对象class Foo { void doit(); }; shared_ptr<Foo> pFoo(new Foo); boost::function<void()> func = boost::bind(&Foo::doit, pFoo);
-
函数参数:因为要修改引用计数(拷贝的时候通常也要加锁),
shared_ptr
的拷贝开销比原始指针要高。但是多数情况下可以以const reference
方式传递,一个线程只需要在最外层函数有一个实体对象,之后都可以使用const reference
来使用这个shared_ptr
void save(const shared_ptr<Foo>& pFoo); void validateAccount(const Foo& foo); bool validate(const shared_ptr<Foo>& pFoo); { validateAccount(*pFoo); } // 通过传常引用提高效率 void onMessage(const string& msg) { shared_ptr<Foo> pFoo(new Foo(msg)); if (validate(pFoo)) { // 没有拷贝,但由于指针在栈上,所以不会产生竞态条件 save(pFoo); // 与上同理 } }
-
析构动作在创建时被捕获:特性,这个特性使得
- 虚析构函数不再是必须
shared_ptr<void>
可以持有任何对象,并且可以安全释放- 析构动作可以定制
-
析构所在的线程:对象的析构是同步的。当指向x的最后一个
shared_ptr
离开作用域时,x就会在同一个线程被销毁。这个线程是不固定的,因此如果对象的析构十分耗时,那么就有可能拖慢关键进程。因此我们可以使用一个单独的线程用来做析构,通过某种手段将对象的析构转移到专用的线程。 -
线程的 RAII(资源获取即初始化) handle:每一个明确的资源配置动作(例如new)都应该在单一语句中执行,并在该语句中立刻将配置获得的资源交给handle对象(如
shared_ptr
),程序中一般不出现delete。使用shared_ptr
的时候需要注意避免循环引用,通常做法是owner
持有child
的shared_ptr
,child
持有owner
的weak_ptr