GO安全并发之无锁原子操作

 

声明:本文是《Go并发编程实战》的样章,禁止以任何形式转载此文。

摘要:

  我们已经知道,原子操作即是进行过程中不能被中断的操作。也就是说,针对某个值的原子操作在被进行的过程当中,CPU绝不会再去进行其它的针对该值的操作。无论这些其它的操作是否为原子操作都会是这样。为了实现这样的严谨性,原子操作仅会由一个独立的CPU指令代表和完成。只有这样才能够在并发环境下保证原子操作的绝对安全。
Go语言提供的原子操作都是非侵入式的。它们由标准库代码包sync/atomic中的众多函数代表。我们可以通过调用这些函数对几种简单的类型的值进行原子操作。这些类型包括int32、int64、uint32、uint64、uintptr和unsafe.Pointer类型,共6个。这些函数提供的原子操作共有5种,即:增或减、比较并交换、载入、存储和交换。它们分别提供了不同的功能,且适用的场景也有所区别。下面,我们就根据这些种类对Go语言提供的原子操作进行逐一的讲解。

 1. 增或减
  被用于进行增或减的原子操作(以下简称原子增/减操作)的函数名称都以“Add”为前缀,并后跟针对的具体类型的名称。例如,实现针对uint32类型的原子增/减操作的函数的名称为AddUint32。事实上,sync/atomic包中的所有函数的命名都遵循此规则。
顾名思义,原子增/减操作即可实现对被操作值的增大或减小。因此,被操作值的类型只能是数值类型。更具体的讲,它只能是我们在前面提到的int32、int64、uint32、uint64和uintptr类型。例如,我们如果想原子的把一个int32类型的变量i32的值增大3的话,可以这样做:

1 newi32 := atomic.AddInt32(&i32, 3)

  我们将指向i32变量的值的指针值和代表增减的差值3作为参数传递给了atomic.AddInt32函数。之所以要求第一个参数值必须是一个指针类型的值,是因为该函数需要获得到被操作值在内存中的存放位置,以便施加特殊的CPU指令。从另一个角度看,对于一个不能被取址的数值,我们是无法进行原子操作的。此外,这类函数的第二个参数的类型被操作值的类型总是相同的。因此,在前面那个调用表达式被求值的时候,字面量3会被自动转换为一个int32类型的值。函数atomic.AddInt32在被执行结束之时会返回经过原子操作后的新值。不过不要误会,我们无需把这个新值再赋给原先的变量i32。因为它的值已经在atomic.AddInt32函数返回之前被原子的修改了。
与该函数类似的还有atomic.AddInt64函数、atomic.AddUint32函数、atomic.AddUint64函数和atomic.AddUintptr函数。这些函数也可以被用来原子的增/减对应类型的值。例如,如果我们要原子的将int64类型的变量i64的值减小3话,可以这样编写代码:

1 var i64 int64
2 atomic.AddInt64(&i64, -3)

  不过,由于atomic.AddUint32函数和atomic.AddUint64函数的第二个参数的类型分别是uint32和uint64,所以我们无法通过传递一个负的数值来减小被操作值。那么,这是不是就意味着我们无法原子的减小uint32或uint64类型的值了呢?幸好,不是这样。Go语言为我们提供了一个可以迂回的达到此目的办法。
如果我们想原子的把uint32类型的变量ui32的值增加NN(NN代表了一个负整数),那么我们可以这样调用atomic.AddUint32函数:

1 atomic.AddUint32(&ui32, ^uint32(-NN-1))

  对于uint64类型的值来说也是这样。调用表达式

1 atomic.AddUint64(&ui64, ^uint64(-NN-1))

  表示原子的把uint64类型的变量ui64的值增加NN(或者说减小-NN)。
  之所以这种方式可以奏效,是因为它利用了二进制补码的特性。我们知道,一个负整数的补码可以通过对它按位(除了符号位之外)求反码并加一得到。我们还知道,一个负整数可以由对它的绝对值减一并求补码后得到的数值的二进制表示来代表。例如,如果NN是一个int类型的变量且其值为-35,那么表达式

