成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

多線程讀寫鎖產生死鎖的故障解決方案

開發 系統
本文從一次協程泄露問題入手,分析golang讀寫鎖可能產生死鎖的場景,希望讀者可以避坑。

作者 | morphis

多線程環境下,讀寫鎖是一種常用的同步原語,適用于多讀者-多寫者的經典問題;合理的使用可以在保證數據一致性的前提下,大幅提升讀性能,但不合理的使用可能會導致死鎖。本文從一次協程泄露問題入手,分析golang讀寫鎖可能產生死鎖的場景,希望讀者可以避坑。

一、故障背景

近期線上某個trpc-go服務一直在OOM,據以往查障經驗,golang服務發生內存持續上漲大概率是由兩個原因導致:

  • 請求量過大,服務處理不過來,造成協程積壓,或者資源積壓;
  • 協程泄露,由于未正確關閉、或者協程阻塞等原因,導致協程積壓。

123平臺容器監控如下:

二、排查思路

1. 檢查請求量級

第一時間排查了一下服務的請求量和CPU/內存情況,發現請求量并未上漲,內存上漲的同時,CPU并未線性相關上漲,但是協程數一直在上漲;這里就排除了請求積壓這個原因。

2. 檢查協程泄露

在請求量并未明顯上漲的前提下,協程數在上漲,比較符合協程泄露的現象;于是借助123平臺的pprof工具,采樣觀測了一下協程積壓情況,如下:

發現協程大量積壓在gopark函數等待上,證明大量協程正掛起等待,根據調用棧定位到業務代碼分別是對一個讀寫鎖的讀鎖、寫鎖加鎖行為:

(*RWMutex).Rock()
(*RWMutex).Lock()

3. 定位協程泄漏點

定位到這里其實已經基本猜到是由于讀寫鎖的阻塞導致協程泄露,遂review代碼,兩個競態的協程分別按順序執行的加鎖行為如下:

(1)協程A:讀鎖-加鎖,第一次。

(2)協程A:讀鎖-加鎖,第二次:

(3)協程B:寫鎖-加鎖

回想操作系統原理中,死鎖產生的4個必要條件:

  • 互斥(Mutual Exclusion):至少有一個資源必須處于非共享模式,也就是說,在一段時間內只能有一個進程使用該資源。如果其他進程請求該資源,請求者只能等待,直到資源被釋放。
  • 占有并等待(Hold and Wait):一個進程至少持有一個資源,并且正在等待獲取額外的資源,這些額外資源被其他進程持有。非搶占(No Preemption):資源不能被強制從一個進程中搶占,只能由持有資源的進程主動釋放。
  • 循環等待(Circular Wait):必須存在一個進程—資源的環形鏈,其中每個進程至少持有一個資源,但又等待另一個被鏈中下一個進程持有的資源。

互斥、非搶占、占有并等待在現代編程語言中的同步語法中都是容易滿足的,問題就出在循環等待上,在讀寫鎖的使用上,經常出現死鎖的情況是:

協程A:讀鎖 - 加鎖 協程A:寫鎖 - 加鎖

一般是同一個協程在持有鎖(讀/寫)的情況下,請求寫鎖,這個時候寫鎖因為持有的鎖并未釋放,而永久等待,陷入死鎖困境;回過頭去review代碼,并未出現這種情況,但是RLock重入了一次,仔細閱讀了golang官方文檔,禁止RLock的遞歸調用,換句話說是在同一協程下,讀鎖未UnLock的情況下,禁止重復調用RLock(當然也不能調用Lock),否則可能導致死鎖:

A RWMutex must not be copied after first use.

If any goroutine calls RWMutex.Lock while the lock is already held by one or more readers, concurrent calls to RWMutex.RLock will block until the writer has acquired (and released) the lock, to ensure that the lock eventually becomes available to the writer. Note that this prohibits recursive read-locking.

排查至此,問題已經明確,是有重入讀鎖導致的死鎖,進而引發的協程泄露。

三、讀寫鎖原理

1. 讀寫鎖適用場景

