C++并发编程指南 09(共享数据)

文章目录

      • 第3章 共享数据
        • 本章主要内容
        • 共享数据的问题
        • 使用互斥保护数据
        • 保护数据的替代方案
      • 3.1 共享数据的问题
        • 共享数据的核心问题
        • 不变量的重要性
        • 示例:删除双链表中的节点
        • 多线程环境中的问题
        • 条件竞争的后果
        • 总结
        • 3.1.1 条件竞争
        • 3.1.2 避免恶性条件竞争
    • 3.2 使用互斥量
      • 3.2.1 互斥量
      • 例子 (多线程插入容器会导致容器失效)
      • **3.2.2 保护共享数据**
        • **代码 3.2 无意中传递了保护数据的引用**
          • 1. `SomeData` 类
          • 2. 全局指针 `unprotected`
          • 3. `DataWrapper` 类
          • 4. `malicious_function` 函数
          • 5. `foo_protected` 和 `foo_unprotected` 函数
          • 6. `main` 函数
          • 总结
          • 输出解析
          • 1. Protected Access (安全访问)
          • 2. Unprotected Access (不安全访问)
          • 代码执行流程总结
        • 不当的指针或引用传递而导致竞争条件(代码例子)
        • 示例代码
        • 问题分析
        • 解决方案
        • 改进版代码
        • 使用改进版代码
        • 总结
    • 3.2.3 接口间的条件竞争
        • 代码 3.3 `std::stack` 容器的实现
      • 如何解决接口设计中的条件竞争问题?
        • **直接解决方案:在 `top()` 中抛出异常**
        • **潜在的条件竞争问题**
      • 解决条件竞争的几种选项
        • **选项 1:传入一个引用**
        • **选项 2:无异常抛出的拷贝构造函数或移动构造函数**
        • **选项 3:返回指向弹出值的指针**
        • **选项 4:“选项 1 + 选项 2” 或 “选项 1 + 选项 3”**
      • 总结
      • 示例:定义线程安全的堆栈
        • 代码 3.4 线程安全的堆栈类定义(概述)
        • 代码 3.5 扩展(线程安全)堆栈
      • 锁粒度的讨论
        • 全局互斥量的问题
        • 细粒度锁的问题
        • 死锁问题
    • 3.2.4 死锁:问题描述及解决方案
        • **问题描述**
        • **避免死锁的建议**
        • **解决方案:使用 `std::lock` 和 `std::scoped_lock`**
          • **1. 使用 `std::lock`**
          • **2. C++17 中的 `std::scoped_lock`**
        • **总结**
    • 3.2.5 避免死锁的进阶指导
        • **问题背景**
      • **1. 避免嵌套锁**
      • **2. 避免在持有锁时调用外部代码**
      • **3. 使用固定顺序获取锁**
      • **4. 使用层次锁结构**
      • **5. 超越锁的延伸扩展**
      • **总结**
      • **完整代码实现**
      • **代码说明**
        • **1. `hierarchical_mutex` 类**
        • **2. 示例函数**
        • **3. 主函数**
      • **运行结果**
    • 3.2.6 `std::unique_lock` —— 灵活的锁
        • **灵活性的特点**
      • **代码示例:交换操作中 `std::lock()` 和 `std::unique_lock` 的使用**
      • **关键点解析**
      • **总结**
      • **完整代码示例**
      • **代码说明**
        • **1. `SharedResource` 类**
        • **2. `swapValues()` 函数**
        • **3. `modifyResource()` 函数**
        • **4. 主函数**
      • **运行结果示例**
      • **关键点解析**
      • 3.2.7 不同域中互斥量的传递
        • **所有权传递机制**
        • **应用场景:函数返回锁**
        • **网关类模式**
        • **提前释放锁**
      • **总结**
      • **完整代码示例**
      • **代码说明**
        • **1. 全局互斥量**
        • **2. 函数 `get_lock()`**
        • **3. 网关类 `Gateway`**
        • **4. 主函数**
      • **运行结果示例**
      • **关键点解析**
    • 3.2.8 锁的粒度
        • **锁粒度的概念**
        • **锁粒度的实际意义**
        • **使用 `std::unique_lock` 减少锁持有时间**
        • **粗粒度锁的问题**
        • **细粒度锁的应用示例**
        • **语义问题与潜在风险**
        • **总结**
      • **完整代码示例:银行账户系统**
      • **代码说明**
        • **1. `BankAccount` 类**
        • **2. 线程函数**
        • **3. 主函数**
      • **运行结果示例**
      • **关键点解析**
      • 3.3 保护共享数据的方式
        • **隐式同步的需求**
      • 3.3.1 保护共享数据的初始化过程
        • **多线程环境下的问题**
        • **双重检查锁模式的问题**
      • **C++ 标准库的解决方案:`std::call_once` 和 `std::once_flag`**
        • **示例代码**
        • **作为类成员的延迟初始化**
      • **静态局部变量的线程安全初始化**
      • **总结**
      • **完整代码示例**
      • **代码说明**
        • **1. `SomeResource` 类**
        • **2. `ResourceManager` 类**
        • **3. 静态局部变量初始化**
        • **4. 测试函数**
        • **5. 主函数**
      • **运行结果示例**
      • **关键点解析**
      • 3.3.2 保护不常更新的数据结构
        • **场景描述**
        • **问题分析**
        • **C++ 标准库中的解决方案**
        • **代码示例**
        • **代码说明**
        • **关键点解析**
      • **完整代码示例**
      • **代码说明**
        • **1. `dns_entry` 类**
        • **2. `dns_cache` 类**
        • **3. 测试函数**
        • **4. 主函数**
      • **运行结果示例**
      • **关键点解析**
    • 3.3.3 嵌套锁
        • **概述**
      • **完整代码示例**
      • **代码说明**
        • **1. `RecursiveMutexExample` 类**
        • **2. `ImprovedDesignExample` 类**
        • **3. 测试函数**
      • **运行结果示例**
      • **关键点解析**

第3章 共享数据

本章主要内容
  • 共享数据的问题
  • 使用互斥保护数据
  • 保护数据的替代方案

在上一章中,我们已经对线程管理有了一定的了解。现在,让我们来探讨一下“共享数据的那些事儿”。

共享数据的问题

想象一下,你和朋友合租一个公寓,公寓中只有一个厨房和一个卫生间。当你的朋友在使用卫生间时,你就无法使用它了。同样的问题也会出现在厨房。例如,如果厨房里有一个烤箱,你在烤香肠的同时,也在做蛋糕,那么你可能会得到不想要的食物(比如香肠味的蛋糕)。此外,在公共空间进行某项任务时,如果发现某些需要的东西被别人拿走,或者在你离开的一段时间内有些东西被移动了位置,这都会让你感到不爽。

类似的问题也困扰着线程。当多个线程访问共享数据时,必须制定一些规则来限定哪些数据可以被哪些线程访问。如果一个线程更新了共享数据,它需要通知其他线程。从易用性的角度来看,同一进程中的多个线程共享数据有利有弊,但错误的共享数据使用是导致bug的主要原因。

使用互斥保护数据

为了避免上述问题,我们可以使用互斥锁(mutex)来保护共享数据。互斥锁确保在同一时间只有一个线程可以访问共享数据,从而防止数据竞争和不一致的状态。

保护数据的替代方案

除了互斥锁,还有其他一些方法可以保护共享数据,例如:

  • 读写锁:允许多个线程同时读取数据,但只允许一个线程写入数据。
  • 原子操作:通过硬件支持的原子操作来确保数据的一致性。
  • 无锁编程:通过复杂的算法和数据结构来实现线程安全,而不使用锁。

本章将以数据共享为主题,探讨如何避免上述及潜在问题的发生,同时最大化共享数据的优势。


通过以上内容,我们希望能够帮助你更好地理解共享数据的问题及其解决方案。在接下来的章节中,我们将深入探讨这些技术的具体实现和应用。

3.1 共享数据的问题

在多线程编程中,共享数据是一个强大但危险的工具。当多个线程同时访问和修改共享数据时,如果没有妥善的管理,就会引发一系列复杂的问题。本节将深入探讨共享数据修改带来的挑战,以及如何通过理解“不变量”来避免潜在的错误。


共享数据的核心问题

共享数据的问题主要源于数据的修改。如果共享数据是只读的,那么所有线程都能安全地访问它,因为数据不会被改变。然而,当一个或多个线程试图修改共享数据时,情况就会变得复杂。修改操作可能会破坏数据的一致性,导致其他线程读取到错误或无效的数据。

这种问题的根源在于条件竞争(Race Condition):当多个线程同时访问共享数据,且至少有一个线程试图修改数据时,程序的执行结果可能依赖于线程调度的顺序,从而导致不可预测的行为。


不变量的重要性

为了理解共享数据修改带来的问题,我们需要引入**不变量(Invariants)**的概念。不变量是描述数据结构在特定条件下必须保持的稳定状态。例如,对于一个双链表,不变量可能是“每个节点的前向指针和后向指针都正确指向相邻节点”。

在修改共享数据时,尤其是复杂的数据结构,更新操作通常会暂时破坏不变量。例如,在删除双链表中的一个节点时,需要更新其相邻节点的指针。在这个过程中,不变量会被暂时破坏,直到所有指针更新完成。


示例:删除双链表中的节点

![[Pasted image 20250209160942.png]]

让我们以双链表的节点删除为例,具体说明共享数据修改可能引发的问题。假设我们有一个双链表,每个节点包含两个指针:一个指向下一个节点,另一个指向前一个节点。删除一个节点的步骤如下:

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

在这个过程中,步骤2和步骤3会暂时破坏不变量。例如,在步骤2完成后,前一个节点的指针已经指向了节点N的下一个节点,但后一个节点的指针还未更新。此时,如果有其他线程访问链表,可能会读取到不一致的数据。


多线程环境中的问题

在多线程环境中,这种临时的不变量破坏会引发严重的问题。例如:

  • 如果一个线程在删除节点的过程中被中断,其他线程可能会访问到一个部分更新的链表,导致读取到无效的数据。
  • 如果多个线程同时尝试删除相邻的节点,可能会导致链表结构的永久性损坏,甚至引发程序崩溃。

这种问题被称为条件竞争(Race Condition),是多线程编程中最常见的错误之一。它的根本原因在于多个线程对共享数据的访问和修改缺乏协调。


条件竞争的后果

条件竞争的后果可能是灾难性的。例如:

  • 数据损坏:链表、树等复杂数据结构可能会被破坏,导致程序无法正常运行。
  • 不可预测的行为:程序的执行结果可能依赖于线程调度的顺序,导致难以复现和调试的bug。
  • 程序崩溃:在极端情况下,条件竞争可能导致程序崩溃或数据丢失。

总结

共享数据的修改是多线程编程中的一个核心挑战。为了确保程序的正确性,我们必须理解不变量在数据结构中的作用,并采取措施避免条件竞争的发生。在接下来的章节中,我们将探讨如何使用互斥锁、原子操作等技术来保护共享数据,确保多线程程序的稳定性和可靠性。

通过以上分析,我们希望你能更清晰地认识到共享数据问题的本质,并为解决这些问题打下坚实的基础。

3.1.1 条件竞争

想象一下你在一个大型电影院买票,售票窗口很多,大家都在同时购票。当你和其他人都在竞争购买同一场电影的票时,你的座位选择就取决于之前的座位预定情况。如果剩余的座位不多了,那么就会出现一场“抢票大战”,看谁能抢到最后一张票。这就是一个典型的条件竞争的例子:你的座位(或者电影票)是否能成功购买,取决于购票的先后顺序。

在并发编程中,竞争条件指的是多个线程的执行顺序会影响到程序的结果。每个线程都试图尽快完成自己的任务。多数情况下,即使执行顺序发生变化,也是良性竞争,结果仍然可以接受。例如,两个线程同时向一个处理队列中添加任务,由于队列的特性,谁先添加任务并不会影响最终的结果。

只有当不变量(invariant)遭到破坏时,才会出现恶性竞争,比如双向链表的例子。并发访问共享数据时,如果多个线程的执行顺序不当,导致数据状态与预期的不变量不符,就会产生恶性竞争。C++ 标准中定义了数据竞争这个术语,它是一种特殊的条件竞争:当多个线程并发地修改同一个独立对象时,就会发生数据竞争。数据竞争会导致未定义行为,这是并发编程中非常严重的问题。

恶性条件竞争通常发生在对多个数据块进行修改的场景,例如修改两个相连的指针(如图 3.1 所示)。当操作需要访问两个独立的数据块时,不同的指令可能会交错执行,一个线程可能正在修改数据块,而另一个线程同时访问了该数据块。由于这种交错执行的概率较低,因此这类问题通常难以发现和复现。即使 CPU 指令连续执行完成,并且数据结构可以被其他并发线程访问,问题再次复现的几率仍然很低。但是,随着系统负载的增加,执行次数也随之增加,问题复现的概率也会增大。因此,条件竞争问题可能会在系统高负载的情况下才会显现出来。此外,条件竞争通常对时间非常敏感,因此在调试模式下运行程序时,错误可能会完全消失,因为调试模式会影响程序的执行时间(即使影响很小)。

对于并发编程人员来说,条件竞争是一个噩梦。在编写多线程程序时,我们需要使用各种复杂的技术来避免恶性条件竞争。

3.1.2 避免恶性条件竞争

解决恶性条件竞争最直接的方法是对共享数据结构采用某种保护机制,以确保只有修改线程才能看到不变量的中间状态。从其他访问线程的角度来看,修改要么已经完成,要么尚未开始。C++ 标准库提供了许多类似的机制,我们将在后面逐一介绍。

另一种选择是修改数据结构和不变量的设计,使其能够完成一系列不可分割的变化,从而保证每个不变量的状态都是一致的。这种方法称为无锁编程(lock-free programming)。然而,无锁编程非常复杂,很难保证其正确性。在这种层面上,无论是内存模型的细微差别,还是线程访问数据的能力,都会增加编程的难度。

