一次讀鎖重入導(dǎo)致的死鎖故障

在兩天前第一次遇到自己的程序出現(xiàn)死鎖, 我一直非常的小心使用鎖,了解死鎖導(dǎo)致的各種可能性,
這次的經(jīng)歷讓我未來會(huì)更加小心,下面來回顧一下死鎖發(fā)生的過程與代碼演進(jìn)的過程吧。

簡述業(yè)務(wù)背景及代碼演進(jìn)過程

我的程序中有一塊緩存,數(shù)據(jù)會(huì)組織好放到內(nèi)存中,會(huì)根據(jù)數(shù)據(jù)源(MySQL)更新而刷新緩存,是讀多寫少的應(yīng)用場景。
內(nèi)存中有一個(gè)很大數(shù)據(jù)列表,緩存模塊會(huì)按數(shù)據(jù)維度進(jìn)行分組,每次訪問根據(jù)維度查找到這個(gè)列表里面的所有數(shù)據(jù)。
業(yè)務(wù)模塊拿到數(shù)據(jù)后會(huì)根據(jù)業(yè)務(wù)需要再做一次篩選,選出N個(gè)符合條件的數(shù)據(jù)(具體多少個(gè)由業(yè)務(wù)模塊的規(guī)則決定)。

以下是簡化的代碼:

package cache

import "sync"

type Cache struct {
    lock sync.RWMutex
    data []int // 實(shí)際數(shù)據(jù)比這個(gè)復(fù)雜很多有很多維度
}

func (c *Cache) Get() []int {
    c.lock.RLock()
    defer c.lock.RUnlock()

    var res []int

    // 篩選數(shù)據(jù), 簡單寫一個(gè)篩選過程
    for i := range c.data {
        if c.data[i] > 10 {
            res = append(res, c.data[i])
        }
    }

    return res
}

這個(gè)方法返回的數(shù)據(jù)會(huì)很多,可實(shí)際業(yè)務(wù)需要的數(shù)據(jù)只有幾個(gè)而已,那做一個(gè)優(yōu)化吧,利用 gochan 實(shí)現(xiàn)一個(gè)迭代生成器,每次只返回一個(gè)數(shù)據(jù),業(yè)務(wù)端找到需要的數(shù)據(jù)后立即終止。

調(diào)整后的方法大致像下面這樣:

package cache

import "sync"

type Cache struct {
    lock sync.RWMutex
    data []int // 實(shí)際數(shù)據(jù)比這個(gè)復(fù)雜很多有很多維度
}

func (c *Cache) Get(next chan struct{}) chan int {
    ch := make(chan int, 1)

    go func() {
        c.lock.RLock()
        defer c.lock.RUnlock()
        defer close(ch)

        // 篩選數(shù)據(jù), 簡單寫一個(gè)篩選過程
        for i := range c.data {
            if c.data[i] > 10 {

                ch <- i

                if _, ok := <-next; !ok {
                    return
                }
            }
        }
    }()

    return ch
}

調(diào)用端的代碼類似下面這樣:

data := make([]int, 0, 10)
c := Cache{}
next := make(chan struct{})
for i := range c.Get(next) {
    data = append(data, i)
    if len(data) >= 10 {
        close(next)
        break
    }

    next <- struct{}{}
}

這樣調(diào)整后查看程序的內(nèi)存分配顯著降低,而且平安無事在生產(chǎn)環(huán)境運(yùn)行了半個(gè)月_,當(dāng)然截止當(dāng)前還不會(huì)出現(xiàn)死鎖的情況。
有一天業(yè)務(wù)調(diào)整了,在 cache 模塊有另外一個(gè)方法,公用這個(gè)鎖(實(shí)際我緩存模塊為了統(tǒng)一,都使用一個(gè)鎖,方便管理),下面的代碼也寫到這個(gè) cache 組件里面。

以下代碼只增加了改變的部分,.... 保持原來的代碼不變。

package cache

import "sync"

type Cache struct {
    ....
    x int
}

