乐趣区

关于golang:这可能是最容易理解的-Go-Mutex-源码剖析

Hi,大家好,我是 haohongfan。

上一篇文章《一文齐全把握 Go math/rand》,咱们晓得 math/rand 的 global rand 有一个全局锁,我的文章外面有一句话:“修复计划: 就是把 rrRand 换成了 globalRand, 在线上高并发场景下, 发现全局锁影响并不大.”,有同学私聊我“他们遇到线上服务的锁竞争特地强烈”。的确我这句话说的并不谨严。然而也让我有了一个思考:到底多高的 QPS 能力让 Mutex 产生强烈的锁竞争?

到底加锁的代码会不会产生线上问题?到底该不该应用锁来实现这个性能?线上的问题是不是因为应用了锁造成的?针对这些问题,本文就从源码角度分析 Go Mutex, 揭开 Mutex 的迷雾。

源码剖析

Go mutex 源码只有短短的 228 行,然而却蕴含了很多的状态转变在外面,很不容易看懂,具体能够参见上面的流程图。Mutex 的实现次要借助了 CAS 指令 + 自旋 + 信号量来实现,具体代码我就不再每一行做剖析了,有趣味的能够依据上面流程图配合源码浏览一番。

Lock

Unlock

一些例子

1. 一个 goroutine 加锁解锁过程

2. 没有加锁,间接解锁问题

3. 两个 Goroutine,相互加锁解锁

4. 三个 Goroutine 期待加锁过程

整篇源码其实波及比拟难以了解的就是 Mutex 状态(mutexLocked,mutexWoken,mutexStarving,mutexWaiterShift)与 Goroutine 之间的状态(starving,awoke)扭转,咱们上面将逐个阐明。

什么是 Goroutine 排队?

如果 Mutex 曾经被一个 Goroutine 获取了锁, 其它期待中的 Goroutine 们只能始终期待。那么等这个锁开释后,期待中的 Goroutine 中哪一个会优先获取 Mutex 呢?

失常状况下, 当一个 Goroutine 获取到锁后, 其余的 Goroutine 开始进入自旋转(为了持有 CPU) 或者进入沉睡阻塞状态(期待信号量唤醒). 然而这里存在一个问题, 新申请的 Goroutine 进入自旋时是依然领有 CPU 的, 所以比期待信号量唤醒的 Goroutine 更容易获取锁. 用官网话说就是,新申请锁的 Goroutine 具备劣势,它正在 CPU 上执行,而且可能有好几个,所以刚刚唤醒的 Goroutine 有很大可能在锁竞争中失败.

于是如果一个 Goroutine 被唤醒过后, 依然没有拿到锁, 那么该 Goroutine 会放在期待队列的最后面. 并且那些期待超过 1 ms 的 Goroutine 还没有获取到锁,该 Goroutine 就会进入饥饿状态。该 Goroutine 是饥饿状态并且 Mutex 是 Locked 状态时,才有可能给 Mutex 设置成饥饿状态.

获取到锁的 Goroutine Unlock, 将 Mutex 的 Locked 状态解除, 收回来解锁信号, 期待的 Goroutine 开始竞争该信号. 如果发现以后 Mutex 是饥饿状态, 间接将唤醒信号发给第一个期待的 Goroutine

这就是所谓的 Goroutine 排队

排队性能是如何实现的

咱们晓得在失常状态下,所有期待锁的 Goroutine 依照 FIFO 程序期待,在 Mutex 饥饿状态下,会间接把开释锁信号发给期待队列中的第一个 Goroutine。排队性能次要是通过 runtime_SemacquireMutex, runtime_Semrelease 来实现的.

1. runtime_SemacquireMutex — 入队

当 Mutex 被其余 Goroutine 持有时,新来的 Goroutine 将会被 runtime_SemacquireMutex 阻塞。阻塞会分为 2 种状况:

Goroutine 第一次被阻塞:

当 Goroutine 第一次尝试获取锁时,因为以后锁可能不能被锁定,于是有可能进入上面逻辑

queueLifo := waitStartTime != 0
if waitStartTime == 0 {waitStartTime = runtime_nanotime()
}
runtime_SemacquireMutex(&m.sema, queueLifo, 1)

因为 waitStartTime 等于 0,runtime_SemacquireMutex 的 queueLifo 等于 false, 于是该 Goroutine 放入到队列的尾部。

