03 重修C++之并发实战3.5-3.8(3end)

上一篇:03 重修C++之并发实战3.3-3.4

03 重修C++之并发实战3.5-3.8(3end)

文章目录

    • 03 重修C++之并发实战3.5-3.8(3end)
      • 3.5 用 std::unique_lock 灵活锁定
      • 3.6 在作用域之间转移锁的所有权
      • 3.7 锁定在恰当的粒度
      • 3.8 用于共享数据保护的替代工具
        • 在初始化时保护数据
        • 保护很少更新的数据结构
        • 递归锁

3.5 用 std::unique_lock 灵活锁定

通过松弛不变量,std::unique_lockstd::lock_gurad提供了更多的灵活性,一个std::unique_lock实例并不总是拥有与之相关联的互斥元。首先,就像可以把std::adopt_lock作为第二个参数传递给构造函数,以便让锁对象来管理互斥元上的锁那样,也可以把std::defer_lock作为第二个参数传递,来表示该互斥元在构造时应该保持未被锁定。这个锁就可以在这之后通过std::unique_lock对象(不是互斥元)上调用lock(),或是通过将std::unique_lock对象本身传递给std::lock()来获取。使用std::unique_lockstd::defer_lock,而不是std::lock_guradstd::adopt_lock,能够很容易实现上述的一个例子。但是std::unique_lock占用更多的空间,并且比std::lock_gurad略慢。允许std::unique_lock实例其它互斥元不具有的灵活性是有代价的,代价就是有额外的信息必须被存储且随时更新。

void swap(int& ldata, int& rdata)
{
    int temp;
    temp = ldata;
    ldata = rdata;
    rdata = temp;
}

class X
{
private:
    int _data;
    std::mutex m;
public:
    X(const int& data):_data(data){ }
    virtual ~X() {}

    friend void swap(X& lhs, X& rhs)
    {	
        //检查参数是不是相同的实例
        if (&lhs == &rhs) //试图在已经锁定的 std::mutex 上获取锁是未定义行为
        {
            return; //允许在同一线程中多重锁定的互斥元为 std::recursive_mutex
        }
        /**********************使用lock_guard的方案***************************
        std::lock(lhs.m, rhs.m); //同时锁定两个互斥元
        //额外参数 std::adopt_lock 告知该方法,锁对象已被锁定,
        //并沿用已有锁的所有权而不是试图在构造函数中锁定互斥元。
        std::lock_guard lock_l(lhs.m, std::adopt_lock);
        std::lock_guard lock_r(rhs.m, std::adopt_lock);
        ********************************************************************/
        
        //使用unique_lock的方案 与原方案效果相同
        std::unique_lock<std::mutex> lock_l(lhs.m, std::defer_lock);
        std::unique_lock<std::mutex> lock_r(rhs.m, std::defer_lock);
        std::lock(lock_l, lock_r); //同时锁定两个unique_lock对象
        swap(lhs._data, rhs._data);
    }
};

在上述代码中std::unique_lock对象能够被传递得std::lock()是因为std::unique_lock提供了lock()unlock()try_lock()三个成员函数。他们会转发给底层互斥元同名的方法去做实际的工作,并且只是更新在std::unique_lock实例内部的一个标识,来表示该实例当前是否拥有此互斥元。这个标识是用来在判断析构时是否需要调用unlock()的。正因为要存放这个标识并且要维护这个标识的状态,所以相比于std::lock_guradstd::unique_lock的性能略有损失。一般都是如果std::lock_gurad能够满足需求会优先考虑使用std::lock_gurad。另外有一些情况比如延迟锁定,锁的所有权的域间转移等是需要std::unique_lock的参与。

3.6 在作用域之间转移锁的所有权