func (c *Cache) XX(i int) int{
    c.lock.RLock()
    defer c.lock.RUnlock()
    
    if  i >c.x {
        return i
    }
    return 0
} 

....

添加一個(gè)方法怎么就導(dǎo)致死鎖了呢,主要是調(diào)用端的業(yè)務(wù)代碼也發(fā)生變化了,更改如下:

data := make([]int, 0, 10)
c := Cache{}
next := make(chan struct{})
for i := range c.Get(next) {
    data = append(data, i)
    if c.XX(i) != i  { // 在這里調(diào)用了緩存模塊的另一個(gè)方法
        close(next)
        break
    }

    next <- struct{}{}
}

修改后的代碼上線存活了5天就掛了,實(shí)際是當(dāng)時(shí)業(yè)務(wù)訂單需求很少,只是有很多流量請求,并沒有頻繁訪問這個(gè)方法,否者會(huì)在極短的時(shí)間導(dǎo)致死鎖,
通過這塊簡化的代碼,也很難分析出會(huì)導(dǎo)致死鎖,真實(shí)的業(yè)務(wù)代碼很多,而且調(diào)用關(guān)系比較復(fù)雜,我們通過代碼審核并沒有發(fā)現(xiàn)任何問題。

事故現(xiàn)場分析排查問題

上線5天后突然接到服務(wù)無法響應(yīng)的報(bào)警,事故發(fā)生立即查看了 grafana 的監(jiān)控?cái)?shù)據(jù),發(fā)現(xiàn)在極段時(shí)間內(nèi)服務(wù)器資源消耗極速增長,然后就立即沒有響應(yīng)了

20181219-011353.jpg

通過業(yè)務(wù)監(jiān)控發(fā)現(xiàn)服務(wù)在極端的時(shí)間打開近10萬個(gè) goroutine 之后持續(xù)了很長一段時(shí)間,
cpu 占用和 gc 都很正常, 內(nèi)存方面可以看出短時(shí)間內(nèi)分配了很多內(nèi)存,但是沒有被釋放,gc 沒法回收說明一直被占用,

看到這里我心里在想可能是有個(gè) goroutine 因?yàn)槭裁丛驅(qū)е聼o法結(jié)束造成的事故吧,
然后我再往下看(實(shí)際頁面是在需要滾動(dòng)屏幕,第一屏只顯示了上面6個(gè)模塊),發(fā)現(xiàn) open files 和 goroutine 的情況一致,并且之后的數(shù)據(jù)突然中斷,
中斷是因?yàn)榉?wù)無法影響,也就無法采集服務(wù)的信息了。

openfd.jpg

goroutine 并不會(huì)占用 open files,一個(gè)http服務(wù)導(dǎo)致這種情況大概只能是網(wǎng)絡(luò)連接過多,我們遭受攻擊了嗎……
顯然是沒有的不然cpu不能很正常,那就是有可能請求無法響應(yīng),什么原因?qū)е履兀?/p>

使用 lsof -n | grep dsp | wc -l 命令去服務(wù)器查找服務(wù)打開文件數(shù),確實(shí)在六萬五千多,
通過 cat /proc/30717/limits 發(fā)現(xiàn) Max open files 65535 65535 files,
配置的最大打開文件數(shù)只有 65535,使用 lsof -n | grep dsp |grep TCP | wc -l 發(fā)現(xiàn)數(shù)據(jù)和之前接近,只小了幾個(gè),那是日志文件占用的。

查看日志發(fā)現(xiàn)大量 http: Accept error: accept tcp 172.17.191.231:8090: accept4: too many open files; retrying in 1s 錯(cuò)誤。

這些數(shù)據(jù)幫助我快速定位確實(shí)是有請求發(fā)送到服務(wù)器,服務(wù)器無法響應(yīng)導(dǎo)致短時(shí)間內(nèi)占用很多文件打開數(shù),導(dǎo)致系統(tǒng)限制無法建立新的連接。
這里要說一下,即使客戶端斷開連接了,服務(wù)器連接還是沒有辦法關(guān)閉,因?yàn)?goroutine 沒有辦法關(guān)閉, 除非自己退出。

