极客时间已完结课程限时免费阅读

29 | 原子操作(上)

29 | 原子操作(上)-极客时间

29 | 原子操作(上)

讲述:黄洲君

时长08:32大小3.92M

我们在前两篇文章中讨论了互斥锁、读写锁以及基于它们的条件变量,先来总结一下。
互斥锁是一个很有用的同步工具,它可以保证每一时刻进入临界区的 goroutine 只有一个。读写锁对共享资源的写操作和读操作则区别看待,并消除了读操作之间的互斥。
条件变量主要是用于协调想要访问共享资源的那些线程。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程,它既可以基于互斥锁,也可以基于读写锁。当然了,读写锁也是一种互斥锁,前者是对后者的扩展。
通过对互斥锁的合理使用,我们可以使一个 goroutine 在执行临界区中的代码时,不被其他的 goroutine 打扰。不过,虽然不会被打扰,但是它仍然可能会被中断(interruption)。

前导内容:原子性执行与原子操作

我们已经知道,对于一个 Go 程序来说,Go 语言运行时系统中的调度器会恰当地安排其中所有的 goroutine 的运行。不过,在同一时刻,只可能有少数的 goroutine 真正地处于运行状态,并且这个数量只会与 M 的数量一致,而不会随着 G 的增多而增长。
所以,为了公平起见,调度器总是会频繁地换上或换下这些 goroutine。换上的意思是,让一个 goroutine 由非运行状态转为运行状态,并促使其中的代码在某个 CPU 核心上执行。
换下的意思正好相反,即:使一个 goroutine 中的代码中断执行,并让它由运行状态转为非运行状态。
这个中断的时机有很多,任何两条语句执行的间隙,甚至在某条语句执行的过程中都是可以的。
即使这些语句在临界区之内也是如此。所以,我们说,互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)。
在众多的同步工具中,真正能够保证原子性执行的只有原子操作(atomic operation)。原子操作在进行的过程中是不允许中断的。在底层,这会由 CPU 提供芯片级别的支持,所以绝对有效。即使在拥有多 CPU 核心,或者多 CPU 的计算机系统中,原子操作的保证也是不可撼动的。
这使得原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性。并且,它的执行速度要比其他的同步工具快得多,通常会高出好几个数量级。不过,它的缺点也很明显。
更具体地说,正是因为原子操作不能被中断,所以它需要足够简单,并且要求快速。
你可以想象一下,如果原子操作迟迟不能完成,而它又不会被中断,那么将会给计算机执行指令的效率带来多么大的影响。因此,操作系统层面只对针对二进制位或整数的原子操作提供了支持。
Go 语言的原子操作当然是基于 CPU 和操作系统的,所以它也只针对少数数据类型的值提供了原子操作函数。这些函数都存在于标准库代码包sync/atomic中。
我一般会通过下面这道题初探一下应聘者对sync/atomic包的熟悉程度。
我们今天的问题是:sync/atomic包中提供了几种原子操作?可操作的数据类型又有哪些?
这里的典型回答是:
sync/atomic包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称 CAS)、加载(load)、存储(store)和交换(swap)。
这些函数针对的数据类型并不多。但是,对这些类型中的每一个,sync/atomic包都会有一套函数给予支持。这些数据类型有:int32int64uint32uint64uintptr,以及unsafe包中的Pointer。不过,针对unsafe.Pointer类型,该包并未提供进行原子加法操作的函数。
此外,sync/atomic包还提供了一个名为Value的类型,它可以被用来存储任意类型的值。

问题解析

