C++(32)——多线程编程(互斥、同步、CAS原子操作)

2023-10-30

线程

线程是进程内部的一条执行序列或执行路径,一个进程可以包含多条线程。

  • 从资源分配的角度来看,进程是操作系统进行资源分配的基本单位。
  • 从资源调度的角度来看,线程是资源调度的最小单位,是程序执行的最小单位

一个进程可以并发执行多个线程,每个线程会执行不同的任务。对应在现实生活中,进程是组长,线程是小组成员。

语言级别的多线程的优势:可以跨平台运行
其原理就是:
语言层面上的thread可以通过编译器的编译的宏信息识别操作系统,系统底层使用仍然是使用底层API如下:

  1. windows:createThread
  2. Linux:pthread_create

接下来的几部分我们主要介绍以下内容:

  • thread
  • mutex(互斥锁):lock_guardunique_lock
  • condition_variable(条件变量)
  • atomic:基于CAS操作的原子类型,线程安全
  • sleep_for

创建启动一个线程

包含头文件:

#include<thread>

创建启动方式如下:

定义一个线程对象,传入线程所需要的线程函数和参数,线程自动开启。

简单示例:

void threadHandle1(int time)
{
	// 让子线程睡眠time秒	
	std::this_thread::sleep_for(std::chrono::seconds(time));
	cout << "hello thread1!" << endl;
}
int main()
{
	//创建一个线程对象,传入一个线程函数,新线程就开始运行了	
	std::thread t1(threadHandle1,2);//参数还可以更多,传入什么样的实参就在线程函数定义即可
	//主线程等待子线程结束(阻塞),主线程继续往下运行
	t1.join();
	
	cout<<"main thread done!"<<endl;
	return 0;
}

注意:由于是两个单独的线程,线程运行的先后顺序完全由CPU的调度算法决定,不能理解为main就会先于子线程执行。

问题主线程一定要等待子线程结束吗?
答:正常情况下,在多线程程序中,我们的主线程需要等待子线程运行完,即主线程运行结束后不能存在正在运行的未结束的其他的子线程否则整个进程就会异常终止

在编写过程中,你也可以不等待,改为如下的书写方式:即把子线程设置为分离线程,检查时不关注该线程。——也就是主线程和子线程断绝父子关系了。

int main()
{
	std::thread t1(threadHandle1,2);
	//t1.join();

	t1.detch();
	
	cout<<"main thread done!"<<endl;
	return 0;
}

上面的子线程由于睡眠了两秒,还没有等到被执行完成,由于主线程结束,它也就自动结束了。

小总结

主线程如何处理子线程?

  • t.join——等待t线程结束,当前线程继续往下运行;
  • t.detch——不再理会子线程执行结束与否,主线程结束,进程结束,所有子线程都会自动结束

区分线程

线程除了站在我们角度上的以名字区分,它还有一个属于自己的id!
通过std::thread::get_id()便可以获取到该成员对象线程的id。

std::cout << "t1 thread :: ID = " << t1.get_id() << std::endl;

而在线程函数中通过std::this_thread::get_id()获取线程id。

std::cout << "inside thread :: ID = " << std::this_thread::get_id() << std::endl;

结束线程

线程结束主要分为以下四种方式:

  • 线程函数返回(推荐)
  • 调用ExitThraed函数,线程自行撤销
  • 同一进程或者另一个进程中调用TerminateThread函数
  • ExitProcessTerminateProcess函数也可以用来终止线程进行

步入多线程编程

总结了线程操作的一些基本知识之后,我们来使用一个小的情境看一下多线程编程究竟是怎样的?

模拟车站的三个买票窗口来卖100张票。

#include <iostream>
#include<thread>
#include<list>
using namespace std;

int ticketCount = 100; // 车站有100张车票,由三个窗口一起卖票

//模拟卖票的线程函数
void sellTicket(int index)
{
	while(ticketCount > 0)
	{
		cout << "窗口:" << index << "卖出第" << ticketCount << "张票" << endl;
		ticketCount--;
		std::this_thread::sleep_for(std::chrono::milliseconds(100));
	}
}
int main()
{
	list<std::thread> tlist;
	for (int i = 1; i <= 3; ++i)
	{
		tlist.push_back(std::thread(sellTicket, i));
	}

	for (std::thread& t : tlist)
	{
		t.join();
	}

	cout << "所有窗口卖票结束!" << endl;

	return 0;
}

