文章目录
- 第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]]
让我们以双链表的节点删除为例,具体说明共享数据修改可能引发的问题。假设我们有一个双链表,每个节点包含两个指针:一个指向下一个节点,另一个指向前一个节点。删除一个节点的步骤如下:
- 找到要删除的节点N。
- 更新前一个节点的指针,使其指向节点N的下一个节点。
- 更新后一个节点的指针,使其指向节点N的前一个节点。
- 删除节点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::mutex
和 std::lock_guard
来保护列表的访问:
#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
保护 ②。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_list
和 list_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
就能万事大吉。通过指针或引用“泄露”受保护数据,同样会使保护形同虚设。虽然检查指针和引用相对容易——只需确保成员函数不通过返回值或输出参数返回指向受保护数据的指针或引用——但更重要的是要全面考虑:
- 防止成员函数“泄露”: 仔细检查所有成员函数,确保它们不会将指向受保护数据的指针或引用传递给调用者。
- 防范外部访问: 除了自己编写的成员函数,还要注意是否有其他代码(尤其是你无法控制的代码)可能通过指针或引用的方式访问你的数据。即使函数本身没有在互斥量保护区域内存储指针或引用,也可能存在风险。
- 避免将保护数据作为运行时参数传递: 像代码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_protected
和 foo_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 同时尝试修改
a
和b
,但由于缺乏同步机制,导致输出混乱。具体表现为:"1 - Unprotected - Thread ID: 3944, a: 0 - Unprotected - Thread ID: 4, b: 111112844, a: 4, b: 1111"
显示了两个线程同时修改数据的结果,但输出格式混乱且不一致,表明发生了数据竞争。
- 这里没有使用互斥量来保护对
代码执行流程总结
-
Protected Access(安全访问):
- 使用互斥量 (
std::mutex
) 来确保对共享数据的操作是线程安全的。 - 每个线程在访问共享数据之前都会获取锁,确保在同一时间只有一个线程可以修改数据。
- 最终的数据状态是一致的,
a
和b
的值正确反映了所有线程的操作。
- 使用互斥量 (
-
Unprotected Access(不安全访问):
- 不使用互斥量来保护共享数据,导致多个线程同时访问和修改同一块数据。
- 数据竞争发生,导致输出混乱且数据状态不可预测。
- 由于没有同步机制,两个线程同时修改
a
和b
,导致最终的数据状态可能是错误的或不一致的。
不当的指针或引用传递而导致竞争条件(代码例子)
以下是一个更清晰的例子,展示如何通过不正确的互斥量使用导致数据保护失效的问题。这个例子涉及一个线程安全的计数器类,展示了如何因为不当的指针或引用传递而导致竞争条件。
示例代码
#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;
}
问题分析
-
正确部分:
increment()
方法通过std::lock_guard
确保了对count
的线程安全操作。getCount()
方法同样在读取时加锁,确保线程安全。
-
错误部分:
getUnsafePointer()
方法直接返回了指向count
的指针,这使得外部可以直接访问和修改count
,而无需通过互斥锁保护。- 在
main()
函数中,accessUnsafePointer
函数通过该指针直接修改了count
的值,绕过了互斥锁机制。
-
结果:
- 由于多个线程同时修改
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()) { // 1
int const value = s.top(); // 2
s.pop(); // 3
do_something(value);
}
这段代码不仅在单线程环境中是安全的,而且在空堆栈上调用 top()
是未定义的行为也符合预期。然而,对于共享的栈对象,这样的调用顺序不再安全。因为在调用 empty()
(①)和调用 top()
(②)之间,可能有来自另一个线程的 pop()
调用并删除了最后一个元素。这是一个经典的条件竞争问题。
即使使用互斥量对栈内部数据进行了保护,这种条件竞争仍然可能发生。这是接口固有的问题,无法仅通过互斥量解决。
以下是经过排版后的文本内容,使其更加清晰易读:
如何解决接口设计中的条件竞争问题?
问题的根源在于接口设计本身,因此解决方案需要从变更接口设计入手。以下是对问题及其解决方案的详细分析。
直接解决方案:在 top()
中抛出异常
一种简单的解决方案是,在调用 top()
时,如果发现栈已经是空的,则抛出异常。这种方法可以避免未定义行为的发生,但存在以下缺点:
- 即使
empty()
返回false
,也需要进行异常捕获,增加了代码复杂性。 - 本质上,这会让
empty()
函数变得多余,因为它无法完全保证后续操作的安全性。
尽管这种方案能够直接解决问题,但它并不是最优解。
潜在的条件竞争问题
仔细观察之前的代码段:
if (!s.empty()) { // ①
int const value = s.top(); // ②
s.pop(); // ③
do_something(value);
}
在调用 top()
(②)和 pop()
(③)之间仍然存在一个潜在的条件竞争。假设两个线程运行相同的代码,并且共享同一个栈对象。例如:
- 栈中最初只有两个元素。
- 每个线程都执行
empty()
和top()
操作。
在这种情况下,即使内部互斥量保护了栈的操作,只有一个线程可以调用栈的成员函数,但由于 do_something()
是可以并发运行的,可能会出现以下执行顺序(如表 3.1 所示):
Thread A | Thread 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); |
在这种执行顺序下:
- 每个线程都调用了两次
top()
,但没有修改栈,因此每个线程可能得到相同的值。 - 在
top()
的两次调用过程中,没有任何线程调用pop()
,导致某个值被处理了两次。
这种条件竞争比未定义的 empty()
/ top()
竞争更为严重,因为结果表面上看起来没有错误,但实际上隐藏了一个难以定位的 Bug。
以下是经过排版后的文本内容,以及对每种方案可行性的说明:
解决条件竞争的几种选项
不幸的是,std::stack
的设计将 top()
和 pop()
分割为两个独立的操作,反而引入了原本想要避免的条件竞争。幸运的是,我们还有其他选项可供选择,但每种选项都有相应的代价。
选项 1:传入一个引用
第一个选项是将变量的引用作为参数,传入 pop()
函数中获取“弹出值”:
std::vector<int> result;
some_stack.pop(result);
优点:
- 简单直观,能够直接将栈顶元素赋值给用户提供的变量。
- 避免了返回值拷贝或移动时可能引发的异常问题。
缺点:
- 需要构造出一个栈中类型的实例,用于接收目标值。对于某些类型,这在时间和资源上可能不划算。
- 对于需要复杂构造函数参数的类型,这种方式可能不可行。
- 需要可赋值的存储类型,这是一个重大限制。即使支持移动构造或拷贝构造(从而允许返回一个值),许多用户自定义类型可能仍不支持赋值操作。
可行性说明:
- 这种方法适用于简单的数据类型或支持赋值操作的类型。
- 它通过直接传递引用的方式,避免了条件竞争,确保了线程安全性。
选项 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
可以避免内存泄漏问题,因为当最后一个指针销毁时,对象也会自动销毁。 - 标准库完全控制内存分配方案,无需显式的
new
和delete
操作。
缺点:
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
,我们可以避免内存分配和管理的问题,同时减少频繁使用 new
和 delete
的需求。原本堆栈中的五个操作(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::lock
和 std::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);
}
};
关键点:
- 调用
std::lock
同时锁定两个互斥量。 - 使用
std::lock_guard
管理锁,通过传递std::adopt_lock
参数表示这些锁已经被std::lock
获取,无需重新构建锁。 - 这种方式可以保证函数退出时,互斥量能够自动解锁,即使发生异常也不会导致资源泄漏。
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_guard
std::scoped_lock guard(lhs.m, rhs.m);
// 执行数据交换
swap(lhs.some_detail, rhs.some_detail);
}
优势:
std::scoped_lock
在构造时锁定所有传入的互斥量,解锁在析构时完成。- 它通过隐式模板参数推导机制,根据传递的对象类型自动构造实例,简化了代码。
- 相较于
std::lock
和std::lock_guard
的组合,std::scoped_lock
更加简洁且不易出错。
总结
虽然 std::lock
和 std::scoped_lock
可以在锁定多个互斥量时避免死锁,但它们无法帮助你单独获取其中一个锁。这需要开发者的经验和纪律性,确保程序逻辑不会导致死锁。
死锁是多线程编程中常见的难题,因为它往往不可预见,且在大多数情况下程序运行正常。然而,遵循一些简单的规则可以帮助我们编写“无死锁”的代码。例如:
4. 始终以相同的顺序锁定多个互斥量。
5. 使用 std::lock
或 std::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
提供更大的灵活性。
总结
通过遵循上述建议,可以有效避免死锁的发生:
- 避免嵌套锁。
- 避免在持有锁时调用外部代码。
- 使用固定顺序获取锁。
- 使用层次锁结构。
- 超越锁的延伸扩展。
这些方法不仅可以帮助我们在设计阶段规避死锁,还可以在运行时检测潜在问题,从而提高程序的健壮性和可靠性。
以下是完整的代码实现,基于您提供的描述和需求。代码包括 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 和线程 B
std::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_mutex
、low_level_mutex
和other_mutex
。 - 启动两个线程:
thread_a
和thread_b
。 - 使用
join()
等待线程结束。
运行结果
-
线程 A:
- 遵守层级规则,正常运行。
- 输出:
High-level function result: 42
-
线程 B:
- 违反层级规则,抛出异常。
- 输出:
Thread B error: Mutex hierarchy violated
-
最终输出:
All threads completed.
通过上述实现,您可以验证 hierarchical_mutex
的正确性和有效性,同时避免死锁的发生。
以下是经过优化排版后的叙述,使其更加清晰、简洁且易于理解:
3.2.6 std::unique_lock
—— 灵活的锁
std::unique_lock
提供了比 std::lock_guard
更加灵活的锁管理方式。与 std::lock_guard
不同,std::unique_lock
实例不必始终绑定到互斥量的数据类型上,这使得它的使用场景更加广泛。
灵活性的特点
-
构造函数参数:
- 可以将
std::adopt_lock
作为第二个参数传入构造函数,用于管理已经锁定的互斥量。 - 也可以将
std::defer_lock
作为第二个参数传递,表明互斥量应保持解锁状态。这种方式允许通过调用lock()
方法手动锁定互斥量,或者将std::unique_lock
对象传递给std::lock()
进行统一锁定。
- 可以将
-
与
std::lock_guard
的对比:- 使用
std::unique_lock
和std::defer_lock
替代std::lock_guard
和std::adopt_lock
,可以轻松实现代码转换(如代码 3.9 所示)。 - 虽然代码长度相同且功能几乎等价,但
std::unique_lock
占用更多内存,并且性能略逊于std::lock_guard
。 - 这种灵活性的代价是:
std::unique_lock
实例可以不绑定到互斥量上,而仅存储和更新相关标志。
- 使用
代码示例:交换操作中 std::lock()
和 std::unique_lock
的使用
以下代码展示了如何在交换操作中使用 std::unique_lock
和 std::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);
}
};
关键点解析
-
std::defer_lock
的作用:- 在构造
std::unique_lock
时,std::defer_lock
表明互斥量应保持解锁状态。 - 这种方式允许我们延迟锁定操作,直到需要时再调用
lock()
或将其传递给std::lock()
。
- 在构造
-
std::lock
的作用:std::lock
可以同时锁定多个互斥量,避免死锁风险。- 在代码中,
std::lock(lock_a, lock_b)
实现了对两个互斥量的安全锁定。
-
标志位的作用:
std::unique_lock
内部维护一个标志位,用于记录该实例是否拥有特定的互斥量。- 如果实例拥有互斥量,则析构函数会自动调用
unlock()
;否则不会调用。 - 可以通过
owns_lock()
成员函数查询该标志。
-
性能与适用性:
- 由于
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_lock
和 std::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_lock
和std::defer_lock
延迟锁定两个互斥量。 - 使用
std::lock()
同时锁定两个互斥量,避免死锁。 - 调用
std::swap()
完成值的交换。
3. modifyResource()
函数
- 模拟线程对共享资源的修改操作。
- 使用
std::this_thread::sleep_for()
模拟耗时操作。
4. 主函数
- 创建两个
SharedResource
实例:resource1
和resource2
。 - 启动两个线程分别修改
resource1
和resource2
的值。 - 调用
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
关键点解析
-
std::unique_lock
的灵活性:- 使用
std::defer_lock
延迟锁定互斥量,避免在构造时立即锁定。 - 使用
std::lock()
同时锁定多个互斥量,防止死锁。
- 使用
-
线程安全:
- 通过互斥量保护共享资源的访问,确保多线程环境下的安全性。
-
性能与适用性:
- 在需要递延锁或锁所有权转移的情况下,
std::unique_lock
是更好的选择。 - 对于简单的锁需求,可以继续使用
std::lock_guard
。
- 在需要递延锁或锁所有权转移的情况下,
通过这个示例,您可以清楚地了解 std::unique_lock
的使用方式及其在多线程编程中的实际应用场景。
以下是经过优化排版后的叙述,使其更加清晰、简洁且易于理解:
3.2.7 不同域中互斥量的传递
std::unique_lock
的灵活性不仅体现在其延迟锁定和多锁管理能力上,还在于它可以将互斥量的所有权通过移动操作在不同的实例之间传递。这种特性在某些场景下非常有用,例如需要将锁从一个函数传递到另一个函数。
所有权传递机制
-
自动发生的情况:
- 在某些情况下,所有权的转移是自动发生的。例如,当函数返回一个
std::unique_lock
实例时,编译器会自动调用移动构造函数,将锁的所有权转移到调用者。
- 在某些情况下,所有权的转移是自动发生的。例如,当函数返回一个
-
显式调用的情况:
- 如果源值是一个左值(实际值或引用),则需要显式调用
std::move()
来执行移动操作。 - 如果源值是一个右值(临时类型),则无需显式调用
std::move()
,因为编译器会自动处理。
- 如果源值是一个左值(实际值或引用),则需要显式调用
-
不可赋值性:
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...
关键点解析
-
锁的所有权传递:
get_lock()
函数返回一个std::unique_lock<std::mutex>
实例,将锁的所有权转移到调用者。- 编译器会自动调用移动构造函数完成所有权转移。
-
网关类模式:
- 网关类
Gateway
封装了对互斥量的锁定和解锁逻辑。 - 所有对保护数据的访问都必须通过网关类,确保线程安全。
- 网关类
-
RAII 原则:
- 使用
std::unique_lock
和网关类实现了 RAII(Resource Acquisition Is Initialization)原则。 - 锁在进入作用域时自动获取,在离开作用域时自动释放。
- 使用
-
性能优化:
- 通过提前释放锁或减少锁持有时间,可以提高多线程应用程序的性能。
通过这个示例,您可以清楚地了解 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()
函数之前(①),手动释放锁。 - 在需要写入数据时(②),重新获取锁。
这种做法可以显著减少锁的持有时间,从而提高并发性能。
粗粒度锁的问题
当只有一个互斥量保护整个数据结构时:
- 更多的操作需要竞争同一个锁。
- 持有锁的时间会更长。
这两方面都会导致性能下降,因此向细粒度锁转移是合理的。
细粒度锁的应用示例
假设我们需要比较两个对象是否相等。如果对象中的数据类型很简单(例如 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; // ④ 比较两个值
}
};
关键点:
- 每次调用
get_detail()
时(①),只短暂地持有锁以读取数据。 - 比较操作符分别获取两个对象的值(② 和 ③),并在之后进行比较(④)。
- 这种方式减少了锁的持有时间,避免了死锁的可能性。
语义问题与潜在风险
尽管上述方法减少了锁的持有时间,但也引入了一些语义问题:
- 如果
lhs.some_detail
和rhs.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.0
threads.emplace_back(withdrawMoney, std::ref(account), 30.0, 2); // 线程 2 取款 30.0
threads.emplace_back(depositMoney, std::ref(account), 20.0, 3); // 线程 3 存款 20.0
threads.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.
关键点解析
-
细粒度锁的应用:
- 在
operator==
中,使用std::lock()
同时锁定两个互斥量,避免死锁。 - 分别获取两个账户的余额,减少锁的持有时间。
- 在
-
线程安全的操作:
- 所有对账户余额的访问和修改都通过加锁保护,确保线程安全。
-
性能优化:
- 通过减少锁的持有时间,提高并发性能。
-
语义一致性:
- 在比较两个账户余额时,注意语义问题:即使返回
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_once
和 std::once_flag
为了消除条件竞争,C++ 标准库提供了 std::once_flag
和 std::call_once
,用于安全地执行一次性初始化操作。
示例代码
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;
void init_resource() {
resource_ptr.reset(new some_resource); // 初始化资源
}
void foo() {
std::call_once(resource_flag, init_resource); // 确保初始化只执行一次
resource_ptr->do_something(); // 使用资源
}
特点:
std::call_once
确保初始化函数只被调用一次。- 相比显式使用互斥量,
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++ 提供了多种机制:
- 互斥量:适用于通用场景,但可能导致性能开销。
- 双重检查锁模式:存在条件竞争风险,不推荐使用。
std::call_once
和std::once_flag
:专为一次性初始化设计,性能更优。- 静态局部变量:在 C++11 中提供线程安全的初始化机制,适合全局实例。
选择合适的保护机制可以显著提高程序的性能和安全性。
以下是一个完整的代码示例,展示了如何使用 std::call_once
和 std::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() {
// 创建多个线程测试 ResourceManager
std::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_once
和std::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.
关键点解析
-
std::call_once
的作用:- 确保初始化函数只执行一次。
- 避免了双重检查锁模式中的条件竞争问题。
-
静态局部变量的线程安全性:
- 在 C++11 中,静态局部变量的初始化是线程安全的。
- 适合需要全局实例且初始化代价较高的场景。
-
性能优化:
std::call_once
的开销比显式使用互斥量更低。- 静态局部变量的初始化机制由编译器自动优化,性能更优。
通过这个示例,您可以清楚地了解如何使用 std::call_once
和静态局部变量来实现线程安全的延迟初始化。
以下是经过优化排版后的叙述,使其更加清晰、简洁且易于理解:
3.3.2 保护不常更新的数据结构
场景描述
假设需要将域名解析为对应的 IP 地址,并将其存储在一个 DNS 缓存表中。通常情况下,DNS 条目在较长时间内保持不变。然而,当用户访问不同的网站时,可能会有新的条目被添加到缓存中。尽管这些条目可能在其生命周期内很少发生变化,但定期检查缓存条目的有效性仍然是必要的。
此外,缓存可能会偶尔进行更新(例如对某些条目进行修改)。虽然更新频率较低,但在多线程环境中,仍然需要保护更新过程的状态,以确保每个线程读取到的数据是有效的。
问题分析
如果使用普通的互斥量(如 std::mutex
)来保护数据结构,可能会导致性能下降,因为在没有发生修改的情况下,它会削减并发读取的可能性。因此,我们需要一种更适合这种场景的锁机制。
在这种情况下,“读者-写者锁”(Reader-Writer Lock)是一种更合适的选择。它允许以下两种访问方式:
- 独占访问:一个“写者”线程可以独占访问数据结构。
- 共享访问:多个“读者”线程可以同时访问数据结构。
C++ 标准库中的解决方案
从 C++17 开始,标准库提供了两种适合读者-写者锁场景的互斥量:
std::shared_mutex
:适用于简单的读者-写者锁场景,性能较高,但功能较少。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; // 更新或添加条目
}
};
代码说明
-
find_entry()
方法- 使用
std::shared_lock<std::shared_mutex>
获取共享锁(①),允许多个线程同时读取缓存数据。 - 如果找到指定的域名条目,则返回该条目;否则返回一个空条目。
- 使用
-
update_or_add_entry()
方法- 使用
std::lock_guard<std::shared_mutex>
获取独占锁(②),确保在更新或添加条目时,其他线程无法访问数据结构。 - 将指定的域名和 DNS 条目插入或更新到缓存中。
- 使用
关键点解析
-
共享锁与独占锁的区别
- 共享锁(
std::shared_lock
)允许多个线程同时读取数据,提高并发性能。 - 独占锁(
std::lock_guard
或std::unique_lock
)确保只有一个线程可以修改数据,避免数据竞争。
- 共享锁(
-
性能优化
- 在大多数情况下,DNS 缓存的读取操作远多于写入操作。因此,使用读者-写者锁可以显著提高并发性能。
- 需要注意的是,当有线程持有共享锁时,尝试获取独占锁的线程会被阻塞,直到所有共享锁释放。同样地,当有线程持有独占锁时,其他线程无法获取任何类型的锁。
-
适用场景
- 读者-写者锁适用于读多写少的场景,例如缓存、日志记录等。
通过上述代码示例和分析,您可以清楚地了解如何使用 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...
关键点解析
-
共享锁与独占锁的配合
find_entry()
使用共享锁,允许多个线程同时读取缓存数据。update_or_add_entry()
和check_and_clean_cache()
使用独占锁,确保只有一个线程可以修改数据。
-
性能优化
- 在大多数情况下,DNS 缓存的读取操作远多于写入操作。通过使用读者-写者锁机制,可以显著提高并发性能。
-
线程安全
- 使用
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() {
// 测试 RecursiveMutexExample
std::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();
// 测试 ImprovedDesignExample
std::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: 6
Testing ImprovedDesignExample:
Incremented value: 1
Doubled value: 2
Incremented value: 3
Doubled value: 6
关键点解析
-
嵌套锁的使用场景
- 当一个线程需要多次加锁时,
std::recursive_mutex
是一种解决方案。 - 然而,嵌套锁可能会掩盖潜在的设计问题,因此应谨慎使用。
- 当一个线程需要多次加锁时,
-
改进设计的优点
- 提取私有成员函数避免了嵌套锁。
- 明确了调用新函数时数据的状态,确保类的不变量不被破坏。
-
性能考虑
std::recursive_mutex
的性能通常低于std::mutex
,因为需要额外的计数机制来跟踪锁的次数。- 如果可以避免嵌套锁,建议优先使用普通的
std::mutex
。
通过这个示例,您可以清楚地了解如何使用 std::recursive_mutex
以及如何改进设计以避免嵌套锁的潜在问题。