CAS到底是怎么回事

2022-09-28 18:52:42 浏览数 (1)

CAS到底是怎么回事

  • 为什么需要CAS
  • 如何实现CAS
  • 关于CAS和ABA
  • 关于应用层的锁和CPU的锁的关系
  • 参考

为什么需要CAS

CAS全称为Compare And Set(比较并交换)

对于现代操作系统而言,一般都是多核机器,会存在好几个cpu,而每块cpu都单独有一套缓存和mmu等组件。

多核CPU的好处是能够实现线程级别的并发操作,极大提高程序执行效率,但是这也会导致并发问题的产生,如下面这个场景:

关于操作系统进程,线程和时间片关系,可以看这篇文章了解一下: Linux系统中 进程 、线程 、时间片的关系?

两个cpu上并行运行着一个java进程中的两个线程,这两个线程几乎同时从主存中读取变量i的值,放入高速缓存中,然后交给cpu进行自增操作,操作完后再写回高速缓存,最后再写回主存。

如果cpu要修改的内存地址在高速缓存中存在,那么称为写命中,这种情况下,有两种策略来将高速缓存中修改后的数据写回主存,分别为:

  • 全写法: 当CPU对Cache写命中时,必须把数据同时写入Cache和主存,一般使用一个缓存区来存放要写回主存的数据,然后慢慢写回主存,此时cpu可以干其他事情,这个过程由硬件实现。
  • 写回法: 当cpu对cache写命中时,只修改cache的内容,而不立即写入主存,只有当此块被换出时才会写回主存。

上面多个cpu同时并发读写同一个内存单元,显然会导致结果与预期不符,其问题本质在于对于内存单元的读写操作是非原子性的,如何保证对内存单元读写操作的原子性,这就是CAS需要完成的使命。

原子性意味着不能允许多个cpu同时读写同一块内存单元


如何实现CAS

要避免多个cpu同时读写同一块内存单元,对于从事上层应用开发的程序员而言,最先想到的就是加锁,但是cpu层面的加锁如何实现呢?

  • 更加接近机器层面的语言就是汇编语言了,我们只需要通过x86汇编语言中提供的lock前缀的指令就可以完成上述加锁功能
  • x86汇编中,如果对一个指令加“lock”前缀,会发生什么 ?

对于早期的CPU,总是采用的是锁总线的方式。具体方法是,一旦遇到了Lock指令,就由仲裁器选择一个核心独占总线。其余的CPU核心不能再通过总线与内存通讯。从而达到“原子性”的目的。

这种方式的确能解决问题,但是非常不高效。为了个原子性结果搞得其他CPU都不能干活了。因此从Intel P6 CPU开始就做了一个优化,改用Ringbus MESI协议,也就是文档里说的cache conherence机制。这种技术被Intel称为“Cache Locking”。

根据文档原文:如果是P6后的CPU,并且数据已经被CPU缓存了,并且是要写回到主存的,则可以用cache locking处理问题。否则还是得锁总线。因此,lock到底用锁总线,还是用cache locking,完全是看当时的情况。当然能用后者的就肯定用后者。

MESI大致的意思是:若干个CPU核心通过ringbus连到一起。每个核心都维护自己的Cache的状态。如果对于同一份内存数据在多个核里都有cache,则状态都为S(shared)。一旦有一核心改了这个数据(状态变成了M),其他核心就能瞬间通过ringbus感知到这个修改,从而把自己的cache状态变成I(Invalid),并且从标记为M的cache中读过来。同时,这个数据会被原子的写回到主存。最终,cache的状态又会变为S。

这相当于给cache本身单独做了一套总线(要不怎么叫ring bus),避免了真的锁总线。

回到CAS。

我们一般说的CAS在x86的大概写法是

代码语言:javascript复制
lock cmpxchg a, b, c 

对于一致性来讲,“lock”前缀是起关键作用的指令。cas的实现用了lock cmpxchg指令。cmpxchg指令涉及一次内存读和一次内存写,需要lock前缀保证中间不会有其它cpu写这段内存。

CAS的特性使得他称为实现任何高层“锁”的必要的构建。几乎所有的“锁”,如Mutex,ReentrantLock等都得用CAS让线程先原子性的抢到一个东西(比如一个队列的头部),然后才能维护其他锁相关的数据。并且很有意思的是,如果一个竞争算法只用到了CAS,却没有让线程“等待”,就会被称为“无锁算法”。CPU会不会有点小郁闷……


关于CAS和ABA

实际上CAS和cmpxchg压根就没处理过ABA问题。严格来说CAS就不会有ABA的问题,它只是一个简单的,原子的"比较-设置值"的指令而已。

会出ABA问题的是这种CAS的用法:

代码语言:javascript复制
var curVal = getValue();
var newVal = modify(curVal);
compareAndSwap(getValueAddr(), curVal, newVal); // 这里是CAS

即这个代码的第一句和第三句可能看到的curVal是一样的,但是有可能造这个curVal在另一个线程ABA了。

如果真的需要解决ABA问题,需要上层代码来处理,比如

  • 把value和version放到一起形成一个变量的值(比如 "62@v1“),然后对这个变量的值做CAS。这种比较适合值本身比较简单的场景。
  • 把value和version包一个对象,然后对对象的引用做CAS(Java的AtomicStampedReference就是这么干的)。

现实工程当中,绝大部分情况下,都不需要考虑ABA问题。


关于应用层的锁和CPU的锁的关系

CPU锁和应用层的锁要解决的问题不一样。

CPU锁主要解决的是多个核心并发访问/修改同一块内存的问题。所以有锁总线和MESI协议来做。对于上层主要的抽象就是CAS。主要的招数就是用CAS 循环来抢东西。如果抢不到就只能:

  • 继续循环下去玩命抢(这时会空耗CPU)
  • 不抢了,回复给上层代码“抢不到”。

应用层的锁存在了“进程/线程“的概念(下文统一都说进程)。解决的是多个进程并发访问同一块内存的问题。比起CPU的层级来说,应用层的锁可以多一个招数,叫做“让当前进程不可调度“。这个是OS提供的支持。

因此在应用层的层次上你可以定义一个高级的“锁”,大概执行这样一个抢锁流程

  1. 尝试用CAS抢到锁
  2. 如果抢不到,则回到1重试
  3. 如果抢了几十次都还抢不到,就把当前进程(的信息)尝试挂到一个等待队列上(当然挂的过程还是要CAS)
  4. 把当前进程设定为不可调度,这样OS就不会把当前进程调度给CPU执行。(这种情况因为需要做一次系统调用,所以有比较大的损耗,一般被称为“重量级锁”)

而当某个进程释放锁时,他就可以做释放锁的流程

  1. 找到释放锁的那个等待队列
  2. 把等待队列里第一个等待的进程信息取出来,并且告诉OS,这个进程程可以执行了(这里也要做一次系统调用)
  3. 这个被复活了的进程一般需要在做一次循环尝试抢锁,然后就回到了上面的抢锁流程。

简单来说,CAS是应用层锁的building block。


参考

cas做了锁了总线或缓存行还是volatile做了锁总线或缓存行?

0 人点赞