文章目录
共享数据的问题
3.1.1 条件竞争
在多线程编程中,共享数据的修改是导致问题的主要原因。如果数据只读,则不会影响数据的一致性,所有线程都能获得相同的数据。然而,当一个或多个线程需要修改共享数据时,就会出现许多复杂的问题。这些问题通常涉及**不变量(invariants)**的概念,即描述特定数据结构的某些属性,例如“变量包含列表中的项数”。更新操作通常会破坏这些不变量,特别是在处理复杂数据结构时。
双链表的例子
以双链表为例,每个节点都有指向前一个节点和后一个节点的指针。为了从列表中删除一个节点,必须更新其前后节点的指针,这会导致不变量暂时被破坏:
- 找到要删除的节点N
- 更新前一个节点指向N的指针,让其指向N的下一个节点
- 更新后一个节点指向N的指针,让其指向前一个节点
- 删除节点N
在这过程中,步骤2和步骤3之间,不变量被破坏,因为此时部分指针已经更新,但还未完全完成。如果其他线程在此期间访问该链表,可能会读取到不一致的状态,从而导致程序错误甚至崩溃。这种问题被称为条件竞争(race condition)。
条件竞争示例
假设你去一家大电影院买电影票,有多个收银台可以同时售票。当另一个收银台也在卖你想看的电影票时,你的座位选择取决于之前已预定的座位。如果有少量座位剩余,可能会出现一场抢票比赛,看谁能抢到最后的票。这就是一个典型的条件竞争例子:你的座位(或电影票)取决于购买的顺序。
在并发编程中,条件竞争取决于多个线程的执行顺序。大多数情况下,即使改变执行顺序,结果仍然是可接受的。然而,当不变量遭到破坏时,条件竞争就可能变成恶性竞争,例如在双链表的例子中,可能导致数据结构永久损坏并使程序崩溃。
C++标准定义了**数据竞争(data race)**这一术语,指的是并发修改独立对象的情况,这种情况会导致未定义行为。
恶性条件竞争的特点
- 难以查找和复现:由于问题出现的概率较低,且依赖于特定的执行顺序,因此很难查找和复现。
- 时间敏感:调试模式下,程序的执行速度变慢,错误可能完全消失,因为调试模式会影响程序的执行时间。
- 负载敏感:随着系统负载增加,执行序列问题复现的概率也会增加。
3.1.2 避免恶性条件竞争
为了避免恶性条件竞争,以下是几种常见的解决方案:
1. 使用互斥量保护共享数据结构
最简单的方法是对共享数据结构使用某种保护机制,确保只有修改线程才能看到不变量的中间状态。C++标准库提供了多种互斥量(如 std::mutex
),可以用来保护共享数据结构,确保只有一个线程能进行修改,其他线程要么等待修改完成,要么读取到一致的数据。
2. 无锁编程
另一种方法是对数据结构和不变量进行设计,使其能够完成一系列不可分割的变化,保证每个不变量的状态。这种方法称为无锁编程,虽然高效,但实现难度较大,容易出错。
3. 软件事务内存(STM)
还有一种处理条件竞争的方式是使用事务的方式处理数据结构的更新,类似于数据库中的事务管理。所需的数据和读取操作存储在事务日志中,然后将之前的操作进行合并并提交。如果数据结构被另一个线程修改,提交操作将失败并重新尝试。这种方法称为软件事务内存(Software Transactional Memory, STM),是一个热门的研究领域,但在C++标准中没有直接支持。
总结
- 共享数据问题:当多个线程共享数据时,特别是当数据需要被修改时,会出现条件竞争问题。
- 不变量:描述数据结构的某些属性,在修改过程中可能会被破坏。
- 条件竞争:多个线程争夺对共享资源的访问权,可能导致程序错误或崩溃。
- 避免恶性条件竞争的方法:
- 互斥量:使用互斥量保护共享数据结构,确保只有一个线程能进行修改。
- 无锁编程:设计数据结构使其能完成一系列不可分割的变化。
- 软件事务内存(STM):使用事务的方式处理数据结构的更新,确保一致性。
通过上述方法,开发者可以有效避免多线程编程中的条件竞争问题,确保程序的正确性和稳定性。
互斥量与共享数据保护
3.2.1 互斥量
使用互斥量保护共享数据
在多线程环境中,使用互斥量(std::mutex
)可以确保对共享数据的访问是互斥的,从而避免条件竞争问题。C++标准库提供了std::lock_guard
,它利用RAII机制自动管理互斥量的锁定和解锁。
示例代码:
#include <iostream>
#include <list>
#include <mutex>
#include <algorithm>
#include <thread>
#include <vector>
// 共享的列表和互斥锁
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();
}
// 测试函数:添加一些值并检查它们是否存在
void test_function()
{
// 添加一些值到列表中
std::vector<int> values_to_add = {
1, 2, 3, 4, 5};
std::vector<std::thread> threads;
// 使用多个线程并发地添加值
for (int value : values_to_add) {
threads.emplace_back(add_to_list, value);
}
// 等待所有线程完成
for (auto& thread : threads) {
if (thread.joinable()) {
thread.join();
}
}
// 检查某些值是否存在于列表中
std::vector<int> values_to_check = {
3, 6};
for (int value : values_to_check) {
bool found = list_contains(value);
std::cout << "Value " << value << (found ? " is" : " is not") << " in the list." << std::endl;
}
}
int main() {
// 运行测试函数
test_function();
// 打印最终的列表内容
std::cout << "Final list contents: ";
for (int value : some_list) {
std::cout << value << " ";
}
std::cout << std::endl;
return 0;
}
- 全局变量与互斥量:
some_list
是一个全局变量,被一个全局互斥量some_mutex
保护。 std::lock_guard
:在add_to_list
和list_contains
函数中,使用std::lock_guard
来自动管理互斥量的锁定和解锁,确保在函数执行期间互斥量处于锁定状态,防止其他线程访问共享数据。
C++17的新特性
C++17引入了模板类参数推导,简化了std::lock_guard
的使用:
std::lock_guard guard(some_mutex); // 模板参数类型由编译器推导
此外,C++17还引入了std::scoped_lock
,提供了更强大的功能:
std::scoped_lock guard(some_mutex);
为了兼容C++11标准,本文将继续使用带有模板参数类型的std::lock_guard
。
面向对象设计中的互斥量
将互斥量与需要保护的数据放在同一个类中,可以使代码更加清晰,并且方便了解什么时候对互斥量上锁。例如:
class ProtectedData {
private:
std::list<int> data;
std::mutex mutex;
public:
void add_to_list(int new_value) {
std::lock_guard<std::mutex> guard(mutex);
data.push_back(new_value);
}
bool contains(int value_to_find) {
std::lock_guard<std::mutex> guard(mutex);
return std::find(data.begin(), data.end(), value_to_find) != data.end();
}
};
这种设计方式不仅封装了数据,还确保了所有对共享数据的访问都在互斥量保护下进行。
3.2.2 保护共享数据
使用互斥量保护数据不仅仅是简单地在每个成员函数中加入一个std::lock_guard
对象。必须注意以下几点:
-
避免返回指向受保护数据的指针或引用:
- 如果成员函数返回指向受保护数据的指针或引用,外部代码可以直接访问这些数据而无需通过互斥量保护,这会破坏数据保护机制。
-
检查成员函数是否通过指针或引用来调用:
- 尤其是在调用不在你控制下的函数时,确保这些函数不会存储指向受保护数据的指针或引用。
示例代码:
class SomeData {
int a;
std::string b;
public:
void do_something();
};
class DataWrapper {
private:
SomeData data;
std::mutex m;
public:
template<typename Function>
void process_data(Function func) {
std::lock_guard<std::mutex> l(m);
func(data); // 传递“保护”数据给用户函数
}
};
SomeData* unprotected;
void malicious_function(SomeData& protected_data) {
unprotected = &protected_data;
}
DataWrapper x;
void foo() {
x.process_data(malicious_function); // 传递恶意函数
unprotected->do_something(); // 在无保护的情况下访问保护数据
}
在这个例子中,尽管process_data
函数内部使用了互斥量保护数据,但传递给用户的函数func
可能会绕过保护机制,导致数据被不安全地访问。
解决方案:
- 不要将受保护数据的指针或引用传递到互斥锁作用域之外。
- 确保所有对受保护数据的访问都在互斥量保护下进行。
3.2.3 接口间的条件竞争
即使使用了互斥量保护数据,如果接口设计不当,仍然可能存在条件竞争。例如,如果某个接口允许返回指向受保护数据的指针或引用,外部代码可以在没有互斥量保护的情况下访问这些数据,导致数据不一致。
示例代码:
class ProtectedData {
private:
std::list<int> data;
std::mutex mutex;
public:
const std::list<int>& get_data() {
// 返回引用,可能导致条件竞争
std::lock_guard<std::mutex> guard(mutex);
return data;
}
};
在这种情况下,虽然get_data
函数内部使用了互斥量保护数据,但返回的引用可以在互斥量保护范围之外被访问,从而导致潜在的条件竞争。
解决方案:
- 避免返回指向受保护数据的指针或引用,除非这些指针或引用本身也在互斥量保护下使用。
- 设计接口时确保所有对受保护数据的访问都在互斥量保护范围内。
总结
- 互斥量的作用:互斥量用于保护共享数据,确保同一时间只有一个线程能够访问和修改数据,从而避免条件竞争。
std::lock_guard
:利用RAII机制自动管理互斥量的锁定和解锁,简化了代码编写。- 面向对象设计中的互斥量:将互斥量与需要保护的数据放在同一个类中,使得代码更加清晰并便于管理。
- 避免返回指针或引用:确保所有对受保护数据的访问都在互斥量保护下进行,避免返回指向受保护数据的指针或引用。
- 接口设计注意事项:确保接口设计合理,避免通过接口泄露受保护数据的指针或引用,防止条件竞争的发生。
通过正确使用互斥量和精心设计接口,开发者可以有效避免多线程编程中的条件竞争问题,确保程序的正确性和稳定性。
接口间的条件竞争与解决方案
3.2.3 接口间的条件竞争
即使使用了互斥量或其他机制保护共享数据,仍然需要确保数据是否真正受到了保护。例如,在双链表的例子中,为了线程安全地删除一个节点,不仅需要保护待删除节点及其前后相邻的节点,还需要保护整个删除操作的过程。最简单的解决方案是使用互斥量来保护整个链表或数据结构。
示例:std::stack
容器的实现
考虑一个类似于 std::stack
的栈类:
template<typename T, typename Container = std::deque<T>>
class stack {
public:
explicit