Linux多线程服务端编程 第一章

Linux多线程服务端编程:使用muduo+C++网络库
第一章 线程安全的对象生命周期管理

如何避免对象析构时可能存在的 race condition(竞态条件)是 C++ 多线程编程面临的基本问题,可以借助 Boost 库中的 shared_ptr 和 weak_ptr 完美解决。

1.1 当析构函数遇到多线程
当一个对象能被多个线程同时看到时,那么对象的销毁时机就会变得模糊不清,可能出现多种竞态条件(race condition):
• 在即将析构一个对象时,从何而知此刻是否有别的线程正在执行该对象的成员函数?
• 如何保证在执行成员函数期间,对象不会在另一个线程被析构?
• 在调用某个对象的成员函数之前,如何得知这个对象还活着?它的析构函数会不会碰巧执行到一半?

解决这些 race condition 是 C++ 多线程编程面临的基本问题。本文试图以shared_ptr 一劳永逸地解决这些问题。    

1.1.1 线程安全的定义
一个线程安全的 class 应当满足以下三个条件:
• 多个线程同时访问时,其表现出正确的行为。
• 无论操作系统如何调度这些线程, 无论这些线程的执行顺序如何交织(interleaving)。
• 调用端代码无须额外的同步或其他协调动作。

依据这个定义, C++ 标准库里的大多数 class 都不是线程安全的,包括 std::string、 std::vector、 std::map 等,因为这些 class 通常需要在外部加锁才能供多个线程同时访问。

1.1.2 MutexLock 与 MutexLockGuard
先约定两个工具类,MutexLock 封装临界区(critical section),这是一个简单的资源类,MutexLockGuard 封装临界区的进入和退出,即加锁和解锁。 MutexLockGuard 一般是个栈上对象,它的作用域刚好等于临界区域。这两个class 都不允许拷贝构造和赋值。

1.2对象的创建很简单
对象构造要做到线程安全,唯一的要求是在构造期间不要泄露 this 指针,即
• 不要在构造函数中注册任何回调;
• 也不要在构造函数中把 this 传给跨线程的对象;
• 即便在构造函数的最后一行也不行。

之所以这样规定,是因为在构造函数执行期间对象还没有完成初始化,如果 this被泄露( escape)给了其他对象(其自身创建的子对象除外),那么别的线程有可能访问这个半成品对象,这会造成难以预料的后果。

// 不要这么做( Don't do this.)
class Foo : public Observer
{
    public:
        Foo(Observable* s)
        {
            s->register_(this); // 错误,非线程安全
        }
        virtual void update();
};

对象构造的正确方法:
// 要这么做( Do this.)
class Foo : public Observer
{
    public:
        Foo();
        virtual void update();
// 另外定义一个函数,在构造之后执行回调函数的注册工作
        void observe(Observable* s)
        {
            s->register_(this);
        }
};
Foo* pFoo = new Foo;
Observable* s = getSubject();
pFoo->observe(s); // 二段式构造,或者直接写 s->register_(pFoo);

这也说明,二段式构造——即构造函数 +initialize()——有时会是好办法,这虽然不符合 C++ 教条,但是多线程下别无选择。另外,既然允许二段式构造,那么构造函数不必主动抛异常,调用方靠 initialize() 的返回值来判断对象是否构造成功,这能简化错误处理。即使构造函数的最后一行也不要泄露 this,因为 Foo 有可能是个基类,基类先于派生类构造,执行完 Foo::Foo() 的最后一行代码还会继续执行派生类的构造函数,这时 most-derived class 的对象还处于构造中,仍然不安全。
相对来说,对象的构造做到线程安全还是比较容易的,毕竟曝光少,回头率为零。而析构的线程安全就不那么简单,这也是本章关注的焦点。

1.3 销毁太难
对象析构,这在单线程里不构成问题,最多需要注意避免空悬指针和野指针。而在多线程程序中,存在了太多的竞态条件。对一般成员函数而言,做到线程安全的办法是让它们顺次执行,而不要并发执行(关键是不要同时读写共享状态),也就是让每个成员函数的临界区不重叠。这是显而易见的,不过有一个隐含条件或许不是每个人都能立刻想到:成员函数用来保护临界区的互斥器本身必须是有效的。而析构函数破坏了这一假设,它会把 mutex 成员变量销毁掉。悲剧啊!

