深入探索并发编程系列(八)-Acquire与Release语义

一般来说,在无锁(lock-free)注1编程中,线程有两种方法来操作共享内存:线程间相互竞争一种资源或者相互合作传递消息。Acquire与Release语义对后者来说很关键:保证在线程间可靠地相互传递消息。实际上,我大胆地猜测,不正确的或者缺乏Acquire与Release语义是导致无锁编程产生错误的最常见 原因。

在这篇文章中,我会去探讨许多在C++中获得Acquire与Release 语义的方法。还会简单介绍一下C++11原子库标准。所以,你事先不必具备这方面的知识。简明起见,这里的讨论仅限于非顺序一致性的无锁编程。我们要关注的是多核或者多处理器环境下的内存执行顺序。

不幸的是,你会发现Acquire与Release语义甚至比lock-free更难理解。关于这个词,如果你在网上搜索的越多,越会感觉与它的定义更矛盾。 得益于Herb Sutter,Bruce Dawson通过white paper提供了一些好的定义。紧密结合C++11原子性背后的原则,我想给出一些我自己的定义。

  • Acquire语义的性质只能应用于共享内存中的读操作,不管是read-modify-write操作还是普通的读数据。这种操作被认为是read-acquire。 Acquire 语义能阻止read-acquire和它之后的任何读写操作的乱序。
  • Release语义的性质只能应用于共享内存中的写操作,而不管是read-modify-write操作还是普通的写操作。这种操作被认为是write-release. Release 语义能阻止write-release和它之前的任何读写操作的乱序。



只要你能消化上述的概念,就不难知道Acquire与Release 语义可以通过我在上一篇文章中提到的memory barrier类型的组合来实现。Barrier必须放置在read-acquire操作之后与write-release操作之前。[更新:请注意这些barrier在技术上比单个内存操作上对Acquire与Release 语义的需求更加严格,但能达到理想中的效果]

有趣的是不管是Acquire还是Release语义都不需要用到一种开销比较昂贵的memory barrier-StoreLoad barrier, 。举个例子,在PowerPC中,lwsync(lightweight sync的简写)指令同时充当LoadLoad barrier, LoadStore barrier和StoreStore barrier三种角色,所以比sync指令(包含了StoreLoad barrier)的开销要昂贵.

显式平台特定的fence指令

获取期望的memory barrier的一种方式就是发出显式的fence指令。我们以一个例子开始。假设我们在PowerPC平台下写代码,__lwsync()是一种编译器内置函数,能发出lwsync指令。由于lwsync提供了许多种memory barrier,因此我们可以在下面的代码中用它来建立所需的Acquire或者Release语义。在线程1中,对Ready的写操作变成了一个write-release,在线程2中,对Ready的读操作变成了一个read-acquire。

如果让两个线程都运行,会发现,r1==1可以作为A的值从线程1成功传递到线程2中的确认标志。如此一来,我们可以保证r2==42。在上一篇文章中,我已经针对LoadLoad 和 StoreStore给出了一个很长的类比来阐述其是如何工作的,我在这里就不再解释了。

在正式的定义中,我们说对Ready的写操作与读操作是同步的。在这里我对synchronizes-with专门写了一篇文章。目前为止,我们已经可以来说明要让这技术能通用,Acquire与Release 语义必须应用在同一个变量中(在这个例子中是Ready变量),读和写操作必须都是原子操作。在这里,Ready是个简单的已经对齐的整型变量,所以这些读写在PowerPC中就已经是原子操作了注2

在可移植性C++11中使用Fences

上述的例子是依赖编译器和处理器的。要支持多平台的一种方法就是将代码转化成C++11. 所有的C++11标识符都存在std空间中,所以为了将下面的例子变得简单一些,我们假设using namespace std;语句提前放在代码的某处了。

C++11的原子库标准定义了一个可移植的函数atomic_thread_fence(),函数采用一个参数来指定fence的类型。这个参数有很多种可能的值,但我们在这里最感兴趣的是memory_order_acquirememory_order_release. 我们用这个函数来替代__lwsync()

在让这个例子变得更完整之前,还需要作一点修改。在PowerPC平台上,我们知道对Ready变量的两个操作都是原子的,但不是每个平台上都这样。我们可以将Ready变量从整型改为atomic<int>。 考虑到针对对齐的整型,读写操作在现今所有的CPU中都是原子的,我知道这是个傻瓜式的修改。我会在synchronizes-with文章中描述更多关于这方面的内容,现在,我们姑且认为理论上能确保100%的准确率。另外, 不必对A做任何修改。

memory_order_relaxed参数意味着“确保这些操作是原子的,但对那些本身不在那里的操作不作任何的顺序限制或者强加memory barrier”

