0%

原子操作与锁

在X86中,加上LOCK指令前缀,不仅使得“原子化”,也使得“禁止重排序”,“刷新缓存到内存”和“使缓存失效”,但是这里只讨论“原子化”效果。

一、原子操作

原子操作的本质描述是:当且仅当操作物理或者逻辑不可中断(不可中断:操作所涉内存不可被读取和修改)时,该操作才是原子的。
原子操作存在于各个指令/语言层级,比如“机器指令层级的原子操作”,“汇编指令层级的原子操作”,“Java语言层级的原子操作”等。


关于原子操作的一些具体描述如下:

  • 在机器指令层级,大多数机器指令是非原子的,只有一些机器指令是原子的(又可被称为“天生原子机器指令”)
  • 关于机器指令“原子”与“中断”的关系:在单核中,不可中断的机器指令是原子的,可中断的机器指令不一定是原子的;在多核中,可中断的机器指令不一定是原子的,不可中断的机器指令也不一定是原子的,比如“不可中断指令M在CPU核C1上执行,在CPU核C2上执行的指令可观察到M指令的中间过程,此时M指令不是原子的”
  • 高级语言语句A/汇编指令B最终被编译/解释成一段机器指令C,A/B是否是原子的取决于C是否是原子的
  • 通过加锁,可将“非原子操作”原子化。比如:给非原子汇编指令A加上LOCK指令前缀将其原子化,在这里LOCK指令前缀是作为一种锁机制的,LOCK指令前缀会带来以下效果:1)被修饰的汇编指令在执行期间,会在内存总线上声言一个#LOCK信号导致内存被锁住,直到该汇编指令执行完成。因此,A执行期间不可能发生涉及到的内存值被读取和修改的情形,故此时A的执行是原子的;2)方案1锁住内存,期间内存不能被读取和修改,代价非常大,因此,现在一般是采用“缓存锁定”的方案,避免降低内存的存取速度。具体是:在指令执行期间锁住所涉及到的Cache Line,此时其他CPU核不能存取其内缓存中对应的Cache Line
  • “缓存一致性协议”与“指令操作的原子性”关系:缓存一致性协议(比如“MESI”)只能用来保证多CPU核内的缓存一致性,并不能保证“指令操作的原子性”。比如“现有CPU核A和B,执行相同的‘自增1’指令C,可分别以AC和BC代指,C所涉及的变量V在Cache Line D上,可分别以AD/AC和BD/BC代指,假定AV和BV的初始值为0,并发执行AC和BC,由于AD和BD并未被锁住,因此AC和BC获取得到的AV和BV的值可能都为0,此时AC和BC的执行结果都为1,在整个过程中,缓存一致性协议正常工作,可发现其并不能使得C的执行是原子的,否则AC和BC的执行结果应该分别是[1,2]或者[2,1]”

二、锁

锁是一种同步机制,类似于“原子操作”,锁也存在于各个指令/语言层级中,比如“机器指令层级的锁”,“汇编指令层级的锁”,“Java语言层级的锁”等。

2.1、原子操作与锁

“原子操作”与“锁”的关系:实现“原子操作”必借助于“锁”,使用“锁”不一定能够实现“原子操作”。
比如:

  • “天生原子机器指令”使用隐式锁机制
  • X86处理器的LOCK指令前缀+CMPXCHG汇编指令构成一个CAS原子指令,这里LOCK指令前缀是作为一种锁机制
  • 在Java程序中,可借助“synchronized关键词/高级锁”实现一段Java代码,如果实现得好,该段Java代码是一个原子操作;但是如果实现得不好(比如“中间状态可被他人存取”),该段Java代码就不是一个原子操作

2.2、锁分类

可基于各种角度对锁进行分类。
常见的锁有:

  • Java语言层级的“synchronized锁”和“高级锁”
  • C++,Rust,Go语言层级的内置锁
  • CAS自旋锁,其本质是CAS+自旋(不断循环),它跟通常所理解的锁有点不一样,但是从用于解决原子操作问题的角度,它跟通常所理解的锁又无二致
  • LOCK指令前缀
  • “天生原子机器指令”使用的隐式锁

