当前位置: 首页 > news >正文

C++并发编程指南04

文章目录

      • 共享数据的问题
        • 3.1.1 条件竞争
          • 双链表的例子
          • 条件竞争示例
          • 恶性条件竞争的特点
        • 3.1.2 避免恶性条件竞争
          • 1. 使用互斥量保护共享数据结构
          • 2. 无锁编程
          • 3. 软件事务内存(STM)
      • 总结
      • 互斥量与共享数据保护
        • 3.2.1 互斥量
          • 使用互斥量保护共享数据
            • 示例代码:
          • C++17的新特性
          • 面向对象设计中的互斥量
        • 3.2.2 保护共享数据
            • 示例代码:
          • 解决方案:
        • 3.2.3 接口间的条件竞争
          • 示例代码:
          • 解决方案:
      • 总结
      • 接口间的条件竞争与解决方案
        • 3.2.3 接口间的条件竞争
          • 示例:`std::stack` 容器的实现
          • 解决方案:重新设计接口
          • 示例:线程安全的堆栈类定义
        • 3.2.4 死锁:问题描述及解决方案
          • 示例:使用 `std::lock` 和 `std::lock_guard`
          • 使用 `std::scoped_lock`(C++17)
          • 总结
      • 3.2.5 避免死锁的进阶指导
        • 死锁的原因与常见场景
        • 避免嵌套锁
        • 避免在持有锁时调用外部代码
        • 使用固定顺序获取锁
        • 使用层次锁结构
        • 示例:使用层次锁来避免死锁
        • 超越锁的延伸扩展
        • 使用 `std::unique_lock` 提供灵活性
          • 示例:使用 `std::unique_lock` 和 `std::defer_lock`
        • 不同域中互斥量的传递
      • 总结
      • 3.2.8 锁的粒度
        • 锁的粒度简介
        • 类比超市结账场景
        • 细粒度锁 vs 粗粒度锁
        • 示例:优化锁的使用
        • 控制锁的持有时间
        • 示例:细粒度锁的应用
        • 条件竞争与语义一致性
        • 寻找合适的机制
      • 总结
      • 3.3 保护共享数据的方式
        • 3.3.1 保护共享数据的初始化过程
          • 单线程延迟初始化
          • 多线程延迟初始化
          • 双重检查锁模式
          • 使用 `std::call_once` 和 `std::once_flag`
          • 静态局部变量的线程安全初始化
        • 3.3.2 保护不常更新的数据结构
          • 使用 `std::shared_mutex`
        • 3.3.3 嵌套锁
          • 使用 `std::recursive_mutex`
      • 总结

共享数据的问题

3.1.1 条件竞争

在多线程编程中,共享数据的修改是导致问题的主要原因。如果数据只读,则不会影响数据的一致性,所有线程都能获得相同的数据。然而,当一个或多个线程需要修改共享数据时,就会出现许多复杂的问题。这些问题通常涉及**不变量(invariants)**的概念,即描述特定数据结构的某些属性,例如“变量包含列表中的项数”。更新操作通常会破坏这些不变量,特别是在处理复杂数据结构时。

双链表的例子

以双链表为例,每个节点都有指向前一个节点和后一个节点的指针。为了从列表中删除一个节点,必须更新其前后节点的指针,这会导致不变量暂时被破坏:

  1. 找到要删除的节点N
  2. 更新前一个节点指向N的指针,让其指向N的下一个节点
  3. 更新后一个节点指向N的指针,让其指向前一个节点
  4. 删除节点N

在这过程中,步骤2和步骤3之间,不变量被破坏,因为此时部分指针已经更新,但还未完全完成。如果其他线程在此期间访问该链表,可能会读取到不一致的状态,从而导致程序错误甚至崩溃。这种问题被称为条件竞争(race condition)

条件竞争示例

假设你去一家大电影院买电影票,有多个收银台可以同时售票。当另一个收银台也在卖你想看的电影票时,你的座位选择取决于之前已预定的座位。如果有少量座位剩余,可能会出现一场抢票比赛,看谁能抢到最后的票。这就是一个典型的条件竞争例子:你的座位(或电影票)取决于购买的顺序。

在并发编程中,条件竞争取决于多个线程的执行顺序。大多数情况下,即使改变执行顺序,结果仍然是可接受的。然而,当不变量遭到破坏时,条件竞争就可能变成恶性竞争,例如在双链表的例子中,可能导致数据结构永久损坏并使程序崩溃。

C++标准定义了**数据竞争(data race)**这一术语,指的是并发修改独立对象的情况,这种情况会导致未定义行为。

恶性条件竞争的特点
  • 难以查找和复现:由于问题出现的概率较低,且依赖于特定的执行顺序,因此很难查找和复现。
  • 时间敏感:调试模式下,程序的执行速度变慢,错误可能完全消失,因为调试模式会影响程序的执行时间。
  • 负载敏感:随着系统负载增加,执行序列问题复现的概率也会增加。
3.1.2 避免恶性条件竞争