Goroutine 被唤醒过,然而没加锁胜利,再次被阻塞
因为 Goroutine 被唤醒过,waitStartTime 不等于 0,runtime_SemacquireMutex 的 queueLifo 等于 true, 于是该 Goroutine 放入到队列的头部。

2. runtime_Semrelease — 出队

当某个 Goroutine 开释锁时,调用 Unlock,这里同样存在两种状况:

以后 mutex 不是饥饿状态

if new&mutexStarving == 0 {
    old := new
    for {if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {return}
        // Grab the right to wake someone.
        new = (old - 1<<mutexWaiterShift) | mutexWoken
        if atomic.CompareAndSwapInt32(&m.state, old, new) {runtime_Semrelease(&m.sema, false, 1)
            return
        }
        old = m.state
    }
}

Unlock 时 Mutex 的 Locked 状态被去掉。当发现以后 Mutex 不是饥饿状态,设置 runtime_Semrelease 的 handoff 参数是 false, 于是唤醒其中一个 Goroutine。

以后 mutex 曾经是饥饿状态

} else {
    // Starving mode: handoff mutex ownership to the next waiter, and yield
    // our time slice so that the next waiter can start to run immediately.
    // Note: mutexLocked is not set, the waiter will set it after wakeup.
    // But mutex is still considered locked if mutexStarving is set,
    // so new coming goroutines won't acquire it.
    runtime_Semrelease(&m.sema, true, 1)
}

同样 Unlock 时 Mutex 的 Locked 状态被去掉。因为以后 Mutex 是饥饿状态,于是设置 runtime_Semrelease 的 handoff 参数是 true, 于是让期待队列头部的第一个 Goroutine 取得锁。

Goroutine 的排队 与 mutex 中记录的 Waiters 之间的关系?

通过下面的剖析,咱们晓得 Goroutine 的排队是通过 runtime_SemacquireMutex 来实现的。Mutex.state 记录了目前通过 runtime_SemacquireMutex 排队的 Goroutine 的数量

Goroutine 的饥饿与 Mutex 饥饿之间的关系?

Goroutine 的状态跟 Mutex 的是非亲非故的。只有在 Goroutine 是饥饿状态下,才有可能给 Mutex 设置成饥饿状态。在 Mutex 是饥饿状态时,才有可能让饥饿的 Goroutine 优先获取到锁。不过须要留神的是,触发 Mutex 饥饿的 Goroutine 并不一定获取锁,有可能被其余的饥饿的 Goroutine 截胡。

Goroutine 可能加锁胜利的状况

Mutex 没有被 Goroutine 占用 Mutex.state = 0, 这种状况下肯定能获取到锁. 例如: 第一个 Goroutine 获取到锁
还有一种状况 Goroutine 有可能加锁胜利:

  1. 以后 Mutex 不是饥饿状态, 也不是 Locked 状态, 尝试 CAS 加锁时, Mutex 的值还没有被其余 Goroutine 扭转, 以后 Goroutine 能力加锁胜利.
  2. 某个 Goroutine 刚好被唤醒后, 从新获取 Mutex, 这个时候 Mutex 处于饥饿状态. 因为这个时候只唤醒了饥饿的 Goroutine, 其余的 Goroutine 都在排队中, 没有其余 Goroutine 来竞争 Mutex, 所以能间接加锁胜利

    Mutex 锁竞争的相干问题

    探测锁竞争

日常开发中锁竞争的问题还是能常常遇到的,咱们如何去发现锁竞争呢?其实还是须要靠 pprof 来人肉来剖析。

《一次谬误应用 go-cache 导致呈现的线上问题》就是我真是遇到的一次线上问题,表象就是接口大量超时,关上 pprof 发现大量 Goroutine 都集中 Lock 上。这个实在场景的具体的剖析过程,有趣味的能够浏览一下。

简略总结一下:
压测或者流量高的时候发现零碎不失常,关上 pprof 发现 goroutine 指标在飙升,并且大量 Goroutine 都阻塞在 Mutex 的 Lock 上,这个根本就能够确定是锁竞争。

pprof 外面是有个 pprof/mutex 指标,不过该指标默认是敞开的,而且并没有太多材料有介绍这个指标如何来剖析 Mutex。有晓得这个指标怎么用的大佬,欢送留言。

mutex 锁的瓶颈