常见的锁分类有:

  • 悲观锁 vs 乐观锁
  • 阻塞锁 vs 非阻塞锁
  • 公平锁 vs 非公平锁
  • 可重入锁 vs 非可重入锁
  • 共享锁 vs 排他锁

2.2.1、悲观锁 vs 乐观锁

分类角度:对同步资源的并发修改是否频繁,不关心对同步资源的读取(当然,如果对同步资源的读取产生了线程安全问题,可能仍然得用锁的方式予以解决,只不过“悲观锁”和“乐观锁”的分类不关注对同步资源的读取)。

悲观锁:对同步资源的并发修改十分频繁,修改前先加锁,适合“同步资源竞争激烈”的场景,比如“synchronized锁”。
乐观锁:对同步资源的并发修改不频繁,修改前无需加锁,到真正修改时才去尝试处理竞争修改的情形,适合“同步资源竞争不激烈”的场景,一般就是“CAS自旋锁”

2.2.2、阻塞锁 vs 非阻塞锁

分类角度:申请锁而不得时,申请线程是否需要阻塞。

阻塞锁:申请锁而不得时,申请线程需要阻塞,具有“阻塞-唤醒”过程成本,比如“synchronized锁”。
非阻塞锁:申请锁而不得时,申请线程不需要阻塞,具有“持续占用分配到的CPU时间片”的成本,一般就是“CAS自旋锁”

2.2.3、公平锁 vs 非公平锁

分类角度:多个线程是否按照申请锁的顺序来获取锁。

公平锁:多个线程是按照申请锁的顺序来获取锁,比如“ReentrantLock内的FairSync锁”。

  • 优点:等待锁的线程不会饿死
  • 缺点:对于每个申请线程,都要付出“阻塞-唤醒”过程成本,由此导致整体吞吐效率相对于非公平锁要低

非公平锁:多个线程不是按照申请锁的顺序来获取锁,比如“synchronized锁”、“ReentrantLock内的NonfairSync锁”。

  • 优点:对于每个申请线程,可能可避免付出“阻塞-唤醒”过程成本,由此导致整体吞吐效率相对于公平锁要高
  • 缺点:处于等待队列中的线程可能会饿死,或者,等很久才能获得锁

2.2.4、可重入锁 vs 非可重入锁

分类角度:已获得锁的线程在不释放该锁的前提下是否可再次获得该锁。

可重入锁:已获得锁的线程在不释放该锁的前提下可再次获得该锁,比如“synchronized锁”、“ReentrantLock锁”。
非可重入锁:已获得锁的线程在不释放该锁的前提下不可再次获得该锁,除非先释放掉该锁,比如“NonReentrantLock锁”。

2.2.5、共享锁 vs 排他锁

分类角度:锁是否只能被一个线程所获取。

共享锁:锁能被多个线程同时所获取,比如“读写锁中的读锁”。
排他锁:锁只能被一个线程所获取,比如“synchronized锁”、“读写锁中的写锁”。

三、其他

3.1、原子操作与事务

原子操作与事务的关系是:“事务”是“原子操作”;“原子操作”不一定是“事务”,因为“原子操作”不一定有“回滚”语义。

3.2、无锁化编程

对于“无锁化编程”,完整的语境是:在并发环境中,以“无锁”的方式解决原子操作问题。而根据实现“原子操作”必借助于“锁”可知,以“无锁”的方式解决原子操作问题不可能,所以“无锁化编程”只是玩个文字游戏,本质还是需要锁的。
因此,我们平常在讨论“无锁化编程”时,实际上说的是:

  • 使用CAS自旋锁
  • 使用“天生原子机器指令”,比如“在JDK 8的AtomicLong类,VM_SUPPORTS_LONG_CAS变量的JavaDoc中有提到一个天生原子机器指令,它实现了针对long变量的CAS操作”

参考文献

[1]https://tech.meituan.com/2018/11/15/java-lock.html

您的支持将鼓励我继续分享!