C++并发编程指南04
文章目录
- 共享数据的问题
- 3.1.1 条件竞争
- 双链表的例子
- 条件竞争示例
- 恶性条件竞争的特点
- 3.1.2 避免恶性条件竞争
- 1. 使用互斥量保护共享数据结构
- 2. 无锁编程
- 3. 软件事务内存(STM)
- 总结
- 互斥量与共享数据保护
- 3.2.1 互斥量
- 使用互斥量保护共享数据
- 示例代码:
- C++17的新特性
- 面向对象设计中的互斥量
- 3.2.2 保护共享数据
- 示例代码:
- 解决方案:
- 3.2.3 接口间的条件竞争
- 示例代码:
- 解决方案:
- 总结
- 接口间的条件竞争与解决方案
- 3.2.3 接口间的条件竞争
- 示例:`std::stack` 容器的实现
- 解决方案:重新设计接口
- 示例:线程安全的堆栈类定义
- 3.2.4 死锁:问题描述及解决方案
- 示例:使用 `std::lock` 和 `std::lock_guard`
- 使用 `std::scoped_lock`(C++17)
- 总结
- 3.2.5 避免死锁的进阶指导
- 死锁的原因与常见场景
- 避免嵌套锁
- 避免在持有锁时调用外部代码
- 使用固定顺序获取锁
- 使用层次锁结构
- 示例:使用层次锁来避免死锁
- 超越锁的延伸扩展
- 使用 `std::unique_lock` 提供灵活性
- 示例:使用 `std::unique_lock` 和 `std::defer_lock`
- 不同域中互斥量的传递
- 总结
- 3.2.8 锁的粒度
- 锁的粒度简介
- 类比超市结账场景
- 细粒度锁 vs 粗粒度锁
- 示例:优化锁的使用
- 控制锁的持有时间
- 示例:细粒度锁的应用
- 条件竞争与语义一致性
- 寻找合适的机制
- 总结
- 3.3 保护共享数据的方式
- 3.3.1 保护共享数据的初始化过程
- 单线程延迟初始化
- 多线程延迟初始化
- 双重检查锁模式
- 使用 `std::call_once` 和 `std::once_flag`
- 静态局部变量的线程安全初始化
- 3.3.2 保护不常更新的数据结构
- 使用 `std::shared_mutex`
- 3.3.3 嵌套锁
- 使用 `std::recursive_mutex`
- 总结
共享数据的问题
3.1.1 条件竞争
在多线程编程中,共享数据的修改是导致问题的主要原因。如果数据只读,则不会影响数据的一致性,所有线程都能获得相同的数据。然而,当一个或多个线程需要修改共享数据时,就会出现许多复杂的问题。这些问题通常涉及**不变量(invariants)**的概念,即描述特定数据结构的某些属性,例如“变量包含列表中的项数”。更新操作通常会破坏这些不变量,特别是在处理复杂数据结构时。
双链表的例子
以双链表为例,每个节点都有指向前一个节点和后一个节点的指针。为了从列表中删除一个节点,必须更新其前后节点的指针,这会导致不变量暂时被破坏:
- 找到要删除的节点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 <list>
#include <mutex>
#include <algorithm>
std::list<int> some_list; // 1: 全局变量
std::mutex some_mutex; // 2: 全局互斥量
void add_to_list(int new_value)
{
std::lock_guard<std::mutex> guard(some_mutex); // 3: 锁定互斥量
some_list.push_back(new_value);
}
bool list_contains(int value_to_find)
{
std::lock_guard<std::mutex> guard(some_mutex); // 4: 锁定互斥量
return std::find(some_list.begin(), some_list.end(), value_to_find) != some_list.end();
}
- 全局变量与互斥量:
some_list
是一个全局变量,被一个全局互斥量some_mutex
保护。 std::lock_guard
:在add_to_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 stack(const Container&);
explicit stack(Container&& = Container());
template <class Alloc> explicit stack(const Alloc&);
template <class Alloc> stack(const Container&, const Alloc&);
template <class Alloc> stack(Container&&, const Alloc&);
template <class Alloc> stack(stack&&, const Alloc&);
bool empty() const;
size_t size() const;
T& top();
T const& top() const;
void push(T const&);
void push(T&&);
void pop();
void swap(stack&&);
template <class... Args> void emplace(Args&&... args); // C++14的新特性
};
尽管每个成员函数都可能在内部使用互斥量保护数据,但接口设计上的问题仍可能导致条件竞争。例如:
empty()
和size()
:虽然这些函数在返回时可能是正确的,但在返回后其他线程可能会修改栈的内容,导致之前的结果变得不可靠。top()
和pop()
:如果两个线程分别调用top()
和pop()
,可能会出现竞态条件,因为在这两个操作之间,另一个线程可能会修改栈的状态。
解决方案:重新设计接口
为了避免上述问题,可以通过重新设计接口来解决条件竞争:
-
选项1:传入引用获取弹出值
std::vector<int> result; some_stack.pop(result);
缺点:
- 需要构造一个目标类型的实例,这可能不现实或资源开销大。
- 不适用于所有类型,特别是那些没有赋值操作的类型。
-
选项2:无异常抛出的拷贝构造函数或移动构造函数
使用无异常抛出的拷贝构造函数或移动构造函数可以避免某些异常问题,但这限制了可使用的类型范围。
-
选项3:返回指向弹出值的指针
返回一个指向弹出元素的指针(如
std::shared_ptr
)可以避免内存分配问题,并且不会抛出异常。std::shared_ptr<T> pop() { std::lock_guard<std::mutex> lock(m); if (data.empty()) throw empty_stack(); std::shared_ptr<T> res(std::make_shared<T>(data.top())); data.pop(); return res; }
-
选项4:结合选项1和选项3
提供多个接口选项,让用户选择最适合的方案。
示例:线程安全的堆栈类定义
以下是一个线程安全的堆栈类定义示例,结合了选项1和选项3:
#include <exception>
#include <memory>
#include <mutex>
#include <stack>
struct empty_stack : std::exception {
const char* what() const throw() {
return "empty stack!";
}
};
template<typename T>
class threadsafe_stack {
private:
std::stack<T> data;
mutable std::mutex m;
public:
threadsafe_stack() : data(std::stack<T>()) {}
threadsafe_stack(const threadsafe_stack& other) {
std::lock_guard<std::mutex> lock(other.m);
data = other.data; // 在构造函数体中的执行拷贝
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
void push(T new_value) {
std::lock_guard<std::mutex> lock(m);
data.push(new_value);
}
std::shared_ptr<T> pop() {
std::lock_guard<std::mutex> lock(m);
if (data.empty()) throw empty_stack();
std::shared_ptr<T> res(std::make_shared<T>(data.top()));
data.pop();
return res;
}
void pop(T& value) {
std::lock_guard<std::mutex> lock(m);
if (data.empty()) throw empty_stack();
value = data.top();
data.pop();
}
bool empty() const {
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};
3.2.4 死锁:问题描述及解决方案
死锁是指两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行的情况。例如:
- 线程A持有互斥量A并请求互斥量B。
- 线程B持有互斥量B并请求互斥量A。
为了避免死锁,可以采取以下措施:
- 保持一致的加锁顺序:确保所有线程以相同的顺序获取互斥量。
- 使用
std::lock
或std::scoped_lock
:C++标准库提供了std::lock
和std::scoped_lock
,可以一次性锁住多个互斥量,避免死锁。
示例:使用 std::lock
和 std::lock_guard
class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);
class X {
private:
some_big_object some_detail;
std::mutex m;
public:
X(some_big_object const& sd) : some_detail(sd) {}
friend void swap(X& lhs, X& rhs) {
if (&lhs == &rhs)
return;
std::lock(lhs.m, rhs.m); // 1 锁住两个互斥量
std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock); // 2
std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock); // 3
swap(lhs.some_detail, rhs.some_detail);
}
};
使用 std::scoped_lock
(C++17)
C++17引入了 std::scoped_lock
,可以简化多互斥量锁定的代码:
void swap(X& lhs, X& rhs) {
if (&lhs == &rhs)
return;
std::scoped_lock guard(lhs.m, rhs.m); // 1 自动推导模板参数
swap(lhs.some_detail, rhs.some_detail);
}
总结
- 条件竞争:即使使用互斥量保护共享数据,接口设计不当仍可能导致条件竞争。通过重新设计接口,可以有效避免这些问题。
- 死锁:避免死锁的关键在于保持一致的加锁顺序,或使用
std::lock
和std::scoped_lock
来一次性锁住多个互斥量。 - 接口设计建议:
- 避免返回指向受保护数据的指针或引用。
- 尽量减少不必要的接口复杂性,确保所有对共享数据的访问都在互斥量保护下进行。
- 使用细粒度锁来提高并发性能,同时避免过度细化导致的死锁风险。
通过合理的设计和使用标准库提供的工具,开发者可以有效地避免多线程编程中的条件竞争和死锁问题,确保程序的正确性和稳定性。
3.2.5 避免死锁的进阶指导
死锁的原因与常见场景
死锁通常是由对锁的不当使用造成的。例如,两个线程互相调用 join()
可能导致死锁,因为每个线程都在等待另一个线程结束。类似地,当多个线程持有不同锁并试图获取对方持有的锁时,也会发生死锁。
为了避免死锁,以下是一些进阶的指导意见:
避免嵌套锁
建议1:避免嵌套锁
最简单的避免死锁的方法是确保每个线程只持有一个锁。如果需要获取多个锁,可以使用 std::lock
来一次性锁定多个互斥量,从而避免死锁。
std::mutex m1, m2;
std::lock(m1, m2); // 同时锁定m1和m2,避免死锁
std::lock_guard<std::mutex> lock1(m1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(m2, std::adopt_lock);
避免在持有锁时调用外部代码
建议2:避免在持有锁时调用外部代码
外部代码的行为是不可预测的,可能包含获取其他锁的操作,这会导致死锁。尽量减少在持有锁的情况下调用外部代码。
使用固定顺序获取锁
建议3:使用固定顺序获取锁
当必须获取多个锁时,确保所有线程以相同的顺序获取这些锁。例如,在链表中删除节点时,确保所有线程按相同顺序锁定节点及其相邻节点。
void delete_node(Node* node) {
std::lock_guard<std::mutex> lock_prev(node->prev->mutex);
std::lock_guard<std::mutex> lock_next(node->next->mutex);
// 确保固定的锁顺序
}
使用层次锁结构
建议4:使用层次锁结构
为每个互斥量分配一个层级值,并确保在任何时刻,只能获取比当前层级更低的锁。这样可以避免循环等待的情况。
class hierarchical_mutex {
std::mutex internal_mutex;
unsigned long const hierarchy_value;
unsigned long previous_hierarchy_value;
static thread_local unsigned long this_thread_hierarchy_value;
void check_for_hierarchy_violation() {
if (this_thread_hierarchy_value <= hierarchy_value) {
throw std::logic_error("mutex hierarchy violated");
}
}
void update_hierarchy_value() {
previous_hierarchy_value = this_thread_hierarchy_value;
this_thread_hierarchy_value = hierarchy_value;
}
public:
explicit hierarchical_mutex(unsigned long value)
: hierarchy_value(value), previous_hierarchy_value(0) {}
void lock() {
check_for_hierarchy_violation();
internal_mutex.lock();
update_hierarchy_value();
}
void unlock() {
if (this_thread_hierarchy_value != hierarchy_value) {
throw std::logic_error("mutex hierarchy violated");
}
this_thread_hierarchy_value = previous_hierarchy_value;
internal_mutex.unlock();
}
bool try_lock() {
check_for_hierarchy_violation();
if (!internal_mutex.try_lock()) {
return false;
}
update_hierarchy_value();
return true;
}
};
thread_local unsigned long hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);
示例:使用层次锁来避免死锁
以下是使用层次锁的一个示例:
hierarchical_mutex high_level_mutex(10000);
hierarchical_mutex low_level_mutex(5000);
hierarchical_mutex other_mutex(6000);
int do_low_level_stuff();
int low_level_func() {
std::lock_guard<hierarchical_mutex> lk(low_level_mutex);
return do_low_level_stuff();
}
void high_level_stuff(int some_param);
void high_level_func() {
std::lock_guard<hierarchical_mutex> lk(high_level_mutex);
high_level_stuff(low_level_func());
}
void thread_a() {
high_level_func();
}
void do_other_stuff();
void other_stuff() {
high_level_func();
do_other_stuff();
}
void thread_b() {
std::lock_guard<hierarchical_mutex> lk(other_mutex);
other_stuff();
}
超越锁的延伸扩展
除了上述方法,还需要注意其他同步构造中的潜在死锁问题。例如,不要在持有锁的情况下等待另一个线程的完成,除非你确定该线程的层级低于当前线程。
使用 std::unique_lock
提供灵活性
std::unique_lock
提供了比 std::lock_guard
更多的灵活性。它可以延迟锁定、手动解锁以及在不同作用域之间转移所有权。
示例:使用 std::unique_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(some_big_object const& sd) : some_detail(sd) {}
friend void swap(X& lhs, X& rhs) {
if (&lhs == &rhs)
return;
std::unique_lock<std::mutex> lock_a(lhs.m, std::defer_lock);
std::unique_lock<std::mutex> lock_b(rhs.m, std::defer_lock);
std::lock(lock_a, lock_b); // 同时锁定两个互斥量
swap(lhs.some_detail, rhs.some_detail);
}
};
不同域中互斥量的传递
std::unique_lock
支持移动操作,可以在不同的作用域之间传递锁的所有权。例如:
std::unique_lock<std::mutex> get_lock() {
extern std::mutex some_mutex;
std::unique_lock<std::mutex> lk(some_mutex);
prepare_data();
return lk; // 返回锁的所有权
}
void process_data() {
std::unique_lock<std::mutex> lk(get_lock());
do_something(); // 在保护的数据上执行操作
}
总结
- 避免嵌套锁:每个线程只持有一个锁,必要时使用
std::lock
一次性锁定多个互斥量。 - 避免在持有锁时调用外部代码:外部代码可能导致意外的锁竞争。
- 使用固定顺序获取锁:确保所有线程以相同的顺序获取锁。
- 使用层次锁结构:通过层级值限制锁的获取顺序,避免死锁。
- 使用
std::unique_lock
提供灵活性:允许延迟锁定、手动解锁及锁的所有权转移。
通过遵循这些指导意见,可以有效避免多线程编程中的死锁问题,提高程序的稳定性和可靠性。
3.2.8 锁的粒度
锁的粒度简介
锁的粒度指的是通过一个锁保护的数据量大小。细粒度锁(fine-grained lock)保护较小的数据量,而粗粒度锁(coarse-grained lock)则保护较大的数据量。选择合适的锁粒度对于提高多线程程序的性能至关重要。
类比超市结账场景
考虑一个超市结账的情景:如果一位顾客在结账时突然发现忘拿了某样商品,离开去取回该商品会导致其他排队的顾客等待。同样地,在多线程环境中,如果某个线程长时间持有锁,其他需要访问共享资源的线程将被迫等待,导致整体性能下降。
细粒度锁 vs 粗粒度锁
- 细粒度锁:每个锁保护的数据量较小,允许多个线程并行访问不同的数据部分,减少竞争和等待时间。
- 粗粒度锁:一个锁保护大量数据,可能导致更多的线程竞争同一把锁,增加等待时间。
示例:优化锁的使用
以下是一个示例,展示了如何优化锁的使用以减少持锁时间:
void get_and_process_data() {
std::unique_lock<std::mutex> my_lock(the_mutex);
some_class data_to_process = get_next_data_chunk();
my_lock.unlock(); // 1 解锁互斥量,避免在处理数据时持有锁
result_type result = process(data_to_process);
my_lock.lock(); // 2 再次上锁,准备写入结果
write_result(data_to_process, result);
}
在这个例子中,my_lock.unlock()
在调用 process()
函数之前解锁互斥量,从而允许其他线程在此期间访问共享数据。当需要写入结果时,再次锁定互斥量。
控制锁的持有时间
为了最小化锁的持有时间,可以采取以下策略:
- 只在必要时持有锁:仅在访问或修改共享数据时持有锁,尽量减少持有锁的时间。
- 分段操作:将复杂的操作分成多个步骤,并在每个步骤之间释放锁。
示例:细粒度锁的应用
假设有一个简单的数据类型 int
,其拷贝操作非常廉价。在这种情况下,可以通过复制数据来避免长时间持有锁:
class Y {
private:
int some_detail;
mutable std::mutex m;
int get_detail() const {
std::lock_guard<std::mutex> lock_a(m); // 1 保护对some_detail的访问
return some_detail;
}
public:
Y(int sd) : some_detail(sd) {}
friend bool operator==(Y const& lhs, Y const& rhs) {
if (&lhs == &rhs)
return true;
int const lhs_value = lhs.get_detail(); // 2 获取lhs的值
int const rhs_value = rhs.get_detail(); // 3 获取rhs的值
return lhs_value == rhs_value; // 4 比较两个值
}
};
在这个例子中,比较操作符首先通过调用 get_detail()
成员函数检索要比较的值(步骤 2 和 3),并在索引时被锁保护(步骤 1)。然后比较这两个值(步骤 4)。这种方法减少了锁的持有时间,但需要注意的是,由于两次获取值之间可能存在数据变化,可能会出现条件竞争的问题。
条件竞争与语义一致性
虽然上述方法减少了锁的持有时间,但也引入了条件竞争的风险。例如,两个值可能在读取后被修改,导致比较的结果不再准确。因此,在设计并发程序时,必须仔细考虑语义一致性问题。
寻找合适的机制
有时,单一的锁机制无法满足所有需求。在这种情况下,可以考虑使用更复杂的同步机制,如读写锁(std::shared_mutex
)、无锁数据结构或其他高级同步技术。
总结
- 锁的粒度:细粒度锁保护较小的数据量,适合高并发场景;粗粒度锁保护较大的数据量,可能导致较多的竞争。
- 控制锁的持有时间:尽可能缩短持有锁的时间,只在必要的时候持有锁。
- 分段操作:将复杂操作分成多个步骤,并在每个步骤之间释放锁。
- 条件竞争:注意在减少锁持有时间的同时,避免引入条件竞争问题。
通过合理选择锁的粒度和控制锁的持有时间,可以显著提高多线程程序的性能和可靠性。
3.3 保护共享数据的方式
在多线程编程中,互斥量是保护共享数据的一种通用机制,但并非唯一方式。根据具体场景选择合适的同步机制可以显著提高程序的性能和可靠性。
3.3.1 保护共享数据的初始化过程
单线程延迟初始化
假设有一个昂贵的资源需要延迟初始化:
std::shared_ptr<some_resource> resource_ptr;
void foo() {
if (!resource_ptr) {
resource_ptr.reset(new some_resource); // 1 初始化资源
}
resource_ptr->do_something();
}
这段代码在单线程环境中工作良好,但在多线程环境中,resource_ptr
的初始化部分需要保护以避免竞争条件。
多线程延迟初始化
使用互斥量保护初始化过程:
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo() {
std::unique_lock<std::mutex> lk(resource_mutex);
if (!resource_ptr) {
resource_ptr.reset(new some_resource); // 只有初始化过程需要保护
}
lk.unlock();
resource_ptr->do_something();
}
虽然这种方法保证了线程安全,但会导致不必要的序列化,降低并发性能。
双重检查锁模式
双重检查锁模式试图减少锁的竞争:
void undefined_behaviour_with_double_checked_locking() {
if (!resource_ptr) { // 1 不需要锁的读取
std::lock_guard<std::mutex> lk(resource_mutex);
if (!resource_ptr) { // 2 锁保护的读取
resource_ptr.reset(new some_resource); // 3 初始化
}
}
resource_ptr->do_something(); // 4 使用资源
}
然而,这种方法存在潜在的条件竞争问题,可能导致未定义行为。
使用 std::call_once
和 std::once_flag
C++ 标准库提供了 std::call_once
和 std::once_flag
来处理这种情况:
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;
void init_resource() {
resource_ptr.reset(new some_resource);
}
void foo() {
std::call_once(resource_flag, init_resource);
resource_ptr->do_something();
}
这种方式不仅简化了代码,还减少了锁的竞争,提高了性能。
静态局部变量的线程安全初始化
C++11 标准确保静态局部变量的初始化是线程安全的:
class my_class;
my_class& get_my_class_instance() {
static my_class instance; // 线程安全的初始化过程
return instance;
}
这种初始化方式在多线程调用时也是安全的,无需额外的同步机制。
3.3.2 保护不常更新的数据结构
对于不经常更新的数据结构,如 DNS 缓存,可以使用读者-作者锁(reader-writer lock)来优化性能。
使用 std::shared_mutex
C++17 提供了 std::shared_mutex
,允许多个读线程同时访问数据,而写线程独占访问。
#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>
class dns_entry;
class dns_cache {
std::map<std::string, dns_entry> entries;
mutable std::shared_mutex entry_mutex;
public:
dns_entry find_entry(const std::string& domain) const {
std::shared_lock<std::shared_mutex> lk(entry_mutex); // 1 共享锁
auto it = entries.find(domain);
return (it == entries.end()) ? dns_entry() : it->second;
}
void update_or_add_entry(const std::string& domain, const dns_entry& dns_details) {
std::lock_guard<std::shared_mutex> lk(entry_mutex); // 2 独占锁
entries[domain] = dns_details;
}
};
在这个例子中,find_entry()
使用 std::shared_lock
允许多个读线程并发访问,而 update_or_add_entry()
使用 std::lock_guard
提供独占访问。
3.3.3 嵌套锁
当一个线程需要多次获取同一个互斥量时,可以使用 std::recursive_mutex
,它允许多次递归锁定而不导致死锁。
使用 std::recursive_mutex
std::recursive_mutex recursive_mutex;
void nested_function() {
std::lock_guard<std::recursive_mutex> lk(recursive_mutex);
// 执行操作
}
void outer_function() {
std::lock_guard<std::recursive_mutex> lk(recursive_mutex);
nested_function(); // 可以再次锁定同一个互斥量
}
需要注意的是,嵌套锁应谨慎使用,通常应通过重构代码避免嵌套锁定的需求。
总结
- 锁的粒度:选择合适的锁粒度可以提高并发性能,细粒度锁适合高并发场景,粗粒度锁适合较少竞争的场景。
- 延迟初始化:使用
std::call_once
和std::once_flag
可以有效地保护共享数据的初始化过程,避免不必要的锁竞争。 - 读者-作者锁:对于不常更新的数据结构,使用
std::shared_mutex
可以提高读操作的并发性能。 - 嵌套锁:在需要递归锁定的情况下,使用
std::recursive_mutex
,但应尽量避免嵌套锁定的需求。
通过合理选择和使用同步机制,可以有效保护共享数据并提升多线程程序的性能和可靠性。