这个问题很简单,因为答案是明摆在代码包文档里的。不过如果你连文档都没看过,那也可能回答不上来,至少是无法做出全面的回答。
我一般会通过此问题再衍生出来几道题。下面我就来逐个说明一下。
第一个衍生问题 :我们都知道,传入这些原子操作函数的第一个参数值对应的都应该是那个被操作的值。比如,atomic.AddInt32函数的第一个参数,对应的一定是那个要被增大的整数。可是,这个参数的类型为什么不是int32而是*int32呢?
回答是:因为原子操作函数需要的是被操作值的指针,而不是这个值本身;被传入函数的参数值都会被复制,像这种基本类型的值一旦被传入函数,就已经与函数外的那个值毫无关系了。
所以,传入值本身没有任何意义。unsafe.Pointer类型虽然是指针类型,但是那些原子操作函数要操作的是这个指针值,而不是它指向的那个值,所以需要的仍然是指向这个指针值的指针。
只要原子操作函数拿到了被操作值的指针,就可以定位到存储该值的内存地址。只有这样,它们才能够通过底层的指令,准确地操作这个内存地址上的数据。
第二个衍生问题: 用于原子加法操作的函数可以做原子减法吗?比如,atomic.AddInt32函数可以用于减小那个被操作的整数值吗?
回答是:当然是可以的。atomic.AddInt32函数的第二个参数代表差量,它的类型是int32,是有符号的。如果我们想做原子减法,那么把这个差量设置为负整数就可以了。
对于atomic.AddInt64函数来说也是类似的。不过,要想用atomic.AddUint32atomic.AddUint64函数做原子减法,就不能这么直接了,因为它们的第二个参数的类型分别是uint32uint64,都是无符号的,不过,这也是可以做到的,就是稍微麻烦一些。
例如,如果想对uint32类型的被操作值18做原子减法,比如说差量是-3,那么我们可以先把这个差量转换为有符号的int32类型的值,然后再把该值的类型转换为uint32,用表达式来描述就是uint32(int32(-3))
不过要注意,直接这样写会使 Go 语言的编译器报错,它会告诉你:“常量-3不在uint32类型可表示的范围内”,换句话说,这样做会让表达式的结果值溢出。
不过,如果我们先把int32(-3)的结果值赋给变量delta,再把delta的值转换为uint32类型的值,就可以绕过编译器的检查并得到正确的结果了。
最后,我们把这个结果作为atomic.AddUint32函数的第二个参数值,就可以达到对uint32类型的值做原子减法的目的了。
还有一种更加直接的方式。我们可以依据下面这个表达式来给定atomic.AddUint32函数的第二个参数值:
^uint32(-N-1))
其中的N代表由负整数表示的差量。也就是说,我们先要把差量的绝对值减去1,然后再把得到的这个无类型的整数常量,转换为uint32类型的值,最后,在这个值之上做按位异或操作,就可以获得最终的参数值了。
这么做的原理也并不复杂。简单来说,此表达式的结果值的补码,与使用前一种方法得到的值的补码相同,所以这两种方式是等价的。我们都知道,整数在计算机中是以补码的形式存在的,所以在这里,结果值的补码相同就意味着表达式的等价。

总结

今天,我们一起学习了sync/atomic代码包中提供的原子操作函数和原子值类型。原子操作函数使用起来都非常简单,但也有一些细节需要我们注意。我在主问题的衍生问题中对它们进行了逐一说明。
在下一篇文章中,我们会继续分享原子操作的衍生内容。如果你对原子操作有什么样的问题,都可以给我留言,我们一起讨论,感谢你的收听,我们下期再见。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 16

提建议

上一篇
28 | 条件变量sync.Cond (下)
下一篇
30 | 原子操作(下)
unpreview
 写留言