1 uint32(int32(NN))

1 ^uint32(-NN-1)

的结果值就都会是11111111111111111111111111011101。由此,我们使用^uint32(-NN-1)和^uint64(-NN-1)来分别表示uint32类型和uint64类型的NN就顺理成章了。这样,我们就可以合理的绕过uint32类型和uint64类型对值的限制了。
以上是官方提供一种通用解决方案。除此之外,我们还有两个非通用的方案可供选择。首先,需要明确的是,对于一个代表负数的字面常量来说,它们是无法通过简单的类型转换将其转换为uint32类型或uint64类型的值的。例如,表达式uint32(-35)和uint64(-35)都是不合法的。它们都不能通过编译。但是,如果我们事先把这个字面量赋给一个变量然后再对这个变量进行类型转换,那么就可以得到Go语言编译器的认可。我们依然以值为-35的变量NN为例,下面这条语句可以通过编译并被正常执行:

1 fmt.Printf("The variable: %b.\n", uint32(NN))

其输出内容为:

1 The variable: 11111111111111111111111111011101.

可以看到,表达式uint32(NN)的结果值的二进制表示与前面的uint32(int32(NN))表达式以及^uint32(-NN-1)表达式的结果值是一致的。它们都可以被用来表示uint32类型的-35。因此,我们也可以使用下面的调用表达式来原子的把变量ui32的值减小-NN:
atomic.AddUint32(&ui32, uint32(NN))
不过,这样的编写方式仅在NN是数值类型的变量的时候才可以通过编译。如果NN是一个常量,那么也会使表达式uint32(NN)不合法并无法通过编译。它与表达式uint32(-35)造成的编译错误是一致的。在这种情况下,我们可以这样来达到上述目的:

1 atomic.AddUint32(&ui32, NN&math.MaxUint32)

其中,我们用到了标准库代码包math中的常量MaxUint32。math.MaxUint32常量表示的是一个32位的、所有二进制位上均为1的数值。我们把NN和math.MaxUint32进行按位与操作的意义是使前者的值能够被视为一个uint32类型的数值。实际上,对于表达式NN&math.MaxUint32来说,其结果值的二进制表示与前面uint32(int32(NN))表达式以及^uint32(-NN-1)表达式的结果值也是一致的。
我们在这里介绍的这两种非官方的解决方案是不能混用的。更具体地说,如果NN是一个常量,那么表达式uint32(NN)是无法通过编译的。而如果NN是一个变量,那么表达式NN&math.MaxUint32就无法通过编译。前者的错误在于代表负整数的字面常量不能被转换为uint32类型的值。后者的错误在于这个按位与运算的结果值的类型不是uint32类型而是int类型,从而导致数据溢出的错误。相比之下,官方给出的那个解决方案的适用范围更广。
有些读者可能会有这样的疑问:为什么如此曲折的实现这一功能?直接声明出atomic.SubUint32()函数和atomic.SubUint64()函数不好吗?作者理解,不这样做是为了让这些原子操作的API可以整齐划一,并且避免在扩充它们的时候使sync/atomic包中声明的程序实体成倍增加。(作者向Go语言官方提出了这个问题并引发了一些讨论,他们也许会使用投票的方式来选取更好一些的方案)
注意,并不存在名为atomic.AddPointer的函数,因为unsafe.Pointer类型值之间既不能被相加也不能被相减。
2. 比较并交换
有些读者可能很熟悉比较并交换操作的英文称谓——Compare And Swap,简称CAS。在sync/atomic包中,这类原子操作由名称以“CompareAndSwap”为前缀的若干个函数代表。
我们依然以针对int32类型值的函数为例。该函数名为CompareAndSwapInt32。其声明如下:

1 func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)

可以看到,CompareAndSwapInt32函数接受三个参数。第一个参数的值应该是指向被操作值的指针值。该值的类型即为*int32。后两个参数的类型都是int32类型。它们的值应该分别代表被操作值的旧值和新值。CompareAndSwapInt32函数在被调用之后会先判断参数addr指向的被操作值与参数old的值是否相等。仅当此判断得到肯定的结果之后,该函数才会用参数new代表的新值替换掉原先的旧值。否则,后面的替换操作就会被忽略。这正是“比较并交换”这个短语的由来。CompareAndSwapInt32函数的结果swapped被用来表示是否进行了值的替换操作。
与我们前面讲到的锁相比,CAS操作有明显的不同。它总是假设被操作值未曾被改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换。而使用锁则是更加谨慎的做法。我们总是先假设会有并发的操作要修改被操作值,并使用锁将相关操作放入临界区中加以保护。我们可以说,使用锁的做法趋于悲观,而CAS操作的做法则更加乐观。
CAS操作的优势是,可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作。这可以大大的减少同步对程序性能的损耗。当然,CAS操作也有劣势。在被操作值被频繁变更的情况下,CAS操作并不那么容易成功。有些时候,我们可能不得不利用for循环以进行多次尝试。示例如下:

1 var value int32
2 func addValue(delta int32) {
3 for {
4 v := value
5 if atomic.CompareAndSwapInt32(&value, v, (v + delta)) {
6 break
7 }
8 }
9 }

可以看到,为了保证CAS操作的成功完成,我们仅在CompareAndSwapInt32函数的结果值为true时才会退出循环。这种做法与自旋锁的自旋行为相似。addValue函数会不断的尝试原子的更新value的值,直到这一操作成功为止。操作失败的缘由总会是value的旧值已不与v的值相等了。如果value的值会被并发的修改的话,那么发生这种情况是很正常的。
CAS操作虽然不会让某个Goroutine阻塞在某条语句上,但是仍可能会使流程的执行暂时停滞。不过,这种停滞的时间大都极其短暂。
请记住,当想并发安全的更新一些类型(更具体的讲是,前文所述的那6个类型)的值的时候,我们总是应该优先选择CAS操作。
与此对应,被用来进行原子的CAS操作的函数共有6个。除了我们已经讲过的CompareAndSwapInt32函数之外,还有CompareAndSwapInt64、CompareAndSwapPointer、CompareAndSwapUint32、CompareAndSwapUint64 和CompareAndSwapUintptr函数。这些函数的结果声明列表与CompareAndSwapInt32函数的完全一致。而它们的参数声明列表与后者也非常类似。虽然其中的那三个参数的类型不同,但其遵循的规则是一致的,即:第二个和第三个参数的类型均为与第一个参数的类型(即某个指针类型)紧密相关的那个类型。例如,如果第一个参数的类型为*unsafe.Pointer,那么后两个参数的类型就一定是unsafe.Pointer。这也是由这三个参数的含义决定的。
3. 载入
在前面示例的for循环中,我们使用语句v := value为变量v赋值。但是,要注意,其中的读取value的值的操作并不是并发安全的。在该读取操作被进行的过程中,其它的对此值的读写操作是可以被同时进行的。它们并不会受到任何限制。
在第7章的第1节的最后,我们举过这样一个例子:在32位计算架构的计算机上写入一个64位的整数。如果在这个写操作未完成的时候有一个读操作被并发的进行了,那么这个读操作很可能会读取到一个只被修改了一半的数据。这种结果是相当糟糕的。
为了原子的读取某个值,sync/atomic代码包同样为我们提供了一系列的函数。这些函数的名称都以“Load”为前缀,意为载入。我们依然以针对int32类型值的那个函数为例。
我们下面利用LoadInt32函数对上一个示例稍作修改:

1 func addValue(delta int32) {
2 for {
3 v := atomic.LoadInt32(&value)
4 if atomic.CompareAndSwapInt32(&value, v, (v + delta)) {
5 break
6 }
7 }
8 }

函数atomic.LoadInt32接受一个*int32类型的指针值,并会返回该指针值指向的那个值。在该示例中,我们使用调用表达式atomic.LoadInt32(&value)替换掉了标识符value。替换后,那条赋值语句的含义就变为:原子的读取变量value的值并把它赋给变量v。有了“原子的”这个形容词就意味着,在这里读取value的值的同时,当前计算机中的任何CPU都不会进行其它的针对此值的读或写操作。这样的约束是受到底层硬件的支持的。
注意,虽然我们在这里使用atomic.LoadInt32函数原子的载入value的值,但是其后面的CAS操作仍然是有必要的。因为,那条赋值语句和if语句并不会被原子的执行。在它们被执行期间,CPU仍然可能进行其它的针对value的值的读或写操作。也就是说,value的值仍然有可能被并发的改变。
与atomic.LoadInt32函数的功能类似的函数有atomic.LoadInt64、atomic.LoadPointer、atomic.LoadUint32、atomic.LoadUint64和atomic.LoadUintptr。
4. 存储
与读取操作相对应的是写入操作。而sync/atomic包也提供了与原子的值载入函数相对应的原子的值存储函数。这些函数的名称均以“Store”为前缀。
在原子的存储某个值的过程中,任何CPU都不会进行针对同一个值的读或写操作。如果我们把所有针对此值的写操作都改为原子操作,那么就不会出现针对此值的读操作因被并发的进行而读到修改了一半的值的情况了。
原子的值存储操作总会成功,因为它并不会关心被操作值的旧值是什么。显然,这与前面讲到的CAS操作是有着明显的区别的。因此,我们并不能把前面展示的addValue函数中的调用atomic.CompareAndSwapInt32函数的表达式替换为对atomic.StoreInt32函数的调用表达式。
函数atomic.StoreInt32会接受两个参数。第一个参数的类型是*int 32类型的,其含义同样是指向被操作值的指针。而第二个参数则是int32类型的,它的值应该代表欲存储的新值。其它的同类函数也会有类似的参数声明列表。
5. 交换
在sync/atomic代码包中还存在着一类函数。它们的功能与前文所讲的CAS操作和原子载入操作都有些类似。这样的功能可以被称为原子交换操作。这类函数的名称都以“Swap”为前缀。
与CAS操作不同,原子交换操作不会关心被操作值的旧值。它会直接设置新值。但它又比原子载入操作多做了一步。作为交换,它会返回被操作值的旧值。此类操作比CAS操作的约束更少,同时又比原子载入操作的功能更强。
以atomic.SwapInt32函数为例。它接受两个参数。第一个参数是代表了被操作值的内存地址的*int32类型值,而第二个参数则被用来表示新值。注意,该函数是有结果值的。该值即是被新值替换掉的旧值。atomic.SwapInt32函数被调用后,会把第二个参数值置于第一个参数值所表示的内存地址上(即修改被操作值),并将之前在该地址上的那个值作为结果返回。其它的同类函数的声明和作用都与此类似。
至此,我们快速且简要地介绍了sync/atomic代码包中的所有函数的功能和用法。这些函数都被用来对特定类型的值进行原子性的操作。如果我们想以并发安全的方式操作单一的特定类型(int32、int64、uint32、uint64、uintptr或unsafe.Pointer)的值的话,应该首先考虑使用这些函数来实现。请注意,原子的减小一些特定类型(确切地说,是uint32类型和uint64类型)的值的实现方式并不那么直观。在Go语言官方对此进行改进之前,我们应该按照他们为我们提供的那种方式来进行此类操作。
6. 应用于实际
下面,我们就使用刚刚介绍的知识再次对在前面示例中创建的*myDataFile类型进行改造。在*myDataFile类型的第二个版本中,我们仍然使用两个互斥锁来对与roffset字段和woffset字段相关的操作进行保护。*myDataFile类型的方法中的绝大多数都包含了这些操作。
首先,我们来看对roffset字段的操作。在*myDataFile类型的Read方法中有这样一段代码:

1 // 读取并更新读偏移量
2 var offset int64
3 df.rmutex.Lock()
4 offset = df.roffset
5 df.roffset += int64(df.dataLen)
6 df.rmutex.Unlock()

这段代码的含义是读取读偏移量的值并把它存入到局部变量中,然后增加读偏移量的值以使其它的并发的读操作能够被正确、有效的进行。为了使程序能够在并发环境下有序的对roffset字段进行操作,我们为这段代码应用了互斥锁rmutex。
字段roffset和变量offset都是int64类型的。后者代表了前者的旧值。而字段roffset的新值即为其旧值与dataLen字段的值的和。实际上,这正是原子的CAS操作的适用场景。我们现在用CAS操作来实现该段代码的功能:

1 // 读取并更新读偏移量
2 var offset int64
3 for {
4 offset = df.roffset
5 if atomic.CompareAndSwapInt64(&df.roffset, offset,
6 (offset + int64(df.dataLen))) {
7 break
8 }
9 }

根据roffset和offset的类型,我们选用atomic.CompareAndSwapInt64来进行CAS操作。我们在调用该函数的时候传入了三个参数,分别代表了被操作值的地址、旧值和新值。如果该函数的结果值是true,那么我们就退出for循环。这时,变量offset即是我们需要的读偏移量的值。另一方面,如果该函数的结果值是false,那么就说明在从完成读取到开始更新roffset字段的值的期间内有其它的并发操作对该值进行了更改。当遇到这种情况,我们就需要再次尝试。只要尝试失败,我们就会重新读取roffset字段的值并试图对该值进行CAS操作,直到成功为止。具体的尝试次数与具体的并发环境有关。
我们在前面说过,在32位计算架构的计算机上写入一个64位的整数也会存在并发安全方面的隐患。因此,我们还应该将这段代码中的offset = df.roffset语句修改为offset = atomic.LoadInt64(&df.roffset)。
除了这里,在*myDataFile类型的Rsn方法中也有针对roffset字段的读操作:

1 df.rmutex.Lock()
2 defer df.rmutex.Unlock()
3 return df.roffset / int64(df.dataLen)

我们现在去掉施加在上面的锁定和解锁操作,转而使用原子操作来实现它。修改后的代码如下:

1 offset := atomic.LoadInt64(&df.roffset)
2 return offset / int64(df.dataLen)

这样,我们就在依然保证相关操作的并发安全的前提下去除了对互斥锁rmutex的使用。对于字段woffset和互斥锁wmutex,我们也应该如法炮制。读者可以试着按照上面的方法修改与之相关的Write方法和Wsn方法。
在修改完成之后,我们就可以把代表互斥锁的rmutex字段和wmutex字段从*myDataFile类型的基本结构中去掉了。这样,该类型的基本结构会显得精简了不少。
通过本次改造,我们减少了*myDataFile类型及其方法对互斥锁的使用。这对该程度的性能和可伸缩性都会有一定的提升。其主要原因是,原子操作由底层硬件支持,而锁则由操作系统提供的API实现。若实现相同的功能,前者通常会更有效率。读者可以为前面展示的这三个版本的*myDataFile类型的实现编写性能测试,以验证上述观点的正确性。
总之,我们要善用原子操作。因为它比锁更加简练和高效。不过,由于原子操作自身的限制,锁依然常用且重要。

 
  另外,可以参考 非阻塞同步算法与CAS(Compare and Swap)无锁算法(java内幕原理),加深理解 。
posted @ 2017-08-30 15:29  sunsky303  阅读(7395)  评论(0编辑  收藏  举报
显示搜索