我们已经知道,原子操作即是进行过程中不能被中断的操作。也就是说,针对某个值的原子操作在被进行的过程当中,CPU绝不会再去进行其它的针对该值的操作。无论这些其它的操作是否为原子操作都会是这样。为了实现这样的严谨性,原子操作仅会由一个独立的CPU指令代表和完成。只有这样才能够在并发环境下保证原子操作的绝对安全。Go语言提供的原子操作都是非侵入式的。它们由标准库代码包sync/atomic中的众多函数代表。我们可以通过调用这些函数对几种简单的类型的值进行原子操作。这些类型包括int32、int64、uint32、uint64、uintptr和unsafe.Pointer类型,共6个。这些函数提供的原子操作共有5种,即:增或减、比较并交换、载入、存储和交换。它们分别提供了不同的功能,且适用的场景也有所区别。下面,我们就根据这些种类对Go语言提供的原子操作进行逐一的讲解。
1. 增或减
1 | newi32 := atomic.AddInt32(&i32, 3 ) |
我们将指向i32变量的值的指针值和代表增减的差值3作为参数传递给了atomic.AddInt32函数。之所以要求第一个参数值必须是一个指针类型的值,是因为该函数需要获得到被操作值在内存中的存放位置,以便施加特殊的CPU指令。从另一个角度看,对于一个不能被取址的数值,我们是无法进行原子操作的。此外,这类函数的第二个参数的类型被操作值的类型总是相同的。因此,在前面那个调用表达式被求值的时候,字面量3会被自动转换为一个int32类型的值。函数atomic.AddInt32在被执行结束之时会返回经过原子操作后的新值。不过不要误会,我们无需把这个新值再赋给原先的变量i32。因为它的值已经在atomic.AddInt32函数返回之前被原子的修改了。
2 | atomic.AddInt64(&i64, - 3 ) |
不过,由于atomic.AddUint32函数和atomic.AddUint64函数的第二个参数的类型分别是uint32和uint64,所以我们无法通过传递一个负的数值来减小被操作值。那么,这是不是就意味着我们无法原子的减小uint32或uint64类型的值了呢?幸好,不是这样。Go语言为我们提供了一个可以迂回的达到此目的办法。
1 | atomic.AddUint32(&ui32, ^uint32(-NN- 1 )) |
对于uint64类型的值来说也是这样。调用表达式
1 | atomic.AddUint64(&ui64, ^uint64(-NN- 1 )) |
表示原子的把uint64类型的变量ui64的值增加NN(或者说减小-NN)。
和
的结果值就都会是11111111111111111111111111011101。由此,我们使用^uint32(-NN-1)和^uint64(-NN-1)来分别表示uint32类型和uint64类型的NN就顺理成章了。这样,我们就可以合理的绕过uint32类型和uint64类型对值的限制了。
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:
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)表达式的结果值也是一致的。
1 | func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool) |
可以看到,CompareAndSwapInt32函数接受三个参数。第一个参数的值应该是指向被操作值的指针值。该值的类型即为*int32。后两个参数的类型都是int32类型。它们的值应该分别代表被操作值的旧值和新值。CompareAndSwapInt32函数在被调用之后会先判断参数addr指向的被操作值与参数old的值是否相等。仅当此判断得到肯定的结果之后,该函数才会用参数new代表的新值替换掉原先的旧值。否则,后面的替换操作就会被忽略。这正是“比较并交换”这个短语的由来。CompareAndSwapInt32函数的结果swapped被用来表示是否进行了值的替换操作。
2 | func addValue(delta int32) { |
5 | if atomic.CompareAndSwapInt32(&value, v, (v + delta)) { |
可以看到,为了保证CAS操作的成功完成,我们仅在CompareAndSwapInt32函数的结果值为true时才会退出循环。这种做法与自旋锁的自旋行为相似。addValue函数会不断的尝试原子的更新value的值,直到这一操作成功为止。操作失败的缘由总会是value的旧值已不与v的值相等了。如果value的值会被并发的修改的话,那么发生这种情况是很正常的。
1 | func addValue(delta int32) { |
3 | v := atomic.LoadInt32(&value) |
4 | if atomic.CompareAndSwapInt32(&value, v, (v + delta)) { |
函数atomic.LoadInt32接受一个*int32类型的指针值,并会返回该指针值指向的那个值。在该示例中,我们使用调用表达式atomic.LoadInt32(&value)替换掉了标识符value。替换后,那条赋值语句的含义就变为:原子的读取变量value的值并把它赋给变量v。有了“原子的”这个形容词就意味着,在这里读取value的值的同时,当前计算机中的任何CPU都不会进行其它的针对此值的读或写操作。这样的约束是受到底层硬件的支持的。
5 | df.roffset += int64(df.dataLen) |
这段代码的含义是读取读偏移量的值并把它存入到局部变量中,然后增加读偏移量的值以使其它的并发的读操作能够被正确、有效的进行。为了使程序能够在并发环境下有序的对roffset字段进行操作,我们为这段代码应用了互斥锁rmutex。
5 | if atomic.CompareAndSwapInt64(&df.roffset, offset, |
6 | (offset + int64(df.dataLen))) { |
根据roffset和offset的类型,我们选用atomic.CompareAndSwapInt64来进行CAS操作。我们在调用该函数的时候传入了三个参数,分别代表了被操作值的地址、旧值和新值。如果该函数的结果值是true,那么我们就退出for循环。这时,变量offset即是我们需要的读偏移量的值。另一方面,如果该函数的结果值是false,那么就说明在从完成读取到开始更新roffset字段的值的期间内有其它的并发操作对该值进行了更改。当遇到这种情况,我们就需要再次尝试。只要尝试失败,我们就会重新读取roffset字段的值并试图对该值进行CAS操作,直到成功为止。具体的尝试次数与具体的并发环境有关。
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方法。
=====================================================
感谢 Coding 对本微信的支持。Coding.net 是一个面向开发者的云端开发平台,目前提供代码托管、运行空间、质量控制、项目管理等功能。