首页 > 编程语言 >【并发编程】第三章 在线程之间共享数据

【并发编程】第三章 在线程之间共享数据

时间:2024-12-08 17:27:42浏览次数:4  
标签:std 第三章 互斥 lock 编程 some 并发 线程 mutex

3.1 线程间共享数据的问题

如果所有共享数据都是只读的,则不会有问题,因为一个线程读取的数据不受另一个线程的影响

  • 不变量(invariants):在程序或数据结构的特定状态下始终为真的属性或条件
  • 无论代码如何执行,这个不变量都应该始终保持成立。如果不成立,那就可能出现了错误

考虑一个双向链表。其中一个不变量是:如果节点(A)的“下一个”指针指向节点(B),则该节点(B)的“上一个”指针将指向节点(A)。

  • 为了从列表中删除一个节点,必须更新两侧的节点以相互指向:
    a 确定要删除的节点:N
    b 更新从 N 之前的节点到 N 之后的节点的链接
    c 更新从 N 之后的节点到 N 之前的节点的链接
    d 删除节点 N。

在步骤 b 和 c 之间,朝一个方向的链接与朝相反方向的链接不一致,不变量被破坏了

在这里插入图片描述

  • 如果一个线程正在读链表,另一个线程正在删除一个节点,那么读取线程很可能会看到一个不变量被破坏了的链表

  • 如果另一个线程试图删除图中的最右边的节点,它可能最终会永久损坏数据结构并最终导致程序崩溃
    这就是并发代码中错误的最常见原因之一:竞争条件

3.1.1 竞争条件

竞争条件是指多个线程或进程同时访问和操作共享资源时,可能会出现的一种情况。当它们尝试同时修改或访问同一个共享资源时,就可能会引发竞争条件。当竞争条件导致不变量被破坏时,就会出现问题

C++标准还将“数据竞争”一词定义为由于对单个对象的并发修改而引起的特定类型的竞争条件

  • 竞争条件的问题往往难以复现。由于竞争条件通常对时间敏感,因此在应用程序在调试器下运行时,它们往往会完全消失,因为调试器会影响程序的时间,即使只是轻微的。
  • 使用并发性编写软件时,大量复杂性来自于如何避免有问题的竞争条件

3.1.2 避免有问题的竞态条件

处理竞争条件问题的几种方法:

  • 使用保护机制包装数据结构,确保只有进行修改的线程能看到违反不变量的中间状态

  • 修改数据结构及其不变量的设计,每个更改都保持不变量。这通常被称为无锁编程,很难做好(内存模型的细微差别、确定哪些线程可能看到哪些值集)

  • 将对数据结构的更新处理为事务,就像对数据库的更新在事务中完成一样。所需的一系列数据修改和读取存储在事务日志中,然后在单个步骤中提交。如果由于数据结构已被另一个线程修改而无法继续提交,则重新启动事务。这被称为软件事务内存 (STM)。C++中没有对 STM 的直接支持

  • C++标准提供的保护共享数据的最基本机制是互斥锁(mutex)

3.2 用互斥锁保护共享数据

mutex :mutual exclusion,相互排斥

  • 在访问共享数据之前,锁定与该数据相关联的互斥锁
  • 完数据成访问后,解锁互斥锁

线程库确保一旦一个线程锁定了特定的互斥锁,所有其他尝试锁定同一互斥锁的线程都必须等待,直到成功锁定互斥锁的线程解锁它

互斥锁也有自己的问题,可能形成死锁以及保护过多或过少的数据

3.2.1 在C++中使用互斥锁

  • 在 C++中,可以通过构造 std::mutex 的实例来创建一个互斥锁,使用对 lock()成员函数的调用来锁定它,并使用对 unlock()成员函数的调用来解锁它
  • 标准 C++库提供了 std::lock_guard 类模板,它为互斥锁实现了 RAII 锁定的互斥锁
#include <list>
#include <mutex>
#include <algorithm>
std::list<int> some_list;  
std::mutex some_mutex;   
void add_to_list(int new_value){ //将新值添加到列表中
    std::lock_guard<std::mutex> guard(some_mutex);  
    some_list.push_back(new_value);                
}
bool list_contains(int value_to_find){//检查列表中是否包含指定的值
    std::lock_guard<std::mutex> guard(some_mutex);  
    return std::find(some_list.begin(), some_list.end(), value_to_find) != some_list.end();
}