找到原因了,服務(wù)沒法響應(yīng),沒法通過現(xiàn)場查找問題了,先重新啟動(dòng)一下服務(wù),恢復(fù)業(yè)務(wù)在查找代碼問題。

接下來就是查找代碼問題了,期間又出現(xiàn)了一次故障,立即重啟服務(wù),恢復(fù)業(yè)務(wù)。

分析解決問題

通過幾個(gè)小時(shí)分析代碼邏輯,終于有了進(jìn)展,發(fā)現(xiàn)上面的示例代碼邏輯塊導(dǎo)致讀鎖重入,存在死鎖風(fēng)險(xiǎn),這種死鎖的碰撞概率非常低,
之前說過我們的緩存是讀多寫少的場景,如果只是讀取數(shù)據(jù),上面的代碼不會(huì)有任何問題,我們一天刷新緩存的次數(shù)也不過百余次而已。

看一下究竟發(fā)生了什么導(dǎo)致的死鎖吧:

  • 程序執(zhí)行 cache.Get 獲取一個(gè) chan, 在 cache.Get 里面有一個(gè) goroutine 讀取數(shù)據(jù)只有加了讀寫鎖,只有 goroutine 關(guān)閉才會(huì)釋放
  • for i := range c.Get(next) { 遍歷 chan 時(shí) goroutine 不會(huì)結(jié)束,也就說讀鎖沒有被釋放
  • 遍歷時(shí)執(zhí)行了 c.XX(i) 方法,在該方面里面也加了讀鎖, 形成了讀鎖重入的場景,但是該放執(zhí)行周期很短,執(zhí)行完就會(huì)馬上釋放

好吧,這樣的流程并沒有形成死鎖,什么情況下導(dǎo)致的死鎖呢,接著看一下一個(gè)場景:

  • 程序執(zhí)行 cache.Get 獲取一個(gè) chan, 在 cache.Get 里面有一個(gè) goroutine 讀取數(shù)據(jù)只有加了讀寫鎖,只有 goroutine 關(guān)閉才會(huì)釋放
  • for i := range c.Get(next) { 遍歷 chan 時(shí) goroutine 不會(huì)結(jié)束,也就說讀鎖沒有被釋放
  • 數(shù)據(jù)發(fā)生了改變,觸發(fā)了緩存刷新,申請獨(dú)占鎖(寫鎖),等待所有讀鎖釋放
  • 遍歷時(shí)執(zhí)行 c.XX(i) 方法,該方法申請讀鎖,因?yàn)閷戞i在等待,所以任何讀鎖都將等待寫鎖釋放后才能添加成功
  • for 循環(huán)被阻塞, cache.Get 里面的 goroutine 無法退出,無法釋放讀鎖
  • 寫鎖等待所有讀鎖釋放
  • c.XX(i) 等待寫鎖釋放
  • ....

重點(diǎn)看第三步,這里是關(guān)鍵,因?yàn)樵趦蓚€(gè)嵌套的讀鎖中間申請寫鎖,導(dǎo)致死鎖發(fā)生,找到原因修復(fù)起來很簡單的,

調(diào)整 cache.Get 加鎖的方法,把 c.data 賦值給一個(gè)臨時(shí)變量 data, 在這段代碼前后加鎖和釋放鎖,鎖的代碼塊更小,時(shí)間更短

c.data 單獨(dú)拷貝是安全的,那怕是指針數(shù)據(jù),因?yàn)槊看嗡⑿戮彺娑紩?huì)給 c.data 重新賦值,分配新的內(nèi)存空間。

package cache

import "sync"

type Cache struct {
    lock sync.RWMutex
    data []int // 實(shí)際數(shù)據(jù)比這個(gè)復(fù)雜很多有很多維度
    x int
}
    
func (c *Cache) XX(i int) int{
    c.lock.RLock()
    defer c.lock.RUnlock()
    
    if  i >c.x {
        return i
    }
    return 0
} 