为了避免恶性条件竞争,以下是几种常见的解决方案:

1. 使用互斥量保护共享数据结构

最简单的方法是对共享数据结构使用某种保护机制,确保只有修改线程才能看到不变量的中间状态。C++标准库提供了多种互斥量(如 std::mutex),可以用来保护共享数据结构,确保只有一个线程能进行修改,其他线程要么等待修改完成,要么读取到一致的数据。

2. 无锁编程

另一种方法是对数据结构和不变量进行设计,使其能够完成一系列不可分割的变化,保证每个不变量的状态。这种方法称为无锁编程,虽然高效,但实现难度较大,容易出错。

3. 软件事务内存(STM)

还有一种处理条件竞争的方式是使用事务的方式处理数据结构的更新,类似于数据库中的事务管理。所需的数据和读取操作存储在事务日志中,然后将之前的操作进行合并并提交。如果数据结构被另一个线程修改,提交操作将失败并重新尝试。这种方法称为软件事务内存(Software Transactional Memory, STM),是一个热门的研究领域,但在C++标准中没有直接支持。

总结

  • 共享数据问题:当多个线程共享数据时,特别是当数据需要被修改时,会出现条件竞争问题。
  • 不变量:描述数据结构的某些属性,在修改过程中可能会被破坏。
  • 条件竞争:多个线程争夺对共享资源的访问权,可能导致程序错误或崩溃。
  • 避免恶性条件竞争的方法
    • 互斥量:使用互斥量保护共享数据结构,确保只有一个线程能进行修改。
    • 无锁编程:设计数据结构使其能完成一系列不可分割的变化。
    • 软件事务内存(STM):使用事务的方式处理数据结构的更新,确保一致性。

通过上述方法,开发者可以有效避免多线程编程中的条件竞争问题,确保程序的正确性和稳定性。

互斥量与共享数据保护

3.2.1 互斥量
使用互斥量保护共享数据

在多线程环境中,使用互斥量(std::mutex)可以确保对共享数据的访问是互斥的,从而避免条件竞争问题。C++标准库提供了std::lock_guard,它利用RAII机制自动管理互斥量的锁定和解锁。

示例代码:
#include <list>
#include <mutex>
#include <algorithm>std::list<int> some_list;    // 1: 全局变量
std::mutex some_mutex;       // 2: 全局互斥量void add_to_list(int new_value)
{std::lock_guard<std::mutex> guard(some_mutex);    // 3: 锁定互斥量some_list.push_back(new_value);
}bool list_contains(int value_to_find)
{std::lock_guard<std::mutex> guard(some_mutex);    // 4: 锁定互斥量return std::find(some_list.begin(), some_list.end(), value_to_find) != some_list.end();
}
  • 全局变量与互斥量some_list是一个全局变量,被一个全局互斥量some_mutex保护。
  • std::lock_guard:在add_to_listlist_contains函数中,使用std::lock_guard来自动管理互斥量的锁定和解锁,确保在函数执行期间互斥量处于锁定状态,防止其他线程访问共享数据。
C++17的新特性

C++17引入了模板类参数推导,简化了std::lock_guard的使用:

std::lock_guard guard(some_mutex);  // 模板参数类型由编译器推导

此外,C++17还引入了std::scoped_lock,提供了更强大的功能:

std::scoped_lock guard(some_mutex);

为了兼容C++11标准,本文将继续使用带有模板参数类型的std::lock_guard

面向对象设计中的互斥量

将互斥量与需要保护的数据放在同一个类中,可以使代码更加清晰,并且方便了解什么时候对互斥量上锁。例如:

class ProtectedData {
private:std::list<int> data;std::mutex mutex;public:void add_to_list(int new_value) {std::lock_guard<std::mutex> guard(mutex);data.push_back(new_value);}bool contains(int value_to_find) {std::lock_guard<std::mutex> guard(mutex);return std::find(data.begin(), data.end(), value_to_find) != data.end();}
};

这种设计方式不仅封装了数据,还确保了所有对共享数据的访问都在互斥量保护下进行。

3.2.2 保护共享数据

使用互斥量保护数据不仅仅是简单地在每个成员函数中加入一个std::lock_guard对象。必须注意以下几点:

  1. 避免返回指向受保护数据的指针或引用

    • 如果成员函数返回指向受保护数据的指针或引用,外部代码可以直接访问这些数据而无需通过互斥量保护,这会破坏数据保护机制。
  2. 检查成员函数是否通过指针或引用来调用

    • 尤其是在调用不在你控制下的函数时,确保这些函数不会存储指向受保护数据的指针或引用。