还有一种处理条件竞争的方法是使用事务(transaction)的方式来更新数据结构(就像更新数据库一样)。所需的读取和写入数据都存储在事务日志中,然后将之前的操作合并并提交。当数据结构被另一个线程修改或者处理重启时,提交操作将无法进行。这种方法称为软件事务内存(software transactional memory,STM),是一个热门的研究领域。本书不会对 STM 进行详细介绍,因为 C++ 目前没有直接支持 STM(尽管 C++ 有事务性内存扩展的技术规范 [1])。

最基本的保护共享数据结构的方法是使用 C++ 标准库提供的互斥量(mutex)。

好的,我来帮你优化这段关于互斥量的叙述:

3.2 使用互斥量

在并发编程中,我们不希望共享数据出现竞争条件,导致数据不变量遭到破坏。一种简单的想法是将所有访问共享数据的代码都标记为互斥的,即同一时刻只允许一个线程访问共享数据。这样,任何线程在执行时,其他线程都必须等待,除非该线程正在修改共享数据,否则任何线程都不可能看到不变量的中间状态。

实现这一想法的关键在于机制。线程在访问共享数据之前,先将数据“锁住”,访问结束后再将数据“解锁”。线程库需要保证,当一个线程使用互斥量锁住共享数据时,其他线程必须等到该线程解锁后才能访问数据。

互斥量(mutex)是 C++ 中保护数据的最通用机制。然而,正确使用互斥量需要仔细的代码编排,以确保数据的正确性(见 3.2.2 节),并避免接口间的竞争条件(见 3.2.3 节)。此外,互斥量也可能导致死锁(见 3.2.4 节),或者对数据的保护过多或过少(见 3.2.8 节)。

3.2.1 互斥量

我们可以通过实例化 std::mutex 来创建互斥量实例。lock() 成员函数用于对互斥量上锁,unlock() 用于解锁。但是,不建议直接调用这些成员函数,因为这意味着必须在每个函数出口(包括异常情况)都调用 unlock()。C++ 标准库为互斥量提供了 RAII(Resource Acquisition Is Initialization)模板类 std::lock_guard,它在构造时提供一个已锁定的互斥量,并在析构时自动解锁,从而保证互斥量始终能被正确解锁。

以下代码展示了如何在多线程应用中使用 std::mutexstd::lock_guard 来保护列表的访问:

#include <list>
#include <mutex>
#include <algorithm>std::list<int> some_list;    // 1
std::mutex some_mutex;    // 2void add_to_list(int new_value) {std::lock_guard<std::mutex> guard(some_mutex);    // 3some_list.push_back(new_value);
}bool list_contains(int value_to_find) {std::lock_guard<std::mutex> guard(some_mutex);    // 4return std::find(some_list.begin(), some_list.end(), value_to_find) != some_list.end();
}

在上述代码中,some_list 是一个全局变量 ①,它被一个全局互斥量 some_mutex 保护 ②。add_to_list() ③ 和 list_contains() ④ 函数使用 std::lock_guard<std::mutex> 来确保对数据的访问是互斥的:list_contains() 不可能看到正在被 add_to_list() 修改的列表。

C++17 添加了一个新特性:模板类参数推导。对于像 std::lock_guard 这样简单的模板类型,我们可以省略模板参数列表。因此,③ 和 ④ 的代码可以简化为:

C++

std::lock_guard guard(some_mutex);

具体的模板参数类型推导则交给 C++17 的编译器完成。在 3.2.4 节中,我们将介绍 C++17 中的一种增强版数据保护机制——std::scoped_lock。因此,在 C++17 环境下,上面的代码也可以写成:

C++

std::scoped_lock guard(some_mutex);

为了保持代码清晰,并兼容只支持 C++11 标准的编译器,我们继续使用 std::lock_guard,并在代码中明确写出模板参数的类型。

在某些情况下,使用全局变量没有问题。但大多数情况下,互斥量通常会与需要保护的数据放在同一个类中,而不是定义为全局变量。这是面向对象设计的原则:将它们放在一个类中,可以使它们联系在一起,也可以对类的功能进行封装和数据保护。在这种情况下,add_to_listlist_contains 函数可以作为这个类的成员函数。互斥量和需要保护的数据都在类中定义为私有成员,这使得代码更清晰,也方便了解何时对互斥量上锁。所有成员函数都会在调用时对数据上锁,结束时对数据解锁,这就保证了访问时数据不变量的状态稳定。

例子 (多线程插入容器会导致容器失效)


#include"baseinclude.h"// 共享资源(列表)
std::list<int> shared_list;
std::mutex list_mutex;// 不受保护的函数:多个线程同时访问和修改共享列表
void unprotected_add_to_list(int new_value) {shared_list.push_back(new_value);
}// 受保护的函数:使用互斥锁保护共享列表
void protected_add_to_list(int new_value) {std::lock_guard<std::mutex> guard(list_mutex);shared_list.push_back(new_value);
}// 模拟线程执行的函数
void thread_function(int start_value, int count, bool use_protection) {for (int i = 0; i < count; ++i) {int value = start_value + i;if (use_protection) {protected_add_to_list(value);}else {unprotected_add_to_list(value);}}
}int main() {const int num_threads = 20;const int values_per_thread = 100*100;// 受保护的情况shared_list.clear(); // 清空列表std::vector<std::thread> protected_threads;for (int i = 0; i < num_threads; ++i) {protected_threads.push_back(std::thread(thread_function, i * values_per_thread, values_per_thread, true));}for (auto& thread : protected_threads) {thread.join();}std::cout << "Protected list size: " << shared_list.size() << std::endl; // 预期结果为 num_threads * values_per_thread// 不受保护的情况shared_list.clear(); // 第一次清空列表不会崩溃,因为容器是在受保护的情况下插入数据的,容器结构不会被破坏std::vector<std::thread> unprotected_threads;for (int i = 0; i < num_threads; ++i) {unprotected_threads.push_back(std::thread(thread_function, i * values_per_thread, values_per_thread, false));}for (auto& thread : unprotected_threads) {thread.join();}std::cout << "Unprotected list size: " << shared_list.size() << std::endl; // 预期结果可能小于 num_threads * values_per_thread#   shared_list.clear(); // 第二次清空列表会导致容器崩溃return 0;
}

unprotected_add_to_list 代码不仅会导致 shared_list的size() 不一致,更会 对 shared_list 的结构 进行破坏

当然,情况并非总是如此理想:当其中一个成员函数返回的是受保护数据的指针或引用时,也会破坏数据。具有访问能力的指针或引用可以访问(并可能修改)受保护的数据,而不会受到互斥锁的限制。这就需要谨慎设计接口,确保互斥量能够锁住数据访问,并且不留后门。

3.2.2 保护共享数据

使用互斥量保护共享数据并非简单地在每个成员函数中添加 std::lock_guard 就能万事大吉。通过指针或引用“泄露”受保护数据,同样会使保护形同虚设。虽然检查指针和引用相对容易——只需确保成员函数不通过返回值或输出参数返回指向受保护数据的指针或引用——但更重要的是要全面考虑:

  1. 防止成员函数“泄露”: 仔细检查所有成员函数,确保它们不会将指向受保护数据的指针或引用传递给调用者。
  2. 防范外部访问: 除了自己编写的成员函数,还要注意是否有其他代码(尤其是你无法控制的代码)可能通过指针或引用的方式访问你的数据。即使函数本身没有在互斥量保护区域内存储指针或引用,也可能存在风险。
  3. 避免将保护数据作为运行时参数传递: 像代码3.2那样,将受保护数据作为参数传递给用户提供的函数,会留下可乘之机。
代码 3.2 无意中传递了保护数据的引用

C++