func (c *Cache) Get(next chan struct{}) chan int {
    ch := make(chan int, 1)

    go func() {
        defer close(ch)

        c.lock.RLock()
        data := c.data
        c.lock.RUnlock()
        
        // 篩選數(shù)據(jù), 簡單寫一個(gè)篩選過程
        for i := range data {
            if data[i] > 10 {

                ch <- i

                if _, ok := <-next; !ok {
                    return
                }
            }
        }
    }()

    return ch
}

修復(fù)之后的業(yè)務(wù)狀態(tài):

20181219-011418.jpg

復(fù)現(xiàn)問題

用程序復(fù)現(xiàn)一下上面的場景可以嗎,好像有點(diǎn)難,我寫了一個(gè)簡單的復(fù)現(xiàn)代碼,如下:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var l = sync.RWMutex{}

func main() {
    var wg sync.WaitGroup
    wg.Add(2)
    c := make(chan int)
    go func() {
        l.RLock() // 讀鎖1
        defer l.RUnlock()
        fmt.Println(1)
        c <- 1
        fmt.Println(2)
        runtime.Gosched()
        fmt.Println(3)
        b()
        fmt.Println(4)
        wg.Done()
    }()

    go func() {
        fmt.Println(5)
        <-c
        fmt.Println(6)
        l.Lock()
        fmt.Println(7)
        fmt.Println(8)
        defer l.Unlock()
        fmt.Println(9)
        wg.Done()
    }()

    go func() {
        i := 1
        for {
            i++
        }
    }()
    wg.Wait()
}

func b() {
    fmt.Println(10)
    l.RLock() // 讀鎖2
    fmt.Println(11)
    defer l.RUnlock()
    fmt.Println(12)
}

這段程序的輸出(受 goroutine 運(yùn)行時(shí)影響在輸出數(shù)字3之前會(huì)有些許差異):

1
5
6
2
3
10

分析一下這個(gè)運(yùn)行流程吧:

  • 首先加上讀鎖1,就是 fmt.Println(1) 之前, 狀態(tài)加讀鎖1
  • 另外一個(gè) goroutine 啟動(dòng),fmt.Println(5), 狀態(tài)加讀鎖1
  • 發(fā)送數(shù)據(jù) c <- 1 , 狀態(tài)加讀鎖1
  • 接受到數(shù)據(jù) <-c fmt.Println(6), 狀態(tài)加讀鎖1
  • 輸出 2 fmt.Println(2), 狀態(tài)加讀鎖1
  • 暫停當(dāng)前 goroutine runtime.Gosched() , 狀態(tài)加讀鎖1
  • 申請寫鎖 l.Lock(), 等待讀鎖1釋放, 狀態(tài)加讀鎖1、寫鎖等待
  • 切換 goroutine 執(zhí)行 fmt.Println(3)b(), 狀態(tài)加讀鎖1、寫鎖等待
  • 輸出10 fmt.Println(10), 申請讀鎖2,等待寫鎖釋放, 狀態(tài)加讀鎖1、寫鎖等待、讀鎖2等待
  • 支持程序永久阻塞……

分析讀寫鎖實(shí)現(xiàn)

func (rw *RWMutex) RLock() {
    if race.Enabled {
        _ = rw.w.state
        race.Disable()
    }
    if atomic.AddInt32(&rw.readerCount, 1) < 0 {
        // A writer is pending, wait for it.
        runtime_SemacquireMutex(&rw.readerSem, false)
    }
    if race.Enabled {
        race.Enable()
        race.Acquire(unsafe.Pointer(&rw.readerSem))
    }
}

申請寫鎖時(shí)會(huì)在 rw.readerCount 讀數(shù)量變量上自增加 1,如果結(jié)果小于 0,當(dāng)前讀鎖進(jìn)入修改等待讀鎖喚醒信號(hào),
單獨(dú)看著一個(gè)方法會(huì)比較懵,為啥讀的數(shù)量會(huì)小于0呢,接著看寫鎖。