1.3.1 mutex 不是办法
mutex 只能保证函数一个接一个地执行,考虑下面的代码,它试图用互斥锁来保
护析构函数:(注意代码中的 (1) 和 (2) 两处标记。)

此时,有 A、 B 两个线程都能看到 Foo 对象 x,线程 A 即将销毁 x,而线程 B 正准备调用 x->update()。

尽管线程 A 在销毁对象之后把指针置为了 NULL,尽管线程 B 在调用 x 的成员函数之前检查了指针 x 的值,但还是无法避免一种 race condition:
1. 线程 A 执行到了析构函数的 (1) 处,已经持有了互斥锁,即将继续往下执行。
2. 线程 B 通过了 if (x) 检测,阻塞在 (2) 处。

接下来会发生什么,只有天晓得。因为析构函数会把 mutex_ 销毁,那么 (2) 处有可能永远阻塞下去,有可能进入“临界区”,然后 core dump,或者发生其他更糟糕的情况。
这个例子至少说明 delete 对象之后把指针置为 NULL 根本没用,如果一个程序要靠这个来防止二次释放,说明代码逻辑出了问题。

1.3.2 作为数据成员的 mutex 不能保护析构
作为 class 数据成员的 MutexLock 只能用于同步本 class 的其他数据成员的读和写,它不能保护安全地析构。因为 MutexLock 成员的生命期最多与对象一样长,而析构动作可说是发生在对象身故之后(或者身亡之时)。另外,对于基
类对象,那么调用到基类析构函数的时候,派生类对象的那部分已经析构了,那么基类对象拥有的 MutexLock 不能保护整个析构过程。再说,析构过程本来也不需要保护,因为只有别的线程都访问不到这个对象时,析构才是安全的。

另外如果要同时读写一个 class 的两个对象,有潜在的死锁可能。比方说有swap() 这个函数:
void swap(Counter& a, Counter& b)
{
    MutexLockGuard aLock(a.mutex_); // potential dead lock
    MutexLockGuard bLock(b.mutex_);
    int64_t value = a.value_;
    a.value_ = b.value_;
    b.value_ = value;
}
如果线程 A 执行 swap(a, b); 而同时线程 B 执行 swap(b, a);,就有可能死锁。operator=() 也是类似的道理。

Counter& Counter::operator=(const Counter& rhs)
{
    if (this == &rhs)
        return *this;
    MutexLockGuard myLock(mutex_); // potential dead lock
    MutexLockGuard itsLock(rhs.mutex_);
    value_ = rhs.value_; // 改成 value_ = rhs.value() 会死锁
    return *this;
}
一个函数如果要锁住相同类型的多个对象,为了保证始终按相同的顺序加锁,我们可以比较 mutex 对象的地址,始终先加锁地址较小的 mutex。

1.4 线程安全的 Observer 有多难
        在面向对象程序设计中,对象的关系主要有三种: 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 是动态创建的并在整个程序结束前有可能被释放,那么就会出现 §1.1 谈到的竞态条件。
那么似乎一个简单的解决办法是:只创建不销毁。程序使用一个对象池来暂存用过的对象,下次申请新对象时,如果对象池里有存货,就重复利用现有的对象,否则就新建一个。对象用完了,不是直接释放掉,而是放回池子里。这个办法当然有其自身的很多缺点,但至少能避免访问失效对象的情况发生。
        这种山寨办法的问题有:
        • 对象池的线程安全,如何安全地、完整地把对象放回池子里,防止出现“部分放回”的竞态?(线程 A 认为对象 x 已经放回了,线程 B 认为对象 x 还活着。)
        • 全局共享数据引发的 lock contention,这个集中化的对象池会不会把多线程并发的操作串行化?
        • 如果共享对象的类型不止一种,那么是重复实现对象池还是使用类模板?
        • 会不会造成内存泄漏与分片?因为对象池占用的内存只增不减,而且多个对象池不能共享内存(想想为何)。