仔细观察运行的程序,我们会发现同一个票数出现两次的情况,也就是一张票被卖了多次。

究其原因:我们也知道,因为这个操作不是原子性的,–操作做了三步:

mov eax, count
sub eax,1
mov count,eax

CPU可能刚执行完sub操作的时候,还没有把99写会到count中,该线程(t1)时间片到了执行到其他线程(t2),这样其他卖票窗口拿到的count也是100,然后这个线程(t2)执行完count = 99,CPU又回去执行t1,这时你就会白给一张票。

注意:多线程程序中的名词

  • 竞态条件多线程程序执行的结果是一致的,不会随着CPU对线程的不同调用顺序,而产生不同的结果。(一旦同一个程序在不同次的调动过程中,运行结果存在差异,就代表存在竞态条件)

为了解决上面的问题,我们引入了锁及锁操作

在多线程程序中,需要满足竞态条件,我们就需要定义一把锁:

std::mutex mtx; // 全局的一把互斥锁

void sellTicket(int index)
{
	while (ticketCount > 0) // ticketCount=1  锁+双重判断
	{
			mtx.lock();
			if (ticketCount > 0)
			{
				cout << "窗口:" << index << "卖出第:" << ticketCount << "张票!" << endl;
				//cout << ticketCount << endl;
				ticketCount--;
			}
			mtx.unlock();
	std::this_thread::sleep_for(std::chrono::milliseconds(100));
	}
}

这样的程序看似运行结果的票数没什么问题了
在这里插入图片描述

但是仔细分析,你会发现,这时只有一个窗口在卖票,为什么呢?
因为第一个线程获取到这一把互斥锁之后,其他线程是获取不到的,因为第一个线程还没有释放这把锁,其他线程就被阻塞了。此时第一个线程一直处在一个循环的状态,所以就是它把所有的票都卖光了。

于是我们修改代码:

int ticketCount = 100;
std::mutex mtx; // 全局的一把互斥锁

void sellTicket(int index)
{
	while (ticketCount > 0) // ticketCount=1  锁+双重判断
	{
		mtx.lock();
		
		//临界区代码段-需要保证原子操作-线程互斥操作-mutex
		cout << "窗口:" << index << "卖出第:" << ticketCount << "张票!" << endl;
		ticketCount--;
		
		mtx.unlock();
		
		std::this_thread::sleep_for(std::chrono::milliseconds(100));
	}
}
int main()
{
	list<std::thread> tlist;
	for (int i = 1; i <= 3; ++i)
	{
		tlist.push_back(std::thread(sellTicket, i));
	}

	for (std::thread& t : tlist)
	{
		t.join();
	}

	cout << "所有窗口卖票结束!" << endl;

	return 0;
}

我们也就得到了思考;加锁操作一定要作用在临界区代码段上下,扩大范围就会出现错误。
好像解决了只有一个窗口在卖票的问题,但是,仔细思考,当剩下最后一张票时,一个线程进入临界区代码段,获取到一把互斥锁之后,正在对count进行–操作,但是此时还没有完成这个操作,此时第二个线程调用该函数,判断后也进入了循环,然后线程1完成–操作后,我们会发现,此时的线程2正在卖第0张票,因此这里也有bug,真的坑!!

于是我们在进入判断后又添加了条件:
锁+双重判断

while (ticketCount > 0) // ticketCount=1  锁+双重判断
{
	mtx.lock();
		
	if(ticketCount > 0)
	{
		cout << "窗口:" << index << "卖出第:" << ticketCount << "张票!" << endl;
		ticketCount--;
	}
		
	mtx.unlock();
	std::this_thread::sleep_for(std::chrono::milliseconds(100));
}

这才完成一个窗口卖票的程序,没有什么大的错误。

继续研究:上述的临界代码段中一旦存在什么结束程序或者return的操作,那么我们 将无法执行到锁释放操作,导致进程出现死锁问题,我们在这里借助智能指针的思想,再次修改代码:

// 模拟卖票的线程函数  lock_guard unique_lock
void sellTicket(int index)
{
	while (ticketCount > 0) // ticketCount=1  锁+双重判断
	{
			//控制一下作用域
			{
				// 保证所有线程都能释放锁,防止死锁问题的发生 
				lock_guard<std::mutex> lock(mtx); //相当于scoped_ptr,封装mutex,不支持移动拷贝构造和赋值重载
	
				if (ticketCount > 0)
				{
					cout << "窗口:" << index << "卖出第:" << ticketCount << "张票!" << endl;
					ticketCount--;
				}
			}
	std::this_thread::sleep_for(std::chrono::milliseconds(100));
	}
}