注:锁住的是 some_mutex 这个变量,而不是 some_list 所在的内存

  • 如果 list_contains 不对 some_mutex 上锁,那么在 add_to_list 对 some_list 进行修改时,list_contains 可能会看到不完整或过时的数据
  • std::lock_guard 类在构造时会自动锁定传入的互斥锁,在析构时自动解锁

C++17 支持类模板参数推导,对于简单类模板,模板参数列表通常可以省略:

std::lock_guard guard(some_mutex);

C++17 还引入了一种增强版的锁定保护器:

std::scoped_lock guard(some_mutex);

3.2.2构建保护共享数据的代码

使用互斥锁保护数据并不像在每个成员函数中添加 std::lock_guard 对象那么简单;一个迷途的指针或引用,将使得所有的保护都失效

class some_data {
    int a;
    std::string b;
public:
    void do_something();
};
class data_wrapper {
private:
    some_data data;
    std::mutex m;
public:
    template<typename Function>
    void process_data(Function func) {
        std::lock_guard<std::mutex> l(m);
        func(data);
    }
};

some_data* unprotected;
void malicious_function(some_data& protected_data) {
    unprotected = &protected_data;
}
data_wrapper x;
void foo() {
    x.process_data(malicious_function);
    // 这是不安全的,因为没有使用互斥锁
    unprotected->do_something(); 
}

3.2.3 发现接口中固有的竞争条件

即使使用了互斥锁或其他机制来保护共享数据,也并不意味着就可以避免竞争条件

template<typename T, typename Container = std::deque<T> >
class stack{ // 定义一个模板类,用于表示栈
public:
    explicit stack(const Container&);
    explicit stack(Container&& = Container());    template <class Alloc> explicit stack(const Alloc&);
    template <class Alloc> stack(const Container&, const Alloc&);
    template <class Alloc> stack(Container&&, const Alloc&);
    template <class Alloc> stack(stack&&, const Alloc&);    bool empty() const;    	// 检查栈是否为空
    size_t size() const;	// 获取栈的大小
    T& top();  		// 获取栈顶部的元素
    T const& top() const; 
    void push(T const&);	// 向栈顶部压入一个元素
    void push(T&&);	
    void pop();		// 从栈顶部弹出一个元素
    void swap(stack&&);	// 交换两个栈的内容
    template <class... Args> void emplace(Args&&... args); //在堆栈顶部构造一个新元素部
};

无法依赖 empty() 和 size() 的结果。虽然它们在调用时可能是正确的,但一旦返回,其他线程就可以自由访问栈,并可能在调用 empty() 或 size() 的线程使用该信息之前,添加或删除元素

这是一个经典的竞争条件,使用内部互斥锁来保护栈内容并不能防止它的发生;这是接口的问题


如果栈内部受到一个互斥锁的保护,那么在任何时候,只有一个线程可以运行栈的成员函数,因此调用可以很好地交错,但是对 do_something() 的调用可以并发运行

在这里插入图片描述

  • 两个线程将看到相同的value
  • 栈上的两个值,一个从未被使用,而另一个被处理了两次
    这是另一种竞争条件

  • 这要求对接口进行更彻底的修改,即在互斥锁的保护下将top()和pop()的调用结合起来。然而,如果栈上对象的拷贝构造函数可能抛出异常,这种组合调用会引发问题
  • 例如当拷贝vector时,内存分配可能失败,因此vector的拷贝构造函数可能会抛出std::bad_alloc异常
  • 如果pop()函数被定义为返回被弹出的值,并同时从栈中移除它,那么就可能遇到一个问题:只有在栈被修改之后,被弹出的值才会返回给调用者,但将数据拷贝到调用者的过程可能会抛出异常。如果发生这种情况,弹出的数据就会丢失;它已经从栈中被移除,但拷贝操作未能成功!

std::stack接口的设计者将操作巧妙地拆分为两步:首先获取栈顶元素(通过top()函数),然后从栈中移除它(通过pop()函数),这样,如果无法安全地拷贝数据,它仍然会留在栈上

