Go高階併發 09 | 同步原語:sync 包讓你對併發控制得心應手

Swenson1992發表於2021-02-08

channel 為什麼是併發安全的呢?是因為 channel 內部使用了互斥鎖來保證併發的安全。
在 Go 語言中,不僅有 channel 這類比較易用且高階的同步機制,還有 sync.Mutex、sync.WaitGroup 等比較原始的同步機制。通過它們,可以更加靈活地控制資料的同步和多協程的併發。

資源競爭

在一個 goroutine 中,如果分配的記憶體沒有被其他 goroutine 訪問,只在該 goroutine 中被使用,那麼不存在資源競爭的問題。

但如果同一塊記憶體被多個 goroutine 同時訪問,就會產生不知道誰先訪問也無法預料最後結果的情況。這就是資源競爭,這塊記憶體可以稱為共享的資源

//共享的資源
var sum = 0
func main() {
  //開啟100個協程讓sum+10
   for i := 0; i < 100; i++ {
      go add(10)
   }
   //防止提前退出
   time.Sleep(2 * time.Second)
   fmt.Println("和為:",sum)
}
func add(i int) {
   sum += i
}

期待的結果可能是“和為 1000”,但當執行程式後,可能如預期所示,但也可能是 990 或者 980。導致這種情況的核心原因是資源 sum 不是併發安全的,因為同時會有多個協程交叉執行 sum+=i,產生不可預料的結果。
既然已經知道了原因,解決的辦法也就有了,只需要確保同時只有一個協程執行 sum+=i 操作即可。要達到該目的,可以使用 sync.Mutex 互斥鎖。

小技巧:使用 go build、go run、go test 這些 Go 語言工具鏈提供的命令時,新增 -race 標識可以幫你檢查 Go 語言程式碼是否存在資源競爭。

同步原語

sync.Mutex

互斥鎖,顧名思義,指的是在同一時刻只有一個協程執行某段程式碼,其他協程都要等待該協程執行完畢後才能繼續執行。

在下面的程式碼中,宣告瞭一個互斥鎖 mutex,然後修改 add 函式,對 sum+=i 這段程式碼加鎖保護。這樣這段訪問共享資源的程式碼片段就併發安全了,可以得到正確的結果。

var(
   sum int
   mutex sync.Mutex
)
func add(i int) {
   mutex.Lock()
   sum += i
   mutex.Unlock()
}

小提示:以上被加鎖保護的 sum+=i 程式碼片段又稱為臨界區。在同步的程式設計中,臨界區段指的是一個訪問共享資源的程式片段,而這些共享資源又有無法同時被多個協程訪問的特性。 當有協程進入臨界區段時,其他協程必須等待,這樣就保證了臨界區的併發安全。

互斥鎖的使用非常簡單,它只有兩個方法 Lock 和 Unlock,代表加鎖和解鎖。當一個協程獲得 Mutex 鎖後,其他協程只能等到 Mutex 鎖釋放後才能再次獲得鎖。

Mutex 的 Lock 和 Unlock 方法總是成對出現,而且要確保 Lock 獲得鎖後,一定執行 UnLock 釋放鎖,所以在函式或者方法中會採用 defer 語句釋放鎖,如下面的程式碼所示:

func add(i int) {
   mutex.Lock()
   defer mutex.Unlock()
   sum += i
}

這樣可以確保鎖一定會被釋放,不會被遺忘。

sync.RWMutex

在 sync.Mutex 模組中,對共享資源 sum 的加法操作進行了加鎖,這樣可以保證在修改 sum 值的時候是併發安全的。如果讀取操作也採用多個協程呢?如下面的程式碼所示:

func main() {
   for i := 0; i < 100; i++ {
      go add(10)
   }
   for i:=0; i<10;i++ {
      go fmt.Println("和為:",readSum())
   }
   time.Sleep(2 * time.Second)
}
//增加了一個讀取sum的函式,便於演示併發
func readSum() int {
   b:=sum
   return b
}

這個示例開啟了 10 個協程,它們同時讀取 sum 的值。因為 readSum 函式並沒有任何加鎖控制,所以它不是併發安全的,即一個 goroutine 正在執行 sum+=i 操作的時候,另一個 goroutine 可能正在執行 b:=sum 操作,這就會導致讀取的 num 值是一個過期的值,結果不可預期。
如果要解決以上資源競爭的問題,可以使用互斥鎖 sync.Mutex,如下面的程式碼所示:

func readSum() int {
   mutex.Lock()
   defer mutex.Unlock()
   b:=sum
   return b
}

因為 add 和 readSum 函式使用的是同一個 sync.Mutex,所以它們的操作是互斥的,也就是一個 goroutine 進行修改操作 sum+=i 的時候,另一個 gouroutine 讀取 sum 的操作 b:=sum 會等待,直到修改操作執行完畢。
因為 add 和 readSum 函式使用的是同一個 sync.Mutex,所以它們的操作是互斥的,也就是一個 goroutine 進行修改操作 sum+=i 的時候,另一個 gouroutine 讀取 sum 的操作 b:=sum 會等待,直到修改操作執行完畢。

