併發編程的數據競爭問題以及解決之道

Go語言以容易進行併發編程而聞名,可是若是稍不注意,併發程序可能致使的數據競爭問題(data race)就會常常出如今你編寫的併發程序的待解決Bug列表中-- 若是你不幸在代碼中遇到這種錯誤,這將是最難調試的錯誤之一。編程

今天這篇文章裏咱們首先來看一個致使數據競爭的示例程序,使用go命令行工具檢測程序的競爭狀況。而後咱們將介紹一些在不改變程序核心邏輯的狀況下如何繞過並解決併發狀況下的數據競爭問題的方法。最後咱們會分析用什麼方法解決數據競爭更合理以及留給你們的一個思考題。併發

本週這篇文章的主旨概要以下:函數

  • 併發程序的數據競爭問題。
  • 使用go命令行工具檢測程序的競爭狀況。
  • 解決數據競爭的經常使用方案。
  • 如何選擇解決數據競爭的方案。
  • 一道測試本身併發編程掌握程度的思考題。

數據競爭

要解釋什麼是數據競爭咱們先來看一段程序:工具

package main

import "fmt"

func main() {
    fmt.Println(getNumber())
}

func getNumber() int {
    var i int
    go func() {
        i = 5
    }()

    return i
}

上面這段程序getNumber函數中開啓了一個單獨的goroutine設置變量i的值,同時在不知道開啓的goroutine是否已經執行完成的狀況下返回了i。因此如今正在發生兩個操做:測試

  • 變量i的值正在被設置成5。
  • 函數getNumber返回了變量i的值。

如今,根據這兩個操做中哪個先完成,最後程序打印出來的值將是0或5。ui

這就是爲何它被稱爲數據競爭:getNumber返回的值根據操做1或操做2中的哪個最早完成而不一樣。spa

下面的兩張圖描述了返回值的兩種可能的狀況對應的時間線:命令行

數據競爭--讀操做先完成


數據競爭--寫操做先完成

你能夠想象一下,每次調用代碼時,代碼表現出來的行爲都不同有多可怕。這就是爲何數據競爭會帶來如此巨大的問題。調試

檢測數據競爭

咱們上面代碼是一個高度簡化的數據競爭示例。在較大的應用程序中,僅靠本身檢查代碼很難檢測到數據競爭。幸運的是,Go(從V1.1開始)有一個內置的數據競爭檢測器,咱們可使用它來肯定應用程序裏潛在的數據競爭條件。code

使用它很是簡單,只需在使用Go命令行工具時添加-race標誌。例如,讓咱們嘗試使用-race標誌來運行咱們剛剛編寫的程序:

go run -race main.go

執行後將輸出:

0
==================
WARNING: DATA RACE
Write at 0x00c00001a0a8 by goroutine 6:
  main.getNumber.func1()
      /QSC/go/src/example.com/http_demo/utils/vlog/main.go:12 +0x38

Previous read at 0x00c00001a0a8 by main goroutine:
  main.getNumber()
      /QSC/go/src/example.com/http_demo/utils/vlog/main.go:15 +0x88
  main.main()
      /QSC/go/src/example.com/http_demo/utils/vlog/main.go:6 +0x33

Goroutine 6 (running) created at:
  main.getNumber()
      /QSC/go/src/example.com/http_demo/utils/vlog/main.go:11 +0x7a
  main.main()
      /QSC/go/src/example.com/http_demo/utils/vlog/main.go:6 +0x33
==================
Found 1 data race(s)
exit status 66

第一個0是打印結果(所以咱們如今知道是操做2首先完成)。接下來的幾行給出了在代碼中檢測到的數據競爭的信息。咱們能夠看到關於數據競爭的信息分爲三個部分:

  • 第一部分告訴咱們,在getNumber函數裏建立的goroutine中嘗試寫入(這是咱們將值5賦給i的位置)
  • 下一部分告訴咱們,在主goroutine裏有一個在同時進行的讀操做。
  • 第三部分描述了致使數據競爭的goroutine是在哪裏被建立的。

除了go run命令外,go buildgo test命令也支持使用-race標誌。這個會使編譯器建立的應用程序可以記錄全部運行期間對共享變量訪問,而且會記錄下每個讀或者寫共享變量的goroutine的身份信息。

競爭檢查器會報告全部的已經發生的數據競爭。然而,它只能檢測到運行時的競爭條件,並不能證實以後不會發生數據競爭。因爲須要額外的記錄,所以構建時加了競爭檢測的程序跑起來會慢一些,且須要更大的內存,即便是這樣,這些代價對於不少生產環境的工做來講仍是能夠接受的。對於一些偶發的競爭條件來講,使用附帶競爭檢查器的應用程序能夠節省不少花在Debug上的時間。

解決數據競爭的方案

Go提供了不少解決它的選擇。全部這些解決方案的思路都是確保在咱們寫入變量時阻止對該變量的訪問。通常經常使用的解決數據競爭的方案有:使用WaitGroup鎖,使用通道阻塞以及使用Mutex鎖,下面咱們一個個來看他們的用法並比較一下這幾種方案的不一樣點。