选项1:传入引用
在调用pop()时,将想要接收弹出值的变量的引用作为参数传递进去,缺点:

  • 调用代码需要在调用之前构建栈类型的实例,以便将其作为目标传递进去
  • 对于某些类型来说,这是不实际的,因为构建实例在时间或资源方面代价高昂。对于其他类型,构造函数在这一点上不一定能得到有效的参数
  • 要求存储的类型是可赋值的:许多自定义的类型不支持赋值,尽管它们可能支持移动构造甚至拷贝构造

选项2:需要拷贝构造函数或移动构造函数承诺不抛出异常

  • 将线程安全栈的使用限制在那些可以安全地按值返回而不会抛出异常的类型上
  • 即使可以使用std::is_nothrow_copy_constructible和std::is_nothrow_move_constructible类型特征在编译时进行检测,但它的限制也很大

选项3:返回一个指向弹出项的指针

  • 这样做的优点是可以自由拷贝指针而不会抛出异常
  • 缺点是返回指针需要一种管理分配给对象的内存的方法,对于诸如整数之类的简单类型,这种内存管理的开销可能超过按值返回类型的成本。std::shared_ptr将是一个很好的选择

选项4:既提供选项1,也提供选项2或3

  • 如果选择了选项 2 或 3,那么提供选项 1 也相对容易,只需很少的额外成本就可获得灵活性

下面展示了一个在接口中没有竞争条件的栈类型定义,实现了选项 1 和 3:

#include <exception>
#include <memory> //std::shared_ptr<>
struct empty_stack : std::exception{
    const char* what() const noexcept;// 重写 what() 函数,返回异常信息
};
// 定义一个线程安全栈模板类,使用类型参数 T
template<typename T>
class threadsafe_stack{
public:
    threadsafe_stack();
    threadsafe_stack(const threadsafe_stack&);
    threadsafe_stack& operator=(const threadsafe_stack&) = delete;
    void push(T new_value);
    std::shared_ptr<T> pop();
    void pop(T& value);
    bool empty() const;// 检查栈是否为空
};
template<typename T> class threadsafe_stack{
private:
    std::stack<T> data;
    mutable std::mutex m;
public:
    threadsafe_stack(){}

    threadsafe_stack(const threadsafe_stack& other){
        std::lock_guard<std::mutex> lock(other.m);
        data=other.data;
    }

    threadsafe_stack& operator=(const threadsafe_stack&) = delete;

    void push(T new_value){
        std::lock_guard<std::mutex> lock(m);
        data.push(std::move(new_value));
    }
    std::shared_ptr<T> pop(){
        std::lock_guard<std::mutex> lock(m);
        if(data.empty()) throw empty_stack(); 	//同下
	 std::shared_ptr<T> const res(std::make_shared<T>(data.top()));
        data.pop();
        return res;
    }
    void pop(T& value){
        std::lock_guard<std::mutex> lock(m);
        if(data.empty()) throw empty_stack(); 	//在尝试弹出值之前检查是否为空
        value=data.top();				//在修改栈之前分配返回值
        data.pop();
    }

    bool empty() const{
        std::lock_guard<std::mutex> lock(m);
        return data.empty();
    }
};


  • 如果堆为空,pop()函数抛出一个 empty_stack 异常,因此即使在调用 empty()之后修改了堆栈,一切仍然可以正常工作

  • 不允许赋值,并且不支持swap,只能拷贝

  • 有两个 pop()的重载,一个接受存储值的位置的引用,另一个返回 std::shared_ptr< >

  • 接口的这种简化允许更好地控制数据

3.2.4死锁:问题及解决方法

