[譯]Go併發程式設計中的那些事

愛布偶的zzy發表於2017-10-22

併發程式設計

bouncing balls
bouncing balls

這篇文章將會以Go語言舉例介紹併發程式設計,包括以下內容

  • 執行緒的併發執行(goroutines)
  • 基本的同步技術(channel和鎖)
  • Go中的基本併發模式
  • 死鎖和資料競爭
  • 平行計算

開始之前,你需要去了解怎樣寫最基本的 Go 程式。 如果你已經對 C/C++,Java 或者Python比較熟悉,A tour of go將會給你一些幫助。你也可以看一下Go for C++ programmers 或者Go for Java programmers

1.多執行緒執行

goroutine 是 go 的一種排程機制。 Go 使用 go 進行宣告,以 goroutine 排程機制開啟一個新的執行執行緒。它會在新建立的 goroutine 執行程式。在單個程式中,所有goroutines都是共享相同的地址空間。

相比於分配棧空間,goroutine 更加輕量,花銷更小。棧空間初始化很小,需要通過申請和釋放堆空間來擴充套件記憶體。Goroutines 內部是被複用在多個作業系統執行緒上。如果一個goroutine阻塞了一個作業系統執行緒,比如正在等待輸入,此時,這個執行緒中的其他 goroutine 為了保證繼續執行,將會遷移到其他執行緒中,而你不需要去關心這些細節。

下面的程式將會列印 "Hello from main goroutine". 是否列印"Hello from another goroutine",取決於兩個goroutines誰先完成.

func main() {

    go fmt.Println("Hello from another goroutine")
    fmt.Println("Hello from main goroutine")

    // 程式執行到這,所有活著的goroutines都會被殺掉

}複製程式碼

goroutine1.go

下一段程式 "Hello from main goroutine""Hello from another goroutine" 可能會以任何順序列印。但有一種可能性是第二個goroutine執行的非常慢,以至於到程式結束之前都不會列印。

func main() {
    go fmt.Println("Hello from another goroutine")
    fmt.Println("Hello from main goroutine")

    time.Sleep(time.Second) // 為其他goroutine完成等1秒鐘
}複製程式碼

goroutine2.go

這有一個更實際的例子,我們定義一個使用併發來推遲事件的函式。

// 在指定時間過期後,文字會被列印到標準輸出
// 這無論如何都不會被阻塞
func Publish(text string, delay time.Duration) {
    go func() {
        time.Sleep(delay)
        fmt.Println("BREAKING NEWS:", text)
    }() // 注意括號。我們必須呼叫匿名函式
}複製程式碼

publish1.go

你可能用下面的方式呼叫 Publish 函式

func main() {
    Publish("A goroutine starts a new thread of execution.", 5*time.Second)
    fmt.Println("Let’s hope the news will published before I leave.")

    // 等待訊息被髮布
    time.Sleep(10 * time.Second)

    fmt.Println("Ten seconds later: I’m leaving now.")
}複製程式碼

publish1.go

該程式很有可能按以下順序列印三行,每行輸出會間隔五秒鐘。

$ go run publish1.go
Let’s hope the news will published before I leave.
BREAKING NEWS: A goroutine starts a new thread of execution.
Ten seconds later: I’m leaving now.複製程式碼

一般來說,我們不可能讓執行緒休眠去等待對方。在下一節中, 我們將會介紹 Go 的一種同步機制, channels 。然後演示如何使用channel來讓一個 goruntine 等待另外的 goruntine。

2. Channels

Sushi conveyor belt
Sushi conveyor belt

壽司輸送帶

channel 是一種 Go 語言結構,它通過傳遞特定元素型別的值來為兩個 goroutines 提供同步執行和交流資料的機制
<- 識別符號表示了channel的傳輸方向,接收或者傳送。如果沒有指定方向。那麼 channel 就是雙向的。

chan Sushi      // 能被用於接收和傳送 Sushi 型別的值
chan<- float64  // 只能被用於傳送 float64 型別的值
<-chan int      // 只能被用於接收 int 型別的值複製程式碼

Channels 是一種被 make 分配的引用型別

ic := make(chan int)        // 不帶快取的  int channel
wc := make(chan *Work, 10)  // 帶緩衝工作的 channel複製程式碼

通過 channel 傳送值,可使用 <- 作為二元運算子。通過 channel 接收值,可使用它作為一元運算子。