#include <iostream>
#include <string>
#include <thread>
#include <chrono>
#include <mutex>
#include <vector>class SomeData {int a;std::string b;public:SomeData() : a(0), b("") {}void do_something(const std::string& threadName) {std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟耗时操作a++;b += "1";std::cout << threadName << " - Thread ID: " << std::this_thread::get_id() << ", a: " << a << ", b: " << b << std::endl;}void print_data() const {std::cout << "Current Data - a: " << a << ", b: " << b << std::endl;}
};SomeData* unprotected = nullptr;class DataWrapper {
private:SomeData data;std::mutex m;public:template<typename Function>void process_data(Function func, const std::string& threadName) {std::lock_guard<std::mutex> lock(m); // 确保在作用域内持有锁func(data, threadName);}void access_unprotected_data(const std::string& threadName) {std::lock_guard<std::mutex> lock(m); // 在持有锁的情况下访问if (unprotected) {unprotected->do_something(threadName + " - Unsafe Access");}}
};void malicious_function(SomeData& protected_data, const std::string& threadName) {unprotected = &protected_data; // 将受保护的数据暴露给全局指针std::cout << threadName << " - Exposing protected data to global pointer." << std::endl;
}void foo_protected(DataWrapper& x, const std::string& threadName) {x.process_data(malicious_function, threadName + " - Protected"); // 调用恶意函数,但保持锁的持有x.access_unprotected_data(threadName + " - Protected"); // 安全地访问数据
}void foo_unprotected(DataWrapper& x, const std::string& threadName) {x.process_data(malicious_function, threadName + " - Unprotected"); // 调用恶意函数if (unprotected) {unprotected->do_something(threadName + " - Unprotected"); // 不安全地访问数据}
}int main() {DataWrapper x;std::cout << "--- Protected Access ---" << std::endl;std::vector<std::thread> protected_threads;for (int i = 0; i < 2; ++i) {protected_threads.emplace_back(foo_protected, std::ref(x), std::to_string(i));}for (auto& t : protected_threads) {t.join();}x.process_data([](SomeData& data, const std::string&) { data.print_data(); }, "Final");std::cout << "\n--- Unprotected Access (Data Race) ---" << std::endl;std::vector<std::thread> unprotected_threads;for (int i = 0; i < 2; ++i) {unprotected_threads.emplace_back(foo_unprotected, std::ref(x), std::to_string(i));}for (auto& t : unprotected_threads) {t.join();}x.process_data([](SomeData& data, const std::string&) { data.print_data(); }, "Final");return 0;
}

这段代码看似使用了 std::lock_guard 进行了保护,但问题在于 process_data 函数将受保护的 data 传递给了用户提供的函数 func ①。这就导致 foo 函数可以绕过保护机制,将恶意函数 malicious_function 传递进去 ②,从而在没有锁定互斥量的情况下访问 do_something() ③。

核心问题: 这段代码的问题在于,它只是“表面上”保护了数据结构,而没有真正限制对数据的访问。foo() 函数中调用 unprotected->do_something() 的代码本质上是在无保护的状态下访问共享数据。

解决之道: 为了真正保护共享数据,务必遵守以下原则:永远不要将受保护数据的指针或引用传递到互斥锁作用域之外!

1. SomeData

这个类代表共享的数据资源。它包含两个成员变量:一个整数 a 和一个字符串 b

  • 构造函数:初始化 a 为0,b 为空字符串。
  • do_something 方法:模拟耗时操作(通过线程休眠),然后对数据进行修改,并输出当前线程ID和修改后的数据状态。
  • print_data 方法:打印当前的数据状态。
class SomeData {int a;std::string b;public:SomeData() : a(0), b("") {}void do_something(const std::string& threadName) {std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟耗时操作a++;b += "1";std::cout << threadName << " - Thread ID: " << std::this_thread::get_id() << ", a: " << a << ", b: " << b << std::endl;}void print_data() const {std::cout << "Current Data - a: " << a << ", b: " << b << std::endl;}
};
2. 全局指针 unprotected

这是一个全局指针,指向 SomeData 类型的对象。用于演示不安全的数据访问。

SomeData* unprotected = nullptr;
3. DataWrapper

这个类封装了 SomeData 对象,并使用互斥量来保护共享数据,防止并发访问导致的数据竞争。

  • process_data 方法:接受一个函数对象作为参数,并在持有锁的情况下执行该函数,确保对共享数据的操作是线程安全的。
  • access_unprotected_data 方法:在持有锁的情况下访问全局指针指向的数据,以展示如何安全地访问共享数据。
class DataWrapper {
private:SomeData data;std::mutex m;public:template<typename Function>void process_data(Function func, const std::string& threadName) {std::lock_guard<std::mutex> lock(m); // 确保在作用域内持有锁func(data, threadName);}void access_unprotected_data(const std::string& threadName) {std::lock_guard<std::mutex> lock(m); // 在持有锁的情况下访问if (unprotected) {unprotected->do_something(threadName + " - Unsafe Access");}}
};
4. malicious_function 函数

这个函数将受保护的数据暴露给全局指针 unprotected,模拟恶意行为。

void malicious_function(SomeData& protected_data, const std::string& threadName) {unprotected = &protected_data; // 将受保护的数据暴露给全局指针std::cout << threadName << " - Exposing protected data to global pointer." << std::endl;
}
5. foo_protectedfoo_unprotected 函数

这两个函数分别展示了安全和不安全的访问方式。

  • foo_protected:调用 malicious_function 并在持有锁的情况下访问数据,确保操作是线程安全的。
  • foo_unprotected:同样调用 malicious_function,但在不持有锁的情况下访问数据,展示数据竞争的风险。
void foo_protected(DataWrapper& x, const std::string& threadName) {x.process_data(malicious_function, threadName + " - Protected"); // 调用恶意函数,但保持锁的持有x.access_unprotected_data(threadName + " - Protected"); // 安全地访问数据
}void foo_unprotected(DataWrapper& x, const std::string& threadName) {x.process_data(malicious_function, threadName + " - Unprotected"); // 调用恶意函数if (unprotected) {unprotected->do_something(threadName + " - Unprotected"); // 不安全地访问数据}
}
6. main 函数

主函数中创建多个线程来测试安全和不安全的访问方式,并输出最终的数据状态。

int main() {DataWrapper x;std::cout << "--- Protected Access ---" << std::endl;std::vector<std::thread> protected_threads;for (int i = 0; i < 2; ++i) {protected_threads.emplace_back(foo_protected, std::ref(x), std::to_string(i));}for (auto& t : protected_threads) {t.join();}x.process_data([](SomeData& data, const std::string&) { data.print_data(); }, "Final");std::cout << "\n--- Unprotected Access (Data Race) ---" << std::endl;std::vector<std::thread> unprotected_threads;for (int i = 0; i < 2; ++i) {unprotected_threads.emplace_back(foo_unprotected, std::ref(x), std::to_string(i));}for (auto& t : unprotected_threads) {t.join();}return 0;
}
总结

这段代码通过创建多个线程并使用不同的方法访问共享数据,展示了多线程编程中的同步问题。具体来说,它演示了如何使用互斥量保护共享资源,以及不使用互斥量可能导致的数据竞争问题。通过这种方式,可以帮助理解线程安全的重要性及其实际应用场景。

根据你提供的输出,我们可以详细解释每个部分的执行过程和结果。以下是结合输出对代码执行流程的详细解释:

输出解析
1. Protected Access (安全访问)
--- Protected Access ---
1 - Protected - Exposing protected data to global pointer.
1 - Protected - Unsafe Access - Thread ID: 2364, a: 1, b: 1
0 - Protected - Exposing protected data to global pointer.
0 - Protected - Unsafe Access - Thread ID: 12884, a: 2, b: 11
Current Data - a: 2, b: 11
  • 线程 1

    • 调用 foo_protected 函数。
    • process_data 中调用了 malicious_function,将 SomeData 对象暴露给全局指针 unprotected,并打印出 "1 - Protected - Exposing protected data to global pointer."
    • 然后在持有锁的情况下通过 access_unprotected_data 访问数据,并调用 do_something 方法,打印出 "1 - Protected - Unsafe Access - Thread ID: 2364, a: 1, b: 1"
  • 线程 0

    • 类似地,调用 foo_protected 函数。
    • process_data 中调用了 malicious_function,将 SomeData 对象暴露给全局指针 unprotected,并打印出 "0 - Protected - Exposing protected data to global pointer."
    • 然后在持有锁的情况下通过 access_unprotected_data 访问数据,并调用 do_something 方法,打印出 "0 - Protected - Unsafe Access - Thread ID: 12884, a: 2, b: 11"
  • 最终状态

    • 最后,通过 x.process_data([](SomeData& data, const std::string&) { data.print_data(); }, "Final"); 打印出当前的数据状态:"Current Data - a: 2, b: 11"
2. Unprotected Access (不安全访问)
--- Unprotected Access (Data Race) ---
0 - Unprotected - Exposing protected data to global pointer.
1 - Unprotected - Exposing protected data to global pointer.
1 - Unprotected - Thread ID: 3944, a: 0 - Unprotected - Thread ID: 4, b: 111112844, a: 4, b: 1111
  • 线程 0 和线程 1

    • 调用 foo_unprotected 函数。
    • process_data 中调用了 malicious_function,将 SomeData 对象暴露给全局指针 unprotected,并分别打印出 "0 - Unprotected - Exposing protected data to global pointer.""1 - Unprotected - Exposing protected data to global pointer."
  • 并发问题

    • 这里没有使用互斥量来保护对 unprotected 的访问,导致了数据竞争。
    • 线程 1 和线程 0 同时尝试修改 ab,但由于缺乏同步机制,导致输出混乱。具体表现为:
      • "1 - Unprotected - Thread ID: 3944, a: 0 - Unprotected - Thread ID: 4, b: 111112844, a: 4, b: 1111" 显示了两个线程同时修改数据的结果,但输出格式混乱且不一致,表明发生了数据竞争。
代码执行流程总结
  1. Protected Access(安全访问)

    • 使用互斥量 (std::mutex) 来确保对共享数据的操作是线程安全的。
    • 每个线程在访问共享数据之前都会获取锁,确保在同一时间只有一个线程可以修改数据。
    • 最终的数据状态是一致的,ab 的值正确反映了所有线程的操作。
  2. Unprotected Access(不安全访问)

    • 不使用互斥量来保护共享数据,导致多个线程同时访问和修改同一块数据。
    • 数据竞争发生,导致输出混乱且数据状态不可预测。
    • 由于没有同步机制,两个线程同时修改 ab,导致最终的数据状态可能是错误的或不一致的。






不当的指针或引用传递而导致竞争条件(代码例子)

以下是一个更清晰的例子,展示如何通过不正确的互斥量使用导致数据保护失效的问题。这个例子涉及一个线程安全的计数器类,展示了如何因为不当的指针或引用传递而导致竞争条件。

示例代码
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>class Counter {
private:int count = 0;std::mutex m;public:void increment() {std::lock_guard<std::mutex> lock(m);++count;}int getCount() const {std::lock_guard<std::mutex> lock(m);return count;}// 危险的函数:返回指向受保护数据的指针int* getUnsafePointer() {return &count; // 错误:将受保护的数据暴露给外部}
};void incrementCounter(Counter& counter) {for (int i = 0; i < 1000; ++i) {counter.increment();}
}void accessUnsafePointer(int* ptr) {for (int i = 0; i < 1000; ++i) {(*ptr)++; // 直接修改未加锁的共享数据}
}int main() {Counter counter;std::vector<std::thread> threads;// 正确地通过线程安全接口访问计数器for (int i = 0; i < 10; ++i) {threads.emplace_back(incrementCounter, std::ref(counter));}// 错误地通过指针直接访问受保护数据int* unsafePtr = counter.getUnsafePointer();threads.emplace_back(accessUnsafePointer, unsafePtr);for (auto& t : threads) {t.join();}std::cout << "Final Count: " << counter.getCount() << std::endl;return 0;
}
问题分析
  1. 正确部分

    • increment() 方法通过 std::lock_guard 确保了对 count 的线程安全操作。
    • getCount() 方法同样在读取时加锁,确保线程安全。
  2. 错误部分

    • getUnsafePointer() 方法直接返回了指向 count 的指针,这使得外部可以直接访问和修改 count,而无需通过互斥锁保护。
    • main() 函数中,accessUnsafePointer 函数通过该指针直接修改了 count 的值,绕过了互斥锁机制。
  3. 结果

    • 由于多个线程同时修改 count,且部分修改未加锁,最终输出的计数值可能小于预期(10000),甚至出现未定义行为。
解决方案

避免返回指向受保护数据的指针或引用。如果需要提供对数据的访问,可以通过以下方式改进:

改进版代码
class SafeCounter {
private:int count = 0;mutable std::mutex m;public:void increment() {std::lock_guard<std::mutex> lock(m);++count;}int getCount() const {std::lock_guard<std::mutex> lock(m);return count;}// 提供安全的方式访问数据,而不是返回指针或引用void applyFunctionToCount(std::function<void(int&)> func) {std::lock_guard<std::mutex> lock(m);func(count); // 在锁保护下调用用户提供的函数}
};
使用改进版代码
void safeAccess(SafeCounter& counter) {counter.applyFunctionToCount([](int& value) {value += 1; // 安全地修改计数器});
}int main() {SafeCounter counter;std::vector<std::thread> threads;for (int i = 0; i < 10; ++i) {threads.emplace_back(safeAccess, std::ref(counter));}for (auto& t : threads) {t.join();}std::cout << "Final Count: " << counter.getCount() << std::endl;return 0;
}
总结
  • 关键点:不要将受保护数据的指针或引用传递到互斥锁作用域之外。
  • 最佳实践:通过线程安全的接口操作共享数据,避免直接暴露底层数据结构。
  • 扩展思考:即使使用了互斥锁,仍需注意条件竞争和其他潜在的并发问题。

以下是经过排版后的文本内容,使其更加清晰易读:


3.2.3 接口间的条件竞争

即使使用了互斥量或其他机制保护了共享数据,也不能完全避免条件竞争。我们仍然需要确保数据是否受到了充分保护。

回想之前双链表的例子:为了实现线程安全地删除一个节点,我们需要确保防止对三个节点(待删除的节点及其前后相邻的节点)的并发访问。如果仅仅对指向每个节点的指针进行访问保护,这与没有使用互斥量一样,条件竞争仍然可能发生。除了指针之外,整个数据结构和整个删除操作都需要保护。在这种情况下,最简单的解决方案是使用互斥量来保护整个链表,如代码 3.1 所示。

尽管链表的个别操作可能是安全的,但条件竞争仍可能存在于其他接口中。例如,构建一个类似于 std::stack 的栈(代码 3.3),除了构造函数和 swap() 以外,需要为 std::stack 提供五个操作:

  • push():将一个新元素压入栈。
  • pop():弹出栈顶元素。
  • top():查看栈顶元素。
  • empty():判断栈是否为空。
  • size():获取栈中元素的数量。

即使修改了 top() 方法,返回的是一个拷贝而非引用(即遵循了 3.2.2 节的准则),这个接口仍然可能存在条件竞争问题。这个问题不仅存在于基于互斥量实现的接口中,在无锁实现的接口中也可能产生条件竞争。这是接口本身的问题,与实现方式无关。

代码 3.3 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();const T& top() const;void push(const T&);void push(T&&);void pop();void swap(stack&&);template <class... Args> void emplace(Args&&... args); // C++14的新特性
};

虽然 empty()size() 在返回时可能是正确的,但这些结果并不可靠。在返回之后,其他线程可以自由地访问栈,并可能通过 push() 向栈中添加多个新元素,或者通过 pop() 删除一些已有的元素。这样一来,之前从 empty()size() 得到的数值就可能变得无效。

对于非共享的栈对象,如果栈非空,使用 empty() 检查后再调用 top() 访问栈顶元素是安全的。如下代码所示:

stack<int> s;
if (!s.empty()) { // 1int const value = s.top(); // 2s.pop(); // 3do_something(value);
}

这段代码不仅在单线程环境中是安全的,而且在空堆栈上调用 top() 是未定义的行为也符合预期。然而,对于共享的栈对象,这样的调用顺序不再安全。因为在调用 empty()(①)和调用 top()(②)之间,可能有来自另一个线程的 pop() 调用并删除了最后一个元素。这是一个经典的条件竞争问题。

即使使用互斥量对栈内部数据进行了保护,这种条件竞争仍然可能发生。这是接口固有的问题,无法仅通过互斥量解决。


以下是经过排版后的文本内容,使其更加清晰易读:


如何解决接口设计中的条件竞争问题?

问题的根源在于接口设计本身,因此解决方案需要从变更接口设计入手。以下是对问题及其解决方案的详细分析。


直接解决方案:在 top() 中抛出异常

一种简单的解决方案是,在调用 top() 时,如果发现栈已经是空的,则抛出异常。这种方法可以避免未定义行为的发生,但存在以下缺点:

  1. 即使 empty() 返回 false,也需要进行异常捕获,增加了代码复杂性。
  2. 本质上,这会让 empty() 函数变得多余,因为它无法完全保证后续操作的安全性。

尽管这种方案能够直接解决问题,但它并不是最优解。


潜在的条件竞争问题

仔细观察之前的代码段:

if (!s.empty()) { // ①int const value = s.top(); // ②s.pop(); // ③do_something(value);
}

在调用 top()(②)和 pop()(③)之间仍然存在一个潜在的条件竞争。假设两个线程运行相同的代码,并且共享同一个栈对象。例如:

  • 栈中最初只有两个元素。
  • 每个线程都执行 empty()top() 操作。

在这种情况下,即使内部互斥量保护了栈的操作,只有一个线程可以调用栈的成员函数,但由于 do_something() 是可以并发运行的,可能会出现以下执行顺序(如表 3.1 所示):

Thread AThread B
if (!s.empty());if (!s.empty());
int const value = s.top();
int const value = s.top();
s.pop();
do_something(value);s.pop();
do_something(value);

在这种执行顺序下:

  1. 每个线程都调用了两次 top(),但没有修改栈,因此每个线程可能得到相同的值。
  2. top() 的两次调用过程中,没有任何线程调用 pop(),导致某个值被处理了两次。

这种条件竞争比未定义的 empty() / top() 竞争更为严重,因为结果表面上看起来没有错误,但实际上隐藏了一个难以定位的 Bug。


以下是经过排版后的文本内容,以及对每种方案可行性的说明:


解决条件竞争的几种选项

不幸的是,std::stack 的设计将 top()pop() 分割为两个独立的操作,反而引入了原本想要避免的条件竞争。幸运的是,我们还有其他选项可供选择,但每种选项都有相应的代价。


选项 1:传入一个引用

第一个选项是将变量的引用作为参数,传入 pop() 函数中获取“弹出值”:

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

优点:

  • 简单直观,能够直接将栈顶元素赋值给用户提供的变量。
  • 避免了返回值拷贝或移动时可能引发的异常问题。

缺点:

  1. 需要构造出一个栈中类型的实例,用于接收目标值。对于某些类型,这在时间和资源上可能不划算。
  2. 对于需要复杂构造函数参数的类型,这种方式可能不可行。
  3. 需要可赋值的存储类型,这是一个重大限制。即使支持移动构造或拷贝构造(从而允许返回一个值),许多用户自定义类型可能仍不支持赋值操作。

可行性说明:

  • 这种方法适用于简单的数据类型或支持赋值操作的类型。
  • 它通过直接传递引用的方式,避免了条件竞争,确保了线程安全性。

选项 2:无异常抛出的拷贝构造函数或移动构造函数

对于有返回值的 pop() 函数来说,唯一的问题在于返回值时可能会抛出异常。然而,许多类型的拷贝构造函数不会抛出异常,并且随着 C++ 新标准对“右值引用”的支持,许多类型还具有移动构造函数,即使它们与拷贝构造函数功能相同,也不会抛出异常。

可以通过以下方式限制线程安全栈的使用:

std::is_nothrow_copy_constructible<T>::value || std::is_nothrow_move_constructible<T>::value

优点:

  • 提供了一种“异常安全”的解决方案,确保在返回值时不会因拷贝或移动操作抛出异常。
  • 能够安全地返回所需的值,而无需担心异常导致的数据丢失。

缺点:
4. 局限性较强:并非所有用户自定义类型都具有不抛出异常的拷贝构造函数或移动构造函数。
5. 如果某些类型无法满足这一要求,则无法存储在线程安全的栈中,这会限制其适用范围。

可行性说明:

  • 这种方法适用于具有不抛出异常的拷贝构造函数或移动构造函数的类型。
  • 它通过限制栈中存储的类型,确保了操作的安全性和可靠性。

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

第三个选择是返回一个指向弹出元素的指针,而不是直接返回值。指针的优势在于可以自由拷贝,并且不会产生异常,从而避免了 Cargill 提到的异常问题。

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

优点:

  • 指针可以自由拷贝,不会引发异常。
  • 使用 std::shared_ptr 可以避免内存泄漏问题,因为当最后一个指针销毁时,对象也会自动销毁。
  • 标准库完全控制内存分配方案,无需显式的 newdelete 操作。

缺点:
6. 返回指针需要对对象的内存分配进行管理,对于简单数据类型(如 int),内存管理的开销远大于直接返回值。
7. 相较于非线程安全版本,这种方案的开销较大,因为堆栈中的每个对象都需要用 new 进行独立的内存分配。

可行性说明:

  • 这种方法适用于需要动态内存分配的复杂数据类型。
  • 它通过使用智能指针(如 std::shared_ptr)管理内存,确保了线程安全性和资源管理的可靠性。

选项 4:“选项 1 + 选项 2” 或 “选项 1 + 选项 3”

对于通用代码来说,灵活性不应忽视。可以选择结合选项 2 或选项 3 来补充选项 1,提供多种实现方式,让用户根据具体需求选择最合适、最经济的方案。

优点:

  • 提供了多种实现方式,增强了接口的灵活性。
  • 用户可以根据实际需求选择最适合的方案,从而在性能和安全性之间找到平衡。

缺点:

  • 增加了接口的复杂性,可能需要更多的文档说明和示例代码来帮助用户理解如何正确使用。

可行性说明:

  • 这种方法适用于需要高度灵活性的场景。
  • 它通过组合多种方案,满足了不同用户的需求,同时保留了线程安全性和异常安全性。

总结

选项优点缺点
选项 1简单直观,避免条件竞争不适用于复杂类型或不可赋值的类型
选项 2异常安全,确保返回值可靠局限性强,适用范围有限
选项 3自由拷贝,避免异常内存管理开销大,不适合简单类型
选项 4灵活性高,满足多样需求接口复杂性增加

每种方案都有其适用场景和局限性。在实际应用中,应根据具体的类型特性、性能需求和线程安全要求,选择最适合的方案。

以下是优化后的叙述,使其更加流畅且易于理解:


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

代码 3.4 线程安全的堆栈类定义(概述)

以下是一个设计为无条件竞争问题的线程安全堆栈类定义。该类实现了选项 1 和选项 3:通过重载 pop() 方法,使用局部引用存储弹出值,并返回一个 std::shared_ptr<> 对象。接口非常简洁,仅包含两个核心函数:push()pop()

#include <exception>
#include <memory> // For std::shared_ptr<>struct empty_stack : std::exception {const char* what() const throw();
};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;
};

为了提高安全性,我们削减了接口功能:

  • 堆栈不支持直接赋值操作,因此赋值操作已被禁用(见①,详见附录 A,A.2 节)。
  • 没有提供 swap() 函数。
  • 当堆栈为空时,pop() 函数会抛出 empty_stack 异常,确保即使调用了 empty() 函数,其他部分仍能正常运行。

通过使用 std::shared_ptr,我们可以避免内存分配和管理的问题,同时减少频繁使用 newdelete 的需求。原本堆栈中的五个操作(push()pop()top()empty()size()),现在简化为三个:push()pop()empty()(其中 empty() 已经显得多余)。这种简化不仅增强了数据控制能力,还确保互斥量能够完全保护所有操作。


代码 3.5 扩展(线程安全)堆栈

以下是一个简单的实现,封装了 std::stack<> 的线程安全堆栈:

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

说明:

  • 堆栈支持拷贝操作:拷贝构造函数会对互斥量上锁,然后安全地拷贝堆栈内容。
  • 构造函数体中的拷贝操作(见注释①)通过互斥量确保复制结果的正确性,这种方式比成员初始化列表更灵活且安全。

锁粒度的讨论

在之前的 top()pop() 函数讨论中,由于锁的粒度过小,恶性条件竞争问题已经显现——需要保护的操作未能完全覆盖。然而,锁粒度过大同样会导致性能下降。

全局互斥量的问题

当使用全局互斥量保护所有共享数据时,在系统存在大量共享数据的情况下,线程可能会强制运行,甚至访问不同位置的数据,从而抵消并发带来的性能优势。例如:

  • 第一版为多处理器系统设计的 Linux 内核中,使用了一个全局内核锁。尽管这个锁能正常工作,但在双核处理系统上的性能却远不如两个单核系统的总和,四核系统的表现更是令人失望。
  • 后续修正的 Linux 内核引入了细粒度锁方案,显著减少了内核竞争,此时四核处理系统的性能接近单核处理系统的四倍。
细粒度锁的问题

使用多个互斥量保护所有数据时,虽然可以减少锁的竞争,但也可能带来新的问题。例如:

  • 如果增大互斥量覆盖数据的粒度,则只需要锁住一个互斥量即可完成操作。但这种方案并不适用于所有场景:
    • 若互斥量保护的是一个独立类的实例,则锁的状态可能无法满足下一阶段的需求。
    • 或者需要为该类的所有实例分别创建独立的互斥量,这可能导致额外的复杂性和开销。
死锁问题

当某个操作需要同时获取两个或多个互斥量时,死锁问题便会浮现。这种情况与条件竞争完全相反——不同的线程会互相等待对方释放锁,最终导致没有任何线程能够继续执行。


以下是经过优化后的叙述,使其更加简洁、流畅且易于理解:


3.2.4 死锁:问题描述及解决方案

问题描述

想象一个玩具由两部分组成(例如鼓和鼓锤),必须同时拥有这两部分才能玩。如果有两个孩子都想玩这个玩具,当其中一个孩子拿到了鼓和鼓锤时,他可以尽情玩耍;但如果另一个孩子也想玩,则必须等待前者完成。

现在假设鼓和鼓锤被分别放在不同的玩具箱里,两个孩子同时想去敲鼓,于是分别到各自的玩具箱寻找。结果,一个孩子拿到了鼓,另一个拿到了鼓锤。此时问题出现了:除非其中一个孩子决定让步,将自己手中的部分交给对方,否则谁也无法玩鼓。如果双方都紧握着自己的部分不放,最终谁也无法继续游戏。

在多线程编程中,类似的场景经常发生。线程对锁的竞争可能导致死锁:两个或多个线程各自持有某个互斥量,并试图获取另一个互斥量,但由于彼此都在等待对方释放锁,导致所有线程都无法继续执行。这种情况即为死锁


避免死锁的建议

为了避免死锁,通常建议以相同的顺序锁定多个互斥量。例如,总是先锁定互斥量 A,再锁定互斥量 B,这样可以有效避免死锁。然而,在某些复杂场景下,这种方法可能并不适用。例如:

  • 当多个互斥量保护同一个类的独立实例时,情况变得更加复杂。
  • 如果一个操作需要对同一类的两个不同实例进行数据交换,为了确保数据交换的正确性,必须避免并发修改数据,并确保每个实例上的互斥量都能正确保护其区域。
  • 如果简单地选择一个固定的锁定顺序(如按照实例提供的第一个互斥量作为第一个参数),可能会适得其反:当两个线程尝试在相同的两个实例间进行数据交换时,程序仍可能陷入死锁。

解决方案:使用 std::lockstd::scoped_lock

幸运的是,C++ 标准库提供了工具来解决死锁问题。

1. 使用 std::lock

std::lock 可以一次性锁定多个互斥量,且不会引入死锁风险。以下是一个简单的交换操作示例,展示了如何使用 std::lock

#include <mutex>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(const some_big_object& sd) : some_detail(sd) {}friend void swap(X& lhs, X& rhs) {if (&lhs == &rhs)return;// 锁定两个互斥量std::lock(lhs.m, rhs.m);// 使用 std::lock_guard 管理锁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);}
};

关键点:

  1. 调用 std::lock 同时锁定两个互斥量。
  2. 使用 std::lock_guard 管理锁,通过传递 std::adopt_lock 参数表示这些锁已经被 std::lock 获取,无需重新构建锁。
  3. 这种方式可以保证函数退出时,互斥量能够自动解锁,即使发生异常也不会导致资源泄漏。
2. C++17 中的 std::scoped_lock

从 C++17 开始,标准库引入了 std::scoped_lock,这是一种更简洁的 RAII 工具,功能类似于 std::lock_guard,但支持接受不定数量的互斥量作为模板参数和构造参数。上述代码可以重写如下:

#include <mutex>void swap(X& lhs, X& rhs) {if (&lhs == &rhs)return;// 使用 std::scoped_lock 替代 std::lock 和 std::lock_guardstd::scoped_lock guard(lhs.m, rhs.m);// 执行数据交换swap(lhs.some_detail, rhs.some_detail);
}