因为std::unique_lock实例并没有拥有与其相关的互斥元,所以通过四处转移(move)实例,互斥元的所有权可以在实例之间进行转移。在某些情况下这种转移是自动的,比如从一个函数中返回一个实例,而在这种情况下,必须通过调用std::move()来显示实现。从根本上说,这却决于源是左值(lvalue)还是右值(rvlaue)。

  • 如果源为右值,则所有权的转移是自动的。
  • 如果源是左值,所有权的转移必须是显式的。以免从变量中意外地转移了所有权。

std::unique_lock就是典型的可移动但是不可复制的类型。还有另一种用法就是允许函数锁定一个互斥元,并将此锁的所有权转移给调用者,于是调用者接下来可以在同一个锁的保护下执行额外的操作。

#include 
#include 
#include 

std::mutex some_mutex;

std::unique_lock<std::mutex> get_lock()
{
    //构造unique_lock对象
    std::unique_lock<std::mutex> lk(some_mutex, std::defer_lock);
    lk.lock();
    //some other operations
    std::cout << "prepare_data()" << std::endl;
    //这里可以直接返回,因为编译器负责调用移动构造函数。
    return lk; 
}

void process_data()
{
    //将锁的所有权转移到自己身上
    std::unique_lock<std::mutex> lk_xxx(get_lock());
    lk_xxx.try_lock(); //尝试锁定 这里一定会出错,是为了测试
    //do something
    std::cout << "do_something()" << std::endl;
}
int main(int argc, const char** argv) {
    process_data();
    return 0;
}

/*************************************************
运行结果:
[wangs7@localhost 3rd_chapter]$ ./exec
prepare_data()
terminate called after throwing an instance of 'std::system_error'
  what():  Resource deadlock avoided
Aborted (core dumped)
*************************************************/

这里看到在尝试再次锁定的时候抛出了异常,说明锁的所有权已经转移到新的函数中了(也可以用unlock测试)。通常这种模式是待锁定的互斥元依赖于当前的状态,或者依赖于传递给返回std::unique_lock对象的函数的参数的地方。这种用法之一,就是不直接返回锁,使用一个网关类的数据成员,以确保正确锁定了需要保护的数据的访问。这种情况下所有对该数据的访问都是通过这个网关类,当想要访问数据时,就获取这个网关类的实例(类似上述的get_lock()函数),他会获取锁。然后,可以通过网关对象的成员函数访问数据。在完成后,销毁网关对象,从而释放锁,并允许其它线程访问受保护的数据。这样的网关对象很可能是可移动的,在这种情况下,锁对象的数据成员也是需要可移动的。

简单理解就是锁和数据放在一起,但是内部访问数据不是直接取锁,要通过new一新的网关类来拿锁的所有权,拿到所有权之后开始操作,这时如果有其他线程也来new网关对象是拿不到所有权的,等上一个完成操作,销毁网关类之后其它线程才能去拿锁。大致是这种思想,具体的数据结构还是要根据具体情况去设计。

3.7 锁定在恰当的粒度

选择一个合适粒度的锁,来保证所有需要保护的数据都被保护是很重要的,而其要保证只有真正需要锁的操作中持有锁。在持有锁的时候不要做任何特别耗时的活动,如文件I/O,除非这个锁是为了保护文件访问。我们要在保证线程安全的前提下尽量减小锁的范围。

如果让一个锁保护整个数据结构,不仅可能会出先对锁的竞争,而且更多操作步骤会需要在同一个锁的保护下进行,所以锁的持有时间会变长,并发性能会下降,所以在这种情况下细粒度的锁是很有必要的。

锁定在恰当的粒度不仅关乎锁定的数据量;也关系到锁会持有多长时间,以及在持有锁时执行哪些操作。**一般情况下,只应该以执行要求的操作所需的最小可能时间去持有锁。**这意味着耗时操作,比如获取另一个锁(即使你知道它不会死锁)或是等待I/O完成,都不应该在持有锁的时候去做,除非绝对有必要。如果不能在整个操作持续时间内持有锁,那么就会把自己暴露在竞争条件中。有些时候,或者说更多时候是没有一个合适粒度级别的,因为并非所有对数据结构的访问都需要同样级别的保护。这种情况下,使用替代机制来代替互斥元可能更加合适。