ic <- 3       // 向channel中傳送3
work := <-wc  // 從channel中接收指標到work複製程式碼

如果 channel 是無緩衝的,傳送者會一直阻塞直到有接收者從中接收值。如果是帶緩衝的,只有當值被拷貝到緩衝區且緩衝區已滿時,傳送者才會阻塞直到有接收者從中接收。接收者會一直阻塞直到 channel 中有值可被接收。

關閉

close 的作用是保證不能再向 channel 中傳送值。 channel 被關閉後,仍然是可以從中接收值的。接收操作會獲得零值而不會阻塞。多值接收操作會額外返回一個布林值,表示該值是否被髮送的。

ch := make(chan string)
go func() {
    ch <- "Hello!"
    close(ch)
}()
fmt.Println(<-ch)  // 列印 "Hello!"
fmt.Println(<-ch)  // 不阻塞的列印空值 ""
fmt.Println(<-ch)  // 再一次列印 ""
v, ok := <-ch      // v 的值是 "" , ok 的值是 false複製程式碼

伴有 range 分句的 for 語句會連續讀取通過 channel 傳送的值,直到 channel 被關閉

func main() {
    var ch <-chan Sushi = Producer()
    for s := range ch {
        fmt.Println("Consumed", s)
    }
}

func Producer() <-chan Sushi {
    ch := make(chan Sushi)
    go func() {
        ch <- Sushi("海老握り")  // Ebi nigiri
        ch <- Sushi("鮪とろ握り") // Toro nigiri
        close(ch)
    }()
    return ch
}複製程式碼

sushi.go

3.同步

下一個例子中,Publish 函式返回一個channel,它會把傳送的文字當做訊息廣播出去。

// 指定時間過期後函式Publish將會列印文字到標準輸出.
// 當文字被髮布channel將會被關閉.
func Publish(text string, delay time.Duration) (wait <-chan struct{}) {
    ch := make(chan struct{})
    go func() {
        time.Sleep(delay)
        fmt.Println("BREAKING NEWS:", text)
        close(ch) // broadcast – a closed channel sends a zero value forever
    }()
    return ch
}複製程式碼

publish2.go

注意我們使用一個空結構的 channel : struct{}。 這表明該 channel 僅僅用於訊號,而不是傳遞資料。

你可能會這樣使用該函式

func main() {
    wait := Publish("Channels let goroutines communicate.", 5*time.Second)
    fmt.Println("Waiting for the news...")
    <-wait
    fmt.Println("The news is out, time to leave.")
}複製程式碼

publish2.go

程式將按給出的順序列印下列三行資訊。在資訊傳送後,最後一行會立刻出現

$ go run publish2.go
Waiting for the news...
BREAKING NEWS: Channels let goroutines communicate.
The news is out, time to leave.複製程式碼

4.死鎖

traffic jam
traffic jam

讓我們去介紹 Publish 函式中的一個bug。

func Publish(text string, delay time.Duration) (wait <-chan struct{}) {
    ch := make(chan struct{})
    go func() {
        time.Sleep(delay)
        fmt.Println("BREAKING NEWS:", text)
        **//close(ch)**
    }()
    return ch
}複製程式碼

這時由 Publish 函式開啟的 goroutine 列印重要資訊然後退出,留下主 goroutine 繼續等待。

func main() {
    wait := Publish("Channels let goroutines communicate.", 5*time.Second)
    fmt.Println("Waiting for the news...")
    **<-wait**
    fmt.Println("The news is out, time to leave.")
}複製程式碼

在某些情況下,程式將不會有任何進展,這種情況被稱為死鎖。

deadlock 是執行緒之間相互等待而都不能繼續執行的一種情況

在執行時,Go 對於執行時死鎖檢測具有良好支援。但在某種情況下goroutine無法取得任何進展,這時Go程式會提供一個詳細的錯誤資訊. 下面就是我們崩潰程式的日誌:

Waiting for the news...
BREAKING NEWS: Channels let goroutines communicate.
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
    .../goroutineStop.go:11 +0xf6

goroutine 2 [syscall]:
created by runtime.main
    .../go/src/pkg/runtime/proc.c:225

goroutine 4 [timer goroutine (idle)]:
created by addtimer
    .../go/src/pkg/runtime/ztime_linux_amd64.c:73複製程式碼