回到正题上来,如果对象 x 注册了任何非静态成员函数回调,那么必然在某处持有了指向 x 的指针,这就暴露在了 race condition 之下。
一个典型的场景是 Observer 模式:
1 class Observer // : boost::noncopyable
2 {
3     public:
4         virtual ~Observer();
5         virtual void update() = 0;
6         // ...
7 };
8
9 class Observable // : boost::noncopyable
10 {
11     public:
12         void register_(Observer* x);
13         void unregister(Observer* x);
14
15         void notifyObservers() {
16             for (Observer* x : observers_) { // 这行是 C++11
17                 x->update(); // (3)
18             }
19         }
20         private:
21             std::vector<Observer*> observers_;
22 };

        当 Observable 通知每一个 Observer 时 (L17),它从何得知 Observer 对象 x 还活着?要不试试在 Observer 的析构函数里调用 unregister() 来解注册?恐难奏效。
class Observer
24 {
25     // 同前
26     void observe(Observable* s) {
27         s->register_(this);
28         subject_ = s;
29     }
30
31     virtual ~Observer() {
32         subject_->unregister(this);
33     }
34
35     Observable* subject_;
36 };

我们试着让 Observer 的析构函数去调用 unregister(this),这里有两个 race conditions。其一: L32 如何得知 subject_ 还活着?其二:就算 subject_ 指向某个永久存在的对象,那么还是险象环生:
1. 线程 A 执行到 L32 之前,还没有来得及 unregister 本对象。
2. 线程 B 执行到 L17, x 正好指向是 L32 正在析构的对象。

这时悲剧又发生了,既然 x 所指的 Observer 对象正在析构,调用它的任何非静态成员函数都是不安全的,何况是虚函数 5。更糟糕的是, Observer 是个基类,执行到 L32 时,派生类对象已经析构掉了,这时候整个对象处于将死未死的状态, core dump 恐怕是最幸运的结果。
这些 race condition 似乎可以通过加锁来解决,但在哪儿加锁,谁持有这些互斥锁,又似乎不是那么显而易见的。要是有什么活着的对象能帮帮我们就好了,它提供一个 isAlive() 之类的程序函数,告诉我们那个对象还在不在。可惜指针和引用都不是对象,它们是内建类型。

1.5 原始指针有何不妥
(1)指向对象的原始指针( raw pointer)是坏的,尤其当暴露给别的线程时。
(2)要想安全地销毁对象,最好在别人(线程)都看不到的情况下,偷偷地做。

1.6 神器 shared_ptr/weak_ptr
(1)shared_ptr 控制对象的生命期。 shared_ptr 是强引用(想象成用铁丝绑住堆上的对象),只要有一个指向 x 对象的 shared_ptr 存在,该 x 对象就不会析构。当指向对象 x 的最后一个 shared_ptr 析构或 reset() 的时候, x 保证会被销毁。
(2) weak_ptr 不控制对象的生命期,但是它知道对象是否还活着(想象成用棉线轻轻拴住堆上的对象)。如果对象还活着,那么它可以提升( promote)为有效的shared_ptr;如果对象已经死了,提升会失败,返回一个空的shared_ptr。“提升/lock()”行为是线程安全的。
(3) shared_ptr/weak_ptr 的“计数”在主流平台上是原子操作,没有用锁,性能不俗。
(4)shared_ptr/weak_ptr 的线程安全级别与 std::string 和 STL 容器一样,后面还会讲。

1.7 插曲:系统地避免各种指针错误
C++ 里可能出现的内存问题大致有这么几个方面:
1. 缓冲区溢出( buffer overrun)。
2. 空悬指针/野指针。
3. 重复释放( double delete)。
4. 内存泄漏( memory leak)。
5. 不配对的 new[]/delete。
6. 内存碎片( memory fragmentation)