3.8 用于共享数据保护的替代工具

虽然互斥元是最通用的机制,但它不是保护数据的唯一选择,还有其它替代品可以在某些特定条件下提供更恰当的保护。一个比较极端的情况下(却很常见)就是共享数据只在初始化的时候需要并发访问的保护,但在初始化之后就不再需要显式同步。在数据初始化之后锁定互斥元,纯粹是为了保护初始化(?),但是这不是必要的,并且对性能会产生不必要的打击。所以C++标准提供了一种机制,纯粹为了在初始化过程中保护数据。

在初始化时保护数据

假设有一个构造起来非常昂贵的共享资源,只有在实际需要时你才会初始化。例如,它会打开一个数据库链接或分配大量内存。像这样的延迟初始化在单线程代码中是很常见的——每个请求资源的操作首先检查它是否经过初始化,如果没有就在使用前初始化。

#include 
#include 
#include 
#include 

std::shared_ptr<std::string> string_ptr;

void foo()
{
    if (!string_ptr)
    {
        string_ptr.reset(new std::string("hello!"));
    }
    std::cout << "string_ptr = " << *string_ptr << std::endl;
}

int main(int argc, const char** argv) {
    foo();
    return 0;
}

当然在多线程并发的过程中,如果共享资源本身对于并发访问是安全的,将其转化成多线程代码时唯一需要考虑要保护的部分就是初始话,例如下面这段代码(使用互斥元进行线程安全的延迟初始化),但是下面的代码有一个问题就是会引起使用该资源的线程产生不必要的序列化。这是因为每个线程都必须等待互斥元,以检查资源是否已经初始化。

#include 
#include 
#include 
#include 

std::shared_ptr<std::string> string_ptr;
std::mutex string_mutex;

void foo()
{
    std::unique_lock<std::mutex> lk(string_mutex);
    if (!string_ptr)
    {
        string_ptr.reset(new std::string("hello!"));
    }
    lk.unlock();
    std::cout << "string_ptr = " << *string_ptr << std::endl;
}

int main(int argc, const char** argv) {
    foo();
    return 0;
}

这段代码是很常见的,但是不必要的序列化问题已经很大了,以至于许多人都尝试想出一种更好的解决办法,包括臭名昭著的二次检查锁定,再不获取锁的前提下首次读取指针,并仅当空指针时获取锁,一旦获取锁就要再次检查,以防在首次检查和这个线程获取锁之间,另一个线程就已经完成初始化。

void undefined_behaviour_with_double_checked_locking()
{
    if (!string_ptr) //1
    {
        std::lock_guard<std::mutex> lk(string_mutex);
        if (!string_ptr) //2
        {
            string_ptr.reset(new std::string("hi!")); //3
        }
    }
    std::cout << "string_ptr = " << *string_ptr << std::endl;
}

不行的是这种模式因为某些原因而臭名昭著。

有可能产生恶劣的竞争条件,因为在锁外部的读取【1】和锁内部又另一个线程完成写入【3】不同步,这个竞争条件不仅涵盖指针也涵盖了指向的对象。就算一个线程看见了另一个线程写入的指针,它也可能看不见新创建的string对象,从而导致下面的操作在错误的值上运行。这种竞争行为被定义成数据竞争是一种未定义的行为。

在我们看来一旦一个线程完成初始化,那么string_ptr一定不为空。但事实并非如此,如果两个线程同时进行初始化操作,A拿到了锁开始执行初始化,B没有拿到锁并等待锁,在A初始化完成后释放锁,且此时指针不为空,并指向有效对象。但是当B获得锁进行第二次检查时很有可能还会看到一个空指针,因为B可能不会从新去内存中读指针的数据,而是直接用缓存的数据做判断,这就造成了内存泄漏 。为了解决这个问题,需要把指针前面加上volatile,表示告诉编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。

