Monitor:都说原子操作比锁更轻量级,为什么更轻量?

Me:….(支支吾吾)

原子操作

​ 即进行过程中不能被中断的操作。

为什么不能打断该操作?

​ 如今的操作系统,多核处理器盛行,多进程并行?

​ 还是多进程每个人任务执行一小段,之后切换到其他的进程或线程继续执行呢?

​ ~很显然是第二种~

​ 多进程交替运行,因为处理器的处理速度,远远大于CPU人的反应,给人的一种感觉就是多任务同时进行工作。

实现:

​ 一般是由独立的CPU指令实现的。

Go中对原子操作的支持

​ 增减、交换、比较交换、存储、加载

增减:

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	n := int32(0)
	wg := sync.WaitGroup{}
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			//n++ data race 存在数据竞争,最后输出不一定会是1000
			atomic.AddInt32(&n, 1)
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Println("n", n)
}

// go run -race main.go

比较交换(CAS):

atomic.CompareAndSwap为前缀,addr,old,new 操作值的地址addr,比较是否为old值,仅为true时替换为new值并返回true。

package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	n := int32(1000)
	fmt.Println(atomic.CompareAndSwapInt32(&n, 999, 1001)) // false
	fmt.Println(atomic.CompareAndSwapInt32(&n, 1000, 2020)) // true
	fmt.Println(n) // 2020
}

因为CAS趋于乐观锁,所以并不是每次比较都会成功,如果想一直比较成功时,可能会造成自旋,要进行不断重试,直到成功为止。

var value int32 = 1

func retry(delta int32) {
	for {
		v := value
		if atomic.CompareAndSwapInt32(&value, v, (v + delta)) {
			break
		}
	}
}

注意,在读取过程中,也可以读取原子变量,但是一定要使用atomic.LoadInt32()进行读取,否则可能读取到操作一半的值。

互斥锁和通道的思路是通过锁来限制共享内存访问权限,即谁获取到了锁就让谁去处理,其协程阻塞。

而原子操作则利用的是不可中断性


原子操作和互斥锁的区别

原子操作相较于互斥锁更轻量:

​ 原子操作可以不用创建临界区和创建互斥变量的情况下完成并发安全的操作—>减少同步程序对性能的损耗。

同步程序-》创建锁-》为获取到锁的协程-》挂起-》等待锁的释放-》获取锁-》成功-》执行任务

​ |

​ 失败 -> 等待阻塞

总结体来说,锁是让多个协程相互等待。原子操作的内存同步原语保证了单个值的互斥操作。使用不当会额外消耗CPU进行比较。

Go中锁的实现原理

最开始的锁的设计

一代锁->基于互斥锁的实现 (<= 1.7)

state是一个int32共享变量,来表示锁的状态。

加锁:

​ 1.无冲突。直接使用原子变量进行加锁

​ 2.有冲突,通过调用 semacquire 进行休眠当前的goroutine,等待锁释放时唤醒。为获取锁继续阻塞,所以是个for。

解锁:

​ 1.通过CAS吧当前的状态设置为解锁状态。

​ 2.唤醒休眠的goroutine,把当前waiter数减一,然后调用semrelease唤醒goroutine。

​ 用 & 判断状态,用 | 设置状态,用 &|清空位置。

存在的问题:

  1. 存在饥饿情况,可能导致某个goroutine永远获取不到锁。
  2. 大多数获取不到锁的goroutine获取不到锁,被唤醒之后又被休眠。增加runtime调度开销。

二代-》基于自旋锁:(1.8-1.11)

​ 加锁:

  1. 无冲突:通过CAS把状态设置为加锁状态。
  2. 有冲突:
    1. 开始自旋:如果自旋过程锁被释放了,直接获取锁。
      1. 自旋的条件
        1. 当前协程未被唤醒
        2. 其他协程未被唤醒
        3. 等待队列大于0
      2. 自旋终止判断
        1. 自旋超过4次(默认)
        2. cpu总数小于1
        3. gomaxprocs <1 并且仅为自己运行
        4. 当前P的LRQ为空,自旋没意义,因为永远都不会触发
    2. 若没释放,进入等待状态,等待其他goroutine唤醒。

存在的问题:

​ 虽然解决了一部分不用全部都休眠的问题。但是进程优先级比较低的进程还是有问题。

最新的锁实现:基于公平锁(1.12以后)

基本逻辑:

  1. Mutex工作模式有两种:normal 和 starvation (正常模式和饥饿模式)

    休眠的goroutine以FIFO链表形式保存在sudog中,被唤醒goroutine与新来到的活跃的goroutine竞争,但是很可能失败。如果一个goroutine等待超过1ms,那么mutex进入饥饿模式。

  2. 饥饿模式下,新来的goroutine不会自旋竞争,直接放入链表队尾。

  3. 如果当前获得锁的goroutine是队尾或等待时间小于1ms,那么退出饥饿模式。

  4. 普通模式下性能是最好的,但是饥饿模式下可以减少队列过长的潜在问题。

  5. ​ 加锁:

    1. 无冲突:通过CAS把状态设置为加锁状态。
    2. 有冲突:
      1. 开始自旋:
        1. 饥饿模式下禁止自旋
        2. 自旋的条件
          1. 当前协程未被唤醒
          2. 其他协程未被唤醒
          3. 等待队列大于0
        3. 自旋终止判断
          1. 自旋超过4次(默认)
          2. cpu总数小于1
          3. gomaxprocs <1 并且仅为自己运行
          4. 当前P的LRQ为空,自旋没意义,因为永远都不会触发
      2. 若没释放,进入等待状态,等待其他goroutine唤醒。
  6. 解锁

    1. 解锁:当前设置为解锁状态
    2. 唤醒休眠的goroutine
      1. waiter数减1
      2. 如果饥饿模式的话,唤醒队列的第一个。

说明:公平锁解决了休眠进程可能一直拿不到锁的饥饿问题。如果goroutine等待超过1毫秒,没拿到锁,那么锁进入饥饿模式。释放资源会给队列中的第一个Goroutine。