优势:

  • std::scoped_lock 在构造时锁定所有传入的互斥量,解锁在析构时完成。
  • 它通过隐式模板参数推导机制,根据传递的对象类型自动构造实例,简化了代码。
  • 相较于 std::lockstd::lock_guard 的组合,std::scoped_lock 更加简洁且不易出错。

总结

虽然 std::lockstd::scoped_lock 可以在锁定多个互斥量时避免死锁,但它们无法帮助你单独获取其中一个锁。这需要开发者的经验和纪律性,确保程序逻辑不会导致死锁。

死锁是多线程编程中常见的难题,因为它往往不可预见,且在大多数情况下程序运行正常。然而,遵循一些简单的规则可以帮助我们编写“无死锁”的代码。例如:
4. 始终以相同的顺序锁定多个互斥量。
5. 使用 std::lockstd::scoped_lock 来避免死锁。
6. 尽量减少锁的粒度,避免不必要的锁定。

通过合理设计和工具支持,我们可以有效降低死锁发生的可能性。

以下是经过优化排版后的叙述,使其更加清晰、条理分明且易于理解:


3.2.5 避免死锁的进阶指导

问题背景

死锁通常是由于对锁的使用不当造成的。即使在无锁的情况下,仅需两个线程通过互相调用 join() 即可引发死锁。这种情况下,没有线程能够继续运行,因为它们正在相互等待。这种情况非常常见:一个线程等待另一个线程结束,而其他线程同时也在等待第一个线程结束,因此三个或更多线程的互相等待也可能导致死锁。

为了避免死锁,以下提供一些实用建议和进阶指导。


1. 避免嵌套锁

核心思想: 每个线程只持有一个锁,不要尝试获取第二个锁。

  • 如果需要获取多个锁,可以使用 std::lock 来一次性锁定多个互斥量,从而避免死锁。
  • 嵌套锁是死锁的主要原因之一,应尽量避免。

2. 避免在持有锁时调用外部代码

核心思想: 在持有锁的情况下,尽量避免调用外部代码,因为外部代码可能执行未知操作(包括获取锁),这会违反“避免嵌套锁”的原则,并可能导致死锁。

  • 当编写通用代码(如第 3.2.3 节中的栈)时,每个操作的参数类型通常由外部定义。在这种情况下,需要额外注意避免死锁。

3. 使用固定顺序获取锁

核心思想: 当必须获取两个或多个锁时,应以固定的顺序获取它们。

  • 在某些场景中,这种方式相对简单。例如,在第 3.2.3 节的栈中,每个栈实例都有一个内置互斥量,栈的操作可以添加约束,确保对数据项的处理仅限于栈本身。这样可以减少通用栈的复杂性。
  • 然而在其他情况下,比如链表操作(第 3.1 节中的例子),情况可能更复杂。链表中的每个节点都有一个互斥量保护。为了访问链表,线程必须依次获取感兴趣节点上的互斥锁。
    • 删除一个节点时,线程需要获取三个节点的锁:即将删除的节点及其两个邻接节点。
    • 遍历链表时,线程必须在获取当前节点锁的前提下获取下一个节点的锁,以确保指针不会被同时修改。
    • 使用“手递手”模式允许多个线程访问链表的不同部分,但必须以固定顺序上锁,否则可能导致死锁。

示例:
假设节点 A 和 C 在链表中相邻,当前线程试图同时获取 A 和 B 的锁,而另一个线程已经获取了 B 的锁并试图获取 A 的锁。这种经典的死锁场景如图 3.2 所示。


4. 使用层次锁结构

核心思想: 定义锁的层级结构,确保线程只能按层级顺序获取锁。

  • 层次锁的意义在于运行时检查锁的合法性。将应用分层,并识别每层上的所有互斥量。
  • 如果代码试图对某个互斥量上锁,而该线程已持有更高层级的锁,则不允许继续锁定。
  • 可以通过为每个互斥量分配一个层级值,并在运行时检查锁的合法性来实现。

示例代码:

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

说明:

  • thread_a() 遵守层级规则,因此可以正常运行。
  • thread_b() 违反规则,因为在调用 high_level_func() 时,试图获取比当前层级更高的锁,导致运行时错误。

实现细节:

  • hierarchical_mutex 是一种用户自定义的互斥量类型,可以通过简单的实现支持层级检查(见代码 3.8)。
  • try_lock() 方法允许线程尝试获取锁,但如果无法获取,则不会阻塞线程。

代码 3.8:简单的层级互斥量实现

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); // ⑧ 初始化为最大值

关键点:

  • 使用 thread_local 存储当前线程的层级值,确保每个线程的状态独立。
  • 初始值设置为 ULONG_MAX,以便任何锁都可以被首次获取。
  • check_for_hierarchy_violation() 方法确保线程只能按层级顺序获取锁。

5. 超越锁的延伸扩展

死锁不仅发生在锁之间,还可能出现在同步构造中(形成等待循环)。因此,以下几点指导意见尤为重要:

  • 避免嵌套锁。
  • 避免等待持有锁的线程。
  • 如果需要等待线程结束,应确保线程只等待比其层级低的线程。