多數情況下下,在 Go 程式中很容易搞清楚是什麼導致了死鎖。接著就是如何去修復它了。

5. 資料競爭

死鎖可能聽起來很糟糕, 但是真正給併發程式設計帶來災難的是資料競爭。它們相當常見,而且難於除錯。

一個 資料競爭 發生在當兩個執行緒併發訪問相同的變數,同時最少有一個訪問是在寫.

資料競爭是沒有規律的。舉個例子,列印數字1,嘗試找出它是如何發生的 — 一個可能的解釋是在程式碼之後.

func race() {
    wait := make(chan struct{})
    n := 0
    go func() {
        **n++** // 一次操作:讀,增長,寫
        close(wait)
    }()
    **n++** // 另一個衝突訪問
    <-wait
    fmt.Println(n) // 輸出: 不確定
}複製程式碼

datarace.go

兩個goroutines, g1g2, 在競爭過程中,我們無法知道他們執行的順序.下面只是許多可能的結果性的一種.

  • g1n變數中讀取值0
  • g2n變數中讀取值0
  • g1 增加它的值從0變為1
  • g1 把它的值把1賦值給n
  • g2 增加它的值從01
  • g2 把它的值把1賦值給n
  • 這段程式將會列印n的值,它的值為1

"資料競爭” 的稱呼多少有些誤導,不僅僅是他的執行順序無法被設定,而且也無法保證接下來會發生的情況。編譯器和硬體時常會為了更好的效能而調整程式碼的順序。如果你仔細觀察一個正在執行的執行緒,那麼你才可能會看到更多細節。

mid action
mid action

避免資料競爭的唯一方式是同步操作線上程間所有共享的可變資料。存在幾種方式,在Go中,可能最多使用 channel 或者 lock。較底層的操作可使用 sync and sync/atomic 包,這裡不再討論。

在Go中,處理併發資料訪問的首選方式是使用一個 channel,它將資料從一個goroutine傳遞到另一個goroutine。有一句經典的話:"不要通過共享記憶體來傳遞資料;而要通過傳遞資料來共享記憶體"。

func sharingIsCaring() {
    ch := make(chan int)
    go func() {
        n := 0 // 區域性變數只能對當前 goroutine 可見
        n++
        ch <- n // 資料通過 goroutine 傳遞
    }()
    n := <-ch   // ...從另外一個 goroutine 中安全接受
    n++
    fmt.Println(n) // 輸出: 2
}複製程式碼

datarace.go

在這份程式碼中 channel 充當了雙重角色。它作為一個同步點,在不同 goroutine 中傳遞資料。傳送的 goroutine 將會等待其它的 goroutine 去接收資料,而接收的 goroutine 將會等待其他的 goroutine 去傳送資料。

Go記憶體模型 - 當一個 goroutine 在讀一個變數,另外一個goroutine在寫相同的變數,這個過程實際上是非常複雜的,但是隻要你用 channel 在不同goroutines中共享資料,那麼這個操作就是安全的。

6. 互斥鎖

lock
lock

有時通過直接鎖定來同步資料比使用 channel 更加方便。為此,Go 標準庫提供了互斥鎖sync.Mutex

要讓這種型別的鎖正確工作,所有對於共享資料的操作(包括讀和寫)必須在一個 goroutine 持有該鎖時進行。這一點至關重要,goroutine 的一次錯誤就足以破壞程式和導致資料競爭。

因此你需要為API去設計一種定製化的資料結構,並且確保所有同步操作都在內部執行。在這個例子中,我們構建了一種安全易用的併發資料結構,AtomicInt,它儲存了單個整型,任何goroutines 都能安全的通過 AddValue 方法訪問數字。

// AtomicInt 是一種持有int型別的支援併發的資料結構。
// 它的初始化值為0.
type AtomicInt struct {
    mu sync.Mutex // 同一時間只能有一個 goroutine 持有鎖。
    n  int
}

// Add adds n to the AtomicInt as a single atomic operation.
// 原子性的將n增加到AtomicInt中
func (a *AtomicInt) Add(n int) {
    a.mu.Lock() // 等待鎖被釋放然後獲取。
    a.n += n
    a.mu.Unlock() // 釋放鎖。
}

