网站首页 > 技术文章 正文
上一篇文章介绍了内存模型,并介绍了两种内存顺序, memory_order_acquire(Acquire)和memory_order_release(Release)。 个人认为,这两种内存顺序是C++定义的六种内存顺序中最重要的两种, 只有理解了Acquire和Release的语义,才能更好理解其他四种内存顺序的语义。 更进一步,在实际使用场景中,Acquire和Release是最常见的两种内存顺序。
如何判断该使用哪种内存顺序?这是开发者在使用原子类型和无锁化编程时最常碰到的问题。 本篇Blog用实际的例子来说明,如何判断该使用哪种内存顺序。 此外,为了更深入理解基于原子操作和基于锁实现的同步关系的本质区别, 本篇Blog还会介绍Happen-Before关系和Synchronize-With关系。
Happen-Before关系
线程间的同步关系,是要约定不同线程里发生的事件的先后次序,互斥关系本质也是一种同步关系。 Happen-Before关系就是用于定义不同事件之间的先后次序。 Happen-Before关系可以是在代码里静态约定好(基于锁的方式),也可以是在程序运行时动态发现(基于原子操作和内存顺序的方式)。
先来看一个简单的例子,这个例子解释了Happen-Before关系:
int data = 0;
int flag = 0;
// thread 1
void thread_func1() {
data = 42;
flag = 1; // 事件1
}
// thread 2
void thread_func2() {
if (flag == 1) // 事件2
printf("%d", data);
}
上面的例子里定义了两个全局变量,线程1设置flag = 1表示完成对data的赋值, 线程2读取flag的值用以判断线程1是否完成对data的赋值,如果flag == 1则输出data的值。 我们定义两个事件,事件1为thread_func1里对flag赋值表示对data的赋值完成, 事件2为thread_func2里判断flag == 1,如果flag == 1则输出data的值。 由于没有用锁的方式在代码里静态规约事件1和事件2的先后顺序,程序运行时可以有多种结果, 有些结果是合理的,有些结果是不合理的。 其中两种合理的结果是:要么线程2输出data的值42,要么不输出。 也就是说要么事件1 Happen-Before事件2,要么事件2 Happen-Before事件1。 但是,还有些不合理的结果,比如线程2有可能输出data的值为0,为什么呢? 因为编译器或CPU会对程序进行优化,使得指令的执行顺序跟代码的逻辑顺序不一致。比如编译器可能对thread_func2进行如下优化:
// thread 2
void thread_func2() {
int tmp = data;
if (flag == 1)
printf("%d", tmp);
}
这里tmp代表某个寄存器,编译器优化thread_func2导致在判断flag == 1前把data的值先载入寄存器,此时data的值可能为0, 判断完flag == 1之后再输出寄存器的值,此时即便data已经被thread_func1赋值为1,但是寄存器tmp里的值仍然是0。 也就是说,程序运行时产生不合理的结果,是由于没有保证事件1和事件2之间的先后次序,导致两个事件在运行时有重叠。 因此,为了保证上面的例子运行产生合理的结果,我们需要确保要么事件1 Happen-Before事件2,要么事件2 Happen-Before事件1。 可以采用基于锁的信号量机制,在代码里静态约定事件1在事件2之前发生, 也可以采用原子操作和内存顺序在程序运行时动态发现事件1和事件2之间的关系。
这里我们先给出基于原子操作和内存顺序实现线程同步的实现。分两个步骤,先确定采用何种内存顺序,再确定采用哪种原子操作。
上面的程序产生不合理的结果,究其原因,是因为编译器和CPU对程序指令的优化,导致代码逻辑顺序和实际指令执行顺序不一致。 因此,我们要用内存顺序来告诉编译器和CPU确保指令执行顺序和代码的逻辑顺序一致。 上述例子里,thread_func1里的两行赋值语句(两个写操作)顺序不能颠倒,thread_func2里判断语句和打印语句(两个读操作)顺序不能颠倒:
int data = 0;
int flag = 0;
// thread 1
void thread_func1() {
data = 42;
// 写操作之前的写操作,之间的顺序不能改变
flag = 1; // 事件1
}
// thread 2
void thread_func2() {
if (flag == 1) // 事件2
// 读操作之后的读操作,之间的顺序不能改变
printf("%d", data);
}
不熟悉读写操作顺序的读者建议先读一下上一篇Blog里介绍的四种读操作与写操作的先后顺序关系。 回想上一篇Blog定义过Acquire和Release的语义:
内存顺序 | 先后次序 | 语义 |
Acquire | 读操作在前 | 读读、读写 |
Release | 写操作在后 | 读写、写写 |
可以看出:要规约“写操作之前的写操作之间的顺序不能改变”(写写),得采用Release语义; 要规约“读操作之后的读操作,之间的顺序不能改变”(读读),得采用Acquire语义。
确定了内存顺序,我们再考虑该如何使用原子操作,确保要么事件1 Happen-Before事件2, 要么事件2 Happen-Before事件1,不能让两个事件在运行时有重叠。 一种做法,我们可以让data成为原子变量,那就不需要flag这个通知变量了,两个线程直接原子访问data。 但是实际中,往往data代表的数据会比较大,不适合作为原子变量,因此才需要flag这个通知变量。 因此,我们让flag成为原子变量,两个线程原子访问flag来实现同步,进而确保事件1和事件2之间的先后顺序:
#include <atomic>
std::atomic_int flag(0); // 初始值为零
int data = 0;
// thread 1
void thread_func1() {
data = 42;
flag.store(1, // 事件1
std::memory_order_release);
}
// thread 2
void thread_func2() {
int ready = flag.load( // 事件2
std::memory_order_acquire);
if (ready == 1)
printf("%d", data);
}
要注意一点,上面采用原子操作和内存顺序,只能确保事件1和事件2之间先后发生,存在先后次序关系, 但是不能保证事件1一定在事件2之前发生,或者事件2一定在事件1之前发生。 两个事件谁先谁后(Happen-Before关系)需要在程序运行时才能确定。
Synchronize-With关系
Synchronize-With关系是指,两个事件,如果事件1 Happen-Before事件2,那要把事件1同步给事件2,确保事件2得知事件1已经发生。
先来看采用信号量机制来实现前述事件1和事件2之间的同步:
sem_t flag;
// 初始化信号量,初始值为0,最大值为1
sem_init(&flag, 0, 1);
int = 0;
void thread_func1() {
data = 42;
sem_post(&flag); // 事件1
}
void thread_func2() {
sem_wait(&flag); // 事件2
printf("%d", data);
}
采用信号量,使得这两个线程运行结果只有一种(静态规约),即只有一种Happen-Before关系,事件1 Happen-Before事件2:
- 不论thread_func1和thread_func2谁先开始运行,thread_func2都会等thread_func1执行完sem_post(&flag)之后,才输出data的值42。
显然,大家看到了基于原子操作和内存顺序,与基于信号量的实现,得到不同的结果。 这也就是我在上一篇Blog里提到的,基于原子操作和内存顺序,跟基于锁和信号量实现的线程间同步关系有本质的差异。 基于锁和信号量的线程间同步关系,比基于原子操作和内存顺序的线程间同步关系要更强。
回到之前的例子,基于信号量实现两个线程间的同步,只有一种运行结果(静态规约), thread_func1里sem_post(&flag)一定在thread_func2输出data的值之前。 也就是说,信号量确保了事件1 Happen-Before事件2,同时也在运行时确保了事件1 Synchronize-With事件2 (通过thread_func1里sem_post(&flag)和thread_func2里sem_wait(&flag)来确保Synchronize-With关系), 因而基于信号量的实现保证最终结果一定是thread_func2输出data的值42。
但是,对于上述例子,基于原子操作和内存顺序实现两个线程间的同步,会有两种运行结果(动态发现), 要么thread_func2输出data的值42,要么thread_func2不输出data的值。 也就是说,基于原子操作和内存顺序,只能保证事件1和事件2之间存在先后次序, 即要么事件1 Happen-Before事件2,要么事件2 Happen-Before事件1。 可见,基于原子操作和内存顺序,无法保证一定只有事件1 Happen-Before事件2这一种关系。 另外,在运行时,如果事件1 Happen-Before事件2, 基于flag这个原子变量的原子操作和内存顺序的实现可以确保事件1 Synchronize-With事件2 (通过thread_func1里flag.store(1, std::memory_order_release)和thread_func2里flag.load(std::memory_order_acquire)来确保); 如果在运行时,事件2 Happen-Before事件1, 那基于flag这个原子变量的原子操作和内存顺序的实现无法确保事件2和事件1之间有Synchronize-With关系,需要另行实现。
一句话总结,基于锁的同步机制,是在代码里静态约定不同线程里发生的事件之间的Happen-Before关系和Synchronize-With关系; 而基于原子操作和内存顺序,是在程序运行时动态发现事件之间的Happen-Before关系以及Synchronize-With关系。
作者 | 王璞
猜你喜欢
- 2024-10-29 RT-Thread快速入门-互斥量 互斥方案用什么指标
- 2024-10-29 操作系统概论:第二章 进程管理 简述操作系统进程管理,并举例说明
- 2024-10-29 Java多线程操作系统(生产者、消费者问题)
- 2024-10-29 高可用架构-容错机制 容错技术可以提高系统的可靠性
- 2024-10-29 计算机操作系统笔记第二章进程管理中篇
- 2024-10-29 六大进程通信机制总结 进程通信有哪几种基本类型?
- 2024-10-29 Java系统过载保护机制之信号量的控制
- 2024-10-29 铂金04:通风报信-为何说信号量是线程间的同步良器
- 2024-10-29 记一次阿里面试题:都有哪些进程间通信方式?麻烦你不要再背了
- 2024-10-29 计算机操作系统基础笔记 《计算机操作系统》
你 发表评论:
欢迎- 最近发表
- 标签列表
-
- oraclesql优化 (66)
- 类的加载机制 (75)
- feignclient (62)
- 一致性hash算法 (71)
- dockfile (66)
- 锁机制 (57)
- javaresponse (60)
- 查看hive版本 (59)
- phpworkerman (57)
- spark算子 (58)
- vue双向绑定的原理 (68)
- springbootget请求 (58)
- docker网络三种模式 (67)
- spring控制反转 (71)
- data:image/jpeg (69)
- base64 (69)
- java分页 (64)
- kibanadocker (60)
- qabstracttablemodel (62)
- java生成pdf文件 (69)
- deletelater (62)
- com.aspose.words (58)
- android.mk (62)
- qopengl (73)
- epoch_millis (61)
本文暂时没有评论,来添加一个吧(●'◡'●)