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

编写线程安全的类不是难事,用同步原语(synchronization primitives)保护内部状态即可。但对象的生与死不能由对象自身拥有的mutex(互斥器)来保护。如何避免对象析构时可能存在的race condition(竞态条件)是C++多线程编程面临的基本问题,可以借助Boost库的shared_ptr和weak_ptr完美解决,这也是实现线程安全的Observer模式(观察者模式,它是一种行为设计模式,用于定义对象之间的一对多依赖关系,以便当一个对象的状态发生变化时,其所有依赖对象都会得到通知并自动更新)的必备技术。

与其他面向对象语言不同,C++要求程序员自己管理对象的生命期,这在多线程环境下尤为困难。当一个对象能被多个线程同时看到时,那么对象的销毁时机就会变得模糊,可能出现多种竞态条件:

1.在即将析构一个对象时,从何得知此刻是否有别的线程在执行该对象的成员函数?

2.如何保证在执行成员函数期间,对象不会被另一个线程析构?

3.在调用某个对象的成员函数前,如何得知这个对象还活着?它的析构函数会不会碰巧执行到一半?

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

一个线程安全的class应满足以下三个条件:

1.多个线程同时访问时,其表现出正确的行为。

2.无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织。

3.调用端代码无须额外的同步或其他协调动作。

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

为了便于后文讨论,先约定两个工具类。

MutexLock封装临界区,这是一个简单的资源类,用RAII(Resource Acquisition Is Initialization,核心思想是,在对象的构造函数中获取资源(如内存、文件句柄、互斥锁等),并在对象的析构函数中释放这些资源,通过这种方式,RAII 确保在资源不再需要时,资源会被自动释放,从而避免资源泄漏和提高代码的健壮性)手法封装互斥器的创建与销毁。临界区在Windows上是struct CRITICAL_SECTION,是可重入的;在Linux下是pthread_mutex_t,默认是不可重入的。(作者此处的可重入和不可重入指的是锁的递归性,即是否可以重复加锁)MutexLock一般是别的class的数据成员。

MutexLockGuard封装临界区的进入和退出,即加锁和解锁,MutexLockGuard一般是栈上对象,它的作用域刚好等于临界区域。

这两个class都不允许拷贝构造和赋值,它们的代码在第二章中。

编写单个的线程安全class不算太难,只需用同步原语保护其内部状态,例如以下计数器类Counter:

cpp 复制代码
// A thread-safe counter
// boost::noncopyable用于禁止类的复制构造函数和赋值操作符
// C++11标准引入了= delete特性,可以更方便地实现类似功能,因此boost::noncopyable在现代C++中的使用逐渐减少
class Counter : boost::noncopyable 
{
// copy-ctor and assignment should be private by default for a class.
// 复制构造函数(copy constructor)和赋值运算符(copy assignment operator)应该默认是私有的
public:
    Counter() : value_(0) {}
    
    int64_t value() const;
    int64_t getAndIncrease();

private:
    int64_t value_;
    // 声明为mutable的成员变量可以在常量成员函数中被修改
    mutable MutexLock mutex_;
};

int64_t Counter::value() const 
{
    MutexLockGuard lock(mutex_);
    return value_;
    // lock在此处析构,晚于返回对象的构造,有效保护了这个共享数据
}

int64_t Counter::getAndIncrease() 
{
    MutexLockGuard lock(mutex_);
    int64_t ret = value_++;
    return ret;
}

// In a real world, atomic operations are perferred.
// 实际项目中,这个class用原子操作更合理,此处用锁仅为了举例

这个class很直白,也容易验证它是线程安全的。每个Counter对象都有自己的mutex_,因此不同对象之间不构成锁征用,即两个线程有可能同时执行value_++,前提是它们访问的不是同一个Counter对象。注意到mutex_成员是mutable的,意味着const成员函数如Counter::value()也能直接使用non-const的mutex_。

尽管Counter本身是线程安全的,但如果Counter是动态创建的并通过指针来访问,则前面提到的对象销毁的race condition依然存在。

对象构造要做到线程安全,唯一的要求是在构造期间不要泄露this指针,即:

1.不要在构造函数中注册任何回调。

2.不要在构造函数中把this传给跨线程的对象。

3.即便在构造函数的最后一行也不行。

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

cpp 复制代码
// 不要这么做,Observer类见后文
class Foo : public Observer
{
public:
    Foo(Observable *s) 
    {
        // 错误,非线程安全
        s->register_(this);
    }
    
    virtual void update();
};

对象构造的正确方法:

cpp 复制代码
// 要这么做
class Foo : public Observer
{
public:
    Foo();
    virtual void update();
    
    // 另外定义一个函数,在构造之后执行回调函数的注册工作
    void observe(Observable *s) {
        s->resigter_(this);
    }
};

Foo *pfoo = new Foo();
Observable *s = getSubject();
// 二段式构造,或直接写s->register_(pFoo);
pFoo->observe(s);

这也说明二段式构造,即构造函数+initialize(),有时是好方法,虽然这不符合C++教条,但多线程下别无选择。另外,既然允许二段式构造,那么构造函数不必主动抛异常,调用方靠initialize()的返回值来判断对象是否构造成功,这能简化错误处理。

即使构造函数的最后一行也不要泄露this,因为Foo可能是个基类,基类先于派生类构造,执行完Foo::Foo()的最后一行代码后还会继续执行派生类的构造函数,这时most-derived class(最底层派生类,在多重继承的类层次结构中的最末端)的对象还处于构造中,仍然不安全。

对象析构在单线程里不构成问题,最多需要注意避免空悬指针(指针指向已经销毁的对象或已经回收的地址)和野指针(未初始化的指针),而在多线程程序中,存在太多的竞态条件。对一般成员函数而言,做到线程安全的方法是让它们顺次执行,而不要并发执行(关键是不要同时读写共享状态),即让每个成员函数的临界区不重叠,这里有一个隐含条件,即成员函数用来保护临界区的互斥器本身必须是有效的,而析构函数破坏了这一假设,它会把mutex成员变量销毁。