// 返回a的值.
func (a *AtomicInt) Value() int {
    a.mu.Lock()
    n := a.n
    a.mu.Unlock()
    return n
}

func lockItUp() {
    wait := make(chan struct{})
    var n AtomicInt
    go func() {
        n.Add(1) // one access
        close(wait)
    }()
    n.Add(1) // 另一個併發訪問
    <-wait
    fmt.Println(n.Value()) // Output: 2
}複製程式碼

datarace.go

7. 檢測資料競爭

競爭有時候難以檢測。當我執行這段存在資料競爭的程式,它列印55555。再試一次,可能會得到不同的結果。 sync.WaitGroup是go標準庫的一部分;它等待一系列 goroutines 執行結束。

func race() {
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; **i++** {
        go func() {
            **fmt.Print(i)** // 區域性變數i被6個goroutine共享
            wg.Done()
        }()
    }
    wg.Wait() // 等待5個goroutine執行結束
    fmt.Println()
}複製程式碼

raceClosure.go

對於輸出 55555 較為合理的解釋是執行 i++ 操作的 goroutine 在其他 goroutines 列印之前就已經執行了5次。事實上,更新後的 i 對於其他 goroutines 可見是隨機的。

一個非常簡單的解決辦法是通過使用本地變數作為引數的方式去啟動另外的goroutine。

func correct() {
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func(n int) { // 區域性變數。
            fmt.Print(n)
            wg.Done()
        }(i)
    }
    wg.Wait()
    fmt.Println()
}複製程式碼

raceClosure.go

這段程式碼是正確的,他列印了期望的結果,24031。回想一下,在不同 goroutines 中,程式的執行順序是亂序的。

我們仍然可以使用閉包去避免資料競爭。但是我們需要注意在每個 goroutine 中需要有不同的變數。

