理解Golang併發程式設計

dasheng發表於2017-07-29

點選檢視原文章

concurrency vs parallelism

併發和並行是彼此相關的兩個概念,並不能完全等價。在程式中,併發強調的是獨立執行的程式的組合;並行強調的是同時執行計算任務 [1]。
計算機核心的數量決定了平行計算的能力,大多數人類作為 “單核” 動物 (老頑童小龍女除外),可以說自己在併發某些任務,如我在聽歌寫程式碼,但是不能說這兩件事在並行,參考下圖: concurrency vs parallelism Golang 的併發模型源於 Communicating Sequential Processes (CSP),通過提供 goroutine 和 channel 來實現併發程式設計模式。

Goroutine

Goroutine 由 Go 執行時建立和管理,是用於排程 CPU 資源的 “最小單元”,和 OS 的執行緒相比更輕量 [2]:

  • 記憶體消耗更低只需 2kB 初始棧空間,而執行緒初始要 1Mb 的空間;
  • 由 golang 的執行時環境建立和銷燬,更加廉價,不支援手動管理;
  • 切換效率更高等。 Goroutine 和執行緒的關係如下圖所示: goroutine vs thread

我們可以輕鬆地建立成百上千的 goroutine,而不會降低程式的執行效率。
通過 goroutine 可以讓一個函式和其他的函式並行執行。可以在函式呼叫前面加上go關鍵字,方便地建立一個 goroutine。
main 函式本身也是一個 goroutine[3]。
舉例如下:

package main

import "fmt"

func main() {
    fmt.Println("begin main goroutine")
    go hello()
    fmt.Println("end main goroutine")
}

func hello() {
    fmt.Println("begin hello goroutine")
}

輸出:

begin main goroutine
end main goroutine

上面的例子中,並不會輸出begin hello goroutine,這是因為,通過使用 goroutine,我們不需要等待函式呼叫的返回結果,而會接著執行下面的程式碼。
可以在go hello()後面新增:

time.Sleep(1 * time.Second)

就可以正常輸出begin hello goroutine

channel

Go 提供了一種機制能夠使 goroutine 之間進行通訊和同步,它就是 channel。
channel 是一種型別,關鍵字chan和 channel 傳輸內容的型別共同定義了某一 channel。
定義方式為:var c chan string = make(chan string),也可以簡寫為:var c = make(chan string)c := make(chan string)

通過左箭頭<-操作符操作 channel 變數:

  • c <- "ping"向 channel 傳送一個值為 “ping” 的字串,
  • msg := <- c接收 channel 中的一個值,並賦給 msg。
package main

import (
    "fmt"
    "strconv"
    "time"
)

func main() {
    c := make(chan string)
    go ping(c)
    go print(c)
    var input string
    fmt.Scanln(&input)
}

func ping(c chan string) {
    for i := 0; ; i++ {
        c <- strconv.Itoa(i)
    }
}

func print(c chan string) {
    for {
        <-c
        fmt.Println("reveving: " + <-c)
        time.Sleep(1 * time.Second)
    }
}

輸出:

reveving: 1
reveving: 3
reveving: 5
reveving: 7
reveving: 9
    ...

按功能,可以將 channel 分為只傳送或只接收 channel,通過修改函式簽名的 channel 形參型別來指定 channel 的 “方向”:

  • 只允許傳送: func ping(c chan<- string)
  • 只允許接收: func print(c <-chan string)
  • 任何對只傳送 channel 的接收操作和只接收 channel 的傳送操作都會產生編譯錯誤。
  • 不指定方向的 channel 被稱作 “雙向” channel,可以將 “雙向” channel 最為引數,傳遞給接收單向 channel 的函式,反之,則不行。

unbuffered channel

非緩衝 channel,也就是緩衝池大小為 0 的 channel 或者同步 channel,上面的例子都是非緩衝 channel,定義方式為:

  • ch := make(chan int)
  • ch := make(chan int, 0)

非緩衝 channel 在同步讀時,如果 channel 的 sendq 中有就緒的 goroutine,那麼就取出(copy)資料並釋放傳送方 goroutine;如果沒有就緒的 goroutine,那麼將接收方 goroutine 掛起。
非緩衝 channel 在同步寫時,如果 channel 的 recvq 中有就緒的 goroutine,那麼就取出(copy)資料到接收方 goroutine,並使其就緒;如果沒有,那麼將傳送發 goroutine 掛起。

buffered channel

緩衝 channel 只能容納固定量的資料,當緩衝池滿之後,傳送發被阻塞,直到資料被接收釋放緩衝池,定義如下:

  • ch := make(chan int) 緩衝 channel 可以用來限制吞吐量,例子如下:
package main

import (
    "fmt"
    "time"
)

// Request struct
type Request struct {
}

var sem = make(chan int, 5)     // Create a buffered channel witch capacity of 5

func main() {
    queue := make(chan *Request)
    go start(queue)
    go serve(queue)
    var input string
    fmt.Scanln(&input)
}

func start(queue chan *Request) {
    for {
        queue <- &Request{}
    }
}

func serve(queue chan *Request) {
    for req := range queue {
        sem <- 1       // Put on signal to channel
        go handle(req) // Don't wait for handle to finish.
    }
}

func handle(r *Request) {
    process(r) // May take a long time.
    <-sem      // Done; enable next request to run.
}

func process(r *Request) {
    fmt.Println("process")
    time.Sleep(4 * time.Second)
}

每隔 4 秒鐘,輸出:

process
process
process
process
process

select

針對於 channel,Golang 提供了一個類似switch的功能,即select,使用如下:

  1. select選擇第一個就緒的 channel 進行處理
  2. 如果有多個就緒的 channel,則隨機選擇一個 channel 進行處理
  3. 如果沒有就緒的 channel,則等待直到某一 channel 就緒
  4. 如果有default,則在 3 情形中不會等待,而是立即執行 default 中的程式碼
package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go ping(ch1)
    go pong(ch2)
    go print(ch1, ch2)
    var input string
    fmt.Scanln(&input)
}

func ping(ch chan int) {
    time.Sleep(2 * time.Second)
    ch<-1
}

func pong(ch chan int) {
    time.Sleep(3 * time.Second)
    ch<-2
}

func print(ch1, ch2 chan int) {
    select {
    case msg := <-ch1:
        fmt.Println(msg)
    case msg := <-ch2:
        fmt.Println(msg)
    }
}

兩秒鐘之後,輸出:1
在 select 語句中新增下面程式碼:

default:
    fmt.Println("nothing received.")

輸出: nothing received.

總結

Golang 將執行緒抽象出來成為輕量級的 goroutine,開發者不再需要過多地關注 OS 層面的邏輯,終於能夠從併發程式設計中解放出來。
channel 作為 goroutine 通訊的媒介,安全高效的實現了 goroutine 之間的通訊和共享記憶體。
用 Effetive go 中的一句話來總結 [4]: > Do not communicate by sharing memory; instead, share memory by communicating.

Reference

[1] https://blog.golang.org/concurrency-is-not-parallelism
[2] http://blog.nindalf.com/how-goroutines-work/
[3] https://www.golang-book.com/books/intro/10
[4] https://golang.org/doc/effective_go.html

更多原創文章乾貨分享,請關注公眾號
  • 理解Golang併發程式設計
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章