一对线程中的每一个都需要锁定一对互斥锁才能执行某个操作,并且每个线程都拥有其中一个互斥锁,并在等待另一个。两个线程都无法继续执行,因为它们都在等待对方释放其持有的互斥锁。这种情况被称为死锁

  • 避免死锁的常见建议是始终以相同的顺序锁定这两个互斥锁:如果总是先锁定互斥锁 A 再锁定互斥锁 B,那么你就永远不会遇到死锁
  • 但当每个互斥锁都在保护同一个类的不同实例时就没那么简单了
  • 假设有一个操作需要在两个相同类的实例之间交换数据;为了不受并发修改的影响,必须同时锁定这两个实例上的互斥锁。但是,如果先锁定作为第一个参数传递的实例的互斥锁,然后再锁定作为第二个参数传递的实例的互斥锁,这可能会适得其反:只需要有两个线程尝试在相同的两个实例之间交换数据,但参数的顺序调换了,就会造成死锁!

C++标准库提供了一种解决方案,即 std::lock:一个可以一次性锁定两个或多个互斥锁而无死锁风险的函数


class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);
class X{
private:
    some_big_object some_detail;
    std::mutex m;
public:
    X(some_big_object const& sd) : some_detail(sd) {}
    friend void swap(X& lhs, X& rhs){
        if (&lhs == &rhs) return;   // 如果lhs 和 rhs 指向同一个对象,直接返回
        std::lock(lhs.m, rhs.m);    // 同时锁定 lhs.m 和 rhs.m
        std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock);
        std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock);
        swap(lhs.some_detail, rhs.some_detail);
    }
};

  • std::adopt_lock参数是为了告诉std::lock_guard对象,互斥锁已经被锁住,不要试图在构造函数中对互斥锁上锁
  • 这确保了函数退出时能正确地解锁;但在调用 std::lock 时,锁定 lhs.m 或 rhs.m 都可能抛出异常,在这种情况下,异常会从 std::lock 中传播出去。如果 std::lock 成功地获取了一个互斥锁,而在尝试获取另一个互斥锁时抛出了异常,那么第一个锁会自动释放

C++17 以新的 RAII 模板 std::scoped_lock< >的形式为这种情况提供了额外的支持。它与 std::lock_guard< >完全等效,只是它是一个可变参数模板:

void swap(X& lhs, X& rhs){
    if (&lhs == &rhs) return;
    std::scoped_lock guard(lhs.m, rhs.m);
    swap(lhs.some_detail, rhs.some_detail);
}

这个例子使用了C++17添加的另一个特性:类模板参数的自动推导

3.2.5避免死锁的进一步指导

  • 死锁不仅会出现在互斥锁上,尽管这是最常见的原因
  • 死锁可以通过让每个线程在另一个线程的std::thread对象上调用join()的方式出现

避免锁嵌套

  • 如果一个线程已经持有一个锁,再去获取第二个锁,就有可能造成死锁。因此,尽量避免在一个锁内部获取另一个锁
  • 如果你需要获取多个锁,请使用std::lock将其作为一个单一操作来完成,以避免发生死锁

避免在持有锁时调用用户提供的代码

  • 无法预知用户代码可能执行的操作;它可能执行获取锁。如果在持有锁的情况下调用用户提供的代码,而该代码又获取了另一个锁,那么就违反了避免嵌套锁的指导原则
  • 有时这是无法避免的;如果在编写通用代码,例如前面的栈,那么对参数类型进行的每个操作都属于用户提供的代码。在这种情况下,需要一个新的指导原则

以固定的顺序获取锁

  • 如果必须获取两个或多个锁,且无法使用std::lock作为单个操作锁住它们,那么最佳选择是在每个线程中以相同的顺序获取它们

保护列表的一种可能方法:

  1. 为每个节点设置一个互斥锁。
  2. 为了访问列表,线程必须获取它们感兴趣的每个节点上的锁,对于要删除项的线程,它必须然后获取要删除的节点和两侧节点的锁
  3. 为了遍历列表,线程必须在获取序列中的下一个节点的锁时保持当前节点的锁,以确保下一个指针在此期间不会被修改。一旦获得了下一个节点的锁,就可以释放第一个节点的锁,因为它不再需要了。
  4. 为了防止死锁,节点必须始终以相同的顺序锁定:如果节点 A 和 B 在列表中相邻,那么一个方向的线程将尝试持有节点 A 的锁并尝试获取节点 B 的锁。另一个方向的线程将持有节点 B 的锁并尝试获取节点 A 的锁——这是死锁的典型场景
  5. 删除位于节点 A 和 C 之间的节点 B 时,如果该线程在获取 A 和 C 的锁之前获取了 B 的锁,则它有可能与遍历列表的线程死锁。这样的线程会首先尝试锁定 A 或 C(取决于遍历的方向),但会发现无法获得 B 的锁,因为正在删除的线程持有 B 的锁并试图获取 A 和 C 的锁