示例代码:
class SomeData {int a;std::string b;
public:void do_something();
};class DataWrapper {
private:SomeData data;std::mutex m;public:template<typename Function>void process_data(Function func) {std::lock_guard<std::mutex> l(m);func(data);  // 传递“保护”数据给用户函数}
};SomeData* unprotected;void malicious_function(SomeData& protected_data) {unprotected = &protected_data;
}DataWrapper x;void foo() {x.process_data(malicious_function);  // 传递恶意函数unprotected->do_something();         // 在无保护的情况下访问保护数据
}

在这个例子中,尽管process_data函数内部使用了互斥量保护数据,但传递给用户的函数func可能会绕过保护机制,导致数据被不安全地访问。

解决方案:
  • 不要将受保护数据的指针或引用传递到互斥锁作用域之外
  • 确保所有对受保护数据的访问都在互斥量保护下进行
3.2.3 接口间的条件竞争

即使使用了互斥量保护数据,如果接口设计不当,仍然可能存在条件竞争。例如,如果某个接口允许返回指向受保护数据的指针或引用,外部代码可以在没有互斥量保护的情况下访问这些数据,导致数据不一致。

示例代码:
class ProtectedData {
private:std::list<int> data;std::mutex mutex;public:const std::list<int>& get_data() {  // 返回引用,可能导致条件竞争std::lock_guard<std::mutex> guard(mutex);return data;}
};

在这种情况下,虽然get_data函数内部使用了互斥量保护数据,但返回的引用可以在互斥量保护范围之外被访问,从而导致潜在的条件竞争。

解决方案:
  • 避免返回指向受保护数据的指针或引用,除非这些指针或引用本身也在互斥量保护下使用。
  • 设计接口时确保所有对受保护数据的访问都在互斥量保护范围内

总结

  • 互斥量的作用:互斥量用于保护共享数据,确保同一时间只有一个线程能够访问和修改数据,从而避免条件竞争。
  • std::lock_guard:利用RAII机制自动管理互斥量的锁定和解锁,简化了代码编写。
  • 面向对象设计中的互斥量:将互斥量与需要保护的数据放在同一个类中,使得代码更加清晰并便于管理。
  • 避免返回指针或引用:确保所有对受保护数据的访问都在互斥量保护下进行,避免返回指向受保护数据的指针或引用。
  • 接口设计注意事项:确保接口设计合理,避免通过接口泄露受保护数据的指针或引用,防止条件竞争的发生。

通过正确使用互斥量和精心设计接口,开发者可以有效避免多线程编程中的条件竞争问题,确保程序的正确性和稳定性。

接口间的条件竞争与解决方案

3.2.3 接口间的条件竞争

即使使用了互斥量或其他机制保护共享数据,仍然需要确保数据是否真正受到了保护。例如,在双链表的例子中,为了线程安全地删除一个节点,不仅需要保护待删除节点及其前后相邻的节点,还需要保护整个删除操作的过程。最简单的解决方案是使用互斥量来保护整个链表或数据结构。

示例:std::stack 容器的实现

考虑一个类似于 std::stack 的栈类:

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); // C++14的新特性
};

尽管每个成员函数都可能在内部使用互斥量保护数据,但接口设计上的问题仍可能导致条件竞争。例如:

  • empty()size():虽然这些函数在返回时可能是正确的,但在返回后其他线程可能会修改栈的内容,导致之前的结果变得不可靠。
  • top()pop():如果两个线程分别调用 top()pop(),可能会出现竞态条件,因为在这两个操作之间,另一个线程可能会修改栈的状态。
解决方案:重新设计接口

为了避免上述问题,可以通过重新设计接口来解决条件竞争:

  1. 选项1:传入引用获取弹出值

    std::vector<int> result;
    some_stack.pop(result);
    

    缺点:

    • 需要构造一个目标类型的实例,这可能不现实或资源开销大。
    • 不适用于所有类型,特别是那些没有赋值操作的类型。
  2. 选项2:无异常抛出的拷贝构造函数或移动构造函数

    使用无异常抛出的拷贝构造函数或移动构造函数可以避免某些异常问题,但这限制了可使用的类型范围。

  3. 选项3:返回指向弹出值的指针

    返回一个指向弹出元素的指针(如 std::shared_ptr)可以避免内存分配问题,并且不会抛出异常。

    std::shared_ptr<T> pop() {std::lock_guard<std::mutex> lock(m);if (data.empty()) throw empty_stack();std::shared_ptr<T> res(std::make_shared<T>(data.top()));data.pop();return res;
    }
    
  4. 选项4:结合选项1和选项3

    提供多个接口选项,让用户选择最适合的方案。

示例:线程安全的堆栈类定义

以下是一个线程安全的堆栈类定义示例,结合了选项1和选项3:

#include <exception>
#include <memory>
#include <mutex>
#include <stack>struct empty_stack : std::exception {const char* what() const throw() {return "empty stack!";}
};template<typename T>
class threadsafe_stack {
private:std::stack<T> data;mutable std::mutex m;public:threadsafe_stack() : data(std::stack<T>()) {}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(new_value);}std::shared_ptr<T> pop() {std::lock_guard<std::mutex> lock(m);if (data.empty()) throw empty_stack();std::shared_ptr<T> 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();}
};
3.2.4 死锁:问题描述及解决方案

死锁是指两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行的情况。例如:

  • 线程A持有互斥量A并请求互斥量B。
  • 线程B持有互斥量B并请求互斥量A。

