C++原子操作
因为在大多时候,Qt库以C++的接口提供,所以本篇所讲虽然划分为QT栏,但是其实是C/C++进阶的必要
为什么要说原子操作呢,因为我下两篇将要深入分析多线程以及QT中的多线程,线程就必然涉及线程同步,举个例子,假如我们设计一个软件,一个线程负责采集传感器数据,一个线程负责将数据显示到屏幕,而采样的频率(速度)远远大于屏幕刷新速率,假如每秒采集10次,而屏幕上的数据1秒才更新一次,反正诸如这样的场景,都需要线程同步,换句话说,也可以理解为控制线程的运行顺序和运行速度
而线程控制,大多用到以下几种手段
- 互斥锁
- 信号量
- 标志位
其实还有其他的,本篇就不逐一分析,但是大多数场景,使用都是这三种,关于互斥锁,用来保护临界区的,信号量用来控制线程执行速度不一样的场景,比如经典的生产者与消费者,本篇也不分析,我们在后面会详细讲生产者与消费者的例子,本篇讲的原子操作,主要是跟标志位有关。
我们来看下面的例子
extern bool myflag;
virtual void run()
{
while(true)
{
if(myflag)
{
//此处是我们要执行的代码
}
else
{
sleep(5);//延时5毫秒,挂起此线程,让CPU调度其他线程
}
}
}
这样的代码大家一定很熟悉,在C++中,继承Thread,重写virtual void run()
,然后启动这个线程开始运行,而我们控制这个线程是否执行,可以通过一个全局变量extern bool myflag;
来进行,当想要线程运行是,我们让这个变量为true
,当不让线程运行时,我们让这个变量为false
。这个场景大家都很熟悉,但是,下面给大家一个灵魂拷问:
我们知道,当涉及多线程访问全局变量的时候,理论上涉及多线程访问同一块内存,涉及写的情况,都需要使用互斥锁或者信号量来进行同步或者互斥操作,这个学过操作系统原理,稍微有一点基础的同学基本都是知道的,但是
**这个extern bool myflag明明没有加互斥锁等,为什么不会出错,为什么能这样使用,或者说,或者有一种情况,互斥锁大多时候也是全局的,互斥锁本身是如何互斥的?**
因为这是原子操作,原子操作在任何情况下包括多线程下,原子操作是不需要写保护的,为什么呢?
我们来区分几个概念
- CPU位数
说直白一些就是CPU寄存器的位宽,也就是大小,我们的台式机X86架构的这种,没有研究过,但是比如熟悉的STM32,GD32等等ARM架构的MCU,IMX6U等等,内部有R0~R12(通用寄存器),R13(堆栈寄存器)、R14(链接寄存器)、以及R15(PC指针)等等,这些寄存器是32位的
-
总线位宽
CPU是通过总线和内存以及其他的一些IO设备进行连接的,然后我们的程序在内存中,数据也在内存中,程序运行的时候,CPU就会去读取程序也就是指令,然后执行
我们来看指令是什么
指令 = 操作码+操作数
-
操作码:就是加减乘除,取地,读寄存器,读内存等等,与或等等,也就是CPU支持的操作
-
操作数:就是数据,可能是立即数或者寄存器
void test(void)//这是一个简单的C程序,我们来试着简单的翻译成汇编 { 00000000 int a = 10; 00000004 int b = 20; 00000008 int c; 0000000c c = a+b; } test: mov r0 , #00000000 //a的地址先加载到R0,指针就是地址,一般都是4字节,这就是指针本质 ldr r1 , [r0] //将r0中存的地址中的数据加载到R1 mov r0 , #00000004 //b的地址先加载到R0 ldr r2 , [r0] //将r0中存的地址中的数据加载到R2 add r3 , r1, r2 //将运算结果存入r3 mov r0 , #00000008 //将变量c的地址加载入r0 str r3, [r0] //将运算结果写入r0中存的地址中
上面的汇编不一定准确,经过编译器编译和优化,通过一些复杂的指令,可能会非常精简,上面是手动翻译的程序执行过程,所以相对繁琐,那这跟原子操作有什么关系呢?有
原子操作,就是多线程相互抢占操作一个变量,都不会产生影响的,都叫原子操作,首先操作的数据不能超过32位,,因为超过32位的话,CPU是不能一次性加载完的
比如假如有一个全局变量extern int a,当运行这段程序,对这段程序的变量进行入栈的时候,往往每个变量都会变成一个编号,这个变量全局只有一份,地址是固定的,当执行代码a = 5时,会变成如下指令
mov r0 , #a的地址
ldr [r0] , #0x05
我们分析发现,线程切换的时候,都不会影响当前线程的结果,为什么呢?
- 假如CPU在运行这两条指令的时候,没有发生线程调度,或者说没有被抢占,那就成功执行,结果确定
假如执行了第一条,发生了线程调度,而在其他线程中又改写了a的值,但是但是但是,当线程切换回来的时候,当前线程的寄存器会被全部恢复成原样,也就是调度之前的,然后继续执行第二条指令,结果还是写入了0x05,因为线程切换会保存上现场上下文,其实就是保存这些寄存器,将这些寄存器的值写入当前线程所在的内存,也就是线程栈中,也可以称为将寄存器压栈,当线程切换回来的时候,又弹栈恢复寄存器,而PC指针,则记录了当前执行到那一条指令,或者说那一条代码
而
a = a+5;a++;
则不能称之为原子操作,因为假如初始值a = 10;
在当前线程中,有a = a+5;按照正确的话,执行完的瞬间,a 应当等于15 ,
mov r0 , #a的地址
ldr r1 , [r0]
add r2 , r1 , #0x05
str [r0] , r2
注意,上面的四条汇编是一条C代码哦,也就是a+=5;如果在第一条指令完之后,发生了线程调度,而其他线程改变了a的值等于50,当线程调度回来继续执行第二条指令时,就会将50写入寄存器,这条代码执行完之后,结果就成了55 , 结果就错了
-
-
其实我写的汇编可能不是很准确,我猜测,像a = 5,这样的指令,有可能经过编译器编译之后,会直接变成
LDR R1 , #0x05,有可能一些比较简单的指令,我们想着不是原子操作,而经过编译器优化,会变成原子操作,编译器总是希望我们更少的出错嘛,这就不深入研究编译器了
分析这么多,原子操作的使用其实很简单,如果是一次性写入以内的32字节的数据,就是原子操作,而需要先读,再写入的,就不是原子操作。所以这就是为什么我们可以通过操作全局标志位,来控制线程的原因
更为常见的场景是,多个线程操作同一个链表啊,队列,数组这样,这个时候涉及访问这块内存的代码加互斥锁,其实就是将非原子操作变为原子操作,理论上互斥锁就是这样的功能,将非原子操作变为伪原子操作。
以上就是关于原子操作的深入详解,手动肝真的好费时间,有想过出录制视频可能会更直观和清晰,以后再说吧