GO基础学习(13)锁

2023-05-07  本文已影响0人  温岭夹糕

写在开头

非原创,知识搬运工/整合工,仅用于自己学习,本文是对锁的学习

往期回顾

  1. 基本数据类型
  2. slice/map/array
  3. 结构体
  4. 接口
  5. nil
  6. 函数
  1. GO调度器
  2. GMP介绍
  3. 调度器初始化
  4. 循环调度
  1. 内存重排
  2. 锁的源码阅读

带着问题去阅读

  1. 介绍一下抢锁流程
  2. 什么是自旋锁,优缺点
  3. Go的锁是公平的吗
  4. 锁的两种模式,切换时机
  5. 新的G更有机会拿到锁吗
  6. 互斥锁和读写锁区别
  7. 读写锁如何做到读锁不互斥,读写互斥
  8. Mutex如何挂起协程G,如何唤醒
  9. GO的锁可以重复上锁或解锁吗
  10. 什么是double-check
  11. 什么是死锁

1.源码复习

锁的源码阅读
我们知道,在多线程的情况下,很容易发生数据竞争和内存重排等现象,这给我们的程序带来很多的不确定性,加锁能很好的防止这种现象。GO的锁分为互斥锁和读写锁,读写锁又在互斥锁的基础上使用装饰器模式进行包装。

1.1互斥锁

其中互斥锁的实现原理为 image.png
  1. 本质上是一个协程G给一把锁(Mutex结构体实例)的state状态字段设置为(利用atomic.compareAndSwapInt32函数)上锁mutexLocked(mutexLocked=1),改变成功则返回,失败说明该锁正被其他协程G占用,开始抢锁lockSlow(锁上并没有记录是哪个协程给他上的锁)
  2. 先尝试进行自旋获取锁(自旋就是当一个线程获取锁,该锁已经被占用,则等待一段时间后再次尝试,如此循环,CPU层面实现的),自旋锁会一直占用CPU核(线程不做切换),当自旋次数满后还没获取锁则进入下一步

自旋锁的优缺点
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu 做无用功,占着 XX 不 XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 cpu 的线程又不能获取到 cpu,造成 cpu 的浪费。所以这种情况下我们要关闭自旋锁。

  1. 此时不再主动获取锁,进入等待队列并休眠,等待被信号唤醒再去抢锁
  2. 还抢不到就重新开始自旋再来一遍(实际上自旋次数已经满了,不需要再自旋了,只是再次进入等待队列,这里我们可以看出)
  3. 长时间等待(1ms)则进入饥饿模式,更有机会被抢到锁,饥饿模式的存在说明了GO的锁是不公平的

这里我们不难看出新来的G更容易抢到锁,因为自旋的机制,旧的G即使从队列中被唤醒,因为无法自旋,若不是饥饿模式需要重新休眠,更难抢到

1.2读写锁

读写锁RWMutex是写锁优先,即读锁可以重复加,但当读写锁之间互斥 image.png

从结构体成员上可以看出是对mutex的包装

type RWMutex struct {
    w           Mutex        // held if there are pending writers
    writerSem   uint32       // semaphore for writers to wait for completing readers
    readerSem   uint32       // semaphore for readers to wait for completing writers
    readerCount atomic.Int32 // number of pending readers
    readerWait  atomic.Int32 // number of departing readers
}

Q:如何实现读锁重复添加?
A:读锁加锁并不是操作内部互斥锁,是执行readerCount+1的操作
Q:如何实现加写锁的时候不能加读锁
A:RWMutex添加写锁时会将readerCount设置为负数,读锁加锁时发现为负数则陷入休眠,等待被唤醒(写锁完成后用循环唤醒全部的读锁)
Q:如何实现加读锁时无法加写锁
A:readerWait大于0表示还有读锁存在,写锁陷入休眠,只有当readerWait==0,写锁才能开始加锁

1.3atomic

另一个同步原语atomic实现都是依赖底层汇编

2.实验-锁的错误使用

2.1同协程重复加互斥锁

type MyData struct {
    id int
    mu sync.Mutex
}

func TestLockAgain(t *testing.T) {
    data := MyData{id: 1}
    data.mu.Lock()
    data.mu.Lock()
    data.mu.Unlock()
    data.mu.Unlock()
}

//panic: test timed out after 30s

运行超时引发的panic,这个就不分析了

2.2重复解锁

func TestUnLockAgain(t *testing.T) {
    data := MyData{id: 0}
    data.mu.Lock()
    defer data.mu.Unlock()
    data.mu.Unlock()
}
//fatal error: sync: unlock of unlocked mutex