为了避免死锁,可以采取以下措施:

  1. 保持一致的加锁顺序:确保所有线程以相同的顺序获取互斥量。
  2. 使用 std::lockstd::scoped_lock:C++标准库提供了 std::lockstd::scoped_lock,可以一次性锁住多个互斥量,避免死锁。
示例:使用 std::lockstd::lock_guard
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;std::lock(lhs.m, rhs.m); // 1 锁住两个互斥量std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock); // 2std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock); // 3swap(lhs.some_detail, rhs.some_detail);}
};
使用 std::scoped_lock(C++17)

C++17引入了 std::scoped_lock,可以简化多互斥量锁定的代码:

void swap(X& lhs, X& rhs) {if (&lhs == &rhs)return;std::scoped_lock guard(lhs.m, rhs.m); // 1 自动推导模板参数swap(lhs.some_detail, rhs.some_detail);
}
总结
  • 条件竞争:即使使用互斥量保护共享数据,接口设计不当仍可能导致条件竞争。通过重新设计接口,可以有效避免这些问题。
  • 死锁:避免死锁的关键在于保持一致的加锁顺序,或使用 std::lockstd::scoped_lock 来一次性锁住多个互斥量。
  • 接口设计建议
    • 避免返回指向受保护数据的指针或引用。
    • 尽量减少不必要的接口复杂性,确保所有对共享数据的访问都在互斥量保护下进行。
    • 使用细粒度锁来提高并发性能,同时避免过度细化导致的死锁风险。

通过合理的设计和使用标准库提供的工具,开发者可以有效地避免多线程编程中的条件竞争和死锁问题,确保程序的正确性和稳定性。

3.2.5 避免死锁的进阶指导

死锁的原因与常见场景

死锁通常是由对锁的不当使用造成的。例如,两个线程互相调用 join() 可能导致死锁,因为每个线程都在等待另一个线程结束。类似地,当多个线程持有不同锁并试图获取对方持有的锁时,也会发生死锁。

为了避免死锁,以下是一些进阶的指导意见:

避免嵌套锁

建议1:避免嵌套锁

最简单的避免死锁的方法是确保每个线程只持有一个锁。如果需要获取多个锁,可以使用 std::lock 来一次性锁定多个互斥量,从而避免死锁。

std::mutex m1, m2;
std::lock(m1, m2); // 同时锁定m1和m2,避免死锁
std::lock_guard<std::mutex> lock1(m1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(m2, std::adopt_lock);
避免在持有锁时调用外部代码

建议2:避免在持有锁时调用外部代码

外部代码的行为是不可预测的,可能包含获取其他锁的操作,这会导致死锁。尽量减少在持有锁的情况下调用外部代码。

使用固定顺序获取锁

建议3:使用固定顺序获取锁

当必须获取多个锁时,确保所有线程以相同的顺序获取这些锁。例如,在链表中删除节点时,确保所有线程按相同顺序锁定节点及其相邻节点。

void delete_node(Node* node) {std::lock_guard<std::mutex> lock_prev(node->prev->mutex);std::lock_guard<std::mutex> lock_next(node->next->mutex);// 确保固定的锁顺序
}
使用层次锁结构

建议4:使用层次锁结构

为每个互斥量分配一个层级值,并确保在任何时刻,只能获取比当前层级更低的锁。这样可以避免循环等待的情况。

class hierarchical_mutex {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);
示例:使用层次锁来避免死锁

以下是使用层次锁的一个示例:

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() {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();
}
超越锁的延伸扩展

除了上述方法,还需要注意其他同步构造中的潜在死锁问题。例如,不要在持有锁的情况下等待另一个线程的完成,除非你确定该线程的层级低于当前线程。

使用 std::unique_lock 提供灵活性

std::unique_lock 提供了比 std::lock_guard 更多的灵活性。它可以延迟锁定、手动解锁以及在不同作用域之间转移所有权。

示例:使用 std::unique_lockstd::defer_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;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); // 同时锁定两个互斥量swap(lhs.some_detail, rhs.some_detail);}
};
不同域中互斥量的传递

std::unique_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; // 返回锁的所有权
}void process_data() {std::unique_lock<std::mutex> lk(get_lock());do_something(); // 在保护的数据上执行操作
}

总结

  • 避免嵌套锁:每个线程只持有一个锁,必要时使用 std::lock 一次性锁定多个互斥量。
  • 避免在持有锁时调用外部代码:外部代码可能导致意外的锁竞争。
  • 使用固定顺序获取锁:确保所有线程以相同的顺序获取锁。
  • 使用层次锁结构:通过层级值限制锁的获取顺序,避免死锁。
  • 使用 std::unique_lock 提供灵活性:允许延迟锁定、手动解锁及锁的所有权转移。

通过遵循这些指导意见,可以有效避免多线程编程中的死锁问题,提高程序的稳定性和可靠性。

3.2.8 锁的粒度

锁的粒度简介