mutex只能保证函数一个接一个地执行,考虑以下代码,它试图用互斥锁来保护析构函数:

此时,有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没用,程序靠这个来防止二次释放说明代码逻辑出了问题。

作为class数据成员的MutexLock只能用于同步本class的其他数据成员的读写,不能保护安全地析构,因为MutexLock成员的生命期最多与对象一样长,而析构动作可以说是发生在对象身亡时。对于基类对象,调用到基类析构函数时,派生类对象的那部分已经析构了,那么基类对象拥有的MutexLock不能保护整个析构过程。

如果要同时读写一个class的两个对象,有潜在死锁的可能,比如有swap这个函数:

cpp 复制代码
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=()也是类似的道理:

cpp 复制代码
Counter &Counter::operator=(const Counter &rhs) 
{
    if (this == &rhs) 
    {
        return *this;
    }
    
    // potential dead lock
    MutexLockGuard myLock(mutex_);
    MutexLockGuard itsLock(rhs.mutex_);
    // 改成value_ = rhs.value()会死锁
    value_ = rhs.value_; 
    return *this;
}

一个函数如果要锁住相同类型的多个对象,为了保证始终按相同的顺序加锁,我们可以比较mutex对象的地址,始终先加锁地址较小的mutex。

一个动态创建的对象是否还活着,光看指针是看不出来的(引用也看不出来)。指针就是指向了一块内存,这块内存上的对象如果已经销毁,那么就不能访问(就像free后的地址不能访问一样),既然不能访问又如何知道对象的状态呢,即判断一个指针是不是合法指针有没有高效的办法,这是C/C++指针问题的根源(万一原址又创建了一个新对象呢,再万一这个新对象的类型异于老对象呢)。而在Java中,一个reference只要不为null,它一定指向有效的对象。

在面向对象程序设计中,对象的关系主要有3种:composition(组合/复合)、aggregation、association:

1.组合/复合(Composition):组合关系表示一个对象包含另一个对象,且被包含对象的生命周期依赖于包含对象。例如,一辆汽车包含引擎,引擎的寿命与汽车的寿命相关联。这是一种强关联,通常用实心菱形表示。

2.聚合(Aggregation):聚合关系表示一个对象包含另一个对象,但被包含对象的生命周期不一定依赖于包含对象。例如,一个大学包含多个学院,但学院可以存在独立于大学。这是一种弱关联,通常用空心菱形表示。

3.关联(Association):关联关系表示两个对象之间有某种关联,但它们之间没有包含或依赖的关系。例如,一个学生与一个教师之间存在关联,但它们是独立的对象。这是一种松散的关系,通常用一条直线表示。

composition关系在多线程里不会遇到麻烦,因为对象x的生命期由其唯一的拥有者owner控制,owner析构时会把x也析构掉。从形式来看,x是owner的直接数据成员,或者scoped_ptr成员(scoped_ptr不是C++标准库的一部分,而是一种智能指针,它的目的是管理动态分配的对象,并在其作用域结束时自动销毁这些对象,以避免内存泄漏),抑或owner持有的容器的元素。

后两种关系在C++中比较难办,处理不好会造成内存泄漏或重复释放。association(关联/联系)是一种很宽泛的关系,它表示一个对象a用到了另一个对象b,调用了后者的成员函数,从代码形式上看,a持有b的指针或引用,但b的生命期不由a单独控制。aggregation(聚合)关系从形式上看与association相同,除了a和b由逻辑上的整体与部分关系,如果b是动态创建的并在整个程序结束前有可能被释放,就会出现竞态条件。

似乎一个简单地解决办法是:只创建不销毁。程序使用一个对象池来暂存用过的对象,下次申请新对象时,如果对象池里有存货,就重复利用现有的对象,否则就新建一个。对象用完了,不是直接释放掉,而是放回池子里。这个办法有自身的很多缺点,但至少能避免访问失效对象的情况发生。

这种山寨办法的问题有:

1.对象池的线程安全,如何安全地、完整地把对象放回池子里,防止出现"部分放回"的竞态?(线程A认为对象x已经放回了,线程B认为对象x还活着)

2.全局共享数据引发的lock contention,这个集中化的对象池会不会把多线程并发的操作串行化?

3.如果共享对象的类型不止一种,那么是重复实现对象池还是使用类模板?

4.会不会造成共享内存泄漏与分片?因为对象池占用的内存只增不减,且多个类型的对象池一般不能共享内存,而是管理不同类型的对象时,为它们分别创建不同的对象池。

如果对象x注册了任何非静态成员函数回调(即将自身作为参数传递给其他对象或系统,使其能够调用对象x的非静态成员函数),那么必然在某处其他对象持有了指向x的指针,这就暴露在了race condition之下。

一个典型的场景是Observer模式:

cpp 复制代码
class Observer // : boost::noncopyable
{
public:
    virtual ~Observer();
    virtual void update() = 0;
    // ...
};

class Observable // : boost::noncopyable
{
public:
    void register_(Observer *x);
    void unregister(Observer *x);
    
    void notifyObservers() 
    {
        for (Observer *x : observers_) 
        {
            x->update();    // (3)
        }
    }

private:
    std::vector<Observer *> observers_;
};

当Observable通知每个Observer时,它从何处得知Observer对象x还活着?下面试试在Observer的析构函数里调用unregister()来解注册:

cpp 复制代码
class Observer
{
    void observe(Observable *s)
    {
        s->register_(this);
        subject_ = s;
    }
    
    virtual ~Observer() 
    {
        subject_->unregister(this);
    }
    
    Observable *subject_;
};

我们试着让Observer的析构函数去调用unregister(this),这里有两个race condition:

1.subject_->unregister(this);如何知道subject_还活着?

2.就算subject_指向某个永久存在的对象,还是险象环生:

(1)线程A执行到subject_->unregister(this);前,还没来得及unregister本对象。

(2)线程B执行到x->update();,x正好指向(1)中正在析构的对象。

