Go 併發編程:goroutine,channel 和 sync

優雅的併發編程範式,完善的併發支持,出色的併發性能是 Go 語言區別於其他語言的一大特色。

在當今這個多核時代,併發編程的意義不言而喻。使用 Go 開發併發程序,操作起來非常簡單,語言級別提供關鍵字 go 用於啓動協程,並且在同一臺機器上可以啓動成千上萬個協程。

下面就來詳細介紹。

goroutine

Go 語言的併發執行體稱爲 goroutine,使用關鍵詞 go 來啓動一個 goroutine。

go 關鍵詞後面必須跟一個函數,可以是有名函數,也可以是無名函數,函數的返回值會被忽略。

go 的執行是非阻塞的。

先來看一個例子:

package main

import (
    "fmt"
    "time"
)

func main() {
    go spinner(100 * time.Millisecond)
    const n = 45
    fibN := fib(n)
    fmt.Printf("\rFibonacci(%d) = %d\n", n, fibN) // Fibonacci(45) = 1134903170
}

func spinner(delay time.Duration) {
    for {
        for _, r := range `-\|/` {
            fmt.Printf("\r%c", r)
            time.Sleep(delay)
        }
    }
}

func fib(x int) int {
    if x < 2 {
        return x
    }
    return fib(x-1) + fib(x-2)
}

從執行結果來看,成功計算出了斐波那契數列的值,說明程序在 spinner 處並沒有阻塞,而且 spinner 函數還一直在屏幕上打印提示字符,說明程序正在執行。

當計算完斐波那契數列的值,main 函數打印結果並退出,spinner 也跟着退出。

再來看一個例子,循環執行 10 次,打印兩個數的和:

package main

import "fmt"

func Add(x, y int) {
    z := x + y
    fmt.Println(z)
}

func main() {
    for i := 0; i < 10; i++ {
        go Add(i, i)
    }
}

有問題了,屏幕上什麼都沒有,爲什麼呢?

這就要看 Go 程序的執行機制了。當一個程序啓動時,只有一個 goroutine 來調用 main 函數,稱爲主 goroutine。新的 goroutine 通過 go 關鍵詞創建,然後併發執行。當 main 函數返回時,不會等待其他 goroutine 執行完,而是直接暴力結束所有 goroutine。

那有沒有辦法解決呢?當然是有的,請往下看。

channel

一般寫多進程程序時,都會遇到一個問題:進程間通信。常見的通信方式有信號,共享內存等。goroutine 之間的通信機制是通道 channel。

使用 make 創建通道:

ch := make(chan int) // ch 的類型是 chan int

通道支持三個主要操作:sendreceiveclose

ch <- x // 發送
x = <-ch // 接收
<-ch // 接收,丟棄結果

close(ch) // 關閉

無緩衝 channel

make 函數接受兩個參數,第二個參數是可選參數,表示通道容量。不傳或者傳 0 表示創建了一個無緩衝通道。

無緩衝通道上的發送操作將會阻塞,直到另一個 goroutine 在對應的通道上執行接收操作。相反,如果接收先執行,那麼接收 goroutine 將會阻塞,直到另一個 goroutine 在對應通道上執行發送。

所以,無緩衝通道是一種同步通道。

下面我們使用無緩衝通道把上面例子中出現的問題解決一下。

package main

import "fmt"

func Add(x, y int, ch chan int) {
    z := x + y
    ch <- z
}

func main() {

    ch := make(chan int)
    for i := 0; i < 10; i++ {
        go Add(i, i, ch)
    }

    for i := 0; i < 10; i++ {
        fmt.Println(<-ch)
    }
}

可以正常輸出結果。

主 goroutine 會阻塞,直到讀取到通道中的值,程序繼續執行,最後退出。

緩衝 channel

創建一個容量是 5 的緩衝通道:

ch := make(chan int, 5)

緩衝通道的發送操作在通道尾部插入一個元素,接收操作從通道的頭部移除一個元素。如果通道滿了,發送會阻塞,直到另一個 goroutine 執行接收。相反,如果通道是空的,接收會阻塞,直到另一個 goroutine 執行發送。