锁的粒度指的是通过一个锁保护的数据量大小。细粒度锁(fine-grained lock)保护较小的数据量,而粗粒度锁(coarse-grained lock)则保护较大的数据量。选择合适的锁粒度对于提高多线程程序的性能至关重要。

类比超市结账场景

考虑一个超市结账的情景:如果一位顾客在结账时突然发现忘拿了某样商品,离开去取回该商品会导致其他排队的顾客等待。同样地,在多线程环境中,如果某个线程长时间持有锁,其他需要访问共享资源的线程将被迫等待,导致整体性能下降。

细粒度锁 vs 粗粒度锁
  • 细粒度锁:每个锁保护的数据量较小,允许多个线程并行访问不同的数据部分,减少竞争和等待时间。
  • 粗粒度锁:一个锁保护大量数据,可能导致更多的线程竞争同一把锁,增加等待时间。
示例:优化锁的使用

以下是一个示例,展示了如何优化锁的使用以减少持锁时间:

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();  // 1 解锁互斥量,避免在处理数据时持有锁result_type result = process(data_to_process);my_lock.lock();  // 2 再次上锁,准备写入结果write_result(data_to_process, result);
}

在这个例子中,my_lock.unlock() 在调用 process() 函数之前解锁互斥量,从而允许其他线程在此期间访问共享数据。当需要写入结果时,再次锁定互斥量。

控制锁的持有时间

为了最小化锁的持有时间,可以采取以下策略:

  1. 只在必要时持有锁:仅在访问或修改共享数据时持有锁,尽量减少持有锁的时间。
  2. 分段操作:将复杂的操作分成多个步骤,并在每个步骤之间释放锁。
示例:细粒度锁的应用

假设有一个简单的数据类型 int,其拷贝操作非常廉价。在这种情况下,可以通过复制数据来避免长时间持有锁:

class Y {
private:int some_detail;mutable std::mutex m;int get_detail() const {std::lock_guard<std::mutex> lock_a(m);  // 1 保护对some_detail的访问return some_detail;}public:Y(int sd) : some_detail(sd) {}friend bool operator==(Y const& lhs, Y const& rhs) {if (&lhs == &rhs)return true;int const lhs_value = lhs.get_detail();  // 2 获取lhs的值int const rhs_value = rhs.get_detail();  // 3 获取rhs的值return lhs_value == rhs_value;  // 4 比较两个值}
};

在这个例子中,比较操作符首先通过调用 get_detail() 成员函数检索要比较的值(步骤 2 和 3),并在索引时被锁保护(步骤 1)。然后比较这两个值(步骤 4)。这种方法减少了锁的持有时间,但需要注意的是,由于两次获取值之间可能存在数据变化,可能会出现条件竞争的问题。

条件竞争与语义一致性

虽然上述方法减少了锁的持有时间,但也引入了条件竞争的风险。例如,两个值可能在读取后被修改,导致比较的结果不再准确。因此,在设计并发程序时,必须仔细考虑语义一致性问题。

寻找合适的机制

有时,单一的锁机制无法满足所有需求。在这种情况下,可以考虑使用更复杂的同步机制,如读写锁(std::shared_mutex)、无锁数据结构或其他高级同步技术。

总结

  • 锁的粒度:细粒度锁保护较小的数据量,适合高并发场景;粗粒度锁保护较大的数据量,可能导致较多的竞争。
  • 控制锁的持有时间:尽可能缩短持有锁的时间,只在必要的时候持有锁。
  • 分段操作:将复杂操作分成多个步骤,并在每个步骤之间释放锁。
  • 条件竞争:注意在减少锁持有时间的同时,避免引入条件竞争问题。

通过合理选择锁的粒度和控制锁的持有时间,可以显著提高多线程程序的性能和可靠性。

3.3 保护共享数据的方式

在多线程编程中,互斥量是保护共享数据的一种通用机制,但并非唯一方式。根据具体场景选择合适的同步机制可以显著提高程序的性能和可靠性。

3.3.1 保护共享数据的初始化过程
单线程延迟初始化

假设有一个昂贵的资源需要延迟初始化:

std::shared_ptr<some_resource> resource_ptr;void foo() {if (!resource_ptr) {resource_ptr.reset(new some_resource);  // 1 初始化资源}resource_ptr->do_something();
}

这段代码在单线程环境中工作良好,但在多线程环境中,resource_ptr的初始化部分需要保护以避免竞争条件。

多线程延迟初始化

使用互斥量保护初始化过程:

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) {  // 1 不需要锁的读取std::lock_guard<std::mutex> lk(resource_mutex);if (!resource_ptr) {  // 2 锁保护的读取resource_ptr.reset(new some_resource);  // 3 初始化}}resource_ptr->do_something();  // 4 使用资源
}

然而,这种方法存在潜在的条件竞争问题,可能导致未定义行为。

使用 std::call_oncestd::once_flag

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

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();
}

这种方式不仅简化了代码,还减少了锁的竞争,提高了性能。

静态局部变量的线程安全初始化

C++11 标准确保静态局部变量的初始化是线程安全的:

class my_class;
my_class& get_my_class_instance() {static my_class instance;  // 线程安全的初始化过程return instance;
}

这种初始化方式在多线程调用时也是安全的,无需额外的同步机制。

3.3.2 保护不常更新的数据结构

对于不经常更新的数据结构,如 DNS 缓存,可以使用读者-作者锁(reader-writer lock)来优化性能。

使用 std::shared_mutex

C++17 提供了 std::shared_mutex,允许多个读线程同时访问数据,而写线程独占访问。

#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>class dns_entry;class dns_cache {std::map<std::string, dns_entry> entries;mutable std::shared_mutex entry_mutex;public:dns_entry find_entry(const std::string& domain) const {std::shared_lock<std::shared_mutex> lk(entry_mutex);  // 1 共享锁auto it = entries.find(domain);return (it == entries.end()) ? dns_entry() : it->second;}void update_or_add_entry(const std::string& domain, const dns_entry& dns_details) {std::lock_guard<std::shared_mutex> lk(entry_mutex);  // 2 独占锁entries[domain] = dns_details;}
};

在这个例子中,find_entry() 使用 std::shared_lock 允许多个读线程并发访问,而 update_or_add_entry() 使用 std::lock_guard 提供独占访问。

3.3.3 嵌套锁

当一个线程需要多次获取同一个互斥量时,可以使用 std::recursive_mutex,它允许多次递归锁定而不导致死锁。

使用 std::recursive_mutex
std::recursive_mutex recursive_mutex;void nested_function() {std::lock_guard<std::recursive_mutex> lk(recursive_mutex);// 执行操作
}void outer_function() {std::lock_guard<std::recursive_mutex> lk(recursive_mutex);nested_function();  // 可以再次锁定同一个互斥量
}

需要注意的是,嵌套锁应谨慎使用,通常应通过重构代码避免嵌套锁定的需求。

总结

  • 锁的粒度:选择合适的锁粒度可以提高并发性能,细粒度锁适合高并发场景,粗粒度锁适合较少竞争的场景。
  • 延迟初始化:使用 std::call_oncestd::once_flag 可以有效地保护共享数据的初始化过程,避免不必要的锁竞争。
  • 读者-作者锁:对于不常更新的数据结构,使用 std::shared_mutex 可以提高读操作的并发性能。
  • 嵌套锁:在需要递归锁定的情况下,使用 std::recursive_mutex,但应尽量避免嵌套锁定的需求。

通过合理选择和使用同步机制,可以有效保护共享数据并提升多线程程序的性能和可靠性。

相关文章:

C++并发编程指南04

文章目录 共享数据的问题3.1.1 条件竞争双链表的例子条件竞争示例恶性条件竞争的特点 3.1.2 避免恶性条件竞争1. 使用互斥量保护共享数据结构2. 无锁编程3. 软件事务内存&#xff08;STM&#xff09; 总结互斥量与共享数据保护3.2.1 互斥量使用互斥量保护共享数据示例代码&…...

常见的同态加密算法收集

随着对crypten与密码学的了解&#xff0c;我们将逐渐深入学习相关知识。今天&#xff0c;我们将跟随同态加密的发展历程对相关算法进行简单的收集整理 。 目录 同态加密概念 RSA算法 ElGamal算法 ELGamal签名算法 Paillier算法 BGN方案 Gentry 方案 BGV 方案 BFV 方案…...

深入探讨数据库索引类型:B-tree、Hash、GIN与GiST的对比与应用

title: 深入探讨数据库索引类型:B-tree、Hash、GIN与GiST的对比与应用 date: 2025/1/26 updated: 2025/1/26 author: cmdragon excerpt: 在现代数据库管理系统中,索引技术是提高查询性能的重要手段。当数据量不断增长时,如何快速、有效地访问这些数据成为了数据库设计的核…...

记录 | Docker的windows版安装

目录 前言一、1.1 打开“启用或关闭Windows功能”1.2 安装“WSL”方式1&#xff1a;命令行下载方式2&#xff1a;离线包下载 二、Docker Desktop更新时间 前言 参考文章&#xff1a;Windows Subsystem for Linux——解决WSL更新速度慢的方案 参考视频&#xff1a;一个视频解决D…...

AI智慧社区--生成验证码

接口文档&#xff1a; 从接口文档中可以得知的信息&#xff1a; 代码的返回格式为json格式&#xff0c;可以将Controlller换为RestController前端发起的请求为Get请求&#xff0c;使用注解GetMapping通过返回的数据类型&#xff0c;定义一个返回类型Result package com.qcby.…...

2501,20个窗口常用操作

窗口是屏幕上的一个矩形区域.窗口分为3种:覆盖窗口,弹窗和子窗口.每个窗口都有由系统绘画的"非客户区"和应用绘画的"客户区". 在MFC中,CWnd类为各种窗口提供了基类. 1,通过窗柄取得CWnd指针 可调用Cwnd::FromHandle函数,通过窗柄取得Cwnd指针. void CD…...

【gopher的java学习笔记】一文讲懂controller,service,mapper,entity是什么