func (rw *RWMutex) Lock() {
    if race.Enabled {
        _ = rw.w.state
        race.Disable()
    }
    // First, resolve competition with other writers.
    rw.w.Lock()
    // Announce to readers there is a pending writer.
    r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
    // Wait for active readers.
    if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
        runtime_SemacquireMutex(&rw.writerSem, false)
    }
    if race.Enabled {
        race.Enable()
        race.Acquire(unsafe.Pointer(&rw.readerSem))
        race.Acquire(unsafe.Pointer(&rw.writerSem))
    }
}

申請寫鎖時(shí)會(huì)先加上互斥鎖,也就是有其它寫的客戶端的話會(huì)等待寫鎖釋放才能加上,具體實(shí)現(xiàn)看互斥鎖的代碼,
然后在 rw.readerCount 上自增一個(gè)極大的負(fù)數(shù) 1 << 30 , 讀寫鎖這里也就限制了我們的同時(shí)讀的進(jìn)程不能超過這個(gè)值。
然后在結(jié)果上加上 rwmutexMaxReaders 也就是 atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders 得到實(shí)際讀客戶端的數(shù)量
如果讀的客戶端不等于0,就在 rw.readerWait 自增讀客戶端的數(shù)量,之后陷入睡眠,等待 rw.writerSem 喚醒。

分析了這兩段代碼我們就能明白,寫鎖等待或者添加時(shí),讀鎖沒法添加上

func (rw *RWMutex) RUnlock() {
    if race.Enabled {
        _ = rw.w.state
        race.ReleaseMerge(unsafe.Pointer(&rw.writerSem))
        race.Disable()
    }
    if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
        if r+1 == 0 || r+1 == -rwmutexMaxReaders {
            race.Enable()
            throw("sync: RUnlock of unlocked RWMutex")
        }
        // A writer is pending.
        if atomic.AddInt32(&rw.readerWait, -1) == 0 {
            // The last reader unblocks the writer.
            runtime_Semrelease(&rw.writerSem, false)
        }
    }
    if race.Enabled {
        race.Enable()
    }
}

釋放讀鎖,先在 rw.readerCount 減 1,然后檢查讀客戶端是否小于0,如果小于0說明有寫鎖在等待,
rw.readerWait 上減1,這個(gè)變量記錄的是寫等待讀客戶端的數(shù)量,如果沒有需要等待的讀客戶端了,就通知 rw.writerSem 喚醒寫鎖

func (rw *RWMutex) Unlock() {
    if race.Enabled {
        _ = rw.w.state
        race.Release(unsafe.Pointer(&rw.readerSem))
        race.Disable()
    }

    // Announce to readers there is no active writer.
    r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
    if r >= rwmutexMaxReaders {
        race.Enable()
        throw("sync: Unlock of unlocked RWMutex")
    }
    // Unblock blocked readers, if any.
    for i := 0; i < int(r); i++ {
        runtime_Semrelease(&rw.readerSem, false)
    }
    // Allow other writers to proceed.
    rw.w.Unlock()
    if race.Enabled {
        race.Enable()
    }
}

寫鎖在釋放時(shí)會(huì)給 rw.readerCount 自增 rwmutexMaxReaders 還原真實(shí)讀客戶端數(shù)量。
for i := 0; i < int(r); i++ { 用來喚醒所有的讀客戶端,因?yàn)樵趯戞i的時(shí)候,申請讀鎖的客戶端會(huì)被計(jì)數(shù),但是都會(huì)陷入睡眠狀態(tài)。

總結(jié)

以前特別強(qiáng)調(diào)過讀鎖重入導(dǎo)致死鎖的問題,而且這個(gè)問題非常難在業(yè)務(wù)代碼里面復(fù)現(xiàn),觸發(fā)幾率很低,
編譯和運(yùn)行時(shí)都無法檢測這種情況,所以千萬不能陷入讀鎖重入的嵌套使用的情況,否者問題非常難以排查。

關(guān)于加鎖的幾個(gè)小經(jīng)驗(yàn):

  • 運(yùn)行時(shí)離開當(dāng)前邏輯就釋放鎖。
  • 鎖的粒度越小越好,加鎖后盡快釋放鎖。
  • 盡量不用 defer 釋放鎖。
  • 讀鎖不要嵌套。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容