深究死鎖產生的原因,不得不探究一下讀寫鎖的適用場景。在大多數編程語言中,如果存在多線程對數據的競爭,最常用的一個方案是數據加互斥鎖/排它鎖(Mutex),互聯網業務特點,在對數據的操作上,普遍是讀遠多于寫,如果所有對數據的操作都互斥,即便是沒有寫行為,大量并發的讀操作也會退化成成串行訪問。這個時候就需要差異化對待讀、寫行為,使用更‘細粒度’的鎖-讀寫鎖(共享鎖),基本特性概括如下:

  • 讀鎖和讀鎖 - 不互斥
  • 讀鎖和寫鎖 - 互斥
  • 寫鎖和寫鎖 - 互斥

這樣就可以在持有寫鎖時,任何讀/寫行為都會被阻塞;且未持有寫鎖時,任何讀行為都不會被阻塞;即保護了數據,又保證了性能;聽起來很“完美”的解決方案,但經常寫技術方案的同學都知道,沒有最好的技術方案,只有合適的技術方案。既然根據讀、寫操作對鎖進行了區分,那么針對不同的業務場景(讀密集 or 寫密集),不同的設計取向(性能優先 or 一致性優先),必然面臨一個問題:讀寫鎖獲取鎖的優先級是怎樣的?

2. 優先級策略

針對reader-writer問題,基于對讀和寫操作的優先級,讀寫鎖的設計和實現也分成三類:

  • Read-preferring: 讀優先策略,可實現最大并發性,但如果讀操作密集,會導致寫鎖饑餓。因為只要一個讀取線程持有鎖,寫入線程就無法獲取鎖。如果有源源不斷的讀操作,寫鎖只能等待所有讀鎖釋放后才能獲取到。
  • Writer-preferring:寫優先的策略,可以保證即便在讀密集的場景下,寫鎖也不會饑餓;只要有一個寫鎖申請加鎖,那么就會阻塞后續的所有讀鎖加鎖行為(已經獲取到讀鎖的reader不受影響,寫鎖仍然要等待這些讀鎖釋放之后才能加鎖)。
  • Unspecified(不指定):不區分reader和writer優先級,中庸之道,讀寫性能不是最優,但是可以避免饑餓問題。

3. 源碼分析

golang標準庫里讀寫鎖的實現(RWMutex),采用了writer-preferring的策略,使用Mutex實現寫-寫互斥,通過信號量等待和喚醒實現讀-寫互斥,RWMutex定義如下:

type RWMutex struct {
  w           Mutex        // 互斥鎖,用于寫鎖互斥
  writerSem   uint32       // writer信號量,讀鎖RUnlock時釋放,可以喚醒等待寫加鎖的線程
  readerSem   uint32       // reader信號量,寫鎖Unlock時釋放,可以喚醒等待讀加鎖的線程
  readerCount atomic.Int32 // 所有reader的數量(包括等待讀鎖和已經獲得讀鎖)
  readerWait  atomic.Int32 // 已經獲取到讀鎖的reader數量,writer需要等待這個變量歸0后才可以獲得寫鎖
}

(1)讀鎖-加鎖

readerCount是一個有多重含義的變量,在沒有寫鎖的情況下,每次讀鎖加鎖,都會原子+1;每次讀鎖釋放,readerCount會原子-1,所以在沒有寫鎖的情況下readerCount=獲取到的讀鎖的reader數量;但是當存寫鎖pending時,都會將readerCount置為負數,所以這里判斷為負數時,直接進入信號量等待。

func (rw *RWMutex) RLock() {
        // ...
 if rw.readerCount.Add(1) < 0 {
               // 當有writer在pending時,readerCount會被加一個很大的負數,保證readerCount變成負數
               // 有writer在等待鎖,需要等待reader信號量
  runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)
 }
 // ...
}

(2)讀鎖-釋放

reader釋放讀鎖時,優先將readerCount-1,這時如果還是負數,證明有寫鎖在pending,這個時候需要釋放信號量,以便喚醒等待寫鎖的writer;當然,前提需要所有已經獲得讀鎖的reader都釋放讀鎖后(readerWait == 0),那問題來了,為什么需要先檢查readerCount,再對readerWait-1,實際上readerWait只有在有寫鎖在pending時才會生效,否則,readerCount就等于已經獲得讀鎖的reader數量。