此时x所指的Observer对象正在析构,调用它的任何非静态成员函数都是不安全的,何况是虚函数(C++标准没有考虑并发调用虚函数的情况),更糟的是,Observer是个基类,执行到subject_->unregister(this);时,派生类对象已经析构掉了,此时整个对象处于将死未死状态,core dump恐怕是最幸运的结果。

这些race condition似乎可通过加锁来解决,但在哪加锁,谁持有这些互斥锁,似乎不是那么显而易见。要是有活着的对象能提供isAlive()之类的程序函数,告诉我们那个对象还在不在,可惜指针和引用都不是对象,它们是内建类型。

指向对象的原始指针(raw pointer)是坏的,尤其当暴露给别的线程时。Observable应该保存的不是原始的Observer *,而是别的东西,能分辨Observer对象是否存活。类似地,如果Observer要在析构函数里解注册(这虽然不能解决前面提到的race condition,但在析构函数里打扫战场还是应该的),那么subject_的类型也不能是原始的Observable *

这两处直接使用shared_ptr是不行的,有些地方需要注意,否则会形成循环引用,直接造成资源泄露。

有两个指针p1和p2,指向堆上的同一个对象Object,p1和p2位于不同的线程中:

假设线程A通过p1指针将对象销毁了,那p2就成了空悬指针,这是一种典型的C/C++内存错误。

要想安全地销毁对象,最好在别的线程看不到的情况下做,这正是垃圾回收的原理,所有人都用不到的东西一定是垃圾。

一个解决空悬指针的办法是,引入一层间接性,让p1和p2所指的对象永久有效,如下图中的proxy对象:

proxy对象持有一个指向Object的指针(从C语言的角度,p1和p2都是二级指针)。

当销毁Object后,proxy对象继续存在,其值变为0:

此时p2没有变成空悬指针,它可以通过查看proxy的内容来判断Object是否还活着。

要线程安全地释放Object也不是那么容易,race condition依旧存在,比如p2看第一眼的时候proxy不是0,正准备去调用Object的成员函数,期间对象已经被p1销毁了。

问题在于,何时释放proxy指针呢。

为了安全地释放proxy,我们可以引入引用计数(reference counting),再把p1和p2都从指针变成对象sp1和sp2,proxy现在有两个成员,指针和计数器。

1.一开始,有两个引用,计数值为2:

2.sp1析构了,引用计数的值减为1:

3.sp2也析构了,引用计数将为0,可以安全地销毁proxy和Object了:

这正是引用计数型智能指针。

引入另外一层间接性(another layer of indirection),用对象来管理共享资源(如果把Object看做资源),即handle/body惯用技法。

注:Handle/Body 是一种设计模式,也称为"Pimpl"(Pointer to Implementation)模式,旨在隐藏类的实现细节,提高封装性和减少编译依赖性,它分为以下两部分:

1.Handle:这是一个轻量级的类,通常包含类的公共接口(公共成员函数、数据成员等),并负责暴露类的功能和行为。Handle类的目的是提供一个稳定的公共接口,以便使用它的代码可以使用它而不关心实现细节。Handle类通常只包含一个指向Body对象的指针。

2.Body:这是实际的类实现,包含类的私有数据成员、私有函数和实际功能实现。Body对象通常被Handle对象所持有,但客户代码不能直接访问它们,因为Body的细节对客户端代码是不可见的。这有助于封装类的内部实现细节。

当前,编写线程安全、高效的引用计数handle的难度很高,用现成的库就行,即C++的TR1标准库里提供的shared_ptr和weak_ptr。

shared_ptr是引用计数型智能指针,在Boost和std::tr1里均提供,也被纳入C++11标准库,现代主流的C++编译器都能很好地支持。shared_ptr是一个类模板,它只有一个类型参数。引用计数是自动化资源管理的常用手法,当引用计数降为0时,对象(资源)即被销毁。weak_ptr也是一个引用计数型智能指针,但它不增加对象的引用次数,即弱引用。

shared_ptr的几个关键点:

1.shared_ptr控制对象的生命期。shared_ptr是强引用,只要有一个指向x对象的shared_ptr存在,该x对象就不会析构。当指向对象x的最后一个shared_ptr析构或reset()时,x保证会被销毁。

2.weak_ptr不控制对象的生命期,但它知道对象是否还活着。如果对象还活着,那么它可以提升(promote)为有效的shared_ptr;如果对象已经死了,提升会失败,返回一个空shared_ptr。提升,即lock(),lock() 方法用于尝试将std::weak_ptr转换为std::shared_ptr,该行为是线程安全的。

3.shared_ptr/weak_ptr的计数在主流平台上是原子操作,没有用锁,性能不俗。

4.shared_ptr/weak_ptr的线程安全级别与std::string和STL容器一样。

智能指针的优势:一旦某对象不再被引用,系统立刻回收内存,这通常发生在关键任务完成后的清理时期,不会影响关键任务的实时性,同时,内存里所有对象都有用,没有垃圾空占内存。

大部分用C写的上规模的软件都存在一些内存方面的错误,需要花费大量时间和精力把产品稳定下来,例如,Nginx这样成熟且广泛使用的C语言产品都会不时暴露出低级的内存错误。

C++里可能出现的内存问题大概有以下方面:

1.缓冲区溢出(buffer overrun)。

2.空悬指针/野指针。

3.重复释放(double delete)。

4.内存泄漏(memory leak)。

5.不配对的new[]/delete。

6.内存碎片(memory fragmentation)。

正确使用智能指针能轻易解决前5个问题,解决第6个问题需要别的思路。

1.缓冲区溢出:用std::vector<char>/std::string或自己编写Buffer class来管理缓冲区,自动记住所用缓冲区的长度,并通过成员函数而非裸指针来修改缓冲区。

2.空悬指针/野指针:用shared_ptr/weak_prt。

3.重复释放:用scoped_ptr,只在对象析构时释放一次。

4.内存泄漏:用scoped_ptr,对象析构时自动释放内存。

5.不配对的new[]/delete:把new[]换成std::vector/scoped_array

在现代C++中一般不会出现delete语句,资源(包括复杂对象本身)都是通过对象(智能指针或容器)来管理的,不需要程序员为此操心。