当初模仿业务开发中的某接口,均匀耗时 10 ms, 在 32C 物理机上压测。CentOS Linux release 7.3.1611 (Core), go1.15.8 
压测代码如下:

package main

import (
    "fmt"
    "log"
    "net/http"
    "sync"
    "time"

    _ "net/http/pprof"
)

var mux sync.Mutex

func testMutex(w http.ResponseWriter, r *http.Request) {mux.Lock()
    time.Sleep(10 * time.Millisecond)
    mux.Unlock()}

func main() {go func() {log.Println(http.ListenAndServe(":6060", nil))
    }()

    http.HandleFunc("/test/mutex", testMutex)
    if err := http.ListenAndServe(":8000", nil); err != nil {fmt.Println("start http server fail:", err)
    }
}

这个例子写的比拟极其了,全局共享一个 Mutex。通过压测发现在 100 qps 时,Mutex 没啥竞争,在 150 QPS 时竞争就开始变的强烈了。

当然咱们写业务代码并不会这么写,然而能够通过这个例子发现 Mutex 在 QPS 很低的时候,锁竞争就会很强烈。须要阐明的一点:这个压测是数值没啥具体的意义,不同的机器上体现必定还会不一样。

这个例子通知咱们几点:

  1. 写业务时不能全局应用同一个 Mutex
  2. 尽量避免应用 Mutex,如果非应用不可,尽量多申明一些 Mutex,采纳取模分片的形式去应用其中一个 Mutex

    日常应用留神点

1. Lock/Unlock 成对呈现

咱们日常开发中应用 Mutex 肯定要记得:先 Lock 再 Unlock。

特地要留神的是:没有 Lock 就去 Unlock。当然这个 case 个别状况下咱们都不会这么写。不过有些变种的写法咱们要尤其留神,例如

var mu sync.Mutex

func release() {mu.Lock()
    fmt.Println("lock1 success")
    time.Sleep(10 * time.Second)

    mu.Lock()
    fmt.Println("lock2 success")
}

func main() {go release()

    time.Sleep(time.Second)
    mu.Unlock()
    fmt.Println("unlock success")
    for {}}

输入后果:

release lock1 success
main unlock success
release lock2 success

咱们看到 release goroutine 的锁居然被 main goroutine 给开释了,同时 release goroutine 又能从新获取到锁。

这段代码可能你想不到有啥问题,其实这个问题蛮重大的,设想一下你的代码中,原本是要加锁给用户加积分的,然而居然被别的 goroutine 给解锁了,导致积分没有减少胜利,同时解锁的时候还别的 Goroutine 的锁给 Unlock 了,相互加锁解锁,导致莫名其妙的问题。

所以个别状况下,要在本 Goroutine 中实现 Mutex 的 Lock&Unlock,千万不要将要加锁和解锁分到两个 Goroutine 中进行。如果你的确须要这么做,请抽支烟沉着一下,你真的是否须要这么做。

2. Mutex 千万不能被复制

我之前发过的《当 Go struct 遇上 Mutex》外面详细分析了不能被复制的起因,以及如何 Mutex 的最佳应用形式,倡议没看过的同学去看一遍。咱们还是举个例子说下为啥不能被复制,以及如何用源码进行剖析

type Person struct {mux sync.Mutex}

func Reduce(p1 Person) {fmt.Println("step...",)
    p1.mux.Lock()
    fmt.Println(p1)
    defer p1.mux.Unlock()
    fmt.Println("over...")
}

func main() {
    var p Person
    p.mux.Lock()
    go Reduce(p)
    p.mux.Unlock()
    fmt.Println(111)
    for {}}

问题剖析:

  1. main Goroutine 曾经给 p.mux 加了锁 , 这个时候 p.mux 的 state 的值是 mutexLocked。
  2. 而后将 p.mux 复制给了 Reduce Goroutine。这个时候被复制的 p1.mux 的 state 的值也是 mutexLocked。
  3. main Goroutine 尽管曾经解锁了, 然而 Reduce Goroutine 跟 main Goroutine 的 mutex 曾经不是同一个 mutex 了, 所以 Reduce Goroutine 就会加锁失败, 产生死锁,要害是编译器还发现不了这个 Deadlock.

对于为什么编译器不能发现这个死锁,能够看我的博客《一次 Golang Deadlock 的探讨》

至此 Go Mutex 的源码分析全副结束了,有什么想跟我交换的能够再评论区留言。

退出移动版