线程以相反的顺序遍历列表时发生死锁

在这里插入图片描述

使用锁层次结构

  • 锁层次结构可以提供一种在运行时检查是否遵守顺序约定的方法
  • 其思想是将应用程序划分层次
  • 当代码尝试锁定一个互斥锁时,如果它已经持有较低层的锁,则不允许锁定该互斥锁

C++标准库没有对此提供支持;
自定义的类型,只要实现了满足互斥锁概念所需的三个成员函数:lock()、unlock() 和 try_lock(),就可以与std::lock_guard<> 一起使用

hierarchical_mutex high_level_mutex(10000); 
hierarchical_mutex low_level_mutex(5000); 
hierarchical_mutex other_mutex(6000); 
int do_low_level_stuff(); // 用于处理低层次的事情
int low_level_func() { // 内部使用low_level_mutex来保护共享资源
    std::lock_guard<hierarchical_mutex> lk(low_level_mutex); 
    return do_low_level_stuff(); 
}
void high_level_stuff(int some_param); // 用于处理高层次的事情
void high_level_func() { 
    std::lock_guard<hierarchical_mutex> lk(high_level_mutex); 
    high_level_stuff(low_level_func()); 
}
void thread_a() { high_level_func(); } // 正确
void do_other_stuff(); 
void other_stuff() { 
    high_level_func(); 
    do_other_stuff(); 
}
void thread_b() { // 错误
    std::lock_guard<hierarchical_mutex> lk(other_mutex); 
    other_stuff(); 
}

如果锁上了一个hierarchical_mutex,那么只能锁上层次更低的hierarchical_mutex


hierarchical_mutex 的实现使用了一个线程局部变量来存储当前的层次值。这个值对所有互斥锁实例都是可访问的,但在每个线程上其值是不同的。每个互斥锁的代码都可以检查当前线程是否允许锁定该互斥锁

class hierarchical_mutex  {  
private:  
    std::mutex internal_mutex;  
    unsigned long const hierarchy_value;   // 该互斥锁实例的层次值  
    unsigned long previous_hierarchy_value;  // 线程在锁定此互斥锁前的层次值,用于在解锁时恢复   
    static thread_local unsigned long this_thread_hierarchy_value;//每个线程都有自己的拷贝  
    
    void check_for_hierarchy_violation()  {  
        if(this_thread_hierarchy_value <= hierarchy_value)  {  
            throw std::logic_error("mutex hierarchy violated");  
        }  
    }  

    void update_hierarchy_value()  {  
        previous_hierarchy_value = this_thread_hierarchy_value;  
        this_thread_hierarchy_value = hierarchy_value;  
    }  
public:  
    explicit hierarchical_mutex(unsigned long value)  
        : hierarchy_value(value),  previous_hierarchy_value(0)  {}   
    void lock()  {  
        check_for_hierarchy_violation();  
        internal_mutex.lock();  
        update_hierarchy_value();  
    }  
    void unlock()  {  
        if(this_thread_hierarchy_value != hierarchy_value)  
            throw std::logic_error("mutex hierarchy violated");  
        this_thread_hierarchy_value = previous_hierarchy_value;  
        internal_mutex.unlock();  
    }  
    bool try_lock()   {  
        check_for_hierarchy_violation();  
        if(!internal_mutex.try_lock())  return false;  
        update_hierarchy_value();  
        return true;  
    }  
};  
// 初始化线程局部静态变量,设为最大值,表示线程在开始时没有锁定任何互斥锁  
thread_local unsigned long  hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);

将这些指导原则扩展到锁之外

  • 死锁也可能发生在任何可能导致环形等待的同步结构上。因此,将这些准则扩展到这些情况也是值得的
  • 就像应该避免嵌套锁一样,在持有锁时等待线程也是一个坏主意
  • 因为另一个线程可能需要获取该锁才能继续
  • 如果要等待另一个线程完成,可能值得采用一个线程层次结构,以便线程只等待层次结构中较低的线程