正确使用智能指针能很轻易地解决前面 5 个问题;
1. 缓冲区溢出:用 std::vector<char>/std::string 或自己编写 Buffer class 来
管理缓冲区,自动记住用缓冲区的长度,并通过成员函数而不是裸指针来修改
缓冲区。
2. 空悬指针/野指针:用 shared_ptr/weak_ptr,这正是本章的主题。
3. 重复释放:用 scoped_ptr,只在对象析构的时候释放一次。
4. 内存泄漏:用 scoped_ptr,对象析构的时候自动释放内存。
5. 不配对的 new[]/delete:把 new[] 统统替换为 std::vector/scoped_array。

虽然我们借 shared_ptr 来实现线程安全的对象释放,但是 shared_ptr 本身不是100% 线程安全的。它的引用计数本身是安全且无锁的,但对象的读写则不是,因为shared_ptr 有两个数据成员,读写操作不能原子化。根据文档 11,shared_ptr 的线程安全级别和内建类型、标准库容器、 std::string 一样,即:
• 一个 shared_ptr 对象实体可被多个线程同时读取;
• 两个 shared_ptr 对象实体可以被两个线程同时写入,“析构”算写操作;
• 如果要从多个线程读写同一个 shared_ptr 对象,那么需要加锁。
请注意,以上是 shared_ptr 对象本身的线程安全级别,不是它管理的对象的线程安全级别。

1.10 shared_ptr 技术与陷阱
(1)意外延长对象的生命期
shared_ptr 是强引用(“铁丝”绑的),只要有一个指向 x 对象的 shared_ptr 存在,该对象就不会析构。
(2)函数参数
因为要修改引用计数(而且拷贝的时候通常要加锁), shared_ptr 的拷贝开销比拷贝原始指针要高,但是需要拷贝的时候并不多。
(3)析构动作在创建时被捕获

1.12 替代方案
除了使用 shared_ptr/weak_ptr,要想在 C++ 里做到线程安全的对象回调与析构,可能的办法有以下一些。
(1)用一个全局的 façade 来*** Foo 类型对象访问,所有的 Foo 对象回调和析构都通过这个 façade 来做,也就是把指针替换为 objId/handle,每次要调用对象的成员函数的时候先 check-out,用完之后再 check-in。
(2) §1.4 提到的“只创建不销毁”手法,实属无奈之举。
(3)自己编写引用计数的智能指针 17。本质上是重新发明轮子,把 shared_ptr 实现一遍。正确实现线程安全的引用计数智能指针不是一件容易的事情,而高效的实现就更加困难。既然 shared_ptr 已经提供了完整的解决方案,那么似乎没有理由抗拒它。
(4)将来在 C++11 里有 unique_ptr,能避免引用计数的开销,或许能在某些场合替换 shared_ptr。

其他语言怎么办
有垃圾回收就好办。 Google 的 Go 语言教程明确指出,没有垃圾回收的并发编程是困难的( Concurrency is hard without garbage collection)。但是由于指针算术的存在,在 C/C++ 里实现全自动垃圾回收更加困难。而那些天生具备垃圾回收的语言在并发编程方面具有明显的优势, Java 是目前支持并发编程最好的主流语言,它的util.concurrent 库和内存模型是 C++11 效仿的对象。

小结
• 原始指针暴露给多个线程往往会造成 race condition 或额外的簿记负担。
• 统一用 shared_ptr/scoped_ptr 来管理对象的生命期,在多线程中尤其重要。
• shared_ptr 是值语意,当心意外延长对象的生命期。例如 boost::bind 和容器都可能拷贝 shared_ptr。
• weak_ptr 是 shared_ptr 的好搭档,可以用作弱回调、对象池等。
• 认真阅读一遍 boost::shared_ptr 的文档,能学到很多东西:
• 保持开放心态,留意更好的解决办法,比如 C++11 引入的 unique_ptr。忘掉已被废弃的 auto_ptr。
#算法工程师##Linux#
全部评论

相关推荐

小红书 后端开发 总包n+8w+期权
点赞 评论 收藏
分享
11-08 17:36
诺瓦科技_HR
点赞 评论 收藏
分享
11-15 17:19
湖南大学 Java
成果成果成果果:这是哪个公司的hr,这么离谱吗,我没见过用性别卡技术岗的,身边女性同学拿大厂offer的比比皆是
点赞 评论 收藏
分享
评论
点赞
8
分享
牛客网
牛客企业服务