这里用了lock_guard<std::mutex> lock(mtx); 把锁包装成了一个类,保证能出函数一定会释放锁,避免死锁问题。

小总结

线程间的互斥 ——> 互斥锁mutex ——> lock_guard封装mutex——> 防止死锁

线程间同步通信——生产者消费者模型

多线程编程中存在两个问题:

  1. 线程间的互斥:发生竞态条件的代码称作临界区代码段,我们每次进入临界区时,必须=保证原子操作,使用互斥锁mutex,或者轻量级的无锁机制CAS
  2. 线程间的同步通信:线程之间如果不通信,那么线程的调度顺序完全由调度算法决定,没有任何顺序可言,所以多线程程序出现问题时经常难以复现,因此需要保证线程之间通信来确定执行顺序。

首先,我们通过一个非常常用的模型:
生产者——消费者模型
来了解一下线程间通信

现在就用一个比较常用的模型来认识一下线程通信。
首先先定义一下互斥锁mtx和条件变量cv

#include<condition_variable>//条件变量
#include<mutex>
std::mutex mtx; // 定义互斥锁,做线程间的互斥操作
std::condition_variable cv; // 定义条件变量,做线程间的同步通信操作

我们知道STL内的容器都不是线程安全的,所以不能够使用在多线程环境下,我们又不可能去容器内部修改其或是pop或者push操作的源代码,因此我们需要简单对其进行封装:

// 生产者生产一个物品,通知消费者消费一个;消费完了,消费者再通知生产者继续生产物品
class Queue
{
public:

	void put(int val) // 生产物品
	{
		//lock_guard<std::mutex> guard(mtx); // scoped_ptr
		unique_lock<std::mutex> lck(mtx); // unique_ptr
		while (!que.empty())// que不为空,生产者应该通知消费者去消费,消费完了,再继续生产
		{
			// 生产者线程进入阻塞等待状态,并且把mtx互斥锁释放掉,让消费者抢到这把锁
			cv.wait(lck);  // lck.lock()  lck.unlock
		}
		que.push(val);
		/* 
		notify_one:通知另外的一个线程的
		notify_all:通知其它所有线程的
		通知其它所有的线程,我生产了一个物品,你们赶紧消费吧
		其它线程得到该通知,就会从等待状态 =》 阻塞状态 =》 获取互斥锁才能继续执行
		*/
		cv.notify_all(); 
		cout << "生产者 生产:" << val << "号物品" << endl;
	}
	
	int get() // 消费物品
	{
		//lock_guard<std::mutex> guard(mtx); // scoped_ptr
		unique_lock<std::mutex> lck(mtx); // unique_ptr
		while (que.empty())
		{
			// 消费者线程发现que是空的,通知生产者线程先生产物品
			// #1 进入等待状态 # 把互斥锁mutex释放
			cv.wait(lck);
		}
		int val = que.front();
		que.pop();
		cv.notify_all(); // 通知其它线程我消费完了,赶紧生产吧
		cout << "消费者 消费:" << val << "号物品" << endl;
		return val;
	}
private:
	queue<int> que;
};

定义生产者和消费者的线程函数:

void producer(Queue *que) // 生产者线程
{
	for (int i = 1; i <= 10; ++i)
	{
		que->put(i);
		std::this_thread::sleep_for(std::chrono::milliseconds(100));
	}
}
void consumer(Queue *que) // 消费者线程
{
	for (int i = 1; i <= 10; ++i)
	{
		que->get();
		std::this_thread::sleep_for(std::chrono::milliseconds(100));
	}
}

创建两个线程:

int main()
{
	Queue que; // 两个线程共享的队列

	std::thread t1(producer, &que);
	std::thread t2(consumer, &que);

	t1.join();
	t2.join();

	return 0;
}

执行逻辑图示:好好看!
在这里插入图片描述