3.2.6使用std::unique_lock实现灵活锁定

  • std::unique_lock 通过放宽不变式提供了比 std::lock_guard 更多的灵活性
  • std::unique_lock 实例并不总是拥有与其关联的互斥锁
  • 可以将 std::adopt_lock 作为第二个参数传递给构造函数,以让锁对象管理互斥锁,也可以将 std::defer_lock 作为第二个参数传递,以指示在构造时应保持互斥锁未锁定
  • 可以通过在 std::unique_lock 对象(而不是互斥锁)上调用 lock()或将 std::unique_lock 对象传递给 std::lock()来稍后获取锁
  • std::unique_lock 占用更多空间并且使用起来比 std::lock_guard 稍慢。允许 std::unique_lock 实例不拥有互斥锁的灵活性是有代价的:必须存储此信息并且必须更新它。
class some_big_object;  
void swap(some_big_object& lhs, some_big_object& rhs);  
class X  {  
private:  
    some_big_object some_detail;  
    std::mutex m;  
public:  
    X(some_big_object const& sd) : some_detail(sd) {}  
    friend void swap(X& lhs, X& rhs)  {  
        if (&lhs == &rhs)  return;  
        // 为lhs和rhs的互斥锁创建unique_lock对象,但初始时不锁定它们  
        std::unique_lock<std::mutex> lock_a(lhs.m, std::defer_lock);  
        std::unique_lock<std::mutex> lock_b(rhs.m, std::defer_lock);  
        std::lock(lock_a, lock_b);  // 使用std::lock函数同时锁定lhs和rhs的互斥锁,确保线程安全
        swap(lhs.some_detail, rhs.some_detail);  
    }  
};

除非需要转移锁的所有权或者执行其他需要std::unique_lock的操作,否则,如果C++17的std::scoped_lock可用,最好还是使用它

因为std::unique_lock提供了lock()、try_lock()和unlock()成员函数。这些成员函数会调用底层互斥锁上同名的成员函数来执行工作,并更新std::unique_lock实例内部的标志位,以指示该互斥锁当前是否由该实例拥有。这个标志位是必要的,以确保在析构函数中正确调用unlock()

  • 如果实例拥有互斥锁,析构函数必须调用unlock();
  • 如果实例不拥有互斥锁,则不能调用unlock()
  • 可以通过调用owns_lock()成员函数来查询这个标志位

3.2.7在作用域之间转移互斥锁的所有权

process_data 函数首先通过调用get_lock来获取锁,然后在保护的代码块中执行需要互斥访问的操作

std::unique_lock<std::mutex> get_lock(){// 获取互斥体锁,并返回锁的所有权
    extern std::mutex some_mutex;
    std::unique_lock<std::mutex> lk(some_mutex);
    prepare_data();
    return lk; // 返回unique_lock实例,将锁的所有权转移给调用者
}
void process_data(){
    std::unique_lock<std::mutex> lk(get_lock());// 调用get_lock()函数获取互斥锁
    do_something();// 执行需要互斥访问的数据操作
}

3.2.8适当粒度的锁定

可能的话,只在访问共享数据时锁定互斥锁;尽量在锁之外处理数据。特别是,不要在持有锁的情况下进行文件I/O等耗时活动。std::unique_lock在这种情况下工作得很好(可以只有在需要的时候才上锁)

void get_and_process_data() {//获取并处理数据
    std::unique_lock<std::mutex> my_lock(the_mutex); 
    some_class data_to_process = get_next_data_chunk(); 
    my_lock.unlock(); 
    result_type result = process(data_to_process); 
    my_lock.lock(); 
    write_result(data_to_process, result); 
}

假设要比较两个int变量。可以在持有被比较对象的锁的情况下轻松拷贝这两个数据,然后比较这些副本:

class Y {
private:
    int some_detail;
    mutable std::mutex m;
    int get_detail() const {
        std::lock_guard<std::mutex> lock_a(m);
        return some_detail; 
    }
public:
    Y(int sd) : some_detail(sd) {}
    friend bool operator==(const Y& lhs, const Y& rhs) {
        if (&lhs == &rhs) return true;
        int const lhs_value = lhs.get_detail();
        int const rhs_value = rhs.get_detail();
        return lhs_value == rhs_value;
    }
};