精选留言(27)

  • hd167
    2020-01-10
    “原子操作在进行的过程中是不允许中断的。在底层,这会由 CPU 提供芯片级别的支持,所以绝对有效。即使在拥有多 CPU 核心,或者多 CPU 的计算机系统中,原子操作的保证也是不可撼动的。”这句话不知道该怎么理解?是不是如果一个原子操作在进行中,这台计算机的其他cpu核心都不能进行相关操作? 举个例子,有两个Int32的变量a,b,如果一个线程x要对a做原子加法操作,另一个线程y想要对b做原子交换操作,多核cpu的话,线程x,y应该可以在不同的物理核心上同时进行操作吧? 但是如果只有一个Int32的变量c,线程x要对c做原子加法操作,线程y要对c做原子交换操作,多核cpu的 话可,线程x和线程y还可以在不同的物理核心上同时进行吗?
    展开

    作者回复: 任何原子操作都是真对某一个共享资源而言的,即内存中的同一个小块区域以及相关的缓存。这个区域足够小,因此通常只用一个CPU指令就可以完成对它的读或写。 所以,这并不是说,一遇到原子操作,所有的CPU核心就都不工作了。你要理解“中断”这个词的真正含义。这里的细节还是很多的,涉及到cache line、多核CPU协调机制(几种方案)、LOCK原语(CPU不同方案不同)等等。一句两句说不太清楚,你可以自己去找资料看看。 总之,你可以理解为,针对不同共享资源的原子操作是有可能并行进行的。而针对相同共享资源的原子操作,即使存在多CPU核心,它们页不可能并行进行。

    共 5 条评论
    27
  • poettian
    2020-05-30
    看到这里有点不理解,CAS 是两个步骤:比较和交换,可以使其成为一个原子操作。那像 load、store 这些都是单次操作呀,比如:a = 1,这种操作本身不是原子性的吗?这种赋值 和 atomic.Load* 方法有什么区别呢?

    作者回复: CAS本身包含两个动作:比较和交换。如果变量的值与我们期望的值相等,那么就用新的值替换,否则就不换。这个过程会保证原子性。这也是唯一一个包含多个步骤的原子性操作。 即使像 a = 1 这种简单的赋值操作也并不一定能够一次完成。如果右边的值的存储宽度超出了计算机的字宽,那么实际的步骤就会多于一个(或者说底层指令多于一个)。比如,你计算机是32位的,但是你要把一个Int64类型的数赋给变量a,那么底层指令就肯定多于一个。在这种情况下,多个底层指令的执行期间是可以被打断的,也就是说CPU在这时可以被切换到别的任务上。如果新任务恰巧要读写这个变量a,那么就会出现值不完整的问题。况且,就算是 a = 1,操作系统和CPU也都不保证这个操作一定不会被打断。只要被打断,就很有可能出现并发访问上的问题,并发安全性也就被破坏了。 所以说,当有多个goroutine在并发的读写这个变量的时候,它们之间就可能会造成干扰。总之,这种操作不是原子性,并发安全性也无法得到保障。 atomic包下的函数会借助操作系统级别的原子指令保障操作的原子性,即使计算机的CPU有多个核心或者有多个CPU,也是如此。

    共 2 条评论
    21
  • ArtistLu
    2020-08-12
    老师 “转换为uint32类型的值,最后,在这个值之上做按位异或操作” 文中题的异或对吗?我查了下go里面^一个操作数表示取反。

    作者回复: 原文是对的,只不过有一段隐含的话没有写上。如下。 如果与一元操作 ^ 联结的唯一操作数的类型是无符号的整数类型,那么这一操作就相当于对这个操作数和其整数类型的最大值进行二元的按位异或操作。例如: ^uint8(1) = 254 00000001 ^ 11111111 = 11111110

    14
  • Richard
    2019-09-25
    "不过,在同一时刻,只可能有少数的 goroutine 真正地处于运行状态,并且这个数量只会与 M 的数量一致,而不会随着 G 的增多而增长。" 个人感觉同时运行的g应该是和p数量相等

    作者回复: 不一定,P 把 G 交给 M 就不管了,调度器发现还有 M 闲着就会继续从那些 P 那边拿 G。

    10
  • 授人以🐟,不如授人...
    2021-05-09
    真正运行的 goroutine 数量应该是和 CPU 核心数是相等,毕竟同一时刻正处在运行状态的 goroutine 必须是运行在真实的 CPU 核心上的。老师我这样理解,是正确的吗?

    作者回复: 首先你要明确,每个G(goroutine)都需要在某个M(系统线程)上运行,而每个M都需要在某个CPU核心中运行。所以: 1. 在同一时刻,Go调度器中的G有很多,但是正在运行的G的数量不会多于M数。如果当前所有的M都忙不过来了,那么Go调度器就会马上生成新的M。 2. 在某些时候,M的数量可能会超过CPU核心数。但是,每个CPU核心在同一时刻只能运行一个M。过多的M会加重CPU核心的负担。 下面正式回答你的问题: Goroutine不一定会与CPU核心数相等,这没有必要,也不容易控制。 Go语言调度模型中的P最好与CPU核心数相等。 因为一个P就代表着一条调度线。可以说,它会不断地把其队列中的G喂给M。反过来讲,每个M一旦饿了(处于空闲状态)就会向调度器要吃的(可运行的G)。 基于此,如果调度线太少,那么这个调度模型的效率就无法完全体现,一些CPU核心就会比较闲;但如果调度线太多,那调度器调度的可运行G又会导致很多M产生,而这些M又会多到CPU核心忙不过来(反而会影响效率)。 一个Goroutine在等待IO、等待计时器、等待锁的时候会处于等待状态。这个时候,当前的P就可以把这个goroutine与运行它的M分离了。之后,这个M就空闲了,就可以去寻找其他的G去运行了。 基于此,我们提交给Go调度器的goroutine可以很多,但是正在运行的goroutine会与M基本保持一致。这种平衡状态会由P的数量控制,所以说P在调度的过程中是至关重要的,会直接影响到Go调度器的效率。

    5
  • 鸠摩·智
    2020-09-01
    继续 poettian 的问题。a=1本身不是原子性的吗? @郝老师 你的回答里只是说了超出字宽的情况。那如果对没有超出字宽的情况,是不是对于多cpu之间的本地三级缓存同步,也是a=1不是原子性的一种case,我这样理解对吗?

    作者回复: 对的。这还连带着内存访问的问题。如果没有CPU级别的原子指令的保护,一个CPU核心根本就阻止不了其他CPU核心在同一期间访问同一个块内存或者同一块缓存。

    5
  • Richard
    2019-12-24
    “这个中断的时机有很多,任何两条语句执行的间隙,甚至在某条语句执行的过程中都是可以的。即使这些语句在临界区之内也是如此“ 我对这段话里的临界区内也能产生中断有些疑问,我查阅相关资料后理解为,CPU提供了一些原子操作机制,os或者语言api使用这些原子操作实现了锁,使用锁能保证更大范围的原子性,也就是说我理解的临界区也是一组不可中断的操作,也是具有原子性的;而且单核上进入临界区会关中断,离开临界区会开中断;所以郝大我这里是不是理解有误?
    展开

    作者回复: 临界区是指互斥锁保护的区域。锁只能保证操作上的互斥,或者说串行,但不能保证不被中断。“互斥”和“不中断”是两个不同的概念。锁保护的代码没有原子性一说,在执行的过程中是有可能被中断的,即可能会被撤下CPU,转而运行其他并发的代码。 互斥锁里面会用到原子操作,但那只是其中的一个或几个步骤而已。原子操作只能针对原始的内存地址来做,其中的一个原因是它对CPU的性能影响巨大。也正因为如此,原子操作根本无法顾及某段代码这么大颗粒度的东西。

    共 3 条评论
    4
  • 给力
    2020-03-29
    // AddUint32 atomically adds delta to *addr and returns the new value. // To subtract a signed positive constant value c from x, do AddUint32(&x, ^uint32(c-1)). // In particular, to decrement x, do AddUint32(&x, ^uint32(0)). func AddUint32(addr *uint32, delta uint32) (new uint32) 想要在无符号中减去有符号数。以上源码中提供了2种方式
    展开
    3
  • 涛声依旧
    2020-03-28
    原子操作虽然快,但使用场景有限;锁虽然使用麻烦,但使用场景较多;这是我对锁与原子操作的理解;

    作者回复: 是的,鱼和熊掌难以兼得。

    共 3 条评论
    3
  • 传说中的成大大
    2020-04-10
    如果我们先把int32(-3)的结果值赋给变量delta,再把delta的值转换为uint32类型的值,就可以绕过编译器的检查并得到正确的结果了。 我终于想明白了 是因为delta是个变量 类型是int32类型 所以编译器在编译的时候是不会报overflow的 但是如果写个字面量-3则是可以编译检查出来的
    共 1 条评论
    2
  • BOB
    2021-01-27
    郝老师您好,在前面讲基准测试的时候,说过go运行时最多同时调度 P 个goroutine,这一篇说同时运行的goroutine数目最大为M。。这两句话是否矛盾?

    作者回复: 调度和同时运行是两码事啊。当goroutine在M上运行并且需要暂时挂起时,P就不去管它了,转而去调度其他的可运行goroutine,等前面那个goroutine从等待或挂起状态恢复成可运行状态时,调度器再给它找P或者放入全局可运行G队列等待调度。 所以,在高并发场景,正在运行的goroutine数量,肯定比正在被调度的goroutine数量多啊。

    1
  • 曼巴
    2022-02-19
    原子操作的意义,老师的回答,摘录出来了。 即使像 a = 1 这种简单的赋值操作也并不一定能够一次完成。如果右边的值的存储宽度超出了计算机的字宽,那么实际的步骤就会多于一个(或者说底层指令多于一个)。比如,你计算机是32位的,但是你要把一个Int64类型的数赋给变量a,那么底层指令就肯定多于一个。在这种情况下,多个底层指令的执行期间是可以被打断的,也就是说CPU在这时可以被切换到别的任务上。如果新任务恰巧要读写这个变量a,那么就会出现值不完整的问题。况且,就算是 a = 1,操作系统和CPU也都不保证这个操作一定不会被打断。只要被打断,就很有可能出现并发访问上的问题,并发安全性也就被破坏了。
    展开

    作者回复: 棒 :b

  • 青山
    2021-12-28
    我有个问题既然add操作的是指针就表示对指针对应的值做操作为啥还要返回一个新值,直接获取操作的指针值就可以获得最新的值了,不需要让他重新返回一个新值把。

    作者回复: 程序再获取这个变量的值也得用原子操作啊(如果要保护共享变量,就应该实施全面的保护),这里直接返回来不就省的再做一次原子操作了嘛。

  • lesserror
    2021-08-17
    郝林老师,请问像demo63中的「forAndCAS1」函数 中的 go function 可以直接使用 外面的 变量 :num,又可以通过 传参的 形式 传递到 go function 中,例如 「forAndCAS2」函数。请问 这 两种 使用 方式 有什么区别 吗?

    作者回复: 对于基本类型的值来说,通过传参在其他函数里使用的话,就意味着使用的是它们的副本。因为无论是参数的传入还是结果的返回,那些值都会经历浅表复制。 相反,如果直接在其他函数里使用基本类型的外部变量,那么就等于在使用它们的本体。如果直接使用本体,就要考虑并发安全问题了。

  • AlexWillBeGood
    2021-03-29
    if newNum == 10 { break } newNum和10不一定相同
  • AlexWillBeGood
    2021-03-29
    自旋锁1例子里面的
  • 郭小菜
    2020-08-22
    异或是两个数的操作,应该表述为按位取反!

    作者回复: 还真不是按位取反,我前两天已经回答过一次了。

    共 2 条评论
  • ArtistLu
    2020-08-12
    老师 请问下一个操作数异或咋个理解?异或不是两个操作数吗?

    作者回复: 已经在你另一个问题下回复了。

  • Tom
    2020-07-28
    清晰的认识到了锁与原子操作的区别。很多时候之所以会认为互斥锁管理的临界区是原子性的,是因为它是串行,在没有释放锁等情况下只有持有锁的那个线程一直在执行,表现出原子现象。 举个例子,如果临界区中有多条状态更新组成的原子块,第一 条状态更新语句执行完之后被中断释放了锁,另一个线程来执行看到了这个状态原子性就被打破了。 cpu这种原子性是硬件层面的支持,一般需要配合自旋来完成
  • 亢(知行合一的路上)
    2020-06-04
    原子操作在多协程同时做计数时还是挺有用的。