func (rw *RWMutex) RUnlock() {
        // ...
        if r := rw.readerCount.Add(-1); r < 0 {
  // 這里將相對slow的代碼封裝起來,以便RUnlock()可以被更多場景內聯,提升程序性能
                // Outlined slow-path to allow the fast-path to be inlined
  rw.rUnlockSlow(r)
 }
 // ...
}
func (rw *RWMutex) rUnlockSlow(r int32) {
 // ...
 // readerWait變量,記錄真正獲得讀鎖的reader數量,當這個變量規0時,需要釋放信號量,以便喚醒等到寫鎖的writer
 if rw.readerWait.Add(-1) == 0 {
  // The last reader unblocks the writer.
  runtime_Semrelease(&rw.writerSem, false, 1)
 }
}

(3)寫鎖-加鎖

寫鎖間的互斥需要依賴互斥量,所以首先需要對w進行競爭加鎖;當獲取到w之后,證明可以獨占寫操作;這個時候再來檢查讀鎖的情況;這里不得不感慨golang底層庫的實現之精妙,在一個計數值上賦予了多重含義,將readerCount加一個巨大的、固定的負數可以保證readerCount為負數,這樣就可以在標記有一個寫鎖在pending的同時,也不會丟失readerCount的數量。

func (rw *RWMutex) Lock() {
 // ...
 rw.w.Lock() // 寫鎖互斥
 // 將readerCount加一個巨大的、固定的負數,保證readerCount為負數
        // r代表readerCount變成負數的那一刻的readCount,代表了請求寫鎖那一刻'注定'能獲取讀鎖的reader數量,在此之后的reader不管怎么對readerCount+1,都會阻塞到信號量等待上;
        // 所以r的值就是在加寫鎖的那一刻,已經獲得讀鎖的reader數量
 r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders
 // 當有reader已經獲得讀鎖時,需要等待信號量
 if r != 0 && rw.readerWait.Add(r) != 0 {
  runtime_SemacquireRWMutex(&rw.writerSem, false, 0)
 }
 // ...
}

r代表著在寫鎖加鎖的那一刻(是一個瞬時值,可能會變),已經獲得讀鎖的reader數量,通過過對readerWait賦值和判斷,決定是否需要等待信號量;那么問題來了,既然r是一個瞬時值,如果r已經變了,怎么保證readerWait是準的,例如:

在執行這行代碼時,r=5,代表有5個reader獲得了讀鎖,此時readerWait==0:

r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders

而在執行下面這行之前,有2個reader已經釋放讀鎖,此時readerWait==-2,再執行下面這樣代碼后,readerWait==3,這樣設計的精妙之處就在于,不管readerWait中間如何變化,只要在使用的那一刻他是最終準確的就可以,所以嚴格意義上講readerWait記錄的是已經持有讀鎖的reader數量,或者"自有寫鎖pending那一刻來,被釋放的讀鎖的負數量"