刚开始上手Java和Spring时&#xff0c;就被controller&#xff0c;service&#xff0c;mapper&#xff0c;entity这几个词搞懵了&#xff0c;搞不懂这些究竟代表什么&#xff0c;感觉使用golang开发的时候也没太接触过这些名词啊~ 经过两三个月的开发后&#xff0c;逐渐搞懂了这…...

消息队列篇--通信协议篇--STOMP(STOMP特点、格式及示例,WebSocket上使用STOMP,消息队列上使用STOMP等)

STOMP&#xff08;Simple Text Oriented Messaging Protocol&#xff0c;简单面向文本的消息传递协议&#xff09;是一种轻量级、基于文本的协议&#xff0c;旨在为消息代理&#xff08;消息队列&#xff09;和客户端之间的通信&#xff08;websocket&#xff09;提供一种简单的…...

基于SpringBoot的租房管理系统(含论文)

基于SpringBoot的租房管理系统是一个集订单管理、房源信息管理、屋主申诉处理、用户反馈等多项功能于一体的系统。该系统通过SpringBoot框架开发&#xff0c;拥有完善的管理员后台、屋主管理模块、用户功能模块等&#xff0c;适用于房地产租赁平台或中介公司进行日常管理与运营…...

提升企业内部协作的在线知识库架构与实施策略

内容概要 在当前快速变化的商业环境中&#xff0c;企业对于提升内部协作效率的需求愈显迫切。在线知识库作为信息存储与共享的平台&#xff0c;成为了推动企业数字化转型的重要工具。本文将深入探讨如何有效打造与实施在线知识库&#xff0c;强调架构设计、知识资产分类管理及…...

【物联网】ARM核常用指令(详解):数据传送、计算、位运算、比较、跳转、内存访问、CPSR/SPSR、流水线及伪指令

文章目录 指令格式&#xff08;重点&#xff09;1. 立即数2. 寄存器位移 一、数据传送指令1. MOV指令2. MVN指令3. LDR指令 二、数据计算指令1. ADD指令1. SUB指令1. MUL指令 三、位运算指令1. AND指令2. ORR指令3. EOR指令4. BIC指令 四、比较指令五、跳转指令1. B/BL指令2. l…...

Jackson中@JsonTypeId的妙用与实例解析

在日常的Java开发中&#xff0c;Jackson库是处理JSON数据的常用工具。其中&#xff0c;JsonTypeId注解是一个非常实用的功能&#xff0c;它可以帮助我们更好地控制多态类型信息在序列化过程中的表现。今天&#xff0c;我们就来深入探讨一下JsonTypeId的用法&#xff0c;并通过具…...

Ubuntu 顶部状态栏 配置,gnu扩展程序

顶部状态栏 默认没有配置、隐藏的地方 安装使用Hide Top Bar 或Just Perfection等进行配置 1 安装 sudo apt install gnome-shell-extension-manager2 打开 安装的“扩展管理器” 3. 对顶部状态栏进行配置 使用Hide Top Bar 智能隐藏&#xff0c;或者使用Just Perfection 直…...

Java---入门基础篇(上)

前言 本片文章主要讲了刚学Java的一些基础内容,例如注释,标识符,数据类型和变量,运算符,还有逻辑控制等,记录的很详细,带你从简单的知识点再到练习题.如果学习了c语言的小伙伴会发现,这篇文章的内容和c语言大致相同. 而在下一篇文章里,我会讲解方法和数组的使用,也是Java中基础…...

Linux C++

一、引言 冯诺依曼架构是现代计算机系统的基础&#xff0c;它的提出为计算机的发展奠定了理论基础。在学习 C 和 Linux 系统时&#xff0c;理解冯诺依曼架构有助于我们更好地理解程序是如何在计算机中运行的&#xff0c;包括程序的存储、执行和资源管理。这对于编写高效、可靠的…...

gradio 合集

知识点 1&#xff1a;基本 Chatbot 创建 import gradio as gr 定义历史记录 history [gr.ChatMessage(role“assistant”, content“How can I help you?”), gr.ChatMessage(role“user”, content“What is the weather today?”)] 使用历史记录创建 Chatbot 组件 ch…...

996引擎 - NPC-动态创建NPC

996引擎 - NPC-动态创建NPC 创建脚本服务端脚本客户端脚本添加自定义音效添加音效文件修改配置参考资料有个小问题,创建NPC时没有控制朝向的参数。所以。。。自己考虑怎么找补吧。 多重影分身 创建脚本 服务端脚本 Mir200\Envir\Market_Def\test\test001-3.lua -- NPC八门名…...

论文阅读(十三):复杂表型关联的贝叶斯、基于系统的多层次分析:从解释到决策

1.论文链接&#xff1a;Bayesian, Systems-based, Multilevel Analysis of Associations for Complex Phenotypes: from Interpretation to Decision 摘要&#xff1a; 遗传关联研究&#xff08;GAS&#xff09;报告的结果相对稀缺&#xff0c;促使许多研究方向。尽管关联概念…...