你可能没想到这个模式还有问题,这就是这个模式臭名昭著的原因。由于不同编译器不同操作系统,对于变量的初始化可能有不同的方法,包括但不限于一下两种。有的是先分配好内存并把地址赋给指针,然后开始数据初始化;有的是先分配内存完成数据初始化后再将地址赋给指针。就第一种而言对于多线程是很危险的,假定A正在初始化变量,刚刚将内存地址赋值给指针,但是还没来得及初始化数据,但是被调度器打断了,这时另一个线程B去检查指针,发现不为空,并拿着指针去执行其它动作。这是很危险的,所以这个看似合理的办法其实漏洞百出,一种挽救的做法是,先使用一个中间量初始化,初始化结束后再将中间量赋给指针。

千万别用这东西!!!看看就得了!!!

但是现在C++标准库提供了std::once_flagstd::call_once来处理这种情况。与其锁定互斥元不停显式地检查指针,还不如每个线程都使用std::call_once,到std::call_once返回时,指针会被某个线程初始化(以同步的方式),这样就安全了。使用std::call_once比显式地使用互斥元通常会有更低的开销,特别是初始化已经完成的时候所以在std::call_once符合所有要求的功能时应优先考虑使用。

#include  //改进后的上述方案
#include 
#include 
#include 

std::shared_ptr<std::string> string_ptr;
std::once_flag string_flag;

void init_string()
{
    string_ptr.reset(new std::string("Hi~"));
}

void foo()
{
    std::call_once(string_flag, init_string);
    std::cout << "string = " << *string_ptr << std::endl;
}

int main(int argc, const char** argv) {
    foo();
    return 0;
}
  • 其它示例:使用std::call_once的线程安全的类成员延迟初始化(单例模式改进版)
#include 
#include 
#include 
#include 

class X
{
private: 
    int num;
    X() { };
public:
    static X* get_instance()
    {
        static X* instance;
        static std::once_flag flag;
        std::call_once(flag, []{instance = new X;});   
        return instance;
    }
    
    void set_num(int new_num) { num = new_num; } 
    void show_num() { std::cout << "num = " << num << std::endl; }
    X(const X &) = delete;
    X &operator=(const X &) = delete;
    ~X(){};    
};

int main(int argc, const char** argv) {

    X* a = X::get_instance();
    std::cout << "a set num:2333333" << std::endl;
    a->set_num(2333333);
    std::cout << "a.show() num is " ;
    a->show_num();
    X* b = X::get_instance();
    std::cout << "b.show() num is " ;
    b->show_num();
    std::cout << "b set num:55555" << std::endl;
    b->set_num(55555);
    std::cout << "a.show() num is " ;
    a->show_num();

    std::thread t([]{
        X* c = X::get_instance();
        std::cout << "[Thread]c.show() num is " ;
        c->show_num();
    });
    t.join();
    return 0;
}
/*************************************************
运行结果:
[wangs7@localhost 3rd_chapter]$ ./exec
a set num:2333333
a.show() num is num = 2333333
b.show() num is num = 2333333
b set num:55555
a.show() num is num = 55555
[Thread]c.show() num is num = 55555
*************************************************/

注:以上代码编译过程需要链接 pthread 库。

保护很少更新的数据结构

假设有一个用于存储DNS条目缓存的表,它用来将域名解析为相应的IP地址。通常,一个给定的DNS条目将在很长的一段时间里保持不变——许多情况下,DNS条目会保持数年不变。虽然随着用户访问不同的网站,新的条目可能暂时会不时地添加到表中,但这一数据却将在其整个生命周期中基本保持不变。定期检查缓存条目地有效性时很重要套的,但是只有细节已有事迹改变地时候才会需要更新。

