【Go併發程式設計】第二篇 - Goroutines和Channels

yummybian發表於2018-06-22

Goroutines

Goroutine是Go中最基本的執行單元。事實上每一個Go程式至少有一個goroutine:主goroutine。當程式啟動時,它會自動建立。

事實上goroutine採用了一種fork-join的模型。

【Go併發程式設計】第二篇 - Goroutines和Channels

sayHello := func() {
	fmt.Println("hello")
}

go sayHello()
複製程式碼

那我們如何來join goroutine呢?需要引入wait操作:

var wg sync.WaitGroup()
sayHello := func() {
	defer wg.Done()
	fmt.Println("hello")
}

wg.Add(1)
go sayHello()
wa.Wait()
複製程式碼

Channel

讀寫channel

goroutine是Go語言的基本排程單位,而channels則是它們之間的通訊機制。操作符<-用來指定管道的方向,傳送或接收。如果未指定方向,則為雙向管道。

// 建立一個雙向channel
ch := make(chan interface{})
複製程式碼

interface{}表示chan可以為任何型別

channel有傳送和接受兩個主要操作。傳送和接收兩個操作都使用<-運算子。在傳送語句中,channel放<-運算子左邊。在接收語句中,channel放<-運算子右邊。一個不使用接收結果的接收操作也是合法的。

// 傳送操作
ch <- x 
// 接收操作
x = <-ch 
// 忽略接收到的值,合法
<-ch     
複製程式碼

我們不能弄錯channel的方向:

writeStream := make(chan<- interface{})
readStream := make(<-chan interface{})

<-writeStream
readStream <- struct{}{}
複製程式碼

上面的語句會產生如下錯誤:

invalid operation: <-writeStream (receive from send-only type chan<- interface {}) invalid operation: readStream <- struct {} literal (send to receive-only type <-chan interface {})
複製程式碼

關閉channel

Channel支援close操作,用於關閉channel,後面對該channel的任何傳送操作都將導致panic異常。對一個已經被close過的channel進行接收操作依然可以接受到之前已經成功傳送的資料;如果channel中已經沒有資料的話將產生一個零值的資料。

從已經關閉的channel中讀:

intStream := make(chan int) 
close(intStream)
integer, ok := <- intStream
fmt.Pritf("(%v): %v", ok, integer)
// (false): 0
複製程式碼

上面例子中通過返回值ok來判斷channel是否關閉,我們還可以通過range這種更優雅的方式來處理已經關閉的channel:

intStream := make(chan int) 
go func() {
	defer close(intStream) 
	for i:=1; i<=5; i++{ 
		intStream <- i 
	}
}()

for integer := range intStream { 
	fmt.Printf("%v ", integer)
}
// 1 2 3 4 5
複製程式碼

帶緩衝(buffered)的channel

建立了一個可以持有三個字串元素的帶緩衝Channel:

ch = make(chan string, 3)
複製程式碼

【Go併發程式設計】第二篇 - Goroutines和Channels

我們可以在無阻塞的情況下連續向新建立的channel傳送三個值:

ch <- "A"
ch <- "B"
ch <- "C"
複製程式碼

【Go併發程式設計】第二篇 - Goroutines和Channels

此刻,channel的內部緩衝佇列將是滿的,如果有第四個傳送操作將發生阻塞。

如果我們接收一個值:

fmt.Println(<-ch) // "A"
複製程式碼

那麼channel的緩衝佇列將不是滿的也不是空的,因此對該channel執行的傳送或接收操作都不會發生阻塞。通過這種方式,channel的緩衝佇列緩衝解耦了接收和傳送的goroutine。

【Go併發程式設計】第二篇 - Goroutines和Channels

帶緩衝的通道可被用作訊號量,例如限制吞吐量。在此例中,進入的請求會被傳遞給 handle,它從通道中接收值,處理請求後將值發回該通道中,以便讓該 “訊號量” 準備迎接下一次請求。通道緩衝區的容量決定了同時呼叫 process 的數量上限。

var sem = make(chan int, MaxOutstanding)

func handle(r *Request) {
    sem <- 1 // 等待活動佇列清空。
    process(r)  // 可能需要很長時間。
    <-sem    // 完成;使下一個請求可以執行。
}

func Serve(queue chan *Request) {
    for {
        req := <-queue
        go handle(req)  // 無需等待 handle 結束。
    }
}
複製程式碼

然而,它卻有個設計問題:儘管只有 MaxOutstanding 個 goroutine 能同時執行,但 Serve 還是為每個進入的請求都建立了新的 goroutine。其結果就是,若請求來得很快, 該程式就會無限地消耗資源。為了彌補這種不足,我們可以通過修改 Serve 來限制建立 Go 程,這是個明顯的解決方案,但要當心我們修復後出現的 Bug。

