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_oncestd::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才能解除对其的锁定.

在开发中使用递归锁是不推荐的.

posted @ 2024-02-04 16:38  Mesonoxian  阅读(13)  评论(0编辑  收藏  举报