func alsoCorrect() {
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; i++ {
        n := i // 為每個閉包建立單獨的變數
        go func() {
            fmt.Print(n)
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Println()
}複製程式碼

raceClosure.go

7. 自動競爭檢測

總的來說.我們不可能自動的發現所有的資料競爭。但是 Go(從1.1版本開始) 提供了一個強大的資料競爭檢測器 data race detector

這個工具使用下來非常簡單: 僅僅增加 -racego 命令後。執行上述程式將會自動檢查並且列印出下面的輸出資訊。

$ go run -race raceClosure.go 
Data race:
==================
WARNING: DATA RACE
Read at 0x00c420074168 by goroutine 6:
  main.race.func1()
      ../raceClosure.go:22 +0x3f

Previous write at 0x00c420074168 by main goroutine:
  main.race()
      ../raceClosure.go:20 +0x1bd
  main.main()
      ../raceClosure.go:10 +0x2f

Goroutine 6 (running) created at:
  main.race()
      ../raceClosure.go:24 +0x193
  main.main()
      ../raceClosure.go:10 +0x2f
==================
12355
Correct:
01234
Also correct:
01234
Found 1 data race(s)
exit status 66複製程式碼

這個工具發現在程式20行存在資料競爭,一個goroutine向某個變數寫值,而22行存在另外一個 goroutine 在不同步的讀取這個變數的值。

注意這個工具只能找到實際執行時發生的資料競爭。

8. Select 語句

在 Go 併發程式設計中,最後講的一個是 select 語句。它會挑選出一系列通訊操作中能夠執行的操作。如果任意的通訊操作都可執行,則會隨機挑選一個並執行相關的語句。否則,如果也沒有預設執行語句的話,則會阻塞直到其中的任意一個通訊操作能夠執行。

這有一個例子,顯示瞭如何用 select 去隨機生成數字.

// RandomBits 返回產生隨機位數的channel
func RandomBits() <-chan int {
    ch := make(chan int)
    go func() {
        for {
            select {
            case ch <- 0: // 沒有相關操作語句
            case ch <- 1:
            }
        }
    }()
    return ch
}複製程式碼

randBits.go

更簡單,這裡 select 被用於設定超時。這段程式碼只能列印 news 或者 time-out 訊息,這取決於兩個接收語句中誰可以執行.

select {
case news := <-NewsAgency:
    fmt.Println(news)
case <-time.After(time.Minute):
    fmt.Println("Time out: no news in one minute.")
}複製程式碼

time.After是 go 標準庫的一部分;他等待特定時間過去,然後將當前時間傳送到返回的 channel.

9. 最基本的併發例項

couples
couples

多花點時間仔細理解這個例子。當你完全理解它,你將會徹底的理解 Go 內部的併發工作機制。

程式演示了單個 channel 同時傳送和接受多個 goroutines 的資料。它也展示了 select 語句如何從多個通訊操作中選擇執行。

func main() {
    people := []string{"Anna", "Bob", "Cody", "Dave", "Eva"}
    match := make(chan string, 1) // 給未匹配的元素預留空間
    wg := new(sync.WaitGroup)
    for _, name := range people {
        wg.Add(1)
        go Seek(name, match, wg)
    }
    wg.Wait()
    select {
    case name := <-match:
        fmt.Printf("No one received %s’s message.\n", name)
    default:
        // 沒有待處理的傳送操作.
    }
}

// 尋求傳送或接收匹配上名稱名稱的通道,並在完成後通知等待組.
func Seek(name string, match chan string, wg *sync.WaitGroup) {
    select {
    case peer := <-match:
        fmt.Printf("%s received a message from %s.\n", name, peer)
    case match <- name:
        // 等待其他人接受訊息.
    }
    wg.Done()
}複製程式碼

matching.go

例項輸出:

$ go run matching.go
Anna received a message from Eva.
Cody received a message from Bob.
No one received Dave’s message.複製程式碼

10. 平行計算

CPUs
CPUs

具有併發特性應用會將一個大的計算劃分為小的計算單元,每個計算單元都會單獨的工作。

多 CPU 上的分散式計算不僅僅是一門科學,更是一門藝術。

  • 每個計算單元執行時間大約在100us至1ms之間.如果這些單元太小,那麼分配問題和管理子模組的開銷可能會增大。如果這些單元太大,整個的計算體系可能會被一個小的耗時操作阻塞。很多因素都會影響計算速度,比如排程,程式終端,記憶體佈局(注意工作單元的個數和 CPU 的個數無關)。

  • 儘量減少資料共享的量。併發寫入是非常消耗效能的,特別是多個 goroutines 在不同CPU上執行時。共享資料讀操作對效能影響不是很大。

  • 資料的合理組織是一種高效的方式。如果資料儲存在快取中,資料的載入和儲存的速度將會大大加快。再次強調,這對寫操作來說是非常重要的。

下面的例子將會顯示如何將多個耗時計算分配到多個可用的 CPU 上。這就是我們想要優化的程式碼。

type Vector []float64

// Convolve computes w = u * v, where w[k] = Σ u[i]*v[j], i + j = k.
// Precondition: len(u) > 0, len(v) > 0.
func Convolve(u, v Vector) Vector {
    n := len(u) + len(v) - 1
    w := make(Vector, n)

    for k := 0; k < n; k++ {
        w[k] = mul(u, v, k)
    }
    return w
}

// mul returns Σ u[i]*v[j], i + j = k.
func mul(u, v Vector, k int) float64 {
    var res float64
    n := min(k+1, len(u))
    j := min(k, len(v)-1)
    for i := k - j; i < n; i, j = i+1, j-1 {
        res += u[i] * v[j]
    }
    return res
}複製程式碼

這個想法很簡單:識別適合大小的工作單元,然後在單獨的 goroutine 中執行每個工作單元. 這就是 Convolve 的併發版本.

func Convolve(u, v Vector) Vector {
    n := len(u) + len(v) - 1
    w := make(Vector, n)

    // 將w劃分為多個將會計算100us-1ms時間計算的工作單元
    size := max(1, 1000000/n)

    var wg sync.WaitGroup
    for i, j := 0, size; i < n; i, j = j, j+size {
        if j > n {
            j = n
        }
        // goroutines只為讀共享記憶體.
        wg.Add(1)
        go func(i, j int) {
            for k := i; k < j; k++ {
                w[k] = mul(u, v, k)
            }
            wg.Done()
        }(i, j)
    }
    wg.Wait()
    return w
}複製程式碼

convolution.go

當定義好計算單元,通常最好將排程留給程式執行和作業系統。然而,在 Go1.*版本中,你需要指定 goroutines 的個數。

func init() {
    numcpu := runtime.NumCPU()
    runtime.GOMAXPROCS(numcpu) // 儘量使用所有可用的 CPU
}複製程式碼

Stefan Nilsson


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章