在以上错误中,内存泄漏相对危害性较小,因为它只是借了东西不归还,程序功能在一段时间内还算正常。其他如缓冲区溢出或重复释放等致命错误可能会造成安全性(security、data safety)方面的严重后果。

scoped_ptr/shared_ptr/weak_ptr都是值语义,要么是栈上对象,或是其他对象的直接数据成员,或是标准库容器里的元素。几乎不会有以下用法:

cpp 复制代码
// WRONG semantic
shared_ptr<Foo> *pFoo = new shared_ptr<Foo>(new Foo);

如果这几种智能指针是对象x的数据成员,而它的模板参数T是个incomplete类型,那么x的析构函数不能是默认的或内联的,必须在.cpp文件里显式定义(从而在编译或运行时访问到类型T的完整定义),否则会有编译错或运行错。

既然通过weak_ptr能探查对象的生死,那么Observer模式的竞态条件就很容易解决,只要让Observable保存weak_ptr<Observer>即可:

cpp 复制代码
// not 100% thread safe!
class Observable 
{
public:
    // 参数类型可用const weak_ptr<Observer> &
    void register_(weak_ptr<Observer> x);
    // 不需要它
    // void unregister(weak_ptr<Observer> x);
    void notifyObservers();

private:
    mutable MutexLock mutex_;
    std::vector<weak_ptr<Observer>> observers_;
    typedef std::vector<weak_ptr<Observer>>iterator Iterator;
};

void Observable::notifyObservers() 
{
    MutexLockGuard lock(mutex_);
    Iterator it = observers_.begin();
    while (it != observers_.end()) 
    {
        // 尝试提升,这一步是线程安全的
        shared_ptr<Observer> obj(it->lock());
        if (obj) 
        {
            // 提升成功,现在引用计数值至少为2
            // 没有竞态条件,因为obj在栈上,对象不可能在本作用域内销毁
            obj->update();
            ++it;
        }
        else 
        {
            // 对象已经销毁,从容器中拿掉weak_ptr
            it = observers_.erase(it);
        }
    }
}

如果把observers_成员的类型改为vector<shared_ptr<Observer>>,则该vector中对象的生命期也由Observable决定,而对于观察者模式,我们只需检查Observer是否存在,如果存在执行回调即可,不需管理Observer的生命期。

把Observer *替换为weak_prt<Observer>部分解决了Observer的线程安全,但还有以下几个疑点:

1.侵入性。强制要求Observer必须以shared_ptr来管理。

2.不是完全线程安全:Observer的析构函数会调用subject_->unregister(this),万一subject_已不复存在了呢,为了解决它,又要求Observable本身是用shared_ptr管理的,且subject_多半是weak_ptr<Observable>

3.锁争用(lock contention):即Observable的三个成员函数都用了互斥器来同步,会造成register_()和unregister()等待notifyObservers(),而后者的执行时间是无上限的,因为它同步回调了用户提供的update函数,我们希望register_()和unregister()的执行时间不会超过某个固定的上限。

4.死锁:如果obj->update();虚函数中调用了(un)register,由于mutex_是不可重入的,会导致死锁;如果mutex_是可重入的,程序会面临迭代器失效(core dump是最好的结果),因为vector observers_在遍历期间被意外修改了。这个问题似乎没有解决办法,除非在文档中做要求。(一种办法是,用可重入的mutex_,把容器换为std::list,并把++it往前挪一行)

作者倾向于使用不可重入的mutex,例如Pthreads默认提供的那个,因为要求mutex可重入本身往往意味着设计上出了问题。Java的intrinsic lock是可重入的,因为要允许synchronized方法相互调用(派生类调用基类的同名synchronized方法),作者认为这也是无奈之举。

注:在Java中,每个对象都有一个内部锁,也称为内部锁(intrinsic lock)或监视器锁(monitor lock)。这个锁是用于控制并发访问对象的机制,确保在任何给定时间只有一个线程能够访问对象的同步代码块或同步方法。当一个线程持有对象的内部锁时,其他线程必须等待,直到锁被释放才能继续执行。如果一个类中的同步方法调用另一个同步方法,它们都使用相同对象的内部锁。这意味着一个线程在调用同步方法A时会获取锁,然后在方法A中又调用了同步方法B,它会继续持有相同的锁,并能够在方法B中执行,而不会被锁阻塞。这就是可重入性的体现。在继承关系中,如果派生类继承了基类的同名同步方法,且派生类在方法中调用基类的同步方法,由于它们都使用相同的内部锁,可重入性确保线程可以正常执行,而不会发生死锁或其他同步问题。

我们借shared_ptr来实现线程安全的对象释放,但shared_ptr本身不是线程安全的,它的引用计数本身是安全且无锁的,但shared_ptr对象本身的读写不是,因为shared_ptr有两个数据成员,读写操作不能原子化。shared_ptr的线程安全级别和内建类型、标准库容器、std::string一样,即:

1.一个shared_ptr对象实体可被多个线程同时读取。

2.两个shared_ptr对象实体可以被两个线程分别同时写入,析构算写操作。

3.如果要从多线程读写同一个shared_ptr对象本身,则需要加锁。

以上是shared_ptr对象本身的线程安全级别,而非它管理的对象的线程安全级别。

要在多个线程中同时访问同一个shared_ptr,需要用mutex保护:

cpp 复制代码
// No need for ReaderWriterLock
MutexLock mutex;
shared_ptr<Foo> globalPtr;

// 我们的任务是把globalPtr安全地传给doit()
void doit(const shared_ptr<Foo> &pFoo);

globalPtr能被多个线程看到,因此它的读写需要加锁,我们不必使用读写锁,而只用最简单的互斥锁,这是为了性能考虑,因为临界区非常小,用互斥锁也不会阻塞并发读。

为了拷贝globalPtr,需要在读取它时加锁:

cpp 复制代码
void read() 
{
    shared_ptr<Foo> localPtr;
    {
        MutexLockGuard lock(mutex);
        // read globalPtr
        // 加锁防止其他线程正在析构或对globalPtr做改变,其他线程在析构或改变globalPtr时也需要加锁
        localPtr = globalPtr;
    }
    // use localPtr since here. 读写localPtr无须加锁
    doit(localPtr);
}

写入时也要加锁:

cpp 复制代码
void write() 
{
    // 对象的创建在临界区外
    shared_ptr<Foo> newPtr(new Foo);
    {
        MutexLockGuard lock(mutex);
        // write to globalPtr
        globalPtr = newPtr;
    }
    // use newPtr since here. 读写newPtr无须加锁
    doit(newPtr);
}

上面的read()和write()在临界区外都没有再访问globalPtr,而是用一个指向同一Foo对象的栈上shared_ptr local copy,只要有这样的local copy存在,shared_ptr作为函数参数传递时不必复制,用reference to const作为参数类型即可。上面的new Foo是在临界区之外执行的,这种写法比在临界区内写globalPtr.reset(new Foo)要好,因为缩短了临界区长度。如果要销毁对象,我们可以在临界区内执行globalPtr.reset(),但这样会让对象析构发生在临界区内,增加了临界区的长度,一种改进方法是像上面那样定义一个localPtr,用它在临界区内与globalPtr交换(使用shared_ptr的swap()成员函数交换两共享指针的资源),这样能保证把对象的销毁推迟到临界区之外。

上面的write()也可能在临界区内销毁原来globalPtr指向的Foo对象,将其销毁行为移出临界区:

cpp 复制代码
void write()
{
    shared_ptr<Foo> newPtr(new Foo);
    shared_ptr<Foo> localPtr;
    {
        MutexLockGuard lock(mutex);
        globalPtr.swap(localPtr);
        globalPtr = newPtr;
    }
    localPtr.reset();
}

shared_ptr技术与陷阱:

1.意外延长对象的生命期。shared_ptr是强引用,只要有一个指向x对象的shared_ptr存在,该对象就不会析构。而shared_ptr又允许拷贝构造和赋值(否则引用计数就无意义了),如果不小心遗留了一个拷贝,那么对象就永世长存了。例如前面观察者模式代码中,如果把observers_的类型改为vector<shared_ptr<Observer>>,那么除非手动调用unregister(),否则Observer对象永远不会析构,即使Observer的析构函数会调用unregister(),但如果不主动调用unregister()就不会调用Observer的析构函数,这变成了鸡与蛋的问题,这也是Java内存泄漏的常见原因。

另一个出错的可能是boost::bind,因为boost::bind会把实参拷贝一份,如果参数是shared_ptr,那么对象的生命期就不会短于boost::function对象:

cpp 复制代码
class Foo
{
    void doit();
};

shared_ptr<Foo> pFoo(new Foo);
// long life foo
// boost::bind用于创建可调用对象,这个可调用对象捕获了pFoo指向的Foo对象的成员函数doit()
boost::function<void()> func = boost::bind(&Foo::doit, pFoo);

这里func对象持有了shared_ptr<Foo>的一份拷贝,有可能在不经意间延长Foo对象的生命期。

2.函数参数。因为要修改引用计数(且拷贝的时候通常要加锁),shared_ptr的拷贝开销比原始指针要高,但需要拷贝的时候并不多,多数情况下它可以以const reference方式传递,一个线程只需要在最外层函数有一个实体对象,之后都可以以const reference来使用这个shared_ptr。例如有几个函数都要用到Foo对象:

cpp 复制代码
void save(const shared_ptr<Foo> &pFoo);    // pass by const reference
void validateAccount(const Foo &foo);

bool validate(const shared_ptr<Foo> &pFoo)    // pass by const reference
{
    validateAccount(*pFoo);
}

那么通常,我们可以传常引用:

cpp 复制代码
void onMessage(const string &msg) 
{
    // 只要在最外层持有一个实体,安全不成问题
    shared_ptr<Foo> pFoo(new Foo(msg));
    // 没有拷贝pFoo
    if (validate(pFoo)) 
    {
        // 没有拷贝pFoo
        save(pFoo);
    }
}

遵照这个规则,基本不会遇到反复拷贝shared_ptr导致的性能问题。由于pFoo是栈上对象,不可能被别的线程看到,那么读取始终是线程安全的。

3.析构动作在创建时被捕获。这意味着:

(1)虚析构不再是必需。一般使用基类指针来管理多态对象时才使用虚析构函数。

(2)shared_ptr<void>可以持有任何对象,且能安全地释放。

(3)shared_ptr对象可以安全地跨越模块边界,比如从DLL里返回,而不会造成从模块A分配的内存在模块B里被释放这种错误。动态链接库(DLL)是一种可重用的二进制模块,它包含了函数和数据,可以被其他程序使用。当你从一个DLL中返回对象时,可能存在内存管理问题,因为对象的内存通常是在DLL内部分配的,但在调用DLL的程序中使用。这种情况下,需要确保对象的内存在正确的时机被释放,以避免内存泄漏或访问无效内存的问题。如果你在DLL中使用shared_ptr来管理对象的内存,然后将该对象返回到另一个模块(例如,调用DLL的程序),你不必担心对象的内存释放问题。shared_ptr会在适当的时候自动释放对象的内存,确保内存管理的安全性。

(4)二进制兼容性,即使Foo对象的大小变了,那么旧的客户代码仍然可以使用新的动态库(因为新Foo对象的析构函数在动态库中,且在创建对象时被捕获),而无须重新编译,前提是Foo的头文件中不出现访问对象的成员的inline函数(客户代码肯定会引用Foo类的头文件,如果其中出现了访问对象成员的inline函数,且内联函数出现了变化,则客户需要重新编译),并且Foo对象由动态库的Factory构造,返回其shared_ptr。

(5)析构动作可以定制。这个特性的实现比较巧妙,因为shared_ptr<T>只有一个模板参数,而析构行为可以是函数指针、仿函数(functor,一个类对象,它可以像函数一样被调用,因为它重载了operator())或其他东西,这是泛型编程和面向对象编程的一次完美结合。(shared_ptr可以自定义析构,如std::shared_ptr<Person> personPtr(new Person("Alice"), CustomDeleter());,其中CustomDeleter的参数就是一个Person类型指针)