使用WaitGroup

解決數據競爭的最直接方法是(若是需求容許的狀況下)阻止讀取訪問,直到寫入操做完成:

func getNumber() int {
    var i int
    // 初始化一個WaitGroup
    var wg sync.WaitGroup
    // Add(1) 通知程序有一個須要等待完成的任務
    wg.Add(1)
    go func() {
        i = 5
        // 調用wg.Done 表示正在等待的程序已經執行完成了
        wg.Done()
    }()
    // wg.Wait會阻塞當前程序直到等待的程序都執行完成爲止
    wg.Wait()
    return i
}

下面是使用WaitGroup後程序執行的時間線:

使用WaitGroup後程序執行的時間線

使用通道阻塞

這個方法原則上與上一種方法相似,只是咱們使用了通道而不是WaitGroup

func getNumber() int {
    var i int
  // 建立一個通道,在等待的任務完成時會向通道發送一個空結構體
    done := make(chan struct{})
    go func() {
        i = 5
        // 執行完成後向通道發送一個空結構體
        done <- struct{}{}
    }()
  // 從通道接收值將會阻塞程序,直到有值發送給done通道爲止
    <-done
    return i
}

下圖是使用通道阻塞解決數據競爭後程序的執行流程:

使用通道解決數據競爭後程序的執行流程

使用Mutex

到目前爲止,使用的解決方案只有在肯定寫入操做完成後再讀取i的值時才適用。如今讓咱們考慮一個更一般的狀況,程序讀取和寫入的順序並非固定的,咱們只要求它們不能同時發生就行。這種狀況下咱們應該考慮使用Mutex互斥鎖。

// 首先,建立一個結構體包含咱們想用互斥鎖保護的值和一個mutex實例
type SafeNumber struct {
    val int
    m   sync.Mutex
}

func (i *SafeNumber) Get() int {、
    i.m.Lock()                       
    defer i.m.Unlock()                    
    return i.val
}

func (i *SafeNumber) Set(val int) {
    i.m.Lock()
    defer i.m.Unlock()
    i.val = val
}

func getNumber() int {
    // 建立一個sageNumber實例
    i := &SafeNumber{}
  // 使用Set和Get代替常規賦值和讀取操做。
  // 咱們如今能夠確保只有在寫入完成時才能讀取,反之亦然
    go func() {
        i.Set(5)
    }()
    return i.Get()
}

下面兩個圖片對應於程序先獲取到寫鎖和先獲取到讀鎖兩種可能的狀況下程序的執行流程:

先獲取到寫鎖時程序的執行流程


先獲取讀鎖時程序的執行流程

Mutex vs Channel

上面咱們使用互斥鎖和通道兩種方法解決了併發程序的數據競爭問題。那麼咱們該在什麼狀況下使用互斥鎖,什麼狀況下又該使用通道呢?答案就在你試圖解決的問題中。若是你試圖解決的問題更適合互斥鎖,那麼就繼續使用互斥鎖。。若是問題彷佛更適合渠道,則使用它。

大多數Go新手都試圖使用通道來解決全部併發問題,由於這是Go語言的一個很酷的特性。這是不對的。語言爲咱們提供了使用MutexChannel的選項,選擇二者都沒有錯。

一般,當goroutine須要相互通訊時使用通道,當確保同一時間只有一個goroutine能訪問代碼的關鍵部分時使用互斥鎖。在咱們上面解決的問題中,我更傾向於使用互斥鎖,由於這個問題不須要goroutine之間的任何通訊。只須要確保同一時間只有一個goroutine擁有共享變量的使用權,互斥鎖原本就是爲解決這種問題而生的,因此使用互斥鎖是更天然的一種選擇。

一道用Channel解決的思考題

上面講數據競爭問題舉的例子裏由於多個goroutine之間不須要通訊,因此使用Mutex互斥鎖的方案更合理些。那麼針對使用Channel的併發編程場景咱們就先留一道思考題給你們,題目以下:

假設有一個超長的切片,切片的元素類型爲int,切片中的元素爲亂序排列。限時5秒,使用多個goroutine查找切片中是否存在給定值,在找到目標值或者超時後馬上結束全部goroutine的執行。

好比切片爲:[23, 32, 78, 43, 76, 65, 345, 762, ...... 915, 86],查找的目標值爲345,若是切片中存在目標值程序輸出:"Found it!"而且當即取消仍在執行查找任務的goroutine。若是在超時時間爲找到目標值程序輸出:"Timeout! Not Found",同時當即取消仍在執行查找任務的goroutine

不用顧忌題目裏切片的元素重不重複,也不須要對切片元素進行排序。解決這個問題確定會用到context、計時器、通道以及select語句(已經提示了不少啦:),至關於把最近關於併發編程文章裏的知識串一遍。

看文章的朋友們儘可能都想一想應該怎麼解,在留言裏說出大家的解題思路,最好能夠私信我你寫的代碼的截圖。我會在下週的文章裏給出這個題目個人解決方法。這個題沒有標準答案,只要能解出來而且思路值得借鑑我都會一塊兒公佈到下週的文章裏。

相關文章
相關標籤/搜索