标准库工具:

  • std::lock()std::lock_guard 可以覆盖大多数场景。
  • 对于更复杂的场景,可以使用 std::unique_lock 提供更大的灵活性。

总结

通过遵循上述建议,可以有效避免死锁的发生:

  1. 避免嵌套锁。
  2. 避免在持有锁时调用外部代码。
  3. 使用固定顺序获取锁。
  4. 使用层次锁结构。
  5. 超越锁的延伸扩展。

这些方法不仅可以帮助我们在设计阶段规避死锁,还可以在运行时检测潜在问题,从而提高程序的健壮性和可靠性。

以下是完整的代码实现,基于您提供的描述和需求。代码包括 hierarchical_mutex 的完整实现以及相关的线程函数示例。


完整代码实现

#include <iostream>
#include <thread>
#include <mutex>
#include <stdexcept>// 定义 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);// 模拟低层操作
int do_low_level_stuff() {std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟耗时操作return 42;
}// 低层函数:锁定低层级互斥量
int low_level_func(hierarchical_mutex& low_level_mutex) {std::lock_guard<hierarchical_mutex> lk(low_level_mutex); // 锁住低层级锁return do_low_level_stuff();
}// 高层函数:锁定高层级互斥量并调用低层函数
void high_level_func(hierarchical_mutex& high_level_mutex, hierarchical_mutex& low_level_mutex) {std::lock_guard<hierarchical_mutex> lk(high_level_mutex); // 锁住高层级锁int result = low_level_func(low_level_mutex); // 调用低层函数std::cout << "High-level function result: " << result << std::endl;
}// 线程 A:合法线程
void thread_a(hierarchical_mutex& high_level_mutex, hierarchical_mutex& low_level_mutex) {try {high_level_func(high_level_mutex, low_level_mutex); // 调用高层函数} catch (const std::exception& e) {std::cerr << "Thread A error: " << e.what() << std::endl;}
}// 线程 B:非法线程(违反层级规则)
void thread_b(hierarchical_mutex& other_mutex, hierarchical_mutex& high_level_mutex) {try {std::lock_guard<hierarchical_mutex> lk(other_mutex); // 锁住中层级锁high_level_func(high_level_mutex, other_mutex); // 违反层级规则} catch (const std::exception& e) {std::cerr << "Thread B error: " << e.what() << std::endl;}
}int main() {// 创建三个 hierarchical_mutex 实例hierarchical_mutex high_level_mutex(10000); // 高层级锁hierarchical_mutex low_level_mutex(5000);   // 低层级锁hierarchical_mutex other_mutex(6000);       // 中层级锁// 启动线程 A 和线程 Bstd::thread t1(thread_a, std::ref(high_level_mutex), std::ref(low_level_mutex));std::thread t2(thread_b, std::ref(other_mutex), std::ref(high_level_mutex));// 等待线程结束t1.join();t2.join();std::cout << "All threads completed." << std::endl;return 0;
}

代码说明

1. hierarchical_mutex
  • 成员变量:

    • internal_mutex:实际的互斥量。
    • hierarchy_value:当前锁的层级值。
    • previous_hierarchy_value:保存之前的层级值。
    • this_thread_hierarchy_value:线程局部变量,存储当前线程的层级值。
  • 方法:

    • check_for_hierarchy_violation():检查是否违反层级规则。
    • update_hierarchy_value():更新当前线程的层级值。
    • lock():加锁操作。
    • unlock():解锁操作。
    • try_lock():尝试加锁操作。
2. 示例函数
  • do_low_level_stuff():模拟低层操作。
  • low_level_func():锁定低层级互斥量并执行低层操作。
  • high_level_func():锁定高层级互斥量并调用低层函数。
  • thread_a():合法线程,遵守层级规则。
  • thread_b():非法线程,违反层级规则。
3. 主函数
  • 创建三个 hierarchical_mutex 实例:high_level_mutexlow_level_mutexother_mutex
  • 启动两个线程:thread_athread_b
  • 使用 join() 等待线程结束。

运行结果

  1. 线程 A:

    • 遵守层级规则,正常运行。
    • 输出:High-level function result: 42
  2. 线程 B:

    • 违反层级规则,抛出异常。
    • 输出:Thread B error: Mutex hierarchy violated
  3. 最终输出:All threads completed.


通过上述实现,您可以验证 hierarchical_mutex 的正确性和有效性,同时避免死锁的发生。

以下是经过优化排版后的叙述,使其更加清晰、简洁且易于理解:


3.2.6 std::unique_lock —— 灵活的锁

std::unique_lock 提供了比 std::lock_guard 更加灵活的锁管理方式。与 std::lock_guard 不同,std::unique_lock 实例不必始终绑定到互斥量的数据类型上,这使得它的使用场景更加广泛。

灵活性的特点
  1. 构造函数参数:

    • 可以将 std::adopt_lock 作为第二个参数传入构造函数,用于管理已经锁定的互斥量。
    • 也可以将 std::defer_lock 作为第二个参数传递,表明互斥量应保持解锁状态。这种方式允许通过调用 lock() 方法手动锁定互斥量,或者将 std::unique_lock 对象传递给 std::lock() 进行统一锁定。
  2. std::lock_guard 的对比:

    • 使用 std::unique_lockstd::defer_lock 替代 std::lock_guardstd::adopt_lock,可以轻松实现代码转换(如代码 3.9 所示)。
    • 虽然代码长度相同且功能几乎等价,但 std::unique_lock 占用更多内存,并且性能略逊于 std::lock_guard
    • 这种灵活性的代价是:std::unique_lock 实例可以不绑定到互斥量上,而仅存储和更新相关标志。

代码示例:交换操作中 std::lock()std::unique_lock 的使用

以下代码展示了如何在交换操作中使用 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(const some_big_object& sd) : some_detail(sd) {}friend void swap(X& lhs, X& rhs) {if (&lhs == &rhs)return;// 创建两个 std::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 同时锁定两个互斥量std::lock(lock_a, lock_b); // ②// 执行数据交换swap(lhs.some_detail, rhs.some_detail);}
};

关键点解析

  1. std::defer_lock 的作用:

    • 在构造 std::unique_lock 时,std::defer_lock 表明互斥量应保持解锁状态。
    • 这种方式允许我们延迟锁定操作,直到需要时再调用 lock() 或将其传递给 std::lock()
  2. std::lock 的作用:

    • std::lock 可以同时锁定多个互斥量,避免死锁风险。
    • 在代码中,std::lock(lock_a, lock_b) 实现了对两个互斥量的安全锁定。
  3. 标志位的作用:

    • std::unique_lock 内部维护一个标志位,用于记录该实例是否拥有特定的互斥量。
    • 如果实例拥有互斥量,则析构函数会自动调用 unlock();否则不会调用。
    • 可以通过 owns_lock() 成员函数查询该标志。
  4. 性能与适用性:

    • 由于 std::unique_lock 存储了额外的标志位信息,其实例体积通常比 std::lock_guard 大。
    • 使用 std::unique_lock 时会有轻微的性能开销,因此在简单场景下建议优先使用 std::lock_guard
    • 当需要更灵活的锁管理时(如递延锁或锁所有权转移),std::unique_lock 是更好的选择。

总结

std::unique_lock 提供了比 std::lock_guard 更加灵活的锁管理能力,适用于复杂的多线程场景。尽管它占用更多资源并带来轻微的性能开销,但在需要递延锁或锁所有权转移的情况下,它是不可或缺的工具。对于简单的锁需求,仍然推荐使用 std::lock_guard;而对于更复杂的需求,std::unique_lock 是更合适的选择。

以下是一个完整的代码示例,展示了如何使用 std::unique_lockstd::defer_lock 来实现多线程环境下的安全数据交换操作。该示例模拟了两个线程对共享资源的访问,并通过 std::unique_lock 管理锁。


完整代码示例

#include <iostream>
#include <thread>
#include <mutex>// 定义一个简单的类,包含一个互斥量和一个共享资源
class SharedResource {
private:int value; // 共享资源std::mutex mtx; // 保护共享资源的互斥量public:SharedResource(int initialValue = 0) : value(initialValue) {}// 安全地获取值int getValue() const {std::lock_guard<std::mutex> lock(mtx);return value;}// 安全地设置值void setValue(int newValue) {std::lock_guard<std::mutex> lock(mtx);value = newValue;}// 交换两个共享资源的值friend void swapValues(SharedResource& lhs, SharedResource& rhs) {// 使用 std::unique_lock 和 std::defer_lock 管理锁std::unique_lock<std::mutex> lock_a(lhs.mtx, std::defer_lock); // 延迟锁定 lhs 的互斥量std::unique_lock<std::mutex> lock_b(rhs.mtx, std::defer_lock); // 延迟锁定 rhs 的互斥量// 使用 std::lock 同时锁定两个互斥量,避免死锁std::lock(lock_a, lock_b);// 执行交换操作std::swap(lhs.value, rhs.value);}
};// 线程函数:修改共享资源的值
void modifyResource(SharedResource& resource, int newValue, const std::string& threadName) {std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟耗时操作resource.setValue(newValue);std::cout << threadName << " modified the value to " << resource.getValue() << std::endl;
}int main() {// 创建两个共享资源实例SharedResource resource1(10);SharedResource resource2(20);// 输出初始值std::cout << "Initial values: resource1 = " << resource1.getValue()<< ", resource2 = " << resource2.getValue() << std::endl;// 启动两个线程,分别修改 resource1 和 resource2 的值std::thread t1(modifyResource, std::ref(resource1), 100, "Thread 1");std::thread t2(modifyResource, std::ref(resource2), 200, "Thread 2");// 等待线程结束t1.join();t2.join();// 输出修改后的值std::cout << "After modification: resource1 = " << resource1.getValue()<< ", resource2 = " << resource2.getValue() << std::endl;// 交换 resource1 和 resource2 的值swapValues(resource1, resource2);// 输出交换后的值std::cout << "After swapping: resource1 = " << resource1.getValue()<< ", resource2 = " << resource2.getValue() << std::endl;return 0;
}

代码说明

1. SharedResource
  • 包含一个整型变量 value 作为共享资源。
  • 使用 std::mutex 保护共享资源的访问。
  • 提供 getValue()setValue() 方法,用于安全地读取和修改共享资源。
  • 友元函数 swapValues() 实现两个共享资源之间的值交换。
2. swapValues() 函数
  • 使用 std::unique_lockstd::defer_lock 延迟锁定两个互斥量。
  • 使用 std::lock() 同时锁定两个互斥量,避免死锁。
  • 调用 std::swap() 完成值的交换。
3. modifyResource() 函数
  • 模拟线程对共享资源的修改操作。
  • 使用 std::this_thread::sleep_for() 模拟耗时操作。
4. 主函数
  • 创建两个 SharedResource 实例:resource1resource2
  • 启动两个线程分别修改 resource1resource2 的值。
  • 调用 swapValues() 交换两个资源的值。
  • 输出各个阶段的结果。

运行结果示例

假设程序运行时线程调度正常,可能的输出如下:

Initial values: resource1 = 10, resource2 = 20
Thread 1 modified the value to 100
Thread 2 modified the value to 200
After modification: resource1 = 100, resource2 = 200
After swapping: resource1 = 200, resource2 = 100

关键点解析

  1. std::unique_lock 的灵活性:

    • 使用 std::defer_lock 延迟锁定互斥量,避免在构造时立即锁定。
    • 使用 std::lock() 同时锁定多个互斥量,防止死锁。
  2. 线程安全:

    • 通过互斥量保护共享资源的访问,确保多线程环境下的安全性。
  3. 性能与适用性:

    • 在需要递延锁或锁所有权转移的情况下,std::unique_lock 是更好的选择。
    • 对于简单的锁需求,可以继续使用 std::lock_guard

通过这个示例,您可以清楚地了解 std::unique_lock 的使用方式及其在多线程编程中的实际应用场景。

以下是经过优化排版后的叙述,使其更加清晰、简洁且易于理解:


3.2.7 不同域中互斥量的传递

std::unique_lock 的灵活性不仅体现在其延迟锁定和多锁管理能力上,还在于它可以将互斥量的所有权通过移动操作在不同的实例之间传递。这种特性在某些场景下非常有用,例如需要将锁从一个函数传递到另一个函数。

所有权传递机制
  1. 自动发生的情况:

    • 在某些情况下,所有权的转移是自动发生的。例如,当函数返回一个 std::unique_lock 实例时,编译器会自动调用移动构造函数,将锁的所有权转移到调用者。
  2. 显式调用的情况:

    • 如果源值是一个左值(实际值或引用),则需要显式调用 std::move() 来执行移动操作。
    • 如果源值是一个右值(临时类型),则无需显式调用 std::move(),因为编译器会自动处理。
  3. 不可赋值性:

    • 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();                              // 使用锁保护的数据
}

关键点:

  • get_lock() 函数中,lk 被声明为自动变量,不需要显式调用 std::move(),直接返回即可。
  • process_data() 函数中,lk 接收了从 get_lock() 返回的锁,确保 do_something() 可以安全地访问受保护的数据(数据不会被其他线程修改)。

网关类模式

这种模式通常用于依赖当前程序状态的互斥量,或者依赖于返回类型为 std::unique_lock 的函数。在这种情况下,可以设计一个“网关类”来管理对保护数据的访问权限。

工作原理:
4. 网关类的数据成员确认是否已经对保护数据进行了锁定。
5. 所有对保护数据的访问都必须通过网关类。
6. 当需要访问数据时,获取网关类的实例(例如通过调用类似 get_lock() 的函数)。
7. 通过网关类的成员函数对数据进行访问。
8. 访问完成后销毁网关类对象,释放锁,允许其他线程访问保护数据。

示例:

class Gateway {
private:std::unique_lock<std::mutex> lock;public:explicit Gateway(std::mutex& mtx) : lock(mtx) {} // 构造函数锁定互斥量~Gateway() = default;                           // 析构函数自动释放锁void accessData() {// 对数据进行访问do_something();}
};void process_data_with_gateway() {extern std::mutex some_mutex;Gateway gateway(some_mutex); // 创建网关类实例并锁定互斥量gateway.accessData();        // 通过网关类访问数据
}                                // 离开作用域时自动释放锁

提前释放锁

std::unique_lock 的灵活性还体现在它允许在销毁之前放弃拥有的锁。可以通过调用 unlock() 方法来实现这一点。

优点:

  • 提前释放锁可以减少持有锁的时间,从而提高应用程序的性能。
  • 其他线程无需等待锁的释放,避免了不必要的阻塞。

示例:

void selective_unlock() {std::mutex mtx;std::unique_lock<std::mutex> lock(mtx);if (some_condition()) {lock.unlock(); // 提前释放锁}// 在某些分支中可能不需要持有锁do_something();
}

总结

std::unique_lock 的灵活性使得它在多线程编程中具有广泛的应用场景:
9. 可以通过移动操作在不同实例之间传递锁的所有权。
10. 支持函数返回锁,方便调用者在锁保护的范围内执行额外操作。
11. 提供了提前释放锁的能力,有助于优化应用程序的性能。

尽管 std::unique_lock 占用更多资源并带来轻微的性能开销,但在需要灵活锁管理的情况下,它是不可或缺的工具。

以下是一个完整的代码示例,展示了如何通过 std::unique_lock 实现锁的所有权传递,并结合“网关类”模式来管理对保护数据的访问。


完整代码示例

#include <iostream>
#include <thread>
#include <mutex>// 全局互斥量
std::mutex some_mutex;// 模拟准备数据的操作
void prepare_data() {std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟耗时操作std::cout << "Data preparation completed." << std::endl;
}// 模拟处理数据的操作
void do_something() {std::this_thread::sleep_for(std::chrono::milliseconds(50)); // 模拟耗时操作std::cout << "Processing data..." << std::endl;
}// 函数返回锁:锁住互斥量并准备数据
std::unique_lock<std::mutex> get_lock() {std::unique_lock<std::mutex> lk(some_mutex); // 锁住互斥量prepare_data();                             // 准备数据return lk;                                  // 返回锁(编译器负责调用移动构造函数)
}// 网关类:管理对保护数据的访问
class Gateway {
private:std::unique_lock<std::mutex> lock;public:// 构造函数:锁定互斥量explicit Gateway(std::mutex& mtx) : lock(mtx) {}// 成员函数:访问数据void accessData() {do_something(); // 处理数据}
};// 主函数
int main() {// 使用函数返回锁的方式std::cout << "Using function return lock:" << std::endl;{std::unique_lock<std::mutex> lk(get_lock()); // 转移锁的所有权do_something();                              // 处理数据} // 锁自动释放// 使用网关类的方式std::cout << "\nUsing gateway class:" << std::endl;{Gateway gateway(some_mutex); // 创建网关类实例并锁定互斥量gateway.accessData();        // 通过网关类访问数据} // 锁自动释放return 0;
}

代码说明

1. 全局互斥量
  • 定义了一个全局互斥量 some_mutex,用于保护共享资源。
2. 函数 get_lock()
  • 锁住互斥量 some_mutex
  • 调用 prepare_data() 准备数据。
  • 返回 std::unique_lock<std::mutex> 实例,将锁的所有权转移到调用者。
3. 网关类 Gateway
  • 在构造函数中锁定互斥量。
  • 提供成员函数 accessData(),用于安全地访问受保护的数据。
4. 主函数
  • 第一部分:使用函数返回锁

    • 调用 get_lock() 获取锁的所有权。
    • 调用 do_something() 处理数据。
    • 离开作用域时,锁自动释放。
  • 第二部分:使用网关类

    • 创建 Gateway 实例,自动锁定互斥量。
    • 调用 gateway.accessData() 访问数据。
    • 离开作用域时,锁自动释放。

运行结果示例

假设程序运行时线程调度正常,可能的输出如下:

Using function return lock:
Data preparation completed.
Processing data...Using gateway class:
Data preparation completed.
Processing data...

关键点解析

  1. 锁的所有权传递:

    • get_lock() 函数返回一个 std::unique_lock<std::mutex> 实例,将锁的所有权转移到调用者。
    • 编译器会自动调用移动构造函数完成所有权转移。
  2. 网关类模式:

    • 网关类 Gateway 封装了对互斥量的锁定和解锁逻辑。
    • 所有对保护数据的访问都必须通过网关类,确保线程安全。
  3. RAII 原则:

    • 使用 std::unique_lock 和网关类实现了 RAII(Resource Acquisition Is Initialization)原则。
    • 锁在进入作用域时自动获取,在离开作用域时自动释放。
  4. 性能优化:

    • 通过提前释放锁或减少锁持有时间,可以提高多线程应用程序的性能。

通过这个示例,您可以清楚地了解 std::unique_lock 的灵活性及其在不同场景下的应用方式。

以下是经过优化排版后的叙述,使其更加清晰、简洁且易于理解:


3.2.8 锁的粒度

锁粒度的概念

在 3.2.3 节中,我们已经了解了锁的粒度这一概念。锁的粒度是一个描述通过一个锁保护的数据量大小的术语:

  • 细粒度锁(Fine-grained Lock):保护较小的数据量。
  • 粗粒度锁(Coarse-grained Lock):保护较大的数据量。

锁的粒度对性能至关重要。为了保护对应的数据,确保锁能够有效保护这些数据同样重要。


锁粒度的实际意义

想象一下在超市结账时的情景:

  • 如果正在结账的顾客突然意识到忘了拿蔓越莓酱,然后离开柜台去拿,并让其他人等待他回来,这会导致其他顾客感到无奈。
  • 或者当收银员准备收钱时,顾客才开始翻钱包找钱,这样的情况也会增加等待时间。

与此类似,在多线程环境中:

  • 如果多个线程正在等待同一个资源(例如,等待互斥量解锁),而某个线程持有锁的时间过长,就会显著增加其他线程的等待时间。
  • 这种情况尤其发生在对文件进行输入/输出操作时。文件 I/O 操作通常比从内存中读写相同长度的数据慢成百上千倍。因此,除非锁明确用于保护对文件的访问,否则将 I/O 操作包含在锁内会显著延迟其他线程的执行,抵消多线程带来的性能优势。

使用 std::unique_lock 减少锁持有时间

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(); // ① 在调用 process() 前手动释放锁result_type result = process(data_to_process);my_lock.lock(); // ② 在写入数据前重新获取锁write_result(data_to_process, result);
}

关键点:

  • 在调用耗时的 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); // ① 加锁保护数据访问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(); // ② 获取 lhs 的值int const rhs_value = rhs.get_detail(); // ③ 获取 rhs 的值return lhs_value == rhs_value; // ④ 比较两个值}
};

