C++多线程 第三章 在线程间共享数据
第三章 在线程间共享数据
共享数据基本问题
如果所有共享数据都只读,那就没有问题.
不变量(invariants): 对特定数据结构总为真的语句.例如:"该变量表示线程数量."
修改线程之间共享数据的一个常见潜在问题就是破坏不变量.
竞争条件(race condition): 线程竞争执行各自的操作,导致不变量的破坏.
数据竞争(data race): 因对当个对象的并发修改而产生的特定类型的竞争条件.
软件事务内存(STM): 所需的一系列数据修改和读取被存储在一个事务日志中,然后在单个步骤中提交.如果该提交因为数据结构已被另一个线程修改而无法进行,该事务将重新启动.
使用互斥元
互斥元(mutex): 在访问共享数据结构之前,锁定(lock) 该数据相关互斥元;当访问数据结构完成后, 解锁(unlock) 该互斥元.线程库会保证一旦某个线程锁定了某个互斥元,所有试图锁定相同互斥元的其他线程都需要等待.
- 创建互斥元:
std::mutex some_mutex;
- 锁定互斥元:
some_mutex.lock();
- 解锁互斥元:
some_mutex.unlock();
- 使用RAII惯用语法的互斥元:
std::lock_guard<std::mutex>guard(some_mutex);
为了解释互斥元的使用,在这里使用一个简单的例子对其进行解释:
#include <iostream>
#include <thread>
#include <windows.h>
int count = 0;
void func_1()
{
for (int i = 0; i < 10000; i++)
count++;
return;
}
void func_2()
{
for (int i = 0; i < 10000; i++)
count++;
return;
}
int main()
{
std::thread t_1(func_1);
std::thread t_2(func_2);
t_1.join();
t_2.join();
for (int i = 0; i < 10000; i++)
count++;
std::cout << "the value of count is :" << count;
return 0;
}
由于对全局变量count的抢用,事实上,这个程序最终得到的结果是随机的.
然而,通过对std::mutex的使用,我们可以尽可能避免这一问题:
#include <iostream>
#include <thread>
#include <mutex>
#include <windows.h>
int count = 0;
std::mutex count_mutex;
void func_1()
{
std::lock_guard<std::mutex>guard(count_mutex);
for (int i = 0; i < 10000; i++)
count++;
return;
}
void func_2()
{
std::lock_guard<std::mutex>guard(count_mutex);
for (int i = 0; i < 10000; i++)
count++;
return;
}
int main()
{
std::thread t_1(func_1);
std::thread t_2(func_2);
t_1.join();
t_2.join();
for (int i = 0; i < 10000; i++)
count++;
std::cout << "the value of count is :" << count;
return 0;
}
在上面的代码中,我们首先创建了一个全局std::mutex互斥量.
然后,在使用的过程中,我们使用RAII惯用语法std::lock_guard来对其进行管理,保证了过程的完整性.
保护并行数据
由于并行常常带来一些意想不到的问题,所以我们需要思考如何更好地保护并行程序中的数据,下面是一个有趣的例子:
#include <iostream>
#include <thread>
#include <mutex>
#include <format>
std::mutex func_mutex;
template<typename Function>
void doSomething(Function func)
{
std::lock_guard<std::mutex>guard(func_mutex);
int data = 100;
func(data);
std::cout << std::format("the data is: {}", data);
return;
}
void badFunc(int& data)
{
data = 200;
return;
}
int main()
{
std::thread t(&doSomething<void(int&)>, badFunc);
t.join();
std::lock_guard<std::mutex>guard(func_mutex);
return 0;
}
在上面的例子中,我们设计了一个函数doSomething,其接收外部的函数来对数据进行操作.
然而,我们模拟了一个恶意函数badFunc传入的情景: 它通过引用绕开了锁并修改了数据!
这在大多数情况下当然不是我们想要的.
因而记住: 不要将指向受保护数据的指针与引用传递到锁的范围之外.
死锁
死锁(deadlock): 一对线程中的每一个都需要同时锁定两个互斥元来执行一些操作,并且每个线程都拥有了一个互斥元,同时等待另外一个.两个线程都无法继续.
下面是一个死锁的例子:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mutex_1;
std::mutex mutex_2;
void func_1()
{
for (int i = 0; i < 10; i++) {
std::cout << "now is func_1" << std::endl;
std::lock_guard<std::mutex>guard_1(mutex_1);
std::lock_guard<std::mutex>guard_2(mutex_2);
}
std::cout << "func_1" << std::endl;
return;
}
void func_2()
{
for (int i = 0; i < 10; i++) {
std::cout << "now is func_2" << std::endl;
std::lock_guard<std::mutex>guard_2(mutex_2);
std::lock_guard<std::mutex>guard_1(mutex_1);
}
std::cout << "func_2" << std::endl;
return;
}
int main()
{
std::thread t_1(func_1);
std::thread t_2(func_2);
t_1.join();
t_2.join();
return 0;
}
为了避免死锁,常见的建议是始终使用相同的顺序锁定这两个互斥元.这在大多数情况有效.
但是,实际上,有一种更为方便的方法: 通过std::lock同时锁定两个或更多互斥元.
- 同时锁定多个互斥元:
std::lock(mutex_1,mutex_2[,other...]);
- 将已锁定的互斥元的所有权转移到lock_guard:
std::lock_guard<std::mutex>guard(mutex_1,std::adopt_lock);
我们利用std::lock,解决上面的死锁问题:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mutex_1;
std::mutex mutex_2;
void func_1()
{
for (int i = 0; i < 10; i++) {
std::lock(mutex_1, mutex_2);
std::cout << "now is func_1" << std::endl;
std::lock_guard<std::mutex>guard_1(mutex_1,std::adopt_lock);
std::lock_guard<std::mutex>guard_2(mutex_2,std::adopt_lock);
}
std::cout << "func_1" << std::endl;
return;
}
void func_2()
{
for (int i = 0; i < 10; i++) {
std::lock(mutex_2, mutex_1);
std::cout << "now is func_2" << std::endl;
std::lock_guard<std::mutex>guard_2(mutex_2,std::adopt_lock);
std::lock_guard<std::mutex>guard_1(mutex_1,std::adopt_lock);
}
std::cout << "func_2" << std::endl;
return;
}
int main()
{
std::thread t_1(func_1);
std::thread t_2(func_2);
t_1.join();
t_2.join();
return 0;
}
然而,死锁的来源远不止锁定.例如下面这个例子:
#include <iostream>
#include <thread>
void func(std::thread& t)
{
t.join();
return;
}
int main()
{
std::thread t1, t2;
std::thread temp_1(func, std::ref(t2));
t1 = std::move(temp_1);
std::thread temp_2(func, std::ref(t1));
t2 = std::move(temp_2);
t1.join();
return 0;
}
其通过两个线程之间的互相调用实现了死锁.
上面的例子为我们说明了死锁现象的防不胜防.为了尽量避免死锁的出现,我们有下面几点建议:
- 避免嵌套锁:如果你已经持有一个锁,就别再获取锁.
- 在持有锁时,避免调用用户提供的代码.
- 以固定顺序获取锁:在每个线程中以相同顺序获得锁.
- 使用锁层次:
锁层次通过对线程当前层次值,上一次层次值的保存,结合锁层次值,在不符合锁层次时抛出logic_error来解决死锁.
- 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 checkForHierarchyViolation()
{
if (this_thread_hierarchy_value <= hierarchy_value)
throw std::logic_error("mutex hierarchy violated");
}
void updateHierarchyValue()
{
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){ return; }
void lock()
{
checkForHierarchyViolation();
internal_mutex.lock();
updateHierarchyValue();
}
void unlock()
{
this_thread_hierarchy_value = previous_hierarchy_value;
internal_mutex.unlock();
}
bool try_lock()
{
checkForHierarchyViolation();
if (!internal_mutex.try_lock())
return false;
updateHierarchyValue();
return true;
}
};
thread_local unsigned long hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);
下面再给出一个使用锁层次的实例:
#include <iostream>
#include <thread>
#include <mutex>
hierarchical_mutex mutex_1(1000);
hierarchical_mutex mutex_2(500);
void func_1()
{
try {
for (int i = 0; i < 10; i++) {
std::cout << "now is func_1" << std::endl;
std::lock_guard<hierarchical_mutex>guard_1(mutex_1);
std::lock_guard<hierarchical_mutex>guard_2(mutex_2);
}
}
catch (std::logic_error) {
std::cout << "func_1" << std::endl;
}
return;
}
void func_2()
{
try {
for (int i = 0; i < 10; i++) {
std::cout << "now is func_2" << std::endl;
std::lock_guard<hierarchical_mutex>guard_2(mutex_2);
std::lock_guard<hierarchical_mutex>guard_1(mutex_1);
}
}
catch (std::logic_error) {
std::cout << "func_2" << std::endl;
}
return;
}
int main()
{
std::thread t_1(func_1);
std::thread t_2(func_2);
t_1.join();
t_2.join();
return 0;
}
由于func_2处存在不符合的层次值的情况,因而该线程的循环很快终止.也避免了死锁.
灵活锁定
通过松弛不变量,std::unique_lock比std::lock_guard提供了更多的灵活性,一个std::unique_lock实例不总是拥有与之相关联的互斥元.
使用std::unique_lock与std::defer_lock相结合,可以很方便地实现std::lock_guard与std::adopt_lock相结合的效果.
std::adopt_lock表示互斥元已被锁上,std::defer_lock则表示互斥元暂未被锁上.
- 将未被锁定的互斥元记录到unique_lock:
std::unique_lock<std::mutex> ulock(mutex_1,std::defer_lock);
下面是通过其解决死锁问题的方式:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mutex_1;
std::mutex mutex_2;
void func_1()
{
for (int i = 0; i < 10; i++) {
std::unique_lock<std::mutex>ulock_1(mutex_1, std::defer_lock);
std::unique_lock<std::mutex>ulock_2(mutex_2, std::defer_lock);
std::lock(mutex_1, mutex_2);
std::cout << "now is func_1" << std::endl;
}
std::cout << "func_1" << std::endl;
return;
}
void func_2()
{
for (int i = 0; i < 10; i++) {
std::unique_lock<std::mutex>ulock_2(mutex_2, std::defer_lock);
std::unique_lock<std::mutex>ulock_1(mutex_1, std::defer_lock);
std::lock(mutex_2, mutex_1);
std::cout << "now is func_2" << std::endl;
}
std::cout << "func_2" << std::endl;
return;
}
int main()
{
std::thread t_1(func_1);
std::thread t_2(func_2);
t_1.join();
t_2.join();
return 0;
}
因为std::unique_lock实例并没有拥有与其相关的互斥元,所以通过四处移动(moving)实例,互斥元的所有权可以在实例之间进行转移.
单一全局实例
设想一个 延迟初始化(lazy initialization) 的例子.这在单线程代码中很常见:每个请求资源的操作首先检查它是否已经初始化,如果没有就在使用之前初始化.
std::shared_ptr<some_resource> resource_ptr;
void foo()
{
if(!resource_ptr)
resource_ptr.reset(new some_resource);
resource_ptr->do_something();
return;
}
一般来说,在其中使用互斥元的做法为:
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();
return;
}
然而,这样会有很大的非必要序列化问题.
于是,有人提出了臭名昭著的 二次检查锁定(double-checked locking) 模式.
这一模式已被证明是灾难性的.
void 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();
return;
}
由于在线程对指针所指向内存进行修改时可能尚未flush,这可能导致数据竞争的问题,新的数据被更新的数据覆盖.
为了解决上面情景的问题,C++提供了 std::call_once 与 std::once_flag 来处理这种情景.
使用std::call_once可以使得某函数只被一个线程执行,且效率比std::mutex高.
我们通过下面这个例子来说明:
#include <iostream>
#include <format>
#include <thread>
#include <mutex>
#include <omp.h>
std::shared_ptr<double>resource_ptr_1;
std::shared_ptr<double>resource_ptr_2;
std::mutex resource_mutex;
std::once_flag resource_flag;
void init_resource()
{
resource_ptr_2.reset(new double);
return;
}
void foo_1()
{
std::unique_lock<std::mutex>lk(resource_mutex);
if (!resource_ptr_1)
resource_ptr_1.reset(new double);
lk.unlock();
return;
}
void foo_2()
{
std::call_once(resource_flag, init_resource);
return;
}
int main()
{
double temp_time, run_time;
temp_time = omp_get_wtime();
std::thread t1(foo_1);
std::thread t2(foo_1);
t1.join(), t2.join();
run_time = omp_get_wtime() - temp_time;
std::cout << std::format("the runtime_1 is {:.15f}s",
run_time
) << std::endl;;
temp_time = omp_get_wtime();
std::thread t3(foo_2);
std::thread t4(foo_2);
t3.join(), t4.join();
run_time = omp_get_wtime() - temp_time;
std::cout << std::format("the runtime_2 is {:.15f}s",
run_time
) << std::endl;;
return 0;
}
其运行结果为:
the runtime_1 is 0.004862599889748s
the runtime_2 is 0.000809999997728s
在C++中,如果需要单一全局实例,那么还可以通过static变量来实现.
在C++11之前,对static变量的初始化可能造成数据竞争.但是现在static可以用作std::call_once的替代品.
读写互斥元
读写互斥元(reader-writer): 由单个"写"线程独占访问或共享,由多个"读"线程并发访问.
C++标准库目前没有直接提供这样的互斥元,但是boost库提供了.
- 创建一个共享锁
mutable boost::shared_mutex entry_mutex;
- 锁定一个共享锁
std::lock_guard<boost::shared_mutex> guard(entry_mutex);
- 共享锁定一个共享锁
boost::shared_lock<boost::shared_mutex>lk(entry_mutex);
- 独占锁定一个共享锁
std::unique_lock<boost::shared_mutex>lk(entry_mutex);
如果一个线程拥有一个共享锁,试图获取独占锁的线程会被阻塞,知道其他线程全部撤回他们的锁.
如果一个线程拥有独占锁,其他线程都不能获取共享锁或独占锁.
共享锁可以用于许多情景,其中一个与我们最贴切的情景就是通过并行串口COM进行串口通信时数据的读写.
递归锁
在前面第二章我们提到过,对同一个std::mutex进行多次锁定是一个 未定义行为(undefined behavior).
所以是否存在一个可以多次锁定的互斥元呢?答案是:是的,那就是递归锁.
- 创建一个递归锁
std::recursive_mutex some_mutex;
这个互斥元是可以锁定多次的.但是,相对的,当你多次lock后,你也需要多次unlock才能解除对其的锁定.
在开发中使用递归锁是不推荐的.