这种方法确保了互斥锁的锁定时间尽可能短

3.3 保护共享数据的备选功能

尽管互斥锁是最通用的机制,但在保护共享数据方面,并不是唯一的选择
有一些替代方案在特定场景下提供了更合适的保护

3.3.1初始化时保护共享数据

假设有一个共享资源,其构建成本非常高,最好是在需要时才构建它(也许是因为它打开了数据库连接或分配了大量内存);

在单线程代码中,这种延迟初始化(Lazy initialization)是常见的

std::shared_ptr<some_resource> resource_ptr;
void foo(){
    if (!resource_ptr){
        resource_ptr.reset(new some_resource);
    }
    resource_ptr->do_something();
}

如果共享资源本身对并发访问是安全的,那么在将其转换为多线程代码时,唯一需要保护的部分是初始化。但是,如果像下面那样进行天真的转换,可能会导致使用资源的线程不必要的等待:

std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo(){
    std::unique_lock<std::mutex> lk(resource_mutex);
    if (!resource_ptr){
        resource_ptr.reset(new some_resource);
    }
    lk.unlock(); // 解锁互斥体,以便其他线程可以访问资源
    resource_ptr->do_something(); // 使用资源
}

这段代码很常见,以至于许多人试图找到更好的处理方式,包括臭名昭著的双重锁定:

void undefined_behaviour_with_double_checked_locking(){
    if (!resource_ptr) {	// 第一次检查,未加锁 ①
        std::lock_guard<std::mutex> lk(resource_mutex); // 加锁
        if (!resource_ptr){ 	// 第二次检查,已加锁
            resource_ptr.reset(new some_resource); // 初始化资源 ②
        }
    }
    resource_ptr->do_something(); // 使用资源
}

在获取锁之后,再次检查指针,以防在第一次检查和当前线程获取锁之间有其他线程已经完成了初始化

不幸的是,它有可能导致严重的竞争条件:

  • 锁外的读取操作①,与另一个线程在锁内进行的写入操作②不同步
  • 即使一个线程看到了另一个线程写入的指针,它也可能看不到新创建的 some_resource 实例,导致对 do_something() 的调用在错误的值上操作。

C++标准库提供了std::once_flag和std::call_once来处理这种情况:

  • 与锁定互斥锁并显式检查指针相比,每个线程都可以使用std::call_once,确信在std::call_once返回时,指针已经被某个线程(以正确同步的方式)初始化了。
  • 必要的同步数据存储在std::once_flag实例中,每个std::once_flag实例对应不同的初始化。
  • 使用std::call_once通常比显式使用互斥锁具有更低的开销,特别是当初始化已经完成时:
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;
void init_resource(){
    resource_ptr.reset(new some_resource);
}
void foo(){
    std::call_once(resource_flag, init_resource); // 初始化只被调用一次
    resource_ptr->do_something();
}

std::call_once() 可以很容易地用于类成员的延迟初始化

class X{
private:
    connection_info connection_details;
    connection_handle connection;
    std::once_flag connection_init_flag;
    void open_connection(){connection = connection_manager.open(connection_details);}
public:
    X(connection_info const &connection_details_):connection_details(connection_details_){}
    void send_data(data_packet const &data){  
        std::call_once(connection_init_flag, &X::open_connection, this); // 只初始化一次连接
        connection.send_data(data);
    }
    data_packet receive_data(){      
        std::call_once(connection_init_flag, &X::open_connection, this); // 只初始化一次连接
        return connection.receive_data();
    }
};

  • 初始化可以通过首次调用 send_data() 或首次调用 receive_data() 来完成
  • 与 std::mutex 一样,std::once_flag 实例不能被拷贝或移动

