go语言提供的原子操作都是非侵入式的,它们由标准库代码包sync/atomic中的众多函数代表。
我们调用sync/atomic中的几个函数可以对几种简单的类型进行原子操作。这些类型包括int32,int64,uint32,uint64,uintptr,unsafe.Pointer,共6个。这些函数的原子操作共有5种:增或减,比较并交换、载入、存储和交换它们提供了不同的功能,切使用的场景也有区别。
增或减
顾名思义,原子增或减即可实现对被操作值的增大或减少。因此该操作只能操作数值类型。
被用于进行增或减的原子操作都是以“Add”为前缀,并后面跟针对具体类型的名称。
//方法源码
func AddUint32(addr *uint32, delta uint32) (new uint32)
增
栗子:(在原来的基础上加n)
atomic.AddUint32(&addr,n)
减
栗子:(在原来的基础上加n(n为负数))
atomic.AddUint32(*addr,uint32(int32(n)))
//或
atomic.AddUint32(&addr,^uint32(-n-1))
比较并交换
比较并交换—-Compare And Swap 简称CAS
他是假设被操作的值未曾被改变(即与旧值相等),并一旦确定这个假设的真实性就立即进行值替换
如果想安全的并发一些类型的值,我们总是应该优先使用CAS
//方法源码
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
栗子:(如果addr和old相同,就用new代替addr)
ok:=atomic.CompareAndSwapInt32(&addr,old,new)
载入
如果一个写操作未完成,有一个读操作就已经发生了,这样读操作使很糟糕的。
为了原子的读取某个值sync/atomic代码包同样为我们提供了一系列的函数。这些函数都以”Load”为前缀,意为载入。
//方法源码
func LoadInt32(addr *int32) (val int32)
栗子
fun addValue(delta int32){
for{
v:=atomic.LoadInt32(&addr)
if atomic.CompareAndSwapInt32(&v,addr,(delta+v)){
break;
}
}
}
存储
与读操作对应的是写入操作,sync/atomic也提供了与原子的值载入函数相对应的原子的值存储函数。这些函数的名称均以“Store”为前缀
在原子的存储某个值的过程中,任何cpu都不会进行针对进行同一个值的读或写操作。如果我们把所有针对此值的写操作都改为原子操作,那么就不会出现针对此值的读操作读操作因被并发的进行而读到修改了一半的情况。
原子操作总会成功,因为他不必关心被操作值的旧值是什么。
//方法源码
func StoreInt32(addr *int32, val int32)
栗子
atomic.StoreInt32(被操作值的指针,新值)
atomic.StoreInt32(&value,newaddr)
交换
原子交换操作,这类函数的名称都以“Swap”为前缀。
与CAS不同,交换操作直接赋予新值,不管旧值。
会返回旧值
//方法源码
func SwapInt32(addr *int32, new int32) (old int32)
栗子
atomic.SwapInt32(被操作值的指针,新值)(返回旧值)
oldval:=atomic.StoreInt32(&value,newaddr)
CAS操作的优势是,可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作。
这可以大大的减少同步对程序性能的损耗。
当然,CAS操作也有劣势。在被操作值被频繁变更的情况下,CAS操作并不那么容易成功。
原子操作共有5种,即:增或减、比较并交换、载入、存储和交换
增或减
被用于进行增或减的原子操作(以下简称原子增/减操作)的函数名称都以“Add”为前缀,并后跟针对的具体类型的名称。
不过,由于atomic.AddUint32函数和atomic.AddUint64函数的第二个参数的类型分别是uint32和uint64,所以我们无法通过传递一个负的数值来减小被操作值。
atomic.AddUint32(&ui32, ^uint32(-NN-1)) 其中NN代表了一个负整数
比较并交换
func CompareAndSwapInt32(addr int32, old, new int32) (swapped bool)
第一个参数的值应该是指向被操作值的指针值。该值的类型即为int32。
后两个参数的类型都是int32类型。它们的值应该分别代表被操作值的旧值和新值
CompareAndSwapInt32函数在被调用之后会先判断参数addr指向的被操作值与参数old的值是否相等。
仅当此判断得到肯定的结果之后,该函数才会用参数new代表的新值替换掉原先的旧值。否则,后面的替换操作就会被忽略。
载入
v := atomic.LoadInt32(&value)
函数atomic.LoadInt32接受一个*int32类型的指针值,并会返回该指针值指向的那个值
有了“原子的”这个形容词就意味着,在这里读取value的值的同时,当前计算机中的任何CPU都不会进行其它的针对此值的读或写操作。
这样的约束是受到底层硬件的支持的。
存储
在原子的存储某个值的过程中,任何CPU都不会进行针对同一个值的读或写操作。
如果我们把所有针对此值的写操作都改为原子操作,那么就不会出现针对此值的读操作因被并发的进行而读到修改了一半的值的情况了。
原子的值存储操作总会成功,因为它并不会关心被操作值的旧值是什么。
函数atomic.StoreInt32会接受两个参数。第一个参数的类型是*int 32类型的,其含义同样是指向被操作值的指针。而第二个参数则是int32类型的,它的值应该代表欲存储的新值。其它的同类函数也会有类似的参数声明列表。
交换
与CAS操作不同,原子交换操作不会关心被操作值的旧值。它会直接设置新值。但它又比原子载入操作多做了一步。作为交换,它会返回被操作值的旧值。此类操作比CAS操作的约束更少,同时又比原子载入操作的功能更强。
以atomic.SwapInt32函数为例。它接受两个参数。第一个参数是代表了被操作值的内存地址的*int32类型值,而第二个参数则被用来表示新值。注意,该函数是有结果值的。该值即是被新值替换掉的旧值。atomic.SwapInt32函数被调用后,会把第二个参数值置于第一个参数值所表示的内存地址上(即修改被操作值),并将之前在该地址上的那个值作为结果返回。
例子:
df.rmutex.Lock()
defer df.rmutex.Unlock()
return df.roffset / int64(df.dataLen)
我们现在去掉施加在上面的锁定和解锁操作,转而使用原子操作来实现它。修改后的代码如下:
offset := atomic.LoadInt64(&df.roffset)
return offset / int64(df.dataLen)
用原子操作来替换mutex锁
其主要原因是,原子操作由底层硬件支持,而锁则由操作系统提供的API实现。若实现相同的功能,前者通常会更有效率。
原子操作即是进行过程中不能被中断的操作。也就是说,针对某个值的原子操作在被进行的过程当中,CPU绝不会再去进行其它的针对该值的操作。无论这些其它的操作是否为原子操作都会是这样。为了实现这样的严谨性,原子操作仅会由一个独立的CPU指令代表和完成。只有这样才能够在并发环境下保证原子操作的绝对安全。
Go语言提供的原子操作都是非侵入式的。它们由标准库代码包sync/atomic中的众多函数代表。我们可以通过调用这些函数对几种简单的类型的值进行原子操作。这些类型包括int32、int64、uint32、uint64、uintptr和unsafe.Pointer类型,共6个。这些函数提供的原子操作共有5种,即:增或减、比较并交换、载入、存储和交换。它们分别提供了不同的功能,且适用的场景也有所区别。下面,我们就根据这些种类对Go语言提供的原子操作进行逐一的讲解。
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类型值之间既不能被相加也不能被相减。
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。这也是由这三个参数的含义决定的。
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。
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类型的实现编写性能测试,以验证上述观点的正确性。
总之,我们要善用原子操作。因为它比锁更加简练和高效。不过,由于原子操作自身的限制,锁依然常用且重要。
关于atomic,并发编程的作者说很细很清楚,再可以看看下面两篇好文档::
Golang 1.3 sync.Mutex 源码解析
Golang 1.3 sync.Atomic源码解析
用原子操作来替换 mutex 锁其主要原因是:原子操作由底层硬件支持,而锁则由操作系统提供的 API 实现。若实现相同的功能,前者通常会更有效率
并发安全中的原子操作
对于并发操作而言,原子操作是个非常现实的问题。典型的就是 i++ 的问题。当两个 CPU 同时对内存中的i进行读取,然后把加一之后的值放入内存中,可能两次 i++ 的结果,这个 i 只增加了一次。
为了保证并发安全,除了使用临界区之外,还可以使用原子操作。顾名思义这类操作满足原子性,其执行过程不能被中断,这也就保证了同一时刻一个线程的执行不会被其他线程中断,也保证了多线程下数据操作的一致性。
原子操作即是进行过程中不能被中断的操作。也就是说,针对某个值的原子操作在被进行的过程当中,CPU 绝不会再去进行其它的针对该值的操作。无论这些其它的操作是否为原子操作都会是这样。为了实现这样的严谨性,原子操作仅会由一个独立的 CPU 指令代表和完成。只有这样才能够在并发环境下保证原子操作的绝对安全。
如果我们善用原子操作,它会比锁更为高效。
Go 中 sync/atomic 包的学习与使用
Go 语言提供的原子操作都是非入侵式的,由标准库 sync/atomic 中的众多函数代表
atomic 包提供了底层的原子级内存操作,类型共有六种:int32, int64, uint32, uint64, uintptr, unsafe.Pinter
对于每一种类型,提供了五类原子操作分别是:
Add, 增加和减少
CompareAndSwap, 比较并交换
Swap, 交换
Load, 读取
Store, 存储
增加或减少 Add
被操作的类型只能是数值类型 int32,int64,uint32,uint64,uintptr。
第一个参数值必须是一个指针类型的值,以便施加特殊的 CPU 指令。
第二个参数值的类型和第一个被操作值的类型总是相同的,传递一个正整数增加值,负整数减少值。
函数会直接在传递的地址上进行修改操作,此外函数会返回修改之后的新值。需要注意的是当你处理 unint32 和 unint64 时,由于 delta 参数类型被限定,不能直接传输负数,所以需要利用二进制补码机制,其中 N 为需要减少的正整数值。
var b uint32
b += 20
// atomic.Adduint32(&b, ^uint32(N-1))
atomic.AddUint32(&b, ^uint32(10-1)) // 等价于 b -= 10
fmt.Println(b == 10) // true
比较并交换 CAS
原子操作中最经典的 CAS 问题。
CAS 的意思是判断内存中的某个值是否等于 old 值,如果是的话,则赋 new 值给这块内存。CAS 是一个方法,并不局限在 CPU 原子操作中。 CAS 比互斥锁乐观,但是也就代表 CAS 是有赋值不成功的时候,调用 CAS 的那一方就需要处理赋值不成功的后续行为了,比如 用 for 循环不断进行尝试,直到成功为止。。
CAS 类似乐观锁,总是假设被操作值未曾被改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换。 而互斥锁是悲观锁总假设会有并发的操作要修改被操作的值,并使用锁将相关操作放入临界区中加以保护。
调用函数后,会先判断参数 addr 指向的被操作值与参数 old 的值是否相等
仅当此判断得到肯定的结果之后,才会用参数 new 代表的新值替换掉原先的旧值,否则操作就会被忽略。
var value int32
func main() {
fmt.Println(“======old value=======”)
fmt.Println(value)
fmt.Println(“======CAS value=======”)
addValue(3)
fmt.Println(value)
}
//不断地尝试原子地更新value的值,直到操作成功为止
func addValue(delta int32){
//在被操作值被频繁变更的情况下,CAS操作并不那么容易成功
//so 不得不利用for循环以进行多次尝试
for {
v := value
if atomic.CompareAndSwapInt32(&value, v, (v + delta)){
//在函数的结果值为true时,退出循环
break
}
//操作失败的缘由总会是value的旧值已不与v的值相等了.
//CAS操作虽然不会让某个Goroutine阻塞在某条语句上,但是仍可能会使流产的执行暂时停一下,不过时间大都极其短暂.
}
}
读取和写入 Load and Store
许多变量的读写无法在一个时钟周期内完成,而此时执行可能会被调度到其他线程,无法保证并发安全。
当我们要读取一个变量的时候,很有可能这个变量正在被写入,这时我们就很有可能读取到写到一半的数据,所以读取操作是需要一个原子行为的。如果有多个 CPU 往内存中一个数据块写入数据的时候,可能导致这个写入的数据不完整。
在原子地存储某个值的过程中,任何 CPU 都不会进行针对同一个值的读或写操作。
原子的值存储操作总会成功,因为它并不会关心被操作值的旧值是什么。
和 CAS 操作有着明显的区别。
交换 Swap
与 CAS 操作不同,原子交换操作不会关心被操作的旧值。
它会直接设置新值,并返回被操作值的旧值。
此类操作比 CAS 操作的约束更少,同时又比原子载入操作的功能更强。
atomic.Value
适用于读多写少并且变量占用内存不是特别大的情况,如果用内存存储大量数据,这个并不适合,技术上主要是常见的写时复制 copy-on-write。
我们已经知道,原子操作即是进行过程中不能被中断的操作。也就是说,针对某个值的原子操作在被进行的过程当中,CPU绝不会再去进行其它的针对该值的操作。无论这些其它的操作是否为原子操作都会是这样。为了实现这样的严谨性,原子操作仅会由一个独立的CPU指令代表和完成。只有这样才能够在并发环境下保证原子操作的绝对安全。
Go语言提供的原子操作都是非侵入式的。它们由标准库代码包sync/atomic中的众多函数代表。我们可以通过调用这些函数对几种简单的类型的值进行原子操作。这些类型包括int32、int64、uint32、uint64、uintptr和unsafe.Pointer类型,共6个。这些函数提供的原子操作共有5种,即:增或减、比较并交换、载入、存储和交换。它们分别提供了不同的功能,且适用的场景也有所区别。下面,我们就根据这些种类对Go语言提供的原子操作进行逐一的讲解。
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类型值之间既不能被相加也不能被相减。
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。这也是由这三个参数的含义决定的。
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。
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类型的实现编写性能测试,以验证上述观点的正确性。
总之,我们要善用原子操作。因为它比锁更加简练和高效。不过,由于原子操作自身的限制,锁依然常用且重要。
https://docs.kilvn.com/The-Golang-Standard-Library-by-Example/chapter16/16.02.html
https://www.kancloud.cn/digest/batu-go/153537