首页 > 编程语言 >C++多线程 第三章 在线程间共享数据

C++多线程 第三章 在线程间共享数据

时间:2024-02-04 16:45:49浏览次数:21  
标签:std include resource lock C++ 互斥 mutex 程间 多线程

第三章 在线程间共享数据


共享数据基本问题

如果所有共享数据都只读,那就没有问题.

不变量(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才能解除对其的锁定.

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

标签:std,include,resource,lock,C++,互斥,mutex,程间,多线程
From: https://www.cnblogs.com/mesonoxian/p/18006483

相关文章

  • C++之INI配置文件读写/注释库 inicpp 介绍【简单易用-包含inicpp.hpp头文件即可】
    一个头文件(header-file-only)搞定INI文件读写、甚至进行注释。跨平台,并且用法极其简单。MITlicense,从此配置INI文件就像喝水。【注:对您有帮助的话,Star或Issues为项目维护提供动力,感谢。】-byofficalofJN-inicppproject.一、库下载https://github.com/dujingning/inicpp......
  • 14. C++函数的编译
    C++函数的编译C++和C语言的编译方式不同。C语言中的函数在编译时名字不变,或者只是简单的加一个下划线_(不同的编译器有不同的实现),例如,func()编译后为func()或_func()。而C++中的函数在编译时会根据它所在的命名空间、它所属的类、以及它的参数列表(也叫参数签名)等信息进行重新......
  • 深入浅出Java多线程(七):重排序与Happens-Before
    引言大家好,我是你们的老伙计秀才!今天带来的是[深入浅出Java多线程]系列的第七篇内容:重排序与Happens-Before。大家觉得有用请点赞,喜欢请关注!秀才在此谢过大家了!!!在上一篇文章中,我们简单提了一下重排序与Happens-Before。在这篇文章中我们将深入讲解一下重排序与Happens-Before,然......
  • 创建大量栅格文件并分别写入像元数据:C++ GDAL代码实现
      本文介绍基于C++语言GDAL库,批量创建大量栅格遥感影像文件,并将数据批量写入其中的方法。  首先,我们来明确一下本文所需实现的需求。已知我们对大量遥感影像进行了批量读取与数据处理操作——具体过程可以参考文章C++GDAL提取多时相遥感影像中像素随时间变化的数值数组;而随......
  • A Knight's JourneyC++
    题目看半天看不懂。題目把我恶心坏了。看网上说按字典顺序输出,到底是什么意思半天没搞懂。#include<iostream>#include<string>usingnamespacestd;intd[8][2]={{-1,-2},{1,-2},{-2,-1},{2,-1},{-2,1},{2,1},{-1,2},{1,2}};intvisit[8][8]={0};boolDFS(i......
  • CLion 2023: 一款专注于性能和效率的C/C++ IDE mac/win版
    JetBrainsCLion2023是一款专为C和C++开发人员打造的强大集成开发环境。这个版本致力于提供卓越的性能、强大的功能和一流的智能代码编辑支持,帮助您更高效地开发高质量的C和C++应用程序。→→↓↓载CLion2023mac+win版首先,CLion2023提供了对最新C和C++标准的全面支持。无论......
  • c++20模块化编程与传统区别
    传统:main.cpp+a.cpp(存放定义)+a.h(存放声明)c++20:main.cpp+a.cppm(存放定义,在定义前面写export即可)模块化编程好处:不再需要修改了函数到对应修改声明,两头跑编译更快,模块只在修改后才重新编译模块化编程举例://my_module.cppmimport<iostream>;exportm......
  • Find The MultipleC++
    这题就是找N的倍数m,M要求是由1和0组成且非0。可以用图来看,从1出发临边是1和0,然后广度遍历,第一个能能整除N的数输出就行。#include<iostream>#include<queue>usingnamespacestd;intmain(){intn=-1;while(cin>>n){if(n==0)break;longlon......
  • c++加速cin和关闭同步流
    signedmain(){ios::sync_with_stdio(0);cin.tie(0),cout.tie(0);intT=1;//cin>>T;while(T--)solve();return0;}一·ios::sync_with_stdio(false);01"c++是否兼容stdio(c)"的开关函数02默认参数为true:将输出流绑到一起保证......
  • 【C++】力扣101-平方数之和
    给定一个非负整数 c ,你要判断是否存在两个整数 a 和 b,使得 a2+b2=c 。使用双指针:#include<iostream>#include<math.h>usingnamespacestd;booljudge(longc){if(c<0)returnfalse;longa=0;longb=(int)sqrt(c);longsum=0;while......