4.析构所在的线程。对象的析构是同步的,当最后一个指向x的shared_ptr离开其作用域时,x会同时在同一线程析构,这个线程不一定是对象诞生的线程。这个特性是一把双刃剑:如果对象的析构比较耗时,则可能会拖慢关键线程的速度(如果最后一个shared_ptr引发的析构在关键线程);同时我们可以用一个单独的线程来专门做析构,通过一个BlockingQueue<shared_ptr<void>>把对象的析构都转移到那个专用线程,从而解放关键线程。BlockingQueue是一个阻塞队列,它结合了队列(Queue)的特性和线程同步机制,以提供多线程编程中的线程安全队列操作。

5.shared_ptr是现成的RAII handle。作者认为RAII(资源获取即初始化)是C++语言区别于其他编程语言的最重要特性。初学C++的教条是:new和delete要配对,new后要记得delete。如果使用RAII,要改成:每个明确的资源配置动作(如new)都应在单一语句中执行,并在该语句中立刻将配置获得的资源交给handle对象(如shared_ptr),程序中一般不使用delete。shared_ptr需要注意避免循环引用,通常的做法是owner持有指向child的shared_ptr,child持有指向owner的weak_ptr(因为owner要管理child的生命周期,而child不能管理owner的生命周期)。

假设有Stock类,代表一只股票的价格,每只股票有一个唯一的字符串标识,如Google的key是"NASDAQ:GOOG",IBM是"NYSE:IBM"。Stock对象是个主动对象,它能不断获取新价格,为了节省系统资源,同一个程序里每一只股票只有一个Stock对象,如果多处用到同一只股票,那么Stock对象应该被共享。如果某一只股票没有再在任何地方用到,其对应的Stock对象应该析构,以释放资源,这隐含了引用计数。

为了达到上述要求,我们可以设计一个对象池StockFactory,它的接口很简单,根据key返回Stock对象,在多线程程序中,对象可能被销毁,那么返回shared_ptr时合理的,自然地,我们可能写出以下错误代码:

cpp 复制代码
// vsersion 1: questionable code
class StockFactory : boost::noncopyable
{
public:
    shared_ptr<Stock> get(const string &key);

private:
    mutable MutexLock mutex_;
    std::map<string, shared_ptr<Stock>> stocks_;
};

get()的逻辑很简单,如果在stocks_中找到了key,就返回stocks_[key],否则新建一个Stock,并存入stocks_[key]。

以上代码的错误在于,Stock对象永远不会被销毁,因为map里存的是shared_ptr,那么或许应仿照前面Observable那样存一个weak_ptr?,如:

cpp 复制代码
// version 2: 数据成员修改为std::map<string, weak_ptr<Stock>> stocks_;
shared_ptr<Stock> StockFactory::get(const string &key) 
{
    shared_ptr<Stock> pStock;
    MutexLockGuard lock(mutex_);
    // 如果key不存在,会默认构造一个
    weak_ptr<Stock> &wkStock = stocks_[key];
    pStock = wkStock.lock();
    // 提升失败,说明weak_ptr指向的对象已被析构
    if (!pStock) 
    {
        pStock.reset(new Stock(key));
        // 这里更新了stocks_[key],wkStock是个引用
        wkStock = pStock;
    }
    return pStock;
}

这样做固然Stock对象销毁了,但程序出现了轻微的内存泄漏,因为stocks_的大小只增不减,stocks_.size()是曾经存活过的Stock对象的总数,即便活的Stock对象数目降为0。可能有人认为这不算泄露,因为内存并不是彻底遗失不能访问了,而是被某个标准库容器占用了,作者认为这也算内存泄露,毕竟战场没有打扫干净。

考虑到世界上的股票数目是有限的,这个内存不会一直泄露下去,大不了把每只股票的对象都创建一遍,估计泄露的内存只有几兆字节,如果这是其他类型的对象池,对象的key的集合不是封闭的,内存就会一直泄露下去。

解决办法是利用shared_ptr的定制析构功能。shared_ptr的构造函数可以有一个额外的模板类型参数,传入一个函数指针或仿函数d,在析构对象时执行d(ptr),其中ptr是shared_ptr保存的对象指针。shared_ptr这么设计不是多余的,因为反正要在创建对象时捕获释放动作,始终需要一个bridge(桥梁,指析构时使用的,在创建时捕获到的析构函数保存在一个中间桥梁(变量)上)。

cpp 复制代码
// template尖括号中的class与typename是等价的
template<class Y, class D> shared_ptr::shared_ptr(Y *p, D d);
template<class Y, class D> void shared_ptr::reset(Y *p, D d);
// Y的类型可以与T不同,只要Y*能隐式转换为T*

我们可以利用这一点,在析构Stock对象的同时清理stocks_:

cpp 复制代码
// version 3
class StockFactory : boost::noncopyable
{
    // 在get()中,将pStock.reset(new Stock(key));改为:
    // pStock.reset(new Stock(key), boost::bind(&stockFactory::deleteStock, this, _1));
    // _1表示将boost::bind返回的可调用对象的第1个参数传给stockFactory::deleteStock的第一个参数

private:
    void deleteStock(Stock *stock)
    {
        if (stock) 
        {
            MutexLockGuard lock(mutex_);
            stocks_.erase(stock->key());
        }
        delete stock;
    }
    // assuming StockFactory lives longer than all Stock's
}

这里我们向pStock.reset()传递了第二个参数,一个boost::function(boost::bind的返回类型是boost::function),让它在析构Stock *p时调用本StockFactory对象的deleteStock成员函数。

这里还有一个问题,即我们把一个原始的StockFactory this指针保存在了boost::function里,这会有线程安全问题,如果这个StockFactory先于Stock对象析构,那么会core dump,即指向Stock的智能指针在析构Stock时,会使用到原始的StockFactory this,但原始的StockFactory this已经被析构了。正如Observer在析构函数里去调用Observable::unregister(),而Observable对象可能已经不存在了。这可使用弱回调技术解决。