虽然更新是罕见地,但是它们会发生,并且如果这个缓存可以从多个线程访问,它就需要在更新过程中适当进行保护,以确保所有线程在读取缓存时都不会看到损坏的数据结构。在缺乏完全符合预期用法并且为并发更新与读取专门设计的专用数据结构的情况下,这种更新要求线程在进行更新时独占访问数据结构,直到它完成了操作。一旦更新完成,该数据结构对多线程并发访问又是安全的。使用std::mutex来保护数据结构就显得大费周章了,因为这会在数据结构没有进行修改时消除并发读取数据结构的可能,因而我们需要的是另一种互斥元,这种互斥元通常称为读写互斥元(读写锁),因为它考虑到了两种不同的用法:单个“写”线程独占访问或共享,由多个“读”线程并发访问。

这里使用boost::shared_mutex的实例来实现同步,而不是std::mutex实例。对于更新操作,std::lock_guradstd::unique_lock可用于锁定。当然boost::shared_mutex不是万能的,性能依赖于处理器的数量以及读线程和更新线程的相对工作负载。因此,分析代码在目标系统上的性能是很重要的,以确保额外的复杂度会有实际的收益。

#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

class dns_entry{

};

class dns_cache
{
    map<string, dns_entry> entries;
    mutable boost::shared_mutex entry_mutex;
public:
    bool find_entry(string const& domain) const
    {
        //使用boost::shared_lock<>实例来保护它,以供共享和只读访问
        //多个线程可以同时调用find_entry()
        boost::shared_lock<boost::shared_mutex> lk(entry_mutex);
        map<string, dns_entry>::const_iterator  it = entries.find(domain);
        return (it == entries.end()) ? false : true;
    }
    void update_or_add_entry(string const& domain, dns_entry const& dns_details)
    {
        //lock_guard表示更新时被占用,仅提供独享访问
        //所有调用update_or_add_entry()和find_entry()都会被阻塞
        lock_guard<boost::shared_mutex> lk(entry_mutex);
        entries[domain] = dns_details;
    }
     
};


int main(int argc, const char** argv) {
    dns_cache cache;
    dns_entry entry;
    cache.update_or_add_entry("baidu", entry);
    if (cache.find_entry("baidu"))
        std::cout << "find baidu" << std::endl;
    else
        std::cout << "can't find baidu" << std::endl;

    if (cache.find_entry("google"))
        std::cout << "find google" << std::endl;
    else
        std::cout << "can't find google" << std::endl;

    cache.update_or_add_entry("google", entry);

    if (cache.find_entry("google"))
        std::cout << "find google" << std::endl;
    else
        std::cout << "can't find google" << std::endl;

    return 0;
}

说明:

boost库需要手动安装,添加环境变量,并且编译时要添加相应的静态库。

静态库:boost_log、boost_log_setup、boost_system、boost_filesystem、boost_serialization 、boost_thread、boost_chrono

上述代码编译时要额外添加 -lboost_thread 选项。

/*************************************************
运行结果:
find baidu
can't find google
find google
*************************************************/

递归锁

在使用std::mutex的情况下,一个线程试图锁定已经拥有的互斥元是错误的,并且试图这么做将导致未定义行为。然而在某些情况下线程多次重复获取同一个互斥元却无需先释放它是可取的。为了这个目的,C++标准库提供了std::recursive_mutex,使用方法同std::mutex,不同的是可以在一个线程中的单个实例上获取多个锁。在互斥元能够被另一个线程锁定之前,当前锁定互斥元的线程一定要释放锁,而且lock多少次就要对应unlock多少次。使用std::lock_guradstd::unique_lock也能帮助你正确处理这些互斥元。

当然这种递归锁的做法是不推荐的,如果设计需要用到这样的结构,那么最好考虑改变你的设计。

【2021.11.02】

下一篇:03 重修C++之并发实战4

你可能感兴趣的:(重修C++之路,c++,开发语言,后端)