代码随想录算法训练营第三十九天-动态规划-198. 打家劫舍

动规五部曲 dp[i]表示在下标为i的房间偷或不偷与前面所偷之和所能获得的最大价值递推公式&#xff1a;dp[i] std::max(dp[i - 2] nums[i], dp[i - 1])初始化&#xff1a;要给dp[0]与dp[1]来给定初始值&#xff0c;因为递推公式有-1与-2。dp[0] nums[0], dp[1] std::max(num…...

CF1098F Ж-function

【题意】 给你一个字符串 s s s&#xff0c;每次询问给你 l , r l, r l,r&#xff0c;让你输出 s s s l , r sss_{l,r} sssl,r​中 ∑ i 1 r − l 1 L C P ( s s i , s s 1 ) \sum_{i1}^{r-l1}LCP(ss_i,ss_1) ∑i1r−l1​LCP(ssi​,ss1​)。 【思路】 和前一道题一样&#…...

浅谈 React Hooks

React Hooks 是 React 16.8 引入的一组 API&#xff0c;用于在函数组件中使用 state 和其他 React 特性&#xff08;例如生命周期方法、context 等&#xff09;。Hooks 通过简洁的函数接口&#xff0c;解决了状态与 UI 的高度解耦&#xff0c;通过函数式编程范式实现更灵活 Rea…...

Leetcode 3576. Transform Array to All Equal Elements

Leetcode 3576. Transform Array to All Equal Elements 1. 解题思路2. 代码实现 题目链接&#xff1a;3576. Transform Array to All Equal Elements 1. 解题思路 这一题思路上就是分别考察一下是否能将其转化为全1或者全-1数组即可。 至于每一种情况是否可以达到&#xf…...

React hook之useRef

React useRef 详解 useRef 是 React 提供的一个 Hook&#xff0c;用于在函数组件中创建可变的引用对象。它在 React 开发中有多种重要用途&#xff0c;下面我将全面详细地介绍它的特性和用法。 基本概念 1. 创建 ref const refContainer useRef(initialValue);initialValu…...

Java多线程实现之Callable接口深度解析

Java多线程实现之Callable接口深度解析 一、Callable接口概述1.1 接口定义1.2 与Runnable接口的对比1.3 Future接口与FutureTask类 二、Callable接口的基本使用方法2.1 传统方式实现Callable接口2.2 使用Lambda表达式简化Callable实现2.3 使用FutureTask类执行Callable任务 三、…...

【Web 进阶篇】优雅的接口设计:统一响应、全局异常处理与参数校验

系列回顾&#xff1a; 在上一篇中&#xff0c;我们成功地为应用集成了数据库&#xff0c;并使用 Spring Data JPA 实现了基本的 CRUD API。我们的应用现在能“记忆”数据了&#xff01;但是&#xff0c;如果你仔细审视那些 API&#xff0c;会发现它们还很“粗糙”&#xff1a;有…...

自然语言处理——Transformer

自然语言处理——Transformer 自注意力机制多头注意力机制Transformer 虽然循环神经网络可以对具有序列特性的数据非常有效&#xff0c;它能挖掘数据中的时序信息以及语义信息&#xff0c;但是它有一个很大的缺陷——很难并行化。 我们可以考虑用CNN来替代RNN&#xff0c;但是…...

ip子接口配置及删除

配置永久生效的子接口&#xff0c;2个IP 都可以登录你这一台服务器。重启不失效。 永久的 [应用] vi /etc/sysconfig/network-scripts/ifcfg-eth0修改文件内内容 TYPE"Ethernet" BOOTPROTO"none" NAME"eth0" DEVICE"eth0" ONBOOT&q…...

A2A JS SDK 完整教程:快速入门指南

目录 什么是 A2A JS SDK?A2A JS 安装与设置A2A JS 核心概念创建你的第一个 A2A JS 代理A2A JS 服务端开发A2A JS 客户端使用A2A JS 高级特性A2A JS 最佳实践A2A JS 故障排除 什么是 A2A JS SDK? A2A JS SDK 是一个专为 JavaScript/TypeScript 开发者设计的强大库&#xff…...

【Nginx】使用 Nginx+Lua 实现基于 IP 的访问频率限制

使用 NginxLua 实现基于 IP 的访问频率限制 在高并发场景下&#xff0c;限制某个 IP 的访问频率是非常重要的&#xff0c;可以有效防止恶意攻击或错误配置导致的服务宕机。以下是一个详细的实现方案&#xff0c;使用 Nginx 和 Lua 脚本结合 Redis 来实现基于 IP 的访问频率限制…...

论文阅读:LLM4Drive: A Survey of Large Language Models for Autonomous Driving

地址&#xff1a;LLM4Drive: A Survey of Large Language Models for Autonomous Driving 摘要翻译 自动驾驶技术作为推动交通和城市出行变革的催化剂&#xff0c;正从基于规则的系统向数据驱动策略转变。传统的模块化系统受限于级联模块间的累积误差和缺乏灵活性的预设规则。…...