C++对象线程安全
最近翻阅了陈硕先生写的《Linux 多线程服务端编程》一书,收益颇丰。书指出了不少异步编程中陷阱,并提供了最佳实践。书中把问题抽丝剥茧、娓娓道来,是一本不可多得的好书。
该书第一章为线程安全的对象生命周期管理,给出了C++中写出线程安全代码的良好建议。写出线程安全的具体逻辑代码并不是什么难事,通过同步原语保护内部数据、进行同步即可。在C++中,除了代码逻辑外,还需要对对象生死进行特殊处理。这是因为对象生死不能由其内部拥有的 mutex 来保护。因此,如何解决对象构造、析构时可能存在的竞争条件是C++多线程编程面临的一个基本问题。
什么是线程安全
这里引用 Wiki 对线程安全的描述:
线程安全是一个计算机编程的概念,适用于多线程编程环境。我们说一段代码是线程安全的,当它只对共享的数据进行操作,且保证它在同一时刻被多个线程安全的执行。有很多策略可以生成线程安全的数据结构。 程序可能在一个共享地址空间中创建多个线程并同步执行一段代码,在该地址空间中每个线程实际都可以访问其他线程的内存空间。 线程安全是一种属性,它通过同步来重建代码片段与控制流的关联,从而保证代码在多线程环境的运行。
陈硕先生的书中也给出了线程安全的 class 应当满足的三个条件:
- 多个线程同时访问时,其表现出正确的行为
- 无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织
- 调用端代码无额外的同步或其他协调动作
按照上述条件来约束对象,那么 C++ 标准库中常用的容器并非线程安全的,如:std::string、std::map、std::vector
。
基本保证
对于对象中的除构造、析构外的成员函数,写出线程安全的代码十分容易,通过 mutex 进行同步就好。
#include <mutex>
templete <typename T>
class blocking_queue
{
public:
void push_back(const T &t) {
std::lock_guard lock(mutex_);
queue_.push(t);
}
T pop() {
std::lock_guard lock(mutex_);
T t = queue_.front();
queue_.pop();
return t;
}
private:
std::mutex mutex_;
std::queue<T> queue_;
};
对象创建约束
根据《Linux 多线程服务端编程》书中所说:
对象构造要做到线程安全,唯一的要求时在构造期间不要泄露 this 指针,即:
- 不要在构造函数中注册任何回调函数
- 也不要在构造函数中把 this 对象传给跨线程对象
- 即便时在构造函数最后一句也不行
也就是说一个对象在未构造完成之际,是不能暴露给外部的。对于上面三条准则最后一条,先生也给了解释:如果该类被继承,那么其优先于子类构造,也会出现访问到不完整对象。
对象销毁
相比对象创建,对象销毁则相对复杂。比如一个线程准备销毁对象,而另一个线程正在进行数据访问,那么致命错误便出现了。对于对象销毁,并没有比较好的方法。
使用 shared_ptr、weak_ptr 管理对象生命周期
使用 shared_ptr 和 weak_ptr 对对象进行生命周期管理能解决对象销毁的问题。对象拥有者持有 shared_ptr,而对象使用者持有 weak_ptr。持有者在使用的时候,申请借用提升为 shared_ptr。这样,如果出项上述情况,那么拥有者便将对象托管给使用者,同时自己释放掉对象引用。等到使用者完成工作时,就会触发 shared_ptr 释放对象。
对于那些在引用之前便被释放的对象,weak_ptr 提升便会失败,也保证了不会悬垂指针的危险。
shared_ptr 线程安全
至于 shared_ptr 本身,并非线程安全的,所以要使用被多个线程访问的 shared_ptr 对象,那么需要手动控制同步。
所以,在编写 C++ 程序的时候,应当尽量使用 shared_ptr 而非原始指针,这样自动化的管理资源,不仅解放大脑,还能避免潜在错误。