func Serve(queue chan *Request) {
    for req := range queue {
        sem <- 1
        go func() {
            process(req) // 這兒有 Bug,解釋見下。
            <-sem
        }()
    }
}
複製程式碼

Bug 出現在 Go 的 for 迴圈中,該迴圈變數在每次迭代時會被重用,因此 req 變數會在所有的 goroutine 間共享,這不是我們想要的。我們需要確保 req 對於每個 goroutine 來說都是唯一的。有一種方法能夠做到,就是將 req 的值作為實參傳入到該 goroutine 的閉包中:

func Serve(queue chan *Request) {
    for req := range queue {
        sem <- 1
        go func(req *Request) {
            process(req)
            <-sem
        }(req)
    }
}
複製程式碼

另一種解決方案就是以相同的名字建立新的變數,如例中所示:

func Serve(queue chan *Request) {
    for req := range queue {
        req := req // 為該 Go 程建立 req 的新例項。
        sem <- 1
        go func() {
            process(req)
            <-sem
        }()
    }
}
複製程式碼

下面再看一個Go語言聖經的例子。它併發地向三個映象站點發出請求,三個映象站點分散在不同的地理位置。它們分別將收到的響應傳送到帶緩衝channel,最後接收者只接收第一個收到的響應,也就是最快的那個響應。因此mirroredQuery函式可能在另外兩個響應慢的映象站點響應之前就返回了結果。

func mirroredQuery() string {
    responses := make(chan string, 3)
    go func() { responses <- request("asia.gopl.io") }()
    go func() { responses <- request("europe.gopl.io") }()
    go func() { responses <- request("americas.gopl.io") }()
    // 僅僅返回最快的那個response
    return <-responses 
}

func request(hostname string) (response string) { /* ... */ }
複製程式碼

如果我們使用了無緩衝的channel,那麼兩個慢的goroutines將會因為沒有人接收而被永遠卡住。這種情況,稱為goroutines洩漏,這將是一個BUG。和垃圾變數不同,洩漏的goroutines並不會被自動回收,因此確保每個不再需要的goroutine能正常退出是重要的。

Channels of channels

Go 最重要的特性就是通道是first-class value,它可以被分配並像其它值到處傳遞。 這種特性通常被用來實現安全、並行的多路分解。

我們可以利用這個特性來實現一個簡單的RPC。 以下為 Request 型別的大概定義。

type Request struct {
    args        []int
    f           func([]int) int
    resultChan  chan int
}
複製程式碼

客戶端提供了一個函式及其實參,此外在請求物件中還有個接收應答的通道。

func sum(a []int) (s int) {
    for _, v := range a {
        s += v
    }
    return
}

request := &Request{[]int{3, 4, 5}, sum, make(chan int)}
// 傳送請求
clientRequests <- request
// 等待迴應
fmt.Printf("answer: %d\n", <-request.resultChan)
複製程式碼

服務端的handler函式:

func handle(queue chan *Request) {
    for req := range queue {
        req.resultChan <- req.f(req.args)
    }
}
複製程式碼

Channels pipeline

Channels也可以用於將多個goroutine連線在一起,一個Channel的輸出作為下一個Channel的輸入。這種串聯的Channels就是所謂的管道(pipeline)。下面的程式用兩個channels將三個goroutine串聯起來:

【Go併發程式設計】第二篇 - Goroutines和Channels

第一個goroutine是一個計數器,用於生成0、1、2、……形式的整數序列,然後通過channel將該整數序列傳送給第二個goroutine;第二個goroutine是一個求平方的程式,對收到的每個整數求平方,然後將平方後的結果通過第二個channel傳送給第三個goroutine;第三個goroutine是一個列印程式,列印收到的每個整數。

func counter(out chan<- int) {
	for x := 0; x < 100; 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() {
	naturals := make(chan int)
	squares := make(chan int)

	go counter(naturals)
	go squarer(squares, naturals)
	printer(squares)
}
複製程式碼

Select多路複用

select用於從一組可能的通訊中選擇一個進一步處理。如果任意一個通訊都可以進一步處理,則從中隨機選擇一個,執行對應的語句。否則,如果又沒有預設分支(default case),select語句則會阻塞,直到其中一個通訊完成。

select {
case <-ch1:
    // ...
case x := <-ch2:
    // ...use x...
case ch3 <- y:
    // ...
default:
    // ...
}
複製程式碼

如何使用select語句為一個操作設定一個時間限制。程式碼會輸出變數news的值或者超時訊息,具體依賴於兩個接收語句哪個先執行:

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

下面的select語句會在abort channel中有值時,從其中接收值;無值時什麼都不做。這是一個非阻塞的接收操作;反覆地做這樣的操作叫做“輪詢channel”。

select {
case <-abort:
    fmt.Printf("Launch aborted!\n")
    return
default:
    // do nothing
}
複製程式碼

參考資料。

  1. Concurrency in Go
  2. gopl
  3. Effective Go

相關文章