原子操作和锁对比

一. 原子操作

1. 什么是原子操作

所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何的上下文切换(context switch 切换到另一个线程)。

原子操作可以是一个步骤,也可以是多个步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分,将这个操作视作一个整体是原子性的核心特征。

原子操作是无锁的,常常直接通过CPU指令直接实现。事实上,其他同步技术的实现常常依赖于原子操作。

2. 如何保证原子操作

从原子操作的定义我们可以知道,对于单核的CPU,原子操作确实能保证得到想要的值,比如i等于0i++的自增操作50次,如果是单核的CPU,多条线程来执行,由于是原子操作,自增过程不可分割,因此每次都是执行完读->自增->写操作,然后再执行下一次的读->自增->写,这样就保证了自增最后数值是50

但如果原子操作对于多核的CPU来说,就不一定能保证得到想要的值。比如i等于0i++的自增操作50次,对于2核的CPU来说,可能操作如下:

image.png

最终得到的值,跟预期产生出入。

因此CPU处理器一般通过基于对缓存加锁或总线加锁的方式来实现多CPU处理器之间的原子操作。

A. 处理器自动保证基于内存操作的原子性

首先处理器会自动保证基于内存操作的原子性。处理器保证从系统内存当中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。最新的处理器能自动保证单处理器对同一个缓存行进行16/32/64位操作是原子的,但是复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度、跨多个缓存行、跨页表的访问。但是处理器提供总线锁和缓存锁两个机制来保证复杂内存操作的原子性。

B. 使用总线保证原子性

通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写操作,那么共享变量就会被多个处理器同时进行操作,这样读改写就不是原子的,操作完之后共享变量实际值与期望值可能不一样。如上面i++自增操作。

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

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

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

频繁使用的内存会缓存在处理器的L1, L2, L3高速缓存里,那么原子操作就可以直接在处理器内部中进行,并不需要声明总线锁,最新CPU可以使用缓存锁定的方式来实现复杂的原子性。

所谓的缓存锁定就是如果缓存存在处理器缓存行中内存区域在LOCK操作期间被锁定,当它执行锁操作回写到内存时,处理器通过修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性会阻止其他CPU同时修改该内存区域数据,当其他CPU回写被锁定的缓存行的数据时会起缓存行无效,

但是有两种情况下处理器不会使用缓存锁定。

第一种情况:当操作的数据不能被缓存在处理器内部,或者操作的数据跨越多个缓存行,则处理器会使用总线锁定。

第二种情况: 有些CPU不支持缓存锁定,就算锁定的内存区域在处理器的缓存行中,也会调用总线锁定。

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

  1. 常见原子操作

一般原子操作需要CPU等硬件支持,使用硬件同步原语来实现原子操作。常用的原语有CAS(compare-and-swap)TAS(test-and-set)、FAA(fetch-and-add)

A. CAS

比较与交换(CAS)是多线程中用来同步的原子指令。CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下在旧值有没有发生变化,如果没有发生变化,才交换成新值,发生变化则不交换。

如果发生变化后,一直使用CAS原语去尝试更新值时,叫做自旋。

CAS存在的问题:

ABA问题:

  • ABA问题可以简单转换为,线程或者进程两次读取相同的值以后是否关注两次读取之前变量的值是否发生改变。如果中间的改变会影响计算的结果,就会出现ABA问题。如果只关注值本身,那么即使出现了ABA也不会影响执行结果。

比如:你有一叠零钱,如果你刚好想找别人5块钱,这时候你从抽屉取出零钱,找到最上面的5块钱,刚好有点事离开了,这时候刚好你家人拿走最上面的几张零钱,但现在最上面还是5块钱,刚好你回来就正好拿最上面5块钱找给别人就行,中间过程不影响你想要的结果。但如果你想计算总零钱数,那中间过程就会产生影响了。

16

关于ABA问题大家也提供了很多解决方案:

  • 使用double-length CAS(DCAS)在32位系统上,可以使用64CAS64位的内存可以分为两个部分。一部分用于存放具体的值,一部分用于存放版本信息,这样就可以解决ABA问题。

  • 对于不支持DCASCPU,如果是32系统,可以进行拆分,前16位用于版本信息,后16位用于对应的值。

  • 使用一个更复杂但更有效的解决方案是实施安全内存回收(SMR)

循环时间长开销大:

  • 多线程反复尝试更新某一个变量,太多重试自旋,会消耗大量的CPU资源,会给CPU带来很大的压力。因此冲突如果过于频繁的场景不建议使用CAS原语进行处理(CAS是乐观锁机制,乐观锁不适合冲突频繁的场景,这时候可以选择悲观锁的机制)。