有沒有感覺,其實緩衝通道和隊列一樣,把操作都解耦了。

單向 channel

類型 chan<- int 是一個只能發送的通道,類型 <-chan int 是一個只能接收的通道。

任何雙向通道都可以用作單向通道,但反過來不行。

還有一點需要注意,close 只能用在發送通道上,如果用在接收通道會報錯。

看一個單向通道的例子:

package main

import "fmt"

func counter(out chan<- int) {
    for x := 0; x < 10; x++ {
        out <- x
    }
    close(out)
}

func squarer(out chan<- int, in <-chan int) {
    for v := range in {
        out <- v * v
    }
    close(out)
}

func printer(in <-chan int) {
    for v := range in {
        fmt.Println(v)
    }
}

func main() {
    n := make(chan int)
    s := make(chan int)

    go counter(n)
    go squarer(s, n)
    printer(s)

}

sync

sync 包提供了兩種鎖類型:sync.Mutexsync.RWMutex,前者是互斥鎖,後者是讀寫鎖。

當一個 goroutine 獲取了 Mutex 後,其他 goroutine 不管讀寫,只能等待,直到鎖被釋放。

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var mutex sync.Mutex
    wg := sync.WaitGroup{}

    // 主 goroutine 先獲取鎖
    fmt.Println("Locking  (G0)")
    mutex.Lock()
    fmt.Println("locked (G0)")

    wg.Add(3)
    for i := 1; i < 4; i++ {
        go func(i int) {
            // 由於主 goroutine 先獲取鎖,程序開始 5 秒會阻塞在這裏
            fmt.Printf("Locking (G%d)\n", i)
            mutex.Lock()
            fmt.Printf("locked (G%d)\n", i)

            time.Sleep(time.Second * 2)
            mutex.Unlock()
            fmt.Printf("unlocked (G%d)\n", i)

            wg.Done()
        }(i)
    }

    // 主 goroutine 5 秒後釋放鎖
    time.Sleep(time.Second * 5)
    fmt.Println("ready unlock (G0)")
    mutex.Unlock()
    fmt.Println("unlocked (G0)")

    wg.Wait()
}

RWMutex 屬於經典的單寫多讀模型,當讀鎖被佔用時,會阻止寫,但不阻止讀。而寫鎖會阻止寫和讀。

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var rwMutex sync.RWMutex
    wg := sync.WaitGroup{}

    Data := 0
    wg.Add(20)
    for i := 0; i < 10; i++ {
        go func(t int) {
            // 第一次運行後,寫解鎖。
            // 循環到第二次時,讀鎖定後,goroutine 沒有阻塞,同時讀成功。
            fmt.Println("Locking")
            rwMutex.RLock()
            defer rwMutex.RUnlock()
            fmt.Printf("Read data: %v\n", Data)
            wg.Done()
            time.Sleep(2 * time.Second)
        }(i)
        go func(t int) {
            // 寫鎖定下是需要解鎖後才能寫的
            rwMutex.Lock()
            defer rwMutex.Unlock()
            Data += t
            fmt.Printf("Write Data: %v %d \n", Data, t)
            wg.Done()
            time.Sleep(2 * time.Second)
        }(i)
    }

    wg.Wait()
}

總結

併發編程算是 Go 的特色,也是核心功能之一了,涉及的知識點其實是非常多的,本文也只是起到一個拋磚引玉的作用而已。

本文開始介紹了 goroutine 的簡單用法,然後引出了通道的概念。

通道有三種:

  1. 無緩衝通道

  2. 緩衝通道

  3. 單向通道

最後介紹了 Go 中的鎖機制,分別是 sync 包提供的 sync.Mutex(互斥鎖) 和 sync.RWMutex(讀寫鎖)。

goroutine 博大精深,後面的坑還是要慢慢踩的。


文章中的腦圖和源碼都上傳到了 GitHub,有需要的同學可自行下載。

地址: https://github.com/yongxinz/gopher/tree/main/sc

關注公衆號 AlwaysBeta,回覆「goebook」領取 Go 編程經典書籍。

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