造成原因通过阅读源码unlock的unlockslow函数就知道了,unlock尝试对Mutex的state字段-1,当值不为0则进入unlockslow,由unlockslow判断
上面的错误由下面代码引发

//上面情况new = -1  mutexLocked =1
func (m *Mutex) unlockSlow(new int32) {
    if (new+mutexLocked)&mutexLocked == 0 {
        fatal("sync: unlock of unlocked mutex")
    }

2.3复制锁引发的错误

使用复制锁引发的错误归根揭底是无法确定这把锁的状态,因为锁上不保存上锁协程的信息,意味着G1上锁了,G2却能解锁,下面是给复制锁解锁的例子

func TestCopyMutex(t *testing.T) {
    data := MyData{id: 0}
    go func() {
        data.mu.Lock()
        defer data.mu.Unlock()
        time.Sleep(time.Hour)
    }()
    time.Sleep(time.Second)
    go func(mydata MyData) {
        mydata.mu.Unlock()
        mydata.id = 2
        t.Log(mydata.id)
        time.Sleep(time.Hour)
    }(data)
    time.Sleep(time.Second * 5)
}

此时代码没问题,锁被复制前是上锁的状态,若去掉第一个G中的defer关键词,则锁被复制时是解锁状态,重复解锁引发panic
所以上锁解锁的原则是在同一个函数体或大括号中

2.4死锁引发的panic

源码阅读那一章举过例子

func TestDeadlock(t *testing.T) {
    d1 := MyData{id: 1}
    d2 := MyData{id: 2}
    go func() {
        d1.mu.Lock()
        defer d1.mu.Unlock()
        time.Sleep(time.Second)
        t.Log("g1-d1.id:", d1.id)
        d2.mu.Lock()
        t.Log("g1-d2.id:", d2.id)
        d2.mu.Unlock()
    }()
    go func() {
        d2.mu.Lock()
        defer d2.mu.Unlock()
        time.Sleep(time.Second)
        t.Log("g2-d2.id:", d2.id)
        d1.mu.Lock()
        t.Log("g2-d1.id:", d1.id)
        d1.mu.Unlock()
    }()
    var ch = make(chan int)
    <-ch
}

G1和G2互相需要对面的资源和拥有的锁,都在互相等待对面释放锁,结果一起休眠,解决办法就是缩小锁住的范围或使用atomic

    go func() {
        d1.mu.Lock()
        time.Sleep(time.Second)
        t.Log("g1-d1.id:", d1.id)
        d1.mu.Unlock() //使用完就释放,不再锁住整个函数
        d2.mu.Lock()
        t.Log("g1-d2.id:", d2.id)
        d2.mu.Unlock()
    }()

2.5双重检查-double-check

解决一个问题就会引发新的问题,如上,锁的粒度太细,在多线程下也会产生奇怪的结果

func TestDoubleCheck(t *testing.T){
    type safeData struct{
        values map[int]int
        lock sync.RWMutex
    }

    data:=safeData{values:make(map[int]int)}
    chang := func(key int,val int)int{
        data.lock.RLock()
        old,ok := data.values[key]  //a1
        data.lock.RUnlock()
        if ok {
            return old
        }

        data.lock.Lock()
        defer data.lock.Unlock()
        data.values[key] = val //a2
        return val
    }
    go chang(0,1)
    go chang(0,2)
}

G1和G2多核下同时执行:

  1. 读锁互不冲突,g1.a1和g2.a1互不冲突,都检测到未设置,要进行a2的操作
  2. 这里问题来了,这里写锁互相冲突,假设g1.a2先执行,已经完成赋值 value[0]=1,这时g2抢到锁后设置values[0]=2是不是不符合代码逻辑?因为values[0]已经赋值过了呀

为了保证上面的先行发生关系,有两种方法:
1.提升锁为互斥锁sync.mutex
2.使用双重检查,即加写锁后再读一次

    chang2 := func(key int,val int)int{
        data.lock.RLock()
        old,ok := data.values[key]
        data.lock.RUnlock()
        if ok {
            return old
        }

        data.lock.Lock()
        defer data.lock.Unlock()
        old,ok = data.values[key]
        if ok {
            return old
        }
        data.values[key] = val
        return val
    }

使用原则小结

  1. 适当减少锁的粒度和范围大小
  2. 尽早释放锁
  3. 避免复制锁
  4. 多线程环境下,注意双重检查
  5. 写多的操作使用Mutex
上一篇 下一篇

猜你喜欢

热点阅读