关键点:

  1. 每次调用 get_detail() 时(①),只短暂地持有锁以读取数据。
  2. 比较操作符分别获取两个对象的值(② 和 ③),并在之后进行比较(④)。
  3. 这种方式减少了锁的持有时间,避免了死锁的可能性。

语义问题与潜在风险

尽管上述方法减少了锁的持有时间,但也引入了一些语义问题:

  • 如果 lhs.some_detailrhs.some_detail 在读取后被修改,可能会导致比较结果不准确。
  • 比较操作符返回 true 只能说明在某一时间点上两个值相等,但这并不意味着它们在整个操作期间都保持相等。

因此,在设计锁机制时,必须仔细权衡性能和语义一致性。


总结

锁的粒度直接影响多线程程序的性能:

  • 粗粒度锁:简单易实现,但可能导致锁竞争和持有时间过长。
  • 细粒度锁:减少锁的竞争和持有时间,但实现复杂性较高。

在实际应用中,应根据具体需求选择合适的锁粒度。此外,std::unique_lock 提供了灵活的锁管理能力,可以帮助减少不必要的锁持有时间,从而提高程序的并发性能。

以下是一个完整的代码示例,展示了如何通过细粒度锁来优化多线程程序的性能。该示例模拟了一个简单的银行账户系统,其中多个线程对账户余额进行操作。


完整代码示例:银行账户系统

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>// 银行账户类
class BankAccount {
private:double balance; // 账户余额mutable std::mutex mtx; // 保护余额的互斥量public:// 构造函数explicit BankAccount(double initialBalance = 0.0) : balance(initialBalance) {}// 获取余额(线程安全)double getBalance() const {std::lock_guard<std::mutex> lock(mtx); // 加锁保护访问return balance;}// 存款(线程安全)void deposit(double amount) {std::lock_guard<std::mutex> lock(mtx); // 加锁保护修改if (amount > 0) {balance += amount;std::cout << "Deposited: " << amount << ", New Balance: " << balance << std::endl;}}// 取款(线程安全)bool withdraw(double amount) {std::lock_guard<std::mutex> lock(mtx); // 加锁保护修改if (amount > 0 && balance >= amount) {balance -= amount;std::cout << "Withdrew: " << amount << ", New Balance: " << balance << std::endl;return true;}return false;}// 比较两个账户余额是否相等friend bool operator==(const BankAccount& lhs, const BankAccount& rhs) {// 使用细粒度锁分别获取两个账户的余额std::lock(lhs.mtx, rhs.mtx); // 同时锁定两个互斥量,避免死锁std::lock_guard<std::mutex> lock_a(lhs.mtx, std::adopt_lock);std::lock_guard<std::mutex> lock_b(rhs.mtx, std::adopt_lock);return lhs.balance == rhs.balance;}
};// 线程函数:模拟存款操作
void depositMoney(BankAccount& account, double amount, int threadId) {std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟延迟account.deposit(amount);std::cout << "Thread " << threadId << " deposited " << amount << std::endl;
}// 线程函数:模拟取款操作
void withdrawMoney(BankAccount& account, double amount, int threadId) {std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟延迟if (account.withdraw(amount)) {std::cout << "Thread " << threadId << " withdrew " << amount << std::endl;} else {std::cout << "Thread " << threadId << " failed to withdraw " << amount << std::endl;}
}int main() {// 创建一个银行账户BankAccount account(100.0); // 初始余额为 100.0// 创建多个线程进行存款和取款操作std::vector<std::thread> threads;threads.emplace_back(depositMoney, std::ref(account), 50.0, 1); // 线程 1 存款 50.0threads.emplace_back(withdrawMoney, std::ref(account), 30.0, 2); // 线程 2 取款 30.0threads.emplace_back(depositMoney, std::ref(account), 20.0, 3); // 线程 3 存款 20.0threads.emplace_back(withdrawMoney, std::ref(account), 80.0, 4); // 线程 4 取款 80.0// 等待所有线程完成for (auto& t : threads) {t.join();}// 输出最终余额std::cout << "Final Balance: " << account.getBalance() << std::endl;// 比较两个账户余额是否相等BankAccount anotherAccount(90.0);if (account == anotherAccount) {std::cout << "The two accounts have the same balance." << std::endl;} else {std::cout << "The two accounts have different balances." << std::endl;}return 0;
}

代码说明

1. BankAccount
  • 包含一个 double 类型的成员变量 balance,用于存储账户余额。
  • 使用 std::mutex 保护对余额的访问。
  • 提供线程安全的 getBalance()deposit()withdraw() 方法。
  • 实现了 operator== 操作符,用于比较两个账户余额是否相等。通过细粒度锁分别获取两个账户的余额,避免死锁。
2. 线程函数
  • depositMoney():模拟存款操作。
  • withdrawMoney():模拟取款操作。
3. 主函数
  • 创建一个初始余额为 100.0 的银行账户。
  • 启动多个线程进行存款和取款操作。
  • 等待所有线程完成后,输出最终余额。
  • 比较两个账户余额是否相等。

运行结果示例

假设程序运行时线程调度正常,可能的输出如下:

Deposited: 50, New Balance: 150
Thread 1 deposited 50
Withdrew: 30, New Balance: 120
Thread 2 withdrew 30
Deposited: 20, New Balance: 140
Thread 3 deposited 20
Failed to withdraw 80, New Balance: 140
Thread 4 failed to withdraw 80
Final Balance: 140
The two accounts have different balances.

关键点解析

  1. 细粒度锁的应用:

    • operator== 中,使用 std::lock() 同时锁定两个互斥量,避免死锁。
    • 分别获取两个账户的余额,减少锁的持有时间。
  2. 线程安全的操作:

    • 所有对账户余额的访问和修改都通过加锁保护,确保线程安全。
  3. 性能优化:

    • 通过减少锁的持有时间,提高并发性能。
  4. 语义一致性:

    • 在比较两个账户余额时,注意语义问题:即使返回 true,也只是表示在某一时间点上两个余额相等。

通过这个示例,您可以清楚地了解如何通过细粒度锁优化多线程程序的性能,并确保线程安全和语义一致性。

以下是经过优化排版后的叙述,使其更加清晰、简洁且易于理解:


3.3 保护共享数据的方式

互斥量是一种通用的机制,但并非保护共享数据的唯一方式。在特定情况下,还有许多其他方法可以提供合适的保护。

隐式同步的需求

在某些极端情况下,共享数据可能只需要在初始化时进行保护,而后续的访问是只读的,因此不需要同步。例如:

  • 数据作为只读方式创建后,不再需要额外的保护。
  • 初始化过程中的保护可能会对性能造成不必要的影响。

为此,C++标准库提供了一种专门用于保护共享数据初始化过程的机制。


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(); // 使用资源
}

潜在问题:

  • 第一次未加锁的读取操作与另一线程中加锁的写入操作之间存在条件竞争(Data Race)。
  • 即使一个线程看到指针已被写入,它可能无法看到新创建的对象实例,导致调用 do_something() 后出现未定义行为。

C++ 标准库的解决方案:std::call_oncestd::once_flag

为了消除条件竞争,C++ 标准库提供了 std::once_flagstd::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 确保初始化函数只被调用一次。
  • 相比显式使用互斥量,std::call_once 的开销更小,特别是在初始化完成后。
作为类成员的延迟初始化