B. TAS

test-and-set指令是一种用来将1(set)写入一个内存位置,并以单个原子的形式返回其旧值的指令,不可中断操作。

// wiki 伪代码

function Lock(boolean *lock) {

while (test_and_set(lock) == 1);

}

TAS特点是自旋,也就是循环,每次尝试去设置值,如果设置成功则会返回,如果没有返回就会一直自旋,直到设置成功。此时进入临界区,执行完临界区数据,再设置bool变量为false。从而让其他线程拿到锁。

TAS问题:

当使用TAS实现TASLock(Test And Set Lock)测试-设置锁,它的特点是自旋时,每次尝试获取锁时,底层还是使用CAS操作,不断的设置锁标志位的过程会一直修改共享变量的值(回写),会引发缓冲一致性流量风暴。因为每一次CAS操作都会发出广播通知其他处理器,从而影响程序的性能。

为什么回写会影响性能:

因为每一次CAS操作都会发出广播通知其他处理器,通知的过程会出现总线锁定,从而影响程序的性能。

TTAS:

Test and test-and-set 用于在多处理器环境中实现互斥的指令。为了解决TAS过程中通过CAS不断修改共享变量而导致的性能问题,而提出的方案。

TTAS的特点也是自旋,主要是为了降低性能开销,主要思路是减少回写(CAS 导致的不断修改共享变量的值)。第一步先去检测lock是否空闲;如果不空闲就让出CPU,如果空闲就执行TAS进行CAS操作,只有一个线程先观测到lock是空闲的,该线程才会尝试去获取它,从而消除一部分回写操作。

C. FAA

Fetch-and-add指令将存储位置的内容增加指定的值(原子操作)。其他线程无法观测中间结果。

FAA特点就是原子性,因为在多处理器的计算计算过程中,X=X+A的操作,实际上有三步:

  • 获取变量X的地址,然后将X的值读入寄存器中

  • 在寄存器中的值增加A

  • 把寄存器中的新值写入X的地址中

FAA的意义就是把这三步原子化,要么都执行要么都不执行。

FAA存在问题:

FAA的使用场景比较局限(自增自建的场景如计算器)。

二. 锁

在多线程编程中,为了保证数据操作的一致性,操作系统引入了锁机制,用于保证临界区代码的安全。通过锁机制,能够保证在多核多线程环境中,在某一个时间点上,只有一个线程进入临界区代码,从而保证临界区中操作数据的一致性。

所谓的锁,说白了就是内存中的一个整型数,拥有两种状态:空闲状态和上锁状态。加锁时,判断锁是否空闲,如果空闲,修改为上锁状态,返回成功;如果已经上锁,则返回失败。解锁时,则把锁状态修改为空闲状态。

那操作系统怎么保证锁操作的原子性呢?

我们先分析下加锁的过程:

  • Read lock

  • 判断lock状态

  • 如果已经加锁,失败返回

  • 如果未加锁,把锁状态设置为上锁

  • 返回成功。

那么什么情况下会导致两个线程同时获取到锁呢?

  • 中断: 假设线程A执行问第一步,发生中断,中断返回后,操作系统调度线程B线程B也来加锁并且加锁成功,这是操作系统调度线程A执行,线程A从第二步快开始执行,也加锁成功。

  • 多核: 两个CPU上的代码同时申请一个锁,两个CPU同时取出锁变量,同时判断锁为空闲状态,然后同时修改为上锁状态,同时返回成功。

针对如上锁失败的原因,解决方法很明确:

  • 既然中断会将上锁的过程打断,造成多线程上锁操作失败。那就先关中断,在加锁操作完成后再开中断。

  • 关中断操作太繁琐,能否硬件做一种加锁的原子操作呢?TAS的原子操作就可以解决该问题。

通过TAS,在单核环境下,锁的实现问题确实可以解决,但是在多核CPU环境下,就需要借助锁总线或者缓存锁来保证原子性了。

当某个CPU执行上锁的TAS操作的时候,我们可以通过锁总线机制,保证只有一个CPU能进行上锁操作,从而避免多核下发生的问题。

总结一下,在硬件层面,CPU提供了原子操作,关中断、锁内存总线、锁缓存等机制;操作系统基于这几个CPU硬件机制,就能够实现锁;在基于锁,就能够实现各种各样的同步机制(信号量、消息、Barrier等).

三. 引用

[原子操作]
[多线程(1): 乱序和屏障]
[多线程(2): 计数器和原子操作]
[多线程(3): 一个CAS应用的例子和ABA问题]
[原子操作的底层实现原理及cas问题]

你可能感兴趣的:(原子操作和锁对比)