再说一次,上述的atomic_thread_fence() 调用可以在PowerPC中实现像lwsync一样的效果。类似的,他们都能在ARM上发出dmb指令,这点我相信至少是和在PowerPC平台上能有同样效果的。在X86/X64平台上,atomic_thread_fence()调用可以简单的实现成和compiler barrier一样的效果,因为一般来说,x86/x64上的每个读操作已经包含了Acquire语义,并且每个写操作都包含了Release 语义。这就是为什么x86/x64经常被说成是强内存模型注3.

在可移植C++11上不用fences

在C++11中,只要在对Ready上的操作中指定内存执行顺序的限制,就可以不发出显式的fence指令来达到Acquire与Release语义。

大专栏  深入探索并发编程系列(八)-Acquire与Release语义src="http://7xppf1.com1.z0.glb.clouddn.com/acquire6.png" />

考虑每个在Ready上的fence指令。[更新:请注意这种形式和使用独立的fences版本是不完全一样的,技术上来说,没有那么严格]。 编译器会发出必要的指令,来取得和memory barrier一样的所需的效果。具体来说,在Itanium上,每个操作都能简单的实现成一个单指令:ld.acqst.rel. 如之前那样,r1 == 1意味着一种
synchronizes-with关系,作为对r2 == 42的确认。

在C++11中,这实际上是一种更好的方式来表达Acquire与 Release语义。前面例子中使用的atomic_thread_fence() 函数在标准的制定中是添加的相对较晚的。

Acquire and Release While Locking

如你所见,这篇文章中没有一个例子能利用Acquire与Release 语义提供的LoadStore barrier优势。 实际上,只有LoadLoad和StoreStore 就足矣。这就是为什么在这篇文章中,我选一个简单的列子来让我们能集中关注API和语义。

必须用到LoadStore一个例子是当使用Acquire与 Release语义来实现(mutex) 锁。
实际上,这就是名字的由来: 获取一个锁意味着Acquire 语义,释放锁意味着Release语义。 之间所有的内存操作都包含在了一个小的barrier的三明治中,用来阻止任何要跨越界限的内存乱序。

译者注

注1:lock free虽然翻译为无锁,但是它并不是“没有锁”的意思,“没有锁”在英文里一般是lockless。lock free考察的是若干个线程组成的系统,不管如何,总能保证至少有一个线程能make progress,因此保证系统,从整体上看,是make progress的。这样的系统或者算法实现就是lock free的。

注2:在X86体系结构下,对于64位架构,只要同时满足以下两个条件,那么对该基础内置数据类型变量(int、bool、指针等)的普通读写都是原子的:

条件1:该变量按cache line对齐

条件2:该变量sizeof值不超过64

所以,要注意的是,32位cpu和系统中,即使64位例如int64_t类型变量即使是对齐也不保证是原子的。这种情况可以使用__sync_fech_and_add等gcc提供的内置原子操作。

注3:即使这样,读者诸君还是得注意,虽然store自带release语义,load自带acquire语义,但是X86还是会对写读不同变量进行乱序。(当然,这点和release 、acquire语义不矛盾)。为什么不矛盾?因为

12
int t = 1;int c = a;

这里是写变量t,读另外一个变量a,因此可能乱序。但是不矛盾。因为写t提供release语义是说它之前的代码不会和它乱序,却没有保证它之后的代码不和它乱序;同理,对于读a提供acquire语义,只保证它之后的代码不会和它乱序,却没有保证它之前的代码不和它乱序。这里的之前之后都是针对program order,也请读者诸君务必注意理解。

注4:一般说来,锁要提供三种语义:

123456
b++;lock.lock();a++;d++;lock.unlock();c++;

语义1:当线程1更新完a(也包括d)的值之后释放锁,线程2进入临界区必须读到a(也包括d)的新值,也就是线程1更新完之后的值。可以认为是acquire release或者happen before语义。

语义2:同一时刻,a++这条语句只能有一个线程在执行。可以认为是临界语义或者互斥语义。

语义3:必须保证a++不会被乱序到b++处执行,也不能乱序到c++处执行。这个自然也是临界语义或者互斥语义的一部分,因为如果乱序,那么无临界区可言了。但是读者诸君要注意到,以下这样的乱序是完全可以的、合法的:

123456
b++;lock.lock();d++;a++;lock.unlock();c++;

或者:

123456
lock.lock();b++;a++;d++;c++;lock.unlock();

Acknowledgement

本文由 Diting0x睡眼惺忪的小叶先森 共同完成,在原文的基础上添加了许多精华注释,帮助大家理解。

感谢好友小伙伴-小伙伴儿阅读了初稿,并给出宝贵的意见。

原文: http://preshing.com/20120913/acquire-and-release-semantics/

本文遵守Attribution-NonCommercial-NoDerivatives 4.0 International License (CC BY-NC-ND 4.0)
仅为学习使用,未经博主同意,请勿转载
本系列文章已经获得了原作者preshing的授权。版权归原作者和本网站共同所有

原文地址:https://www.cnblogs.com/liuzhongrong/p/12251265.html