現在解決了多個 goroutine 同時讀寫的資源競爭問題,但是又遇到另外一個問題——效能。因為每次讀寫共享資源都要加鎖,所以效能低下,這該怎麼解決呢?

現在分析讀寫這個特殊場景,有以下幾種情況:

  • 寫的時候不能同時讀,因為這個時候讀取的話可能讀到髒資料(不正確的資料);
  • 讀的時候不能同時寫,因為也可能產生不可預料的結果;
  • 讀的時候可以同時讀,因為資料不會改變,所以不管多少個 goroutine 讀都是併發安全的。

所以就可以通過讀寫鎖 sync.RWMutex 來優化這段程式碼,提升效能。現在我將以上示例改為讀寫鎖,來實現我們想要的結果,如下所示:

var mutex sync.RWMutex
func readSum() int {
   //只獲取讀鎖
   mutex.RLock()
   defer mutex.RUnlock()
   b:=sum
   return b
}

對比互斥鎖的示例,讀寫鎖的改動有兩處:

  • 把鎖的宣告換成讀寫鎖 sync.RWMutex。
  • 把函式 readSum 讀取資料的程式碼換成讀鎖,也就是 RLock 和 RUnlock。
    這樣效能就會有很大的提升,因為多個 goroutine 可以同時讀資料,不再相互等待。

    sync.WaitGroup

    在上面的程式碼中,相信你注意到了這段 time.Sleep(2 * time.Second) 程式碼,這是為了防止主函式 main 返回使用,一旦 main 函式返回了,程式也就退出了。

因為不知道 100 個執行 add 的協程和 10 個執行 readSum 的協程什麼時候完全執行完畢,所以設定了一個比較長的等待時間,也就是兩秒。

小提示:一個函式或者方法的返回 (return) 也就意味著當前函式執行完畢。

所以存在一個問題,如果這 110 個協程在兩秒內執行完畢,main 函式本該提前返回,但是偏偏要等兩秒才能返回,會產生效能問題。

如果這 110 個協程執行的時間超過兩秒,因為設定的等待時間只有兩秒,程式就會提前返回,導致有協程沒有執行完畢,產生不可預知的結果。

那麼有沒有辦法解決這個問題呢?也就是說有沒有辦法監聽所有協程的執行,一旦全部執行完畢,程式馬上退出,這樣既可保證所有協程執行完畢,又可以及時退出節省時間,提升效能。第一時間應該會想到 channel。沒錯,channel 的確可以解決這個問題,不過非常複雜,Go 語言為我們提供了更簡潔的解決辦法,它就是 sync.WaitGroup。

在使用 sync.WaitGroup 改造示例之前,我先把 main 函式中的程式碼進行重構,抽取成一個函式 run,這樣可以更好地理解,如下所示:

func main() {
   run()
}
func run(){
   for i := 0; i < 100; i++ {
      go add(10)
   }
   for i:=0; i<10;i++ {
      go fmt.Println("和為:",readSum())
   }
   time.Sleep(2 * time.Second)
}

這樣執行讀寫的 110 個協程程式碼邏輯就都放在了 run 函式中,在 main 函式中直接呼叫 run 函式即可。現在只需通過 sync.WaitGroup 對 run 函式進行改造,讓其恰好執行完畢,如下所示:

func run(){
   var wg sync.WaitGroup
   //因為要監控110個協程,所以設定計數器為110
   wg.Add(110)
   for i := 0; i < 100; i++ {
      go func() {
         //計數器值減1
         defer wg.Done()
         add(10)
      }()
   }
   for i:=0; i<10;i++ {
      go func() {
         //計數器值減1
         defer wg.Done()
         fmt.Println("和為:",readSum())
      }()
   }
   //一直等待,只要計數器值為0
   wg.Wait()
}

sync.WaitGroup 的使用比較簡單,一共分為三步:

  • 宣告一個 sync.WaitGroup,然後通過 Add 方法設定計數器的值,需要跟蹤多少個協程就設定多少,這裡是 110;
  • 在每個協程執行完畢時呼叫 Done 方法,讓計數器減 1,告訴 sync.WaitGroup 該協程已經執行完畢;
  • 最後呼叫 Wait 方法一直等待,直到計數器值為 0,也就是所有跟蹤的協程都執行完畢。

通過 sync.WaitGroup 可以很好地跟蹤協程。在協程執行完畢後,整個 run 函式才能執行完畢,時間不多不少,正好是協程執行的時間。

sync.WaitGroup 適合協調多個協程共同做一件事情的場景,比如下載一個檔案,假設使用 10 個協程,每個協程下載檔案的 1/10 大小,只有 10 個協程都下載好了整個檔案才算是下載好了。這就是經常聽到的多執行緒下載,通過多個執行緒共同做一件事情,顯著提高效率。