StockFactory::get()把原始指针this保存到了boost::function中,如果StockFactory的生命期比Stock短,那么Stock析构时去回调StockFactory::deleteStock就会core dump。似乎我们应使用惯用的shared_ptr来解决对象生命期问题,但StockFactory::get()本身是个成员函数,如何获得一个指向当前对象的shared_ptr<StockFactory>对象呢?

可用enable_shared_from_this,这是一个以其派生类为模板类型实参的基类模板,继承它,this指针就能变为shared_ptr:

cpp 复制代码
class StockFactory : public boost::enable_shared_from_this<StockFactory>, boost::noncopyable 
{ /* ... */ }

为了使用shared_from_this(),StockFactory不能是stack object(如果是栈上对象,栈结束时对象就被销毁了),必须是heap object且由shared_ptr管理其生命期,即:

cpp 复制代码
shared_ptr<StockFactory> stockFactory(new StockFactory);

然后就可以让this变为shared_ptr<StockFactory>了:

cpp 复制代码
// version 4
shared_ptr<Stock> StockFactory::get(const string &key) 
{
    // change
    // pStock.reset(new Stock(key), boost::bind(&StockFactory::deleteStock, this, _1));
    // to
    pStock.reset(new Stock(key), boost::bind(&StockFactory::deleteStock, shared_from_this(), _1));
}

这样,boost::function里保存了一份shared_ptr<StockFactory>,可以保证调用StockFactory::deleteStock的时候那个StockFactory对象还活着。

shared_from_this()不能在构造函数里调用,因为在构造StockFactory的时候,它还没有被交给shared_ptr接管。

这样会有一个问题,StockFactory的生命期被意外延长了。

把shared_ptr绑(boost::bind)到boost::function里,那么回调的时候StockFactory对象始终存在,是安全的,这同时也延长了StockFactory对象的生命期,使之不短于绑定到的boost::function对象。

有时我们需要这样的语意:如果对象还活着,就调用它的成员函数,否则忽略之。就像Observable::notifyObservers()那样,作者称之为弱回调。这也是可以实现的,利用weak_ptr,我们可以把weak_ptr绑到boost::function里,这样对象的生命期就不会被延长,然后在回调时先尝试提升为shared_ptr,如果提升成功,说明接受回调的对象还健在,那么就执行回调,如果提升失败,就不执行回调。

使用这一技术的StockFactory完整代码如下:

cpp 复制代码
class StockFactory : public boost::enable_shared_from_this<StockFactory>, boost::noncopyable
{
public:
    shared_ptr<Stock> get(const string &key) 
    {
        shared_ptr<Stock> pStock;
        MutexLockGuard lock(mutex_);
        // wkStock是引用
        weak_ptr<Stock> &wkStock = stocks_[key];
        pStock = wkStock.lock();
        if (!pStock) 
        {
            pStock.reset(new Stock(key), boost::bind(&StockFactory::weakDeleteCallback,
                                                     boost::weak_ptr<StockFactory>(shared_from_this()),
                                                     _1);
            // 必须把shared_from_this()转型为weak_ptr,才不会延长生命期
            wkStock = pStock;
        }
        return pStock;
    }

private:
    static void weakDeleteCallback(const boost::weak_ptr<StockFactory> &wkFactory, Stock *stock) 
    {
        // 尝试提升
        shared_ptr<StockFactory> factory(wkFactory.lock());
        // 如果factory还在,就清理stocks_
        if (factory)
        {
            factory->removeStock(stock);
        }
        delete stock;
    }
    
    void removeStock(Stock *stock) 
    {
        if (stock)
        {
            MutexLockGuard lock(mutex_);
            stocks_.erase(stock->key());
        }
    }

private:
    mutable MutexLock mutex_;
    std::map<string, weak_ptr<Stock>> stocks_;
};

两个简单的测试:

cpp 复制代码
void testLongLifeFactory()
{
    shared_ptr<StockFactory> factory(new StockFactory);
    {
        shared_ptr<Stock> stock = factory->get("NYSE:IBM");
        shared_ptr<Stock> stock2 = factory->get("NYSE:IBM");
        assert(stock == stock2);
        // stock destructs here
    }
    // factory destructs here
}

void testShortLifeFactory()
{
    shared_ptr<Stock> stock;
    {
        shared_ptr<StockFactory> factory(new StockFactory);
        stock = factory->get("NYSE:IBM");
        shared_ptr<Stock> stock2 = factory->get("NYSE:IBM");
        assert(stock == stock2);
        // factory destructs here
    }
    // stock destructs here
}

这样,无论Stock和StockFactory谁先挂掉都不会影响程序的正确运行。这里我们借助了shared_ptr和weak_ptr完美解决了两个对象相互引用的问题。

当然,通常Factory对象是一个singleton,在程序正常运行期间不会销毁,这里只是为了展示弱回调技术,这个技术在事件通知中非常有用。

以上StockFactory只有针对单个Stock对象的操作,如果程序需要遍历整个stocks_,稍不注意就会造成死锁或数据损坏,可参考第二章中的解决办法。

除了使用shared_ptr/weak_ptr,要想在C++里做到线程安全的对象回调与析构,可能的办法如下:

1.用一个全局的facade(facade是一种设计模式,通常用于创建一个简化的接口,用于访问复杂系统、库或子系统,以便客户端代码能够更容易地与系统交互,而不必了解系统内部的复杂性)来代理Foo类型对象访问,所有的Foo对象回调和析构都通过这个facade来做,即把指针替换为objId或handle,每次要调用队形的成员函数时先check-out(签出或借出,表示从某个库存、资源池或系统中获取一个项目、对象或资源以供使用),用完后再check-in,这样理论上能避免race condition,但代价很大,因为要想把这个facade做成线程安全的,那么必然要用互斥锁,让本来能并行执行的函数变成了串行执行,没能发挥多核优势。当然,也能像Java的ConcurrentHashMap那样用多个buckets,每个bucket分别加锁,以降低contention。

2.只创建不销毁,是无奈之举。

3.自己编写引用计数的智能指针,本质上是重新发明轮子,把shared_ptr实现一遍。正确实现线程安全的引用计数智能指针不容易,而高效实现就更困难,既然shared_ptr已经提供了完整的解决方案,就使用它。

4.将来在C++里有unique_ptr,能避免引用计数的开销,可能能在某些场合替换shared_ptr。

在其它语言中,如果有垃圾回收,做到线程安全的对象回调与析构就好办。Google的Go语言教程指出,没有垃圾回收的并发编程是困难的。但是由于指针算术(即允许指针操作和手动内存管理)的存在,在C/C++里实现全自动垃圾回收更加困难,而那些天生具备垃圾回收的语言在并发编程方面有明显优势,Java是目前支持并发编程最好的语言,它的util.concurrent库和内存模型是C++11效仿的对象。

本章通篇在讲如何安全使用(包括析构)跨线程的对象,但尽量减少使用跨线程的对象。用流水线、生产者消费者、任务队列这些有规律的机制,最低限度地共享数据,这是最好的多线程编程的建议。

Observer模式的本质问题在于其面向对象的设计,Observer是基类,这带来了非常强的耦合(在观察者模式中,通常有一个主题(Subject)和多个观察者(Observer),在典型的观察者模式中,观察者类通常被设计为一个基类,所有具体的观察者都派生自这个基类),强度仅次于友元,这种耦合不仅限制了成员函数的名字、参数、返回值,还限制了成员函数所属的类型(必须是Observer的派生类)。

Observer class是基类,这意味着如果Foo要想观察两个类型的事件(如时钟和温度,这通常需要从两个观察者基类中派生,Foo需要继承来自时钟事件观察者基类和温度事件观察者基类的接口和行为,以便成为同时观察这两种事件的观察者),需要使用多继承。这还不是最糟糕的,如果要重复观察同一类型的事件(如1秒一次的心跳和30秒一次的自检,需要同时继承两个不同的观察者基类,由于这两个事件类型相同,这两个不同的观察者基类又派生自相同的基类),就要用到一些伎俩来work around(解决办法、绕过方法),因为不能从一个Base class继承两次。

现在的语言一般可以绕过Observer模式的限制,如Java可以用匿名内部类,Java 8用Closure,C#用delegate,C++用boost::function/boost::bind。

在C++里为了替换Observer,可以用Signal/Slots(信号/槽是一种软件设计模式,通常用于编程框架和库,以实现对象之间的事件通信和解耦,这种模式最初由Qt库引入,但后来被许多其他编程语言和框架采用,Signal/Slots模式的核心思想是将对象之间的通信分离,使发出事件(信号)的对象不需要显式知道哪些对象会对该事件做出响应(槽),这种分离有助于降低代码的耦合性,提高代码的可维护性和可扩展性),此处指的不是QT那种靠语言扩展的实现,而是完全靠标准库实现的thread safe、race condition free、thread contention free的Signal/Slots,且不强制要求shared_ptr来管理对象。

在C++11中,借助variadic template(可变参数模板),实现最简单的一对多回调很容易:

cpp 复制代码
template<typename Signature> 
class SignalTrivial;

// NOT thread safe!!!
// 此处进行SignalTrivial的特化,在前向声明中,只有一个模板参数,而此处有两个模板参数
// 一般来说,在特化时特例化的模板的模板实参与原始模板中的模板参数按位置对应,如:
// template <typename T1, typename T2> class clazz { }
// template <typename T2> class clazz<int, T2> { }
// 上例中,int与T1对应,而SignlTrivial中,RET(ARGS...)与Signature对应
// 我们不必管这一行template<typename RET, typename... ARGS>
// 而只需要看模板特化时,类名后的模板参数是否与前向声明中的对应
// 此处的RET是返回类型,而ARGS是参数列表
template<typename RET, typename... ARGS>
class SignalTrivial<RET (ARGS...)>
{
public:
    typedef std::function<void (ARGS...)> Functor;
    
    // 将一个可调用对象连接到信号上,它接受一个Functor类型的右值引用,这表示可以将函数或函数对象与信号关联起来
    // 这是线程不安全的,不能同时向vector中push_back数据
    void connect(Functor &&func)
    {
        functors_.push_back(std::forward<Functor>(func));
    }
    
    // 用于触发信号并调用与之关联的所有槽
    // 它接受与Signature中定义的参数相匹配的参数包,并将这些参数传递给存储在functors_容器中的所有可调用对象
    // 此处在调用functors_中的函数时,需要迭代容器,如果其他线程在修改容器,会形成竞争条件
    void call(ARGS&&.. args) 
    {
        for (const Functor &f : functors_)
        {
            f(args...);
        }
    }

private:
    std::vector<Functor> functors_;
};
相关推荐
机智的叉烧11 分钟前
前沿重器[57] | sigir24:大模型推荐系统的文本ID对齐学习
人工智能·学习·机器学习
gywl1 小时前
openEuler VM虚拟机操作(期末考试)
linux·服务器·网络·windows·http·centos
量子-Alex1 小时前
【多模态聚类】用于无标记视频自监督学习的多模态聚类网络
学习·音视频·聚类
吉大一菜鸡1 小时前
FPGA学习(基于小梅哥Xilinx FPGA)学习笔记
笔记·学习·fpga开发
日记跟新中2 小时前
Ubuntu20.04 修改root密码
linux·运维·服务器
码农君莫笑3 小时前
信管通低代码信息管理系统应用平台
linux·数据库·windows·低代码·c#·.net·visual studio
BUG 4043 小时前
Linux——Shell
linux·运维·服务器
大霞上仙3 小时前
Linux 多命令执行
linux·运维·服务器
晨欣3 小时前
Kibana:LINUX_X86_64 和 DEB_X86_64两种可选下载方式的区别
linux·运维·服务器
AI青年志4 小时前
【服务器】linux服务器管理员查看用户使用内存情况
linux·运维·服务器