最近翻阅了陈硕先生写的《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 而非原始指针,这样自动化的管理资源,不仅解放大脑,还能避免潜在错误。