if r != 0 && rw.readerWait.Add(r) != 0 {

這也是在讀鎖釋放時,必須要判斷readerWait==0,而不是<=0的原因:

if rw.readerWait.Add(-1) == 0 {

(4)寫鎖-釋放

寫鎖的釋放主要做3件事,將readerCount置為正數,表示新的reader可以不用等待信號量,直接獲取讀鎖了;對正在阻塞等待信號量的reader,依次喚醒;注意,這里的r也是一個瞬時值,代表著寫鎖釋放那一刻,正在等待信號量的reader,之后不管新的reader如何對readerCount+1,writer需要喚醒的reader也停留在“寫鎖釋放那一刻”;最后釋放互斥量,允許其他writer獲取寫鎖。

func (rw *RWMutex) Unlock() {
 // ...
 // 恢復readerCount為正數,表示reader可以獲取讀鎖了
 r := rw.readerCount.Add(rwmutexMaxReaders)
 // 省略...
 // 釋放信號量,喚醒等待的reader
 for i := 0; i < int(r); i++ {
  runtime_Semrelease(&rw.readerSem, false, 0)
 }
 // 釋放互斥量,允許其他寫鎖獲取
 rw.w.Unlock()
 // ...
}

golang讀寫鎖的實現,可以保證在沒有writer請求寫鎖時,讀操作可以保證最大的性能,此時的讀鎖加鎖-釋放行為,開銷非常小,僅僅是原子更新readerCount;當有writer請求寫鎖時,寫鎖的加鎖-釋放行為會重一些,已經獲得讀鎖的reader也不受影響;后續再請求讀鎖的reader將被阻塞,直到寫鎖釋放,大道至簡。

四、問題總結

1. 故障原因

回到故障本身,死鎖的原因是讀鎖的重入,造成了死鎖;實際上單純的讀鎖重入,不會造成死鎖;造成死鎖的case時序可以表示如下:

  • reader0:加鎖R0成功
  • writer0:  加寫鎖W0申請,標記已經有writer等待寫鎖,然后等待R0釋放,W0->R0
  • reader0:  嘗試加鎖R1,發現已經有writer在等待,因為寫鎖優先級較高,所以需要等待W0釋放,R1->W0
  • reader0:  由于代碼邏輯上R0,R1是先后關系,所以R0需要依賴R1釋放,R0->R1

這樣就造成了死鎖必要的循環依賴條件:R0->R1->W0->R0

2. 解決方案

明確了故障原因,解決方案就顯而易見了,避免重復調用鎖就可以避免此類死鎖的發生,但函數調用靈活復雜的,無法避免函數的嵌套調用,目前也沒有效的手段可以在編譯期發現這個問題;但我們可以通過一些范式來盡量避免,總結而言就是-臨界區一定要小:

  • 鎖的粒度一定要小,一方面避免粒度過大造成鎖忘記釋放,另一方面避免臨界區過大造成資源浪費。
  • 避免在臨界區嵌套調用函數,一般情況下,需要加鎖的情況無外乎對數據的讀和寫操作,應當在一眼所視范圍內對數據進行讀寫操作,而不是通過調用函數的方式。
  • defer釋放鎖要格外小心,defer可以方便的釋放鎖,可以避免忘記釋放鎖,可能第一版代碼臨界區就幾行,隨著后續維護者的拓展,臨界區變成了幾十行、上百行,這個時候其實就需要review一下,鎖的保護范圍到底是誰,是否粒度過大;若粒度過大,就應該棄用defer,主動盡早釋放鎖。

3. 拓展思考

(1) golang

在排查故障的過程中,了解到golang RWMutext的使用還有幾個可能踩到的坑:

  • 不可復制,復制可能導致內部計數值readCount、readWait的原子性遭到破壞,進而導致信號量的等待/釋放錯亂,最后導致鎖被重復釋放,或者鎖永遠被持有無法釋放等異常現象。
  • 重入讀鎖導致死鎖(本文所示)
  • 釋放未加鎖的RWMutex,或重復釋放,本質上是對RUnlock/Unlock操作的重入,RLock/Lock的重入會導致死鎖,重復解鎖會導致panic,所以RLock/RUnlock、Lock/Unlock一定要成對出現。

(2) pthread-c

讀寫鎖并不是golang的原創,posix標準線程庫里也有對應的實現:

int pthread_rwlock_rdlock(pthread_rwlock_t *rwptr);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwptr);
int pthread_rwlock_unlock(pthread_rwlock_t *rwptr);

posix標準并未指定讀寫鎖的優先級,但允許實現者采用writer-preferring來避免writer饑餓問題,

The pthread_rwlock_rdlock() function applies a read lock to the read-write lock referenced by rwlock. The calling thread acquires the read lock if a writer does not hold the lock and there are no writers blocked on the lock. It is unspecified whether the calling thread acquires the lock when a writer does not hold the lock and there are writers waiting for the lock. If a writer holds the lock, the calling thread will not acquire the read lock. If the read lock is not acquired, the calling thread blocks (that is, it does not return from the pthread_rwlock_rdlock() call) until it can acquire the lock. Results are undefined if the calling thread holds a write lock on rwlock at the time the call is made.

mplementations are allowed to favour writers over readers to avoid writer starvation

粗略追了一下glibc的源碼,pthread讀寫鎖的實現也是采用writer-preferring(__nr_writers_queued記錄正在等待寫鎖的writer)。

/* Acquire read lock for RWLOCK.  */
int
__pthread_rwlock_rdlock (rwlock)
     pthread_rwlock_t *rwlock;
{
  int result = 0;

  LIBC_PROBE (rdlock_entry, 1, rwlock);

  /* Make sure we are alone.  */
  lll_lock (rwlock->__data.__lock, rwlock->__data.__shared);

  while (1)
    {
      /* Get the rwlock if there is no writer...  */
      if (rwlock->__data.__writer == 0
   /* ...and if either no writer is waiting or we prefer readers.  */
   && (!rwlock->__data.__nr_writers_queued
       || PTHREAD_RWLOCK_PREFER_READER_P (rwlock)))
 {
   /* Increment the reader counter.  Avoid overflow.  */
   if (__builtin_expect (++rwlock->__data.__nr_readers == 0, 0))
     {
       /* Overflow on number of readers.  */
       --rwlock->__data.__nr_readers;
       result = EAGAIN;
     }

(3) C++

C++ 17提供了“共享鎖”這種語義的同步原語shared_mutex,類似于讀寫鎖,通過對shared_mutex加獨占鎖lock()和共享鎖lock_shared()來實現讀寫鎖語義,不過標準本身也沒有定義讀寫鎖優先級。

Acquires shared ownership of the mutex. If another thread is holding the mutex in exclusive ownership, a call to lock_shared will block execution until shared ownership can be acquired.

If lock_shared is called by a thread that already owns the mutex in any mode (exclusive or shared), the behavior is undefined.

namespace std {
  class shared_mutex {
  public:
// ......
    // exclusive ownership
    void lock();                // blocking
    bool try_lock();
    void unlock();
    // shared ownership
    void lock_shared();         // blocking
    bool try_lock_shared();
    void unlock_shared();
    // ......
  };
}

查閱了一下資料,有網友做過一些優先級相關的實驗,結果如下(實驗結果引用自引用地址):

  • gcc version 9.3.0的實現中,shared_mutex是讀優先的
  • gcc version 10.2.0的實現中,shared_mutex是寫優先的
責任編輯:趙寧寧 來源: 騰訊技術工程
相關推薦

2009-09-14 19:39:14

批量線程同步

2024-01-19 21:55:57

C++編程代碼

2012-05-18 11:17:58

Java多線程

2009-07-15 17:09:32

Swing線程

2022-09-06 08:02:40

死鎖順序鎖輪詢鎖

2010-04-20 11:56:30

Oracle物理結構故

2024-07-16 08:03:43

2024-09-26 00:00:10

死鎖阿里面試

2011-09-27 09:42:01

Linux系統

2025-03-03 01:25:00

SpringAOP日志

2009-12-22 15:50:11

2010-06-07 09:22:21

MySQL+PHP亂碼

2018-01-11 21:32:45

機房漏電機房安全

2018-06-25 10:43:40

機房漏電方案

2009-03-18 09:26:23

Winform多線程C#

2009-12-29 09:01:49

2014-09-10 09:58:39

U-Mail郵件系統

2024-07-08 12:51:05

2021-08-31 07:57:21

輪詢鎖多線編程Java

2009-09-10 14:00:00

點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 涩涩鲁亚洲精品一区二区 | 国产一区www| 欧美激情五月 | 男女爱爱福利视频 | 国产二区av | 久久免费观看视频 | 日韩在线观看一区 | 欧洲一级毛片 | 亚洲一区 | 亚洲国产成人精品女人 | 69福利影院 | 国产精品午夜电影 | 成人亚洲精品久久久久软件 | 理论片午午伦夜理片影院 | 一级毛片播放 | 欧美在线观看一区 | 99tv| 免费a网站| 拍真实国产伦偷精品 | 日韩精品一区二区三区老鸭窝 | 可以免费看的毛片 | 9久9久9久女女女九九九一九 | 国产黄色av网站 | 日韩欧美手机在线 | 亚洲一区二区三区在线 | 最新中文字幕在线播放 | 综合色在线 | 久久免费高清 | 亚洲午夜视频 | 日韩精品视频一区二区三区 | 国产综合在线视频 | 国产精品久久久久久久久大全 | 成人欧美一区二区三区白人 | 亚洲欧美日韩一区二区 | 四虎成人在线播放 | 天堂亚洲| 日本羞羞影院 | 亚洲狠狠爱 | 欧美精品一区在线 | 欧美精品一区二区三区在线 | 精精国产xxxx视频在线野外 |