上一篇文章介绍了内存模型,并介绍了两种内存程序, 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 1void thread_func1() { data = 42; flag = 1; // 事件1}// thread 2void 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 2void 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 1void thread_func1() { data = 42; // 写操作之前的写操作,之间的程序不能扭转 flag = 1; // 事件1}// thread 2void 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 1void thread_func1() { data = 42; flag.store(1, // 事件1 std::memory_order_release); }// thread 2void 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,最大值为1sem_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关系。
作者 | 王璞