总结:再谈lock_guard和unique_lock以及condition_variable

  1. mtx.lock()mtx.unlock()的临界区之间可能会造成死锁问题,于是我们采用lock_guard,类似于智能指针的做法来避免死锁。
  2. lock_guard类似于scoped_ptr,其源代码中,将左值引用的拷贝构造和赋值删除,所以简单的使用场景下(临界区代码段的加锁解锁)可以使用,但是在函数参数传递或返回过程中不能使用。
  3. unique_lock它不仅可以使用在简单的临界区代码段的互斥操作中,还能用在函数调用过程中(删除了左值引用的拷贝构造和赋值,但提供了右值的拷贝构造和赋值)。
  4. 条件变量(条件锁)也可以解决线程同步和共享资源访问的问题,条件变量是对互斥锁的补充,它允许一个线程阻塞并等待另一个线程发送的信号,当收到信号时,阻塞的线程被唤醒并试图锁定与之相关的互斥锁。
  5. cv.wait(lck)①使线程进入阻塞状态,②lck.unlock可以把mtx释放掉
  6. cv.notify_all通知在cv上等待的线程,条件成立了,起来干活;其他cv上等待的线程,收到通知,从等待状态,变成阻塞状态,直到其他线程获取到互斥锁,其他线程继续执行。

CAS原子操作

因为互斥锁的操作是比较重,如果在临界区代码做的事情比较复杂,比较多。我们确实应该采用互斥锁。但对于类似于卖票系统的++--操作,我们没必要用互斥锁,于是引入了CAS来保证上面的++--操作的原子特性,同时这也是无锁操作。

需要的头文件:

#include<atomic>
volatile std::atomic_bool isReady = false;
volatile std::atomic_int mycount = 0;

其实如果不用volatile修饰,上面两个变量处在数据段,不同的线程栈不同,但是堆和数据段都是共享的,对于共享的变量,多线程是会进行缓存的,某个线程修改后,其他线程不能保证马上看到修改为true,因为读的都是缓存,volatile防止了多线程对对共享变量进行缓存,保证了每次数据都是从原始内存拿,而不是有一定安全性风险的寄存器。

void task()
{
	while (!isReady)
	{
		std::this_thread::yield(); // 线程出让当前的CPU时间片,等待下一次调度
	}

	for (int i = 0; i < 100; ++i)
	{
		mycount++;
	}
}
int main()
{
	list<std::thread> tlist;
	for (int i = 0; i < 10; ++i)
	{
		tlist.push_back(std::thread(task));
	}

	std::this_thread::sleep_for(std::chrono::seconds(3));
	isReady = true;

	for (std::thread &t : tlist)
	{
		t.join();
	}
	cout << "mycount:" << mycount << endl;

	return 0;
}
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

C++(32)——多线程编程(互斥、同步、CAS原子操作) 的相关文章