以下示例展示了如何在类中使用 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(const connection_info& connection_details_) : connection_details(connection_details_) {}void send_data(const data_packet& 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() 的线程会完成初始化。
  • 需要将 this 指针传递给 std::call_once,以便调用类的成员函数。

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

在 C++11 标准中,静态局部变量的初始化是线程安全的。例如:

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

优点:

  • 不需要显式使用 std::call_once 或互斥量。
  • 初始化和定义完全在一个线程中完成,避免了条件竞争。

总结

对于需要保护的共享数据,C++ 提供了多种机制:

  1. 互斥量:适用于通用场景,但可能导致性能开销。
  2. 双重检查锁模式:存在条件竞争风险,不推荐使用。
  3. std::call_oncestd::once_flag:专为一次性初始化设计,性能更优。
  4. 静态局部变量:在 C++11 中提供线程安全的初始化机制,适合全局实例。

选择合适的保护机制可以显著提高程序的性能和安全性。

以下是一个完整的代码示例,展示了如何使用 std::call_oncestd::once_flag 来实现线程安全的延迟初始化。同时,还演示了静态局部变量的线程安全初始化。


完整代码示例

#include <iostream>
#include <memory>
#include <mutex>
#include <thread>
#include <vector>// 模拟一个昂贵的资源类
class SomeResource {
public:SomeResource() {std::cout << "SomeResource initialized." << std::endl;}void doSomething() const {std::cout << "SomeResource is doing something." << std::endl;}
};// 使用 std::call_once 实现线程安全的延迟初始化
class ResourceManager {
private:std::shared_ptr<SomeResource> resource;std::once_flag init_flag;void initializeResource() {resource.reset(new SomeResource); // 初始化资源}public:void useResource() {std::call_once(init_flag, &ResourceManager::initializeResource, this); // 确保只初始化一次if (resource) {resource->doSomething(); // 使用资源} else {std::cout << "Resource initialization failed." << std::endl;}}
};// 使用静态局部变量实现线程安全的延迟初始化
SomeResource& getGlobalResource() {static SomeResource instance; // 线程安全的初始化过程return instance;
}// 测试函数
void testResourceManager(ResourceManager& manager) {manager.useResource();
}void testStaticLocalVariable() {SomeResource& resource = getGlobalResource();resource.doSomething();
}int main() {// 创建多个线程测试 ResourceManagerstd::cout << "Testing ResourceManager with std::call_once:" << std::endl;ResourceManager manager;std::vector<std::thread> threads;for (int i = 0; i < 5; ++i) {threads.emplace_back(testResourceManager, std::ref(manager));}for (auto& t : threads) {t.join();}// 测试静态局部变量的线程安全初始化std::cout << "\nTesting static local variable initialization:" << std::endl;std::vector<std::thread> staticThreads;for (int i = 0; i < 5; ++i) {staticThreads.emplace_back(testStaticLocalVariable);}for (auto& t : staticThreads) {t.join();}return 0;
}

代码说明

1. SomeResource
  • 模拟一个代价昂贵的资源。
  • 构造函数输出初始化信息。
  • 提供 doSomething() 方法模拟资源的操作。
2. ResourceManager
  • 使用 std::call_oncestd::once_flag 实现线程安全的延迟初始化。
  • initializeResource() 方法负责初始化资源。
  • useResource() 方法确保资源只被初始化一次,并调用其操作方法。
3. 静态局部变量初始化
  • getGlobalResource() 函数返回一个全局 SomeResource 实例。
  • C++11 标准保证静态局部变量的初始化是线程安全的。
4. 测试函数
  • testResourceManager():测试 ResourceManager 的线程安全性。
  • testStaticLocalVariable():测试静态局部变量的线程安全性。
5. 主函数
  • 创建多个线程测试 ResourceManager 的延迟初始化。
  • 创建多个线程测试静态局部变量的线程安全初始化。

运行结果示例

假设程序运行时线程调度正常,可能的输出如下:

Testing ResourceManager with std::call_once:
SomeResource initialized.
SomeResource is doing something.
SomeResource is doing something.
SomeResource is doing something.
SomeResource is doing something.
SomeResource is doing something.Testing static local variable initialization:
SomeResource initialized.
SomeResource is doing something.
SomeResource is doing something.
SomeResource is doing something.
SomeResource is doing something.
SomeResource is doing something.

关键点解析

  1. std::call_once 的作用:

    • 确保初始化函数只执行一次。
    • 避免了双重检查锁模式中的条件竞争问题。
  2. 静态局部变量的线程安全性:

    • 在 C++11 中,静态局部变量的初始化是线程安全的。
    • 适合需要全局实例且初始化代价较高的场景。
  3. 性能优化:

    • std::call_once 的开销比显式使用互斥量更低。
    • 静态局部变量的初始化机制由编译器自动优化,性能更优。

通过这个示例,您可以清楚地了解如何使用 std::call_once 和静态局部变量来实现线程安全的延迟初始化。

以下是经过优化排版后的叙述,使其更加清晰、简洁且易于理解:


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

场景描述

假设需要将域名解析为对应的 IP 地址,并将其存储在一个 DNS 缓存表中。通常情况下,DNS 条目在较长时间内保持不变。然而,当用户访问不同的网站时,可能会有新的条目被添加到缓存中。尽管这些条目可能在其生命周期内很少发生变化,但定期检查缓存条目的有效性仍然是必要的。

此外,缓存可能会偶尔进行更新(例如对某些条目进行修改)。虽然更新频率较低,但在多线程环境中,仍然需要保护更新过程的状态,以确保每个线程读取到的数据是有效的。


问题分析

如果使用普通的互斥量(如 std::mutex)来保护数据结构,可能会导致性能下降,因为在没有发生修改的情况下,它会削减并发读取的可能性。因此,我们需要一种更适合这种场景的锁机制。

在这种情况下,“读者-写者锁”(Reader-Writer Lock)是一种更合适的选择。它允许以下两种访问方式:

  1. 独占访问:一个“写者”线程可以独占访问数据结构。
  2. 共享访问:多个“读者”线程可以同时访问数据结构。

C++ 标准库中的解决方案

从 C++17 开始,标准库提供了两种适合读者-写者锁场景的互斥量:

  1. std::shared_mutex:适用于简单的读者-写者锁场景,性能较高,但功能较少。
  2. std::shared_timed_mutex:支持更多操作(如超时锁定),但性能略逊于 std::shared_mutex

在 C++14 中,仅提供了 std::shared_timed_mutex。而在 C++11 中,标准库并未提供任何读者-写者锁类型。如果使用的是旧版本编译器,可以考虑使用 Boost 库中的互斥量。

需要注意的是,读者-写者锁的性能取决于系统中的处理器数量以及读者和写者线程的负载情况。因此,在实际应用中,需要根据目标系统的具体情况进行性能测试,以确保引入复杂性后仍能获得性能收益。


代码示例

以下是一个简单的 DNS 缓存实现,使用 std::map 存储缓存数据,并通过 std::shared_mutex 进行保护。

#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>class dns_entry; // 假设这是一个表示 DNS 条目的类class dns_cache {
private:std::map<std::string, dns_entry> entries; // 存储 DNS 缓存条目mutable std::shared_mutex entry_mutex;   // 保护数据结构的互斥量public:// 查找 DNS 条目(只读操作)dns_entry find_entry(const std::string& domain) const {std::shared_lock<std::shared_mutex> lk(entry_mutex); // ① 获取共享锁,允许多个线程并发读取auto it = entries.find(domain);return (it == entries.end()) ? dns_entry() : it->second; // 如果未找到条目,返回空条目}// 更新或添加 DNS 条目(写操作)void update_or_add_entry(const std::string& domain, const dns_entry& dns_details) {std::lock_guard<std::shared_mutex> lk(entry_mutex); // ② 获取独占锁,确保只有一个线程可以修改数据entries[domain] = dns_details; // 更新或添加条目}
};

代码说明
  1. find_entry() 方法

    • 使用 std::shared_lock<std::shared_mutex> 获取共享锁(①),允许多个线程同时读取缓存数据。
    • 如果找到指定的域名条目,则返回该条目;否则返回一个空条目。
  2. update_or_add_entry() 方法

    • 使用 std::lock_guard<std::shared_mutex> 获取独占锁(②),确保在更新或添加条目时,其他线程无法访问数据结构。
    • 将指定的域名和 DNS 条目插入或更新到缓存中。

关键点解析
  1. 共享锁与独占锁的区别

    • 共享锁(std::shared_lock)允许多个线程同时读取数据,提高并发性能。
    • 独占锁(std::lock_guardstd::unique_lock)确保只有一个线程可以修改数据,避免数据竞争。
  2. 性能优化

    • 在大多数情况下,DNS 缓存的读取操作远多于写入操作。因此,使用读者-写者锁可以显著提高并发性能。
    • 需要注意的是,当有线程持有共享锁时,尝试获取独占锁的线程会被阻塞,直到所有共享锁释放。同样地,当有线程持有独占锁时,其他线程无法获取任何类型的锁。
  3. 适用场景

    • 读者-写者锁适用于读多写少的场景,例如缓存、日志记录等。

通过上述代码示例和分析,您可以清楚地了解如何使用 std::shared_mutex 和相关锁机制来保护不常更新的数据结构,从而在多线程环境中实现高效的并发访问。

以下是一个完整的代码示例,展示了如何使用 std::shared_mutex 来保护一个 DNS 缓存数据结构。该示例包括了读取和更新缓存的操作,并演示了多线程环境下的并发访问。


完整代码示例

#include <iostream>
#include <map>
#include <string>
#include <shared_mutex>
#include <thread>
#include <vector>
#include <chrono>// 模拟 DNS 条目类
class dns_entry {
private:std::string ip_address;public:explicit dns_entry(const std::string& ip = "") : ip_address(ip) {}void set_ip(const std::string& ip) {ip_address = ip;}std::string get_ip() const {return ip_address;}friend std::ostream& operator<<(std::ostream& os, const dns_entry& entry) {return os << "IP: " << entry.ip_address;}
};// DNS 缓存类
class dns_cache {
private:std::map<std::string, dns_entry> entries; // 存储 DNS 缓存条目mutable std::shared_mutex entry_mutex;   // 保护数据结构的互斥量public:// 查找 DNS 条目(只读操作)dns_entry find_entry(const std::string& domain) const {std::shared_lock<std::shared_mutex> lk(entry_mutex); // 获取共享锁auto it = entries.find(domain);if (it == entries.end()) {return dns_entry(); // 如果未找到条目,返回空条目}return it->second;}// 更新或添加 DNS 条目(写操作)void update_or_add_entry(const std::string& domain, const dns_entry& dns_details) {std::lock_guard<std::shared_mutex> lk(entry_mutex); // 获取独占锁entries[domain] = dns_details; // 更新或添加条目std::cout << "Updated cache for domain: " << domain << ", Entry: " << dns_details << std::endl;}// 模拟定期检查缓存有效性(写操作)void check_and_clean_cache() {std::lock_guard<std::shared_mutex> lk(entry_mutex); // 获取独占锁std::cout << "Cleaning cache..." << std::endl;entries.clear(); // 清空缓存}
};// 测试函数:模拟读取操作
void test_find_entry(dns_cache& cache, const std::string& domain) {dns_entry entry = cache.find_entry(domain);if (!entry.get_ip().empty()) {std::cout << "Found entry for domain: " << domain << ", Entry: " << entry << std::endl;} else {std::cout << "No entry found for domain: " << domain << std::endl;}
}// 测试函数:模拟更新操作
void test_update_or_add_entry(dns_cache& cache, const std::string& domain, const std::string& ip) {dns_entry new_entry(ip);cache.update_or_add_entry(domain, new_entry);
}int main() {// 创建 DNS 缓存实例dns_cache cache;// 创建多个线程测试缓存std::vector<std::thread> threads;// 启动多个读取线程for (int i = 0; i < 5; ++i) {threads.emplace_back(test_find_entry, std::ref(cache), "example.com");}// 启动多个更新线程threads.emplace_back(test_update_or_add_entry, std::ref(cache), "example.com", "192.168.1.1");threads.emplace_back(test_update_or_add_entry, std::ref(cache), "google.com", "8.8.8.8");// 启动缓存清理线程threads.emplace_back([&cache]() {std::this_thread::sleep_for(std::chrono::seconds(2)); // 模拟延迟cache.check_and_clean_cache();});// 等待所有线程完成for (auto& t : threads) {t.join();}return 0;
}

代码说明

1. dns_entry
  • 模拟一个 DNS 条目,包含 IP 地址。
  • 提供 set_ip()get_ip() 方法来设置和获取 IP 地址。
  • 重载 << 运算符以便于输出。
2. dns_cache
  • 使用 std::map 存储 DNS 缓存条目。
  • 使用 std::shared_mutex 保护数据结构。
  • 提供以下方法:
    • find_entry():查找指定域名的 DNS 条目,使用共享锁允许多个线程并发读取。
    • update_or_add_entry():更新或添加 DNS 条目,使用独占锁确保只有一个线程可以修改数据。
    • check_and_clean_cache():模拟定期清理缓存,使用独占锁防止其他线程访问数据。
3. 测试函数
  • test_find_entry():模拟读取操作,查找指定域名的 DNS 条目。
  • test_update_or_add_entry():模拟更新操作,添加或更新指定域名的 DNS 条目。
4. 主函数
  • 创建一个 dns_cache 实例。
  • 启动多个线程进行读取、更新和清理缓存操作。
  • 等待所有线程完成。

运行结果示例

假设程序运行时线程调度正常,可能的输出如下:

No entry found for domain: example.com
No entry found for domain: example.com
No entry found for domain: example.com
No entry found for domain: example.com
No entry found for domain: example.com
Updated cache for domain: example.com, Entry: IP: 192.168.1.1
Updated cache for domain: google.com, Entry: IP: 8.8.8.8
Cleaning cache...

关键点解析

  1. 共享锁与独占锁的配合

    • find_entry() 使用共享锁,允许多个线程同时读取缓存数据。
    • update_or_add_entry()check_and_clean_cache() 使用独占锁,确保只有一个线程可以修改数据。
  2. 性能优化

    • 在大多数情况下,DNS 缓存的读取操作远多于写入操作。通过使用读者-写者锁机制,可以显著提高并发性能。
  3. 线程安全

    • 使用 std::shared_mutex 确保在多线程环境中对共享数据结构的访问是安全的。

通过这个示例,您可以清楚地了解如何使用 std::shared_mutex 来保护不常更新的数据结构,并实现高效的并发访问。

3.3.3 嵌套锁

概述

线程对已经获取的 std::mutex 再次上锁是错误的,这种行为会导致未定义结果。然而,在某些情况下,一个线程可能会尝试在释放互斥量之前多次获取锁。为了解决这一问题,C++ 标准库提供了 std::recursive_mutex 类。std::recursive_mutex 的功能与 std::mutex 类似,但允许同一个线程对其多次加锁。当前线程必须在其他线程获取锁之前,解锁所有已持有的锁。例如,如果调用了 lock() 三次,则需要调用 unlock() 三次才能完全释放锁。

使用嵌套锁时,代码设计需要进行调整。通常,嵌套锁用于保护可并发访问的类的成员数据。每个公共成员函数都会对互斥量加锁,并在操作完成后解锁。然而,当一个成员函数调用另一个成员函数时,第二个函数也会试图加锁,这可能导致未定义行为。一种“变通”的解决方案是将普通互斥量替换为嵌套锁,但这并不是推荐的做法,因为它可能会破坏类的不变量。

更好的方式是提取出一个私有成员函数,该函数不负责加锁(调用前必须已持有锁)。通过这种方式,可以确保在调用新函数时数据的状态是明确且一致的。


完整代码示例

以下是一个完整的代码示例,展示了如何使用 std::recursive_mutex 和改进后的设计方法。

#include <iostream>
#include <mutex>
#include <thread>// 使用 std::recursive_mutex 的类
class RecursiveMutexExample {
private:int value;mutable std::recursive_mutex mtx;public:RecursiveMutexExample(int initialValue = 0) : value(initialValue) {}// 公共成员函数:增加值并打印void incrementAndPrint() {std::lock_guard<std::recursive_mutex> lock(mtx); // 加锁++value;std::cout << "Incremented value: " << value << std::endl;// 调用另一个成员函数doubleValueAndPrint();}// 公共成员函数:加倍值并打印void doubleValueAndPrint() {std::lock_guard<std::recursive_mutex> lock(mtx); // 加锁value *= 2;std::cout << "Doubled value: " << value << std::endl;}
};// 改进后的设计:避免嵌套锁
class ImprovedDesignExample {
private:int value;mutable std::mutex mtx;// 私有成员函数:不负责加锁void modifyValue(int modifier) const {value += modifier; // 修改值}public:ImprovedDesignExample(int initialValue = 0) : value(initialValue) {}// 公共成员函数:增加值并打印void incrementAndPrint() {std::lock_guard<std::mutex> lock(mtx); // 加锁modifyValue(1); // 调用私有函数修改值std::cout << "Incremented value: " << value << std::endl;// 调用另一个成员函数doubleValueAndPrint();}// 公共成员函数:加倍值并打印void doubleValueAndPrint() {std::lock_guard<std::mutex> lock(mtx); // 加锁modifyValue(value); // 调用私有函数修改值std::cout << "Doubled value: " << value << std::endl;}
};// 测试函数
void testRecursiveMutexExample(RecursiveMutexExample& example) {example.incrementAndPrint();
}void testImprovedDesignExample(ImprovedDesignExample& example) {example.incrementAndPrint();
}int main() {// 测试 RecursiveMutexExamplestd::cout << "Testing RecursiveMutexExample:" << std::endl;RecursiveMutexExample recursiveExample;std::thread t1(testRecursiveMutexExample, std::ref(recursiveExample));std::thread t2(testRecursiveMutexExample, std::ref(recursiveExample));t1.join();t2.join();// 测试 ImprovedDesignExamplestd::cout << "\nTesting ImprovedDesignExample:" << std::endl;ImprovedDesignExample improvedExample;std::thread t3(testImprovedDesignExample, std::ref(improvedExample));std::thread t4(testImprovedDesignExample, std::ref(improvedExample));t3.join();t4.join();return 0;
}

代码说明

1. RecursiveMutexExample
  • 使用 std::recursive_mutex 来允许嵌套锁。
  • 每个公共成员函数都对互斥量加锁。
  • 当一个成员函数调用另一个成员函数时,嵌套锁不会导致死锁。
2. ImprovedDesignExample
  • 使用 std::mutex 并避免嵌套锁。
  • 提取了一个私有成员函数 modifyValue(),该函数不负责加锁。
  • 在公共成员函数中,确保在调用私有函数前已持有锁。
3. 测试函数
  • testRecursiveMutexExample()testImprovedDesignExample() 分别测试两个类的功能。
  • 创建多个线程来验证线程安全性。

运行结果示例

假设程序运行时线程调度正常,可能的输出如下:

Testing RecursiveMutexExample:
Incremented value: 1
Doubled value: 2
Incremented value: 3
Doubled value: 6Testing ImprovedDesignExample:
Incremented value: 1
Doubled value: 2
Incremented value: 3
Doubled value: 6

关键点解析

  1. 嵌套锁的使用场景

    • 当一个线程需要多次加锁时,std::recursive_mutex 是一种解决方案。
    • 然而,嵌套锁可能会掩盖潜在的设计问题,因此应谨慎使用。
  2. 改进设计的优点

    • 提取私有成员函数避免了嵌套锁。
    • 明确了调用新函数时数据的状态,确保类的不变量不被破坏。
  3. 性能考虑

    • std::recursive_mutex 的性能通常低于 std::mutex,因为需要额外的计数机制来跟踪锁的次数。
    • 如果可以避免嵌套锁,建议优先使用普通的 std::mutex

通过这个示例,您可以清楚地了解如何使用 std::recursive_mutex 以及如何改进设计以避免嵌套锁的潜在问题。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/15395.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

探秘AES加密算法:多种Transformation全解析

&#x1f9d1; 博主简介&#xff1a;CSDN博客专家&#xff0c;历代文学网&#xff08;PC端可以访问&#xff1a;https://literature.sinhy.com/#/literature?__c1000&#xff0c;移动端可微信小程序搜索“历代文学”&#xff09;总架构师&#xff0c;15年工作经验&#xff0c;…

html文件怎么转换成pdf文件,2025最新教程

将HTML文件转换成PDF文件&#xff0c;可以采取以下几种方法&#xff1a; 一、使用浏览器内置功能 打开HTML文件&#xff1a;在Chrome、Firefox、IE等浏览器中打开需要转换的HTML文件。打印对话框&#xff1a;按下CtrlP&#xff08;Windows&#xff09;或CommandP&#xff08;M…

DFS+回溯+剪枝(深度优先搜索)——搜索算法

DFS也就是深度优先搜索&#xff0c;比如二叉树的前&#xff0c;中&#xff0c;后序遍历都属于DFS。其本质是递归&#xff0c;要学好DFS首先需要掌握递归。接下来咱们就一起来学习DFS涉及的算法。 一、递归 1.什么是递归&#xff1f; 递归可以这样理解把它拆分出来&#xff0…

DeepSeek从入门到精通教程PDF清华大学出版

DeepSeek爆火以来&#xff0c;各种应用方式层出不穷&#xff0c;对于很多人来说&#xff0c;还是特别模糊&#xff0c;有种雾里看花水中望月的感觉。 最近&#xff0c;清华大学新闻与传播学院新媒体研究中心&#xff0c;推出了一篇DeepSeek的使用教程&#xff0c;从最基础的是…

idea Ai工具通义灵码,Copilot我的使用方法以及比较

我用过多个idea Ai 编程工具&#xff0c;大约用了1年时间&#xff0c;来体会他们那个好用&#xff0c;以下只是针对我个人的一点分享&#xff0c;不一定对你适用 仅作参考。 介于篇幅原因我觉得能说上好用的 目前只有两个 一个是阿里的通义灵码和Copilot&#xff0c;我用它来干…

C++ Primer sizeof运算符

欢迎阅读我的 【CPrimer】专栏 专栏简介&#xff1a;本专栏主要面向C初学者&#xff0c;解释C的一些基本概念和基础语言特性&#xff0c;涉及C标准库的用法&#xff0c;面向对象特性&#xff0c;泛型特性高级用法。通过使用标准库中定义的抽象设施&#xff0c;使你更加适应高级…

【C++】命名空间

&#x1f31f; Hello&#xff0c;我是egoist2023&#xff01; &#x1f30d; 种一棵树最好是十年前&#xff0c;其次是现在&#xff01; 目录 背景知识 命名空间(namespace) 为何引入namespace namespace的定义 namespace的使用 背景知识 C的起源要追溯到1979年&#xff0…

(2024|Nature Medicine,生物医学 AI,BiomedGPT)面向多种生物医学任务的通用视觉-语言基础模型

BiomedGPT: A generalist vision–language foundation model for diverse biomedical tasks 目录 1. 摘要 2. 引言 3. 相关研究 3.1 基础模型与通用生物医学 AI 3.2 生物医学 AI 的局限性 3.3 BiomedGPT 的创新点 4. 方法 4.1 架构及表示 4.1.1 模型架构选择 4.1.2 …

使用PyCharm进行Django项目开发环境搭建

如果在PyCharm中创建Django项目 1. 打开PyCharm&#xff0c;选择新建项目 2.左侧选择Django&#xff0c;并设置项目名称 3.查看项目解释器初始配置 4.新建应用程序 执行以下操作之一&#xff1a; 转到工具| 运行manage.py任务或按CtrlAltR 在打开的manage.pystartapp控制台…

AD域控粗略了解

一、前提 转眼大四&#xff0c;目前已入职上饶一公司从事运维工程师&#xff0c;这与我之前干的开发有着很大的差异&#xff0c;也学习到了许多新的知识。今天就写下我对于运维工作中常用的功能——域控的理解。 二、为什么要有域控&#xff0c;即域控的作用 首先我们必须要…

Linux(21)——系统日志

目录 一、系统日志架构&#xff1a; 1、系统日志&#xff1a; 2、日志文件类型&#xff1a; 二、查看 syslog 文件&#xff1a; 1、将事件记录到系统&#xff1a; &#xff08;1&#xff09;syslog 设备&#xff1a; &#xff08;2&#xff09;syslog 优先级&#xff1a…

学习数据结构(6)单链表OJ上

1.移除链表元素 解法一&#xff1a;&#xff08;我的做法&#xff09;在遍历的同时移除&#xff0c;代码写法比较复杂 解法二&#xff1a;创建新的链表&#xff0c;遍历原链表&#xff0c;将非val的节点尾插到新链表&#xff0c;注意&#xff0c;如果原链表结尾是val节点需要将…

第433场周赛:变长子数组求和、最多 K 个元素的子序列的最值之和、粉刷房子 Ⅳ、最多 K 个元素的子数组的最值之和

Q1、变长子数组求和 1、题目描述 给你一个长度为 n 的整数数组 nums 。对于 每个 下标 i&#xff08;0 < i < n&#xff09;&#xff0c;定义对应的子数组 nums[start ... i]&#xff08;start max(0, i - nums[i])&#xff09;。 返回为数组中每个下标定义的子数组中…

CSS 伪类(Pseudo-classes)的详细介绍

CSS 伪类详解与示例 在日常的前端开发中&#xff0c;CSS 伪类可以帮助我们非常精准地选择元素或其特定状态&#xff0c;从而达到丰富页面表现的目的。本文将详细介绍以下伪类的使用&#xff1a; 表单相关伪类 :checked、:disabled、:enabled、:in-range、:invalid、:optional、…

Centos挂载镜像制作本地yum源,并补装图形界面

内网环境centos7.9安装图形页面内网环境制作本地yum源 上传镜像到服务器目录 创建目录并挂载镜像 #创建目录 cd /mnt/ mkdir iso#挂载 mount -o loop ./CentOS-7-x86_64-DVD-2009.iso ./iso #前面镜像所在目录&#xff0c;后面所挂载得目录#检查 [rootlocalhost mnt]# df -h…

大模型推理——MLA实现方案

1.整体流程 先上一张图来整体理解下MLA的计算过程 2.实现代码 import math import torch import torch.nn as nn# rms归一化 class RMSNorm(nn.Module):""""""def __init__(self, hidden_size, eps1e-6):super().__init__()self.weight nn.Pa…

Python截图轻量化工具

一、兼容局限性 这是用Python做的截图工具&#xff0c;不过由于使用了ctypes调用了Windows的API, 同时访问了Windows中"C:/Windows/Cursors/"中的.cur光标样式文件, 这个工具只适用于Windows环境&#xff1b; 如果要提升其跨平台性的话&#xff0c;需要考虑替换cty…

链表(LinkedList) 1

上期内容我们讲述了顺序表&#xff0c;知道了顺序表的底层是一段连续的空间进行存储(数组)&#xff0c;在插入元素或者删除元素需要将顺序表中的元素整体移动&#xff0c;时间复杂度是O(n)&#xff0c;效率比较低。因此&#xff0c;在Java的集合结构中又引入了链表来解决这一问…

SpringAI系列 - 使用LangGPT编写高质量的Prompt

目录 一、LangGPT —— 人人都可编写高质量 Prompt二、快速上手2.1 诗人 三、Role 模板3.1 Role 模板3.2 Role 模板使用步骤3.3 更多例子 四、高级用法4.1 变量4.2 命令4.3 Reminder4.4 条件语句4.5 Json or Yaml 方便程序开发 一、LangGPT —— 人人都可编写高质量 Prompt La…

jupyterLab插件开发

jupyter lab安装、配置&#xff1a; jupyter lab安装、配置教程_容器里装jupyterlab-CSDN博客 『Linux笔记』服务器搭建神器JupyterLab_linux_布衣小张-腾讯云开发者社区 Jupyter Lab | 安装、配置、插件推荐、多用户使用教程-腾讯云开发者社区-腾讯云 jupyterLab插件开发教…