Golang 可重入鎖的實現

項目中遇到了可重入鎖的需求和實現,具體記錄下。

爲什麼需要可重入鎖

我們平時說的分佈式鎖,一般指的是在不同服務器上的多個線程中,只有一個線程能搶到一個鎖,從而執行一個任務。而我們使用鎖就是保證一個任務只能由一個線程來完成。所以我們一般是使用這樣的三段式邏輯:

Lock();
DoJob();
Unlock();

但是由於我們的系統都是分佈式的,這個鎖一般不會只放在某個進程中,我們會借用第三方存儲,比如 Redis 來做這種分佈式鎖。但是一旦藉助了第三方存儲,我們就必須面對這個問題:Unlock 是否能保證一定運行呢?

這個問題,我們面對的除了程序的 bug 之外,還有網絡的不穩定,進程被殺死,服務器被 down 機等。我們是無法保證 Unlock 一定被運行的。

那麼我們就一般在 Lock 的時候爲這個鎖加一個超時時間作爲兜底。

LockByExpire(duration);
DoJob();
Unlock();

這個超時時間是爲了一旦出現異常情況導致 Unlock 沒有被運行,這個鎖在 duration 時間內也會被自動釋放。這個在 redis 中我們一般就是使用set ex 來進行鎖超時的設定。

但是有這個超時時間我們又遇上了問題,超時時間設置多久合適呢?當然要設置的比 DoJob 消耗的時間更長,否則的話,在任務還沒結束的時候,鎖就被釋放了,還是有可能導致併發任務的存在。

但是實際上,同樣由於網絡超時問題,系統運行狀況問題等,我們是無法準確知道 DoJob 這個函數要執行多久的。那麼這時候怎麼辦呢?

有兩個辦法:

第一個方法,我們可以對 DoJob 做一個超時設置。讓 DoJob 最多隻能執行 n 秒,那麼我的分佈式鎖的超時時長設置比 n 秒長就可以了。爲一個任務設置超時時間在很多語言是可以做到的。比如 golang 中的 TimeoutContext。

而第二種方法,就是我們先爲鎖設置一個比較小的超時時長,然後不斷續期這個鎖。對一個鎖的不斷需求,也可以理解爲重新開始加鎖,這種可以不斷續期的鎖,就叫做可重入鎖。

除了主線程之外,可重入鎖必然有一個另外的線程(或者攜程)可以對這個鎖進行續期,我們叫這個額外的程序叫做 watchDog(看門狗)。

具體實現

在 Golang 中,語言級別天生支持協程,所以這種可重入鎖就非常容易實現:

// DistributeLockRedis 基於redis的分佈式可重入鎖,自動續租
type DistributeLockRedis struct {
 key       string             // 鎖的key
 expire    int64              // 鎖超時時間
 status    bool               // 上鎖成功標識
 cancelFun context.CancelFunc // 用於取消自動續租攜程
 redis     redis.Client       // redis句柄
}

// 創建可
func NewDistributeLockRedis(key string, expire int64) *DistributeLockRedis {
 return &DistributeLockRedis{
   key : key,
   expire : expire,
 }
}

// TryLock 上鎖
func (dl *DistributeLockRedis) TryLock() (err error) {
 if err = dl.lock(); err != nil {
  return err
 }
 ctx, cancelFun := context.WithCancel(context.Background())
 dl.cancelFun = cancelFun
 dl.startWatchDog(ctx) // 創建守護協程,自動對鎖進行續期
 dl.status = true
 return nil
}

// competition 競爭鎖
func (dl *DistributeLockRedis) lock() error {
 if res, err := redis.String(dl.redis.Do(context.Background()"SET", dl.key, 1, "NX""EX", dl.expire)); err != nil {
  return err
 } 
 return nil
}


// guard 創建守護協程,自動續期
func (dl *DistributeLockRedis) startWatchDog(ctx context.Context) {
 safeGo(func() error {
  for {
   select {
   // Unlock通知結束
   case <-ctx.Done():
    return nil
   default:
    // 否則只要開始了,就自動重入(續租鎖)
    if dl.status {
     if res, err := redis.Int(dl.redis.Do(context.Background()"EXPIRE", dl.key, dl.expire)); err != nil {
      return nil
     } 
     // 續租時間爲 expire/2 秒
     time.Sleep(time.Duration(dl.expire/2) * time.Second)
    }
   }
  }
 })
}

// Unlock 釋放鎖
func (dl *DistributeLockRedis) Unlock() (err error) {
 // 這個重入鎖必須取消,放在第一個地方執行
 if dl.cancelFun != nil {
  dl.cancelFun() // 釋放成功,取消重入鎖
 }
 var res int
 if dl.status {
  if res, err = redis.Int(dl.redis.Do(context.Background()"Del", dl.key)); err != nil {
   return fmt.Errorf("釋放鎖失敗")
  }
  if res == 1 {
   dl.status = false
   return nil
  }
 }
 return fmt.Errorf("釋放鎖失敗")
}

這段代碼的邏輯基本上都以註釋的形式來寫了。其中主要就在 startWatchDog,對鎖進行重新續期

ctx, cancelFun := context.WithCancel(context.Background())
dl.cancelFun = cancelFun
dl.startWatchDog(ctx) // 創建守護協程,自動對鎖進行續期
dl.status = true

首先創建一個 cancelContext,它的 context 函數 cancelFunc 是給 Unlock 進行調用的。然後啓動一個 goroutine 進程來循環續期。

這個新啓動的 goroutine 在主 goroutine 處理結束,調用 Unlock 的時候,纔會結束,否則會在 過期時間 / 2 的時候,調用一次 redis 的 expire 命令來進行續期。

至於外部,在使用的時候如下

func Foo() error {
  key := foo
  
  // 創建可重入的分佈式鎖
 dl := NewDistributeLockRedis(key, 10)
 // 爭搶鎖
 err := dl.TryLock()
 if err != nil {
  // 沒有搶到鎖
  return err
 }
 
 // 搶到鎖的記得釋放鎖
 defer func() {
  dl.Unlock()
 }
 
 // 做真正的任務
 DoJob()
}

參考文章

如果還想了解更多,以下的參考文章值得閱讀。

redisson

https://github.com/redisson/redisson

滴滴 曾奇:談談我所認識的分佈式鎖

http://blog.itpub.net/69908606/viewspace-2644366/

Redis 分佈式鎖|從青銅到鑽石的五種演進方案

https://my.oschina.net/u/4499317/blog/5039486

分佈式鎖中的王者方案 - Redisson

https://xie.infoq.cn/article/d8e897f768eb1a358a0fd6300#:~:text=Redisson%20%E6%98%AF%E4%B8%80%E4%B8%AA%E5%9C%A8Redis,In%2DMemory%20Data%20Grid%EF%BC%89%E3%80%82

redisson 中的看門狗機制總結

https://www.cnblogs.com/jelly12345/p/14699492.html

Redis 分佈式鎖如何自動續期

https://blog.csdn.net/yangbindxj/article/details/123189395

到底什麼是重入鎖,拜託,一次搞清楚!

https://zhuanlan.zhihu.com/p/71018541

 

Hi,我是軒脈刃,一個名不見經傳碼農,體制內的小憤青,躁動的騷年,2022 年想堅持寫一些學習 / 工作 / 思考筆記,謂之倒逼學習。歡迎關注個人公衆號:軒脈刃的刀光劍影。

軒脈刃的刀光劍影 工作生活中遇到的日常點滴記錄,或許有技術筆記,或許有日常思考。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/xD0LwoFCyw4oYEOJNtFasw