随机推荐

  • 演讲:文档什么鬼分享会

    作为一个初创技术公司 我司的信息管理水平 基本还停留在茹毛饮血的原始水平 领导让我给全公司的同事做一个分享 说是要提升一下文档意识的水位 作为一只热爱解决具体问题的攻城狮 竟然勉强我去讲 哲学 瞬间化身嘤嘤怪 不过转念回想起当年挥斥方遒 写
  • Learning Ceph

    Author 海峰 http weibo com 344736086 参考章宇兄的开源项目学习ABC的方法来对ceph进行简单的学习与分析 下面是分析过程中画的图片
  • RBF神经网络对iris鸢尾花数据集进行分类识别

    RBF神经网络对iris鸢尾花数据集进行分类 http blog csdn net fubin0000 设计要求 iris以鸢尾花的特征作为数据来源 数据集包含150个数据集 分为3类 setosa versicolor virginica
  • Qt 界面加载卡顿或刷新问题

    主要有以下几个解决方案可以去尝试下 一 设置WA Mapped属性 让界面可以及时更新 void CMainStaticsWindows showEvent QShowEvent event 这句话解决第二次打开窗口没有刷新情况 窗口一片空
  • java fx数据库,Java FX中的数据库连接最佳实践

    目前我也在使用数据库连接的JavaFX应用程序 我选择的方式如下 创建一个SQL Controller Class 这个类应该包含处理你的SQL数据的所有东西 例如 一个连接方法来打开一个连接 一个close方法也没有错 在所有控制器类中使
  • Button 点击没有反应

    原因 检查一下你是不是把button TargetGraphic目标翻转了180度 因为UGUI的射线检测默认只检测正面 解决办法 在你的button检测目标也就是 TargetGraphic目标上加个GraphicRayCaster组件
  • 关于Java环境变量配置之后在CMD中键入JavaC、Java -version无反应

    本机装的是jdk 11 安装后配置环境 在cmd中键入JavaC Java version均无反应 如下图 上网查阅多方资料 终于在知乎大佬的分享贴下解决此问题 鸣谢 步骤如下 右键点击此电脑 gt 属性 gt 高级系统设置 gt 环境配置
  • CCF-CSP201903-4-消息传递接口

    首先应当思考的是如何对输入数据进行存储 通过样例输入可以看出 每一个进程执行的操作数量都是不定的 因此可以采用 vectorg N 进行存储 其中g i 表示i号进程应执行操作 也可以采用queueq N 进行存储q i 表示i号进程应执行
  • 传感器学习——ESP8266(无线串口使用)

    ESP8266模块是一款高性能的WIFI串口模块 只要知道串口怎么编程使用 就可以实现所谓的透明传输 将usb转ttl如下连接 USB转TTL模块上有3 3V和5V两个引脚可以作为VCC 但是一般选取5V作为VCC AT指令 在使用USB转
  • Python案例分析,streamlit模块,Python制作销售数据可视化看板

    前言 Python实战案例 streamlit模块 Python制作销售数据可视化看板 让我们愉快地开始吧 开发工具 Python版本 3 6 4 相关模块 streamlit模块 Plotly模块 Pandas模块 以及一些Python自
  • 比较两个float 2010-9-15 12:01

    float 类型不能比较相等或不等 但可以比较 gt lt gt lt 用 从语法上说没错 但是本来应该相等的两个浮点数由于计算机内部表示的原因可能略有微小的误差 这时用 就会认为它们不等 应该使用两个浮点数之间的差异的绝对值小于某个可以接
  • matlab基础语法总结

    文章目录 1 界面认识 2 变量命名 3 数据类型 4 矩阵构造和四则运算 5 程序结构 6 二维平面绘图 7 三维立体绘图 8 线性规划 9 积分 1 界面认识 命令行输入clc 清除命令行窗口 命令行输入clear all 清除右侧工作
  • IDEA 中 JUnit 的使用

    文章目录 1 JUnit 是什么 2 IDEA 的 JUnit 配置 3 测试类的生成 4 测试覆盖率的查看 1 JUnit 是什么 JUnit 是一个 Java 语言的回归测试框架 regression testing framework
  • 漏洞检查win

    提示 文章写完后 目录可以自动生成 如何生成可参考右边的帮助文档 文章目录 前言 一 漏洞 SMB Signing not required 前言 提示 这里可以添加本文要记录的大概内容 例如 随着人工智能的不断发展 机器学习这门技术也越来
  • linux oracle pdb安装,Linux下安装Oracle(CentOS-Oracle 12c)的方法

    第一步 网络连接 在我的上一篇博客中有介绍 不再多说 网络连接的目的 为了能使用yum命令 在网上直接下载文件 第二步 前往oracle官网下载12c database服务器端的两个文件 安装在Linux linuxamd64 12102
  • 什么是WBS?

    我们可以从一下3点来说明WBS是什么 1 定义 指组织和定义整个项目范围的可交付成果 以逻辑的形式列出必须进行的全部活动的结构 2 图示 从图中可以看出WBS图是一个自上而下逐步分解的解构 3 作用 将大系统变成具体的小工作单元 是复杂变简
  • 元宇宙群涉及到技术

    元宇宙六大核心技术包括 1 物联网技术 分为感知层 网络层 应用层 2 区块链技术 算法及时间戳技术 数据传播及验证技术 共识机制 分布式存储 智能合约 分布式账本等 3 交互技术 VR虚拟现实技术 AR虚拟现实技术 MR混合现实技术 全息
  • 387. First Unique Character in a String

    暴力解法 两个循环 hashmap 两个循环 public int solution 1 String s if s null s length 0 return 1 Map
  • Arduino监控和可视化心率脉搏

    硬件 工作原理 光学心率传感器在理论上非常容易理解 如果您曾经通过指尖照过手电筒并且看到了心跳脉冲 那么您就可以很好地掌握光学心率脉搏传感器的理论 脉搏传感器或任何光学心率传感器都可以通过在手指上发出绿光 约550nm 并使用光电传感器测量
  • C++(32)——多线程编程(互斥、同步、CAS原子操作)

    线程 线程是进程内部的一条执行序列或执行路径 一个进程可以包含多条线程 从资源分配的角度来看 进程是操作系统进行资源分配的基本单位 从资源调度的角度来看 线程是资源调度的最小单位 是程序执行的最小单位 一个进程可以并发执行多个线程 每个线程