当前位置:网站首页>golang中的atomic,以及CAS操作

golang中的atomic,以及CAS操作

2022-07-06 17:22:00 raoxiaoya

CAS无锁算法

要实现无锁(lock-free)的非阻塞算法有多种实现方法,其中CAS(比较与交换,Compare and swap)是一种有名的无锁算法。CAS是CPU指令,在大多数处理器架构,包括 IA32、Space中采用的都是CAS指令,CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

CAS在golang的实现

for {
    
    if atomic.CompareAndSwapInt64(&data, old, new) {
    
        return new
    }
}

CompareAndSwap会先进行比较,如果data的值等于old,那么就会执行替换操作并返回true,如果不等于,则说明已经被其他线程操作了就返回false,所以它并不一定总能成功,尤其是在并发大的情况下,所以使用for循环来自旋。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。

通常使用的i++操作,其过程是先从内存读取出i,然后加1,然后赋值给内存,很显然这个过程并不是原子的,我们可以使用CAS来实现原子化自增操作。

func AddInt64(addr *int64, inc int64) int64 {
    
	for {
    
		old := *addr
		if atomic.CompareAndSwapInt64(addr, old, old+inc) {
    
			return old
		}
	}
}

所以,基于CAS,我们可以对一个内存地址上的值先比较再赋值,保证了原子性。

atomic一般是由汇编语言实现的,比如文件src/runtime/internal/atomic/atomic_386.s

CAS指令

知道了CAS是个什么意思之后,接下来就要去探究CAS的实现原理,以及CAS的比较+赋值过程是如何做到原子性的。

CAS自身的原子性是由CPU指令来实现的,我没有找到具体代码,下面是网上的代码。

// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line // so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
                       __asm je L0      \
                       __asm _emit 0xF0 \
                       __asm L0:
  
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
    
  // alternative for InterlockedCompareExchange
  int mp = os::is_MP();
  __asm {
    
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }
}

程序会根据当前处理器的类型来决定是否为 cmpxchg指令 添加 lock前缀。如果程序是在多处理器上运行,就为 cmpxchg指令 加上 lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略 lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。

intel的手册对lock前缀的说明如下

  1. 确保对内存的 读-改-写 操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
  2. 禁止该指令与之前和之后的读和写指令重排序。
  3. 把写缓冲区中的所有数据刷新到内存中。

关于CPU的锁有如下2种

1. 使用总线锁保证原子性

第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写(i++就是经典的读改写操作)操作,那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。原因是有可能多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存当中。那么想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。

处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。

2. 使用缓存锁保证原子性

第二个机制是通过缓存锁定保证原子性。在同一时刻我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,最近的处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

频繁使用的内存会缓存在处理器的L1,L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在奔腾6和最近的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”就是如果缓存在处理器缓存行中内存区域在LOCK操作期间被锁定,当它执行锁操作回写内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时会起缓存行无效。

但是有两种情况下处理器不会使用缓存锁定。第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line),则处理器会调用总线锁定。第二种情况是:有些处理器不支持缓存锁定。对于Inter486和奔腾处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

以上两个机制我们可以通过Inter处理器提供了很多LOCK前缀的指令来实现。比如位测试和修改指令BTS,BTR,BTC,交换指令XADD,CMPXCHG和其他一些操作数和逻辑指令,比如ADD(加),OR(或)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。

锁(lock)的代价

锁是用来做并发最简单的方式,当然其代价也是最高的。内核态的锁的时候需要操作系统进行一次上下文切换,加锁、释放锁会导致比较多的上下文切换和调度延时,等待锁的线程会被挂起直至锁释放。在上下文切换的时候,cpu之前缓存的指令和数据都将失效,对性能有很大的损失。操作系统对多线程的锁进行判断就像两姐妹在为一个玩具在争吵,然后操作系统就是能决定他们谁能拿到玩具的父母,这是很慢的。用户态的锁虽然避免了这些问题,但是其实它们只是在没有真实的竞争时才有效。

CAS缺点

1、 CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。

ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。

2、比较花费CPU资源,即使没有任何争用也会做一些无用功。

3、会增加程序测试的复杂度,稍不注意就会出现问题。

应用

CAS操作的优势是,可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作。
这可以大大的减少同步对程序性能的损耗。

当然,CAS操作也有劣势。在被操作值被频繁变更的情况下,CAS操作并不那么容易成功。

1. 增或减
被用于进行增或减的原子操作(以下简称原子增/减操作)的函数名称都以“Add”为前缀,并后跟针对的具体类型的名称。
不过,由于atomic.AddUint32函数和atomic.AddUint64函数的第二个参数的类型分别是uint32uint64,所以我们无法通过传递一个负的数值来减小被操作值。atomic.AddUint32(&uint32, ^uint32(-NN-1)) 其中NN代表了一个负整数。

2. 比较并交换
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
第一个参数的值应该是指向被操作值的指针值。该值的类型即为*int32。后两个参数的类型都是int32类型。它们的值应该分别代表被操作值的旧值和新值。CompareAndSwapInt32函数在被调用之后会先判断参数addr指向的被操作值与参数old的值是否相等。仅当此判断得到肯定的结果之后,该函数才会用参数new代表的新值替换掉原先的旧值。否则,后面的替换操作就会被忽略。

3. 载入
v := atomic.LoadInt32(&value)
接受一个*int32类型的指针值,并会返回该指针值指向的那个值。 有了“原子的”这个形容词就意味着,在这里读取value的值的同时,当前计算机中的任何CPU都不会进行其它的针对此值的读或写操作。这样的约束是受到底层硬件的支持的。

4. 存储
在原子的存储某个值的过程中,任何CPU都不会进行针对同一个值的读或写操作。如果我们把所有针对此值的写操作都改为原子操作,那么就不会出现针对此值的读操作因被并发的进行而读到修改了一半的值的情况了。原子的值存储操作总会成功,因为它并不会关心被操作值的旧值是什么。函数atomic.StoreInt32会接受两个参数。第一个参数的类型是*int 32类型的,其含义同样是指向被操作值的指针。而第二个参数则是int32类型的,它的值应该代表欲存储的新值。其它的同类函数也会有类似的参数声明列表。

5. 交换
与CAS操作不同,原子交换操作不会关心被操作值的旧值。它会直接设置新值。但它又比原子载入操作多做了一步。作为交换,它会返回被操作值的旧值。此类操作比CAS操作的约束更少,同时又比原子载入操作的功能更强。以atomic.SwapInt32函数为例。它接受两个参数。第一个参数是代表了被操作值的内存地址的*int32类型值,而第二个参数则被用来表示新值。注意,该函数是有结果值的。该值即是被新值替换掉的旧值。atomic.SwapInt32函数被调用后,会把第二个参数值置于第一个参数值所表示的内存地址上(即修改被操作值),并将之前在该地址上的那个值作为结果返回。

用原子操作来替换mutex锁

其主要原因是,原子操作由底层硬件支持,而锁则由操作系统提供的API实现。若实现相同的功能,前者通常会更有效率。

原网站

版权声明
本文为[raoxiaoya]所创,转载请带上原文链接,感谢
https://blog.csdn.net/raoxiaoya/article/details/125604847