小提示:其實也可以把 Go 語言中的協程理解為平常說的執行緒,從使用者體驗上也並無不可,但是從技術實現上,你知道他們是不一樣的就可以了。

sync.Once

在實際的工作中,可能會有這樣的需求:讓程式碼只執行一次,哪怕是在高併發的情況下,比如建立一個單例。

針對這種情形,Go 語言為我們提供了 sync.Once 來保證程式碼只執行一次,如下所示:

func main() {
   doOnce()
}
func doOnce() {
   var once sync.Once
   onceBody := func() {
      fmt.Println("Only once")
   }
   //用於等待協程執行完畢
   done := make(chan bool)
   //啟動10個協程執行once.Do(onceBody)
   for i := 0; i < 10; i++ {
      go func() {
         //把要執行的函式(方法)作為引數傳給once.Do方法即可
         once.Do(onceBody)
         done <- true
      }()
   }
   for i := 0; i < 10; i++ {
      <-done
   }
}

這是 Go 語言自帶的一個示例,雖然啟動了 10 個協程來執行 onceBody 函式,但是因為用了 once.Do 方法,所以函式 onceBody 只會被執行一次。也就是說在高併發的情況下,sync.Once 也會保證 onceBody 函式只執行一次。

sync.Once 適用於建立某個物件的單例、只載入一次的資源等只執行一次的場景。

sync.Cond

在 Go 語言中,sync.WaitGroup 用於最終完成的場景,關鍵點在於一定要等待所有協程都執行完畢。

而 sync.Cond 可以用於發號施令,一聲令下所有協程都可以開始執行,關鍵點在於協程開始的時候是等待的,要等待 sync.Cond 喚醒才能執行。

sync.Cond 從字面意思看是條件變數,它具有阻塞協程和喚醒協程的功能,所以可以在滿足一定條件的情況下喚醒協程,但條件變數只是它的一種使用場景。

下面以 10 個人賽跑為例來演示 sync.Cond 的用法。在這個示例中有一個裁判,裁判要先等這 10 個人準備就緒,然後一聲發令槍響,這 10 個人就可以開始跑了,如下所示:

//10個人賽跑,1個裁判發號施令
func race(){
   cond :=sync.NewCond(&sync.Mutex{})
   var wg sync.WaitGroup
   wg.Add(11)
   for i:=0;i<10; i++ {
      go func(num int) {
         defer  wg.Done()
         fmt.Println(num,"號已經就位")
         cond.L.Lock()
         cond.Wait()//等待發令槍響
         fmt.Println(num,"號開始跑……")
         cond.L.Unlock()
      }(i)
   }
   //等待所有goroutine都進入wait狀態
   time.Sleep(2*time.Second)
   go func() {
      defer  wg.Done()
      fmt.Println("裁判已經就位,準備發令槍")
      fmt.Println("比賽開始,大家準備跑")
      cond.Broadcast()//發令槍響
   }()
   //防止函式提前返回退出
   wg.Wait()
}

以上示例中有註釋說明,已經很好理解,這裡再大概講解一下步驟:

  • 通過 sync.NewCond 函式生成一個 *sync.Cond,用於阻塞和喚醒協程;
  • 然後啟動 10 個協程模擬 10 個人,準備就位後呼叫 cond.Wait() 方法阻塞當前協程等待發令槍響,這裡需要注意的是呼叫 cond.Wait() 方法時要加鎖;
  • time.Sleep 用於等待所有人都進入 wait 阻塞狀態,這樣裁判才能呼叫 cond.Broadcast() 發號施令;
  • 裁判準備完畢後,就可以呼叫 cond.Broadcast() 通知所有人開始跑了。
    sync.Cond 有三個方法,它們分別是:
  • Wait,阻塞當前協程,直到被其他協程呼叫 Broadcast 或者 Signal 方法喚醒,使用的時候需要加鎖,使用 sync.Cond 中的鎖即可,也就是 L 欄位。
  • Signal,喚醒一個等待時間最長的協程。
  • Broadcast,喚醒所有等待的協程。

    注意:在呼叫 Signal 或者 Broadcast 之前,要確保目標協程處於 Wait 阻塞狀態,不然會出現死鎖問題。

如果以前學過 Java,會發現 sync.Cond 和 Java 的等待喚醒機制很像,它的三個方法 Wait、Signal、Broadcast 就分別對應 Java 中的 wait、notify、notifyAll。

sync.Map

sync.Map 的方法:

  • Store:儲存一對 key-value 值。
  • Load:根據 key 獲取對應的 value 值,並且可以判斷 key 是否存在。
  • LoadOrStore:如果 key 對應的 value 存在,則返回該 value;如果不存在,儲存相應的 value。
  • Delete:刪除一個 key-value 鍵值對。
  • Range:迴圈迭代 sync.Map,效果與 for range 一樣。
本作品採用《CC 協議》,轉載必須註明作者和本文連結
?

相關文章