3.3.3递归锁

  • 对于std::mutex而言,如果一个线程尝试锁定它已经拥有的互斥锁,将导致未定义的行为
  • 但在某些情况下,希望一个线程在没有首先释放它的情况下多次重新获取同一个互斥锁。为此,C++ 标准库提供了 std::recursive_mutex。
  • 在另一个线程可以锁定互斥锁之前,必须释放所有锁,如果调用 lock() 三次,也必须调用 unlock() 三次
  • std::lock_guard<std::recursive_mutex> 和 std::unique_lock<std::recursive_mutex> 可以处理此问题

大多数情况下,如果认为需要一个递归互斥锁,可能需要更改你的设计,特别是,当持有锁时,类的不变量通常会被破坏,这意味着第二个成员函数在不变量被破坏时也需要工作

标签:std,第三章,互斥,lock,编程,some,并发,线程,mutex
From: https://blog.csdn.net/Antonio915/article/details/144309992

相关文章

  • 实验5 C语言指针应用编程
    实验一:#include<stdio.h>#defineN5voidinput(intx[],intn);voidoutput(intx[],intn);voidfind_min_max(intx[],intn,int*pmin,int*pmax);intmain(){inta[N];intmin,max;printf("录入%d个数据:\n",N);input(......
  • 重拾Java:穿越最具多功能性的编程语言之旅
    你知道Java是世界上最广泛使用的编程语言之一吗?无论是用于Web应用、企业系统,还是Android开发,Java始终是各级开发者的可靠选择。在完成SESISENAI的系统开发技术培训后,我决定重新学习这门语言。现在,我将其与我正在学习的React、Node.js和JavaScript相结合。在这个空间里,我将分享我......
  • 《Python 图神经网络编程全指南》
    《Python图神经网络编程全指南》一、引言Python中的图神经网络编程正逐渐成为数据科学和机器学习领域的热门话题。随着数据的日益复杂和多样化,传统的数据分析方法往往难以有效地处理具有复杂关系结构的数据。而图神经网络作为一种新兴的技术,能够很好地捕捉图结构数据中......
  • Day43--GUI编程简介
    Day43--GUI编程简介GUI是GraphicalUserInterface的缩写,即图形用户界面。它是指采用图形方式显示的计算机操作用户界面,使用户可以通过视觉元素如窗口、图标、菜单等直观地与计算机进行交互,而无需记忆和输入复杂的命令行指令。GUI的定义和组成定义:GUI是一种人与计算机通信的界......
  • Python内存管理的秘密,你必须知道的高效编程技巧!
    Python内存管理的秘密,你必须知道的高效编程技巧!前言亲爱的Python爱好者们,大家好!......
  • 如果同事编程能力比你低,你是如何与他合作的?反之呢?
    如果同事的前端编程能力比我低,我会采取以下策略与他合作:耐心和尊重:每个人的学习速度和经验不同,我会尊重同事的水平,并以耐心和鼓励的态度对待他。避免任何居高临下或贬低的行为。提供指导和帮助:当同事遇到问题时,我会乐于提供帮助,解释概念,并指导他找到解决方案。我会尽量用清晰......
  • Python CGI编程
    什么是CGICGI目前由NCSA维护,NCSA定义CGI如下:CGI(CommonGatewayInterface),通用网关接口,它是一段程序,运行在服务器上如:HTTP服务器,提供同客户端HTML页面的接口。网页浏览为了更好的了解CGI是如何工作的,我们可以从在网页上点击一个链接或URL的流程:1、使用你......
  • [C高手编程] 程序性能优化:原理与实践
    ......
  • Vue组件化编程1:模块与组件、模块化与组件化
    欢迎来到“雪碧聊技术”CSDN博客!在这里,您将踏入一个专注于Java开发技术的知识殿堂。无论您是Java编程的初学者,还是具有一定经验的开发者,相信我的博客都能为您提供宝贵的学习资源和实用技巧。作为您的技术向导,我将不断探索Java的深邃世界,分享最新的技术动态、实战经验以及项目......
  • 【在线学编程】十大优秀IT在线教育网站推荐
    在这个信息技术高速发展的时代,我们似乎已离不开网络。而且随着互联网技术的发展,许多传统的领域已经发生了很大的变化,比如教育。除了自己啃书本或是在教室里听老师讲课,我们现在可以借助互联网接受在线(视频)教育。这使得我们可以更加便捷地获取学习资源,打破时间和空间的限制来学习......