时间: 2024-08-01 13:32:43

深入探索并发编程系列(八)-Acquire与Release语义的相关文章

Python并发编程系列之多线程

1引言 2 创建线程 2.1 函数的方式创建线程 2.2 类的方式创建线程 3 Thread类的常用属性和方法 3.1 守护线程:Deamon 3.2 join()方法 4 线程间的同步机制 4.1 互斥锁:Lock 4.2 递归锁:RLock 4.3 Condition 4.4 信号量:Semaphore 4.5 事件:Event 4.6 定时器:Timer 5 线程间的通行 5.1队列:Queue 6 线程池 7 总结 1 引言 上一篇博文详细总结了Python进程的用法,这一篇博文来所以说

Java并发编程系列(一)-线程的基本使用

最近在学习java并发编程基础.一切从简,以能理解概念为主. 并发编程肯定绕不过线程.这是最基础的. 那么就从在java中,如何使用线程开始. 继承Thread类 继承Thread类,重写run方法,new出对象,调用start方法. 在新启的线程里运行的就是重写的run方法. 1 /** 2 * 集成Thread类 实现run() 3 */ 4 public class C1 extends Thread { 5 6 @Override 7 public void run() { 8 try

java并发编程系列一、多线程

1.什么是线程 线程是CPU独立运行和独立调度的基本单位: 2.什么是进程 进程是资源分配的基本单位: 3.线程的状态 新创建   线程被创建,但是没有调用start方法 可运行(RUNNABLE)  运行状态,由cpu决定是不是正在运行 被阻塞(BLOCKING)  阻塞,线程被阻塞于锁 等待/计时等待(WAITING) 等待某些条件成熟 被终止  线程执行完毕 线程的生命周期及五种基本状态: 4.线程的优先级 成员变量priority控制优先级,范围1-10之间,数字越高优先级越高,缺省为5

Java并发编程系列之二十八:CompletionService

CompletionService简介 CompletionService与ExecutorService类似都可以用来执行线程池的任务,ExecutorService继承了Executor接口,而CompletionService则是一个接口,那么为什么CompletionService不直接继承Executor接口呢?主要是Executor的特性决定的,Executor框架不能完全保证任务执行的异步性,那就是如果需要实现任务(task)的异步性,只要为每个task创建一个线程就实现了任务的异

并发编程(八):线程安全策略

通常我们保证线程安全策略的方式有以下几种: a.不可变对象 b.线程封闭 c.同步容器 d.并发容器 不可变对象 可参考string类,可以采用的方式是将类声明为final,将所有成员都声明为私有的,对变量不提供set方法,将所有可变成员声明为final,通过构造器初始化所有成员,进行深度拷贝,在get方法中不直接返回对象本身,而是返回对象的拷贝. 关于final,我们详细说明一下 final-demo @Slf4j public class ImmutableExample1 { privat

[高并发]Java高并发编程系列开山篇--线程实现

ava是最早开始有并发的语言之一,再过去传统多任务的模式下,人们发现很难解决一些更为复杂的问题,这个时候我们就有了并发. 引用 多线程比多任务更加有挑战.多线程是在同一个程序内部并行执行,因此会对相同的内存空间进行并发读写操作.这可能是在单线程程序中从来不会遇到的问题.其中的一些错误也未必会在单CPU机器上出现,因为两个线程从来不会得到真正的并行执行.然而,更现代的计算机伴随着多核CPU的出现,也就意味着不同的线程能被不同的CPU核得到真正意义的并行执行. 那么,要开始Java并发之路,就要开始

Java并发编程系列-(2) 线程的并发工具类

2.线程的并发工具类 2.1 Fork-Join JDK 7中引入了fork-join框架,专门来解决计算密集型的任务.可以将一个大任务,拆分成若干个小任务,如下图所示: Fork-Join框架利用了分而治之的思想:什么是分而治之?规模为N的问题,N<阈值,直接解决,N>阈值,将N分解为K个小规模子问题,子问题互相对立,与原问题形式相同,将子问题的解合并得到原问题的解. 具体使用中,需要向ForkJoinPool线程池提交一个ForkJoinTask任务.ForkJoinTask任务有两个重要

Java并发编程系列-(8) JMM和底层实现原理

8. JMM和底层实现原理 8.1 线程间的通信与同步 线程之间的通信 线程的通信是指线程之间以何种机制来交换信息.在编程中,线程之间的通信机制有两种,共享内存和消息传递. 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信. 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是wait()和notify(). 线程之间的同步

Java并发编程系列 concurrent包概览

从JDK 1.5开始,增加了java.util.concurrent包,concurrent包的引入大大简化了多线程程序的开发. 查看JDK的API可以发现,java.util.concurrent包分成了三个部分,分别是java.util.concurrent.java.util.concurrent.atomic和java.util.concurrent.lock. >>Atomic包 API中的说明是“A small toolkit of classes that support loc