Go語言學習 二十二 併發

本文最初發表在我的個人博客,查看原文,獲得更好的閱讀體驗


併發是每個編程語言繞不開的一個話題,Go在併發編程方面提供了許多特性,幫助簡化併發模型,如輕量級的線程goroutine,信道等,同樣也提供瞭如sync.Mutex等的鎖機制。

爲實現對共享變量的正確訪問,Go語言提供了一種特殊的控制方式,即將共享的值通過信道傳遞。信道是一種帶有方向的管道,數據可以在其中流轉。在任意一個的時間點,只有一個goroutine能夠訪問該值,既無需加鎖,也無需同步。數據競爭從設計上就被杜絕了。這種思想,被總結爲一句話:
不要通過共享內存來通信,而應通過通信來共享內存。

Go的併發處理方式源於Hoare的通信順序處理(Communicating Sequential Processes, CSP)

一 併發處理

1.1 快速開始

在Go中,通過使用關鍵字go,可以快速創建一個goroutine,例如:

package main

import (
	"fmt"
	"time"
)

func main() {
	go printWord("A") // 開啓一個新的goroutine
	printWord("C")
}

func printWord(s string) {
	for i := 0; i < 5; i++ {
		fmt.Println(s)
		time.Sleep(500 * time.Millisecond)
	}
}

上面一個例子中,在多核CPU系統中,會交替打印出字母"A"、“C”,在單核CPU中則稍有不同。

1.2 Goroutine

goroutine是一種輕量級的線程。它具有簡單的模型:它與其它goroutine併發運行在同一地址空間,因此,訪問共享的內存時必須進行同步(或者使用信道)。它的所有消耗幾乎就只有棧空間的分配,而且棧最開始是非常小的,所以它們很廉價,僅在需要時纔會隨着堆空間的分配(和釋放)而變化。

goroutine在多線程操作系統上可實現多路複用,因此若一個線程阻塞,比如說等待I/O,那麼其它的線程會繼續運行。goroutine的設計隱藏了線程創建和管理的諸多複雜性。

直接在函數或方法前添加go關鍵字即可在新的goroutine中調用它。當調用完成後,該goroutine也會安靜地退出。(效果有點像Unix Shell中的&符號,它可以讓命令在後臺運行。)

有些地方將Goroutine翻譯爲Go程,但這個詞感覺並不怎麼合適(也不好聽),所以本文索性就將其稱之爲goroutine

前面的示例中我們已經看到過:

func main() {
	go printWord("A") // 開啓一個新的goroutine
	printWord("C")
}

func printWord(s string) {
	for i := 0; i < 5; i++ {
		fmt.Println(s)
		time.Sleep(500 * time.Millisecond)
	}
}

有時,爲了簡化程序,可以直接將函數定義爲一個函數字面量(即匿名函數):

package main

import (
	"fmt"
	"time"
)

func main() {
	s := "hello"
	go func() { // 函數字面量,開啓一個新的goroutine
		for i := 0; i < 5; i++ {
			fmt.Println(s+":", i)
			time.Sleep(500 * time.Millisecond)
		}
	}()

	// 等待主程序運行結束
	time.Sleep(3000 * time.Millisecond)
}

這些示例都是一些簡單的函數,要想體現出Go的精妙,還需要配合另一種數據類型,即信道。

二 信道

信道(channel)是一種重要的數據類型,既可以用作信號量,也可以用於數據傳遞,其結果值充當了對底層數據結構的引用。信道具有方向,可選的信道操作符<-指定了通道的方向,發送或接收。如果沒有給出方向,則它是雙向的,信道可以通過分配或顯式轉換來限制只發送或接收。信道的零值是nil,嘗試往未初始化的信道或已關閉的信道發送或接收值都會導致運行時恐慌。

chan<- float64  // 單向信道,只能用於發送float64的值
<-chan int      // 單向信道,只能用於接收int值

<-運算符與最左邊的chan關聯:

chan<- chan int    // 同 chan<- (chan int)
chan<- <-chan int  // 同 chan<- (<-chan int)
<-chan <-chan int  // 同 <-chan (<-chan int)
chan (<-chan int)

“箭頭”就是數據流的方向。

2.1 無緩衝信道

和映射一樣,在使用前需要先使用make初始化:

// 創建一個可用於發送和接收字符串類型的信道
c := make(chan string)

重新看一下本文開頭的例子,如果我們只保留一個goroutine中的打印,會怎麼樣:

package main

import (
	"fmt"
	"time"
)

func main() {
	go printWord("A") // 開啓一個新的goroutine
}

func printWord(s string) {
	for i := 0; i < 5; i++ {
		fmt.Println(s)
		time.Sleep(500 * time.Millisecond)
	}
}

結果發現,這次什麼也沒有輸出。
爲什麼?一方面,新開啓的goroutine不會阻塞當前的main函數,另一方面,一旦main函數執行完畢,整個程序也就結束,所以上面那個goroutine還沒來得及打印,主程序就已經結束了。除了用time.Sleep函數讓main函數等待一段時間,我們還可以藉助信道來更優雅的實現:

package main

import (
	"fmt"
	"time"
)

func main() {
	c := make(chan bool)
	go printWord("A", c) // 開啓一個新的goroutine
	<-c                  // 等待打印完畢,丟棄傳遞過來的值
}

func printWord(s string, c chan bool) {
	for i := 0; i < 5; i++ {
		fmt.Println(s)
		time.Sleep(500 * time.Millisecond)
	}

	c <- true // 發送信號,告知執行完畢
}

默認情況下,信道的發送和接收操作在另一端準備好之前都會阻塞。這使得goroutine可以在沒有顯式的鎖或競態變量的情況下進行同步。這樣,一旦打印任務執行完畢,就會告知主函數,主函數收到通知,就會立即往下執行。

除了將信道用作信號量,也可以用於傳輸數據:

package main

import (
	"fmt"
	"time"
)

func main() {
	info := make(chan string) // 用於傳輸數據的信道
	go send(info)

	for w := range info {
		w := w // 注意這裏變量w的特殊用法
		go func() {
			time.Sleep(600 * time.Millisecond)
			fmt.Println(w)
			// fmt.Println(&w)
		}()
	}

	time.Sleep(1000 * time.Millisecond)
}

// 模擬發送信息
func send(info chan string) {
	word := [...]string{"A", "B", "C", "D", "E", "F"}
	for _, s := range word {
		info <- s // 將數據放入信道
	}
	close(info) // 傳輸完畢關閉
}

輸出:

E
B
A
C
F
D

這裏特別要注意上述第13行代碼

w := w

該循環變量在每次迭代時會被重用,因此變量w會在所有的goroutine間共享,爲了避免這種情況,我們使用同名的變量重新獲取了一份該變量的值,就好像我們在局部把原來的變量屏蔽了一樣。你可以試着將第13行代碼註釋掉看一下效果。這種用法雖然看起來很奇怪,但這是Go的慣用語法。

註釋掉後會打印出相同的結果:

F
F
F
F
F
F

可以使用go vet命令來檢查這種問題:

$ go vet hello.go
# command-line-arguments
./hello.go:16:16: loop variable w captured by func literal

針對這個問題,另外一種寫法同樣有效:

package main

import (
	"fmt"
	"time"
)

func main() {
	info := make(chan string) // 用於傳輸數據

	go send(info)

	for w := range info {
		go func(w string) {
			time.Sleep(600 * time.Millisecond)
			fmt.Println(w)
			// fmt.Println(&w)
		}(w) // 作爲實參傳入
	}

	time.Sleep(1000 * time.Millisecond)
}

// 模擬發送信息
func send(info chan string) {
	word := [...]string{"A", "B", "C", "D", "E", "F"}
	for _, s := range word {
		info <- s // 將數據放入信道
	}
	close(info) // 傳輸完畢關閉
}

注意代碼第14、18行與之前代碼的區別。

最後需要注意的是,這種無緩衝的信道由於兩端需要同時就緒才能工作,所以只在一個main函數中是無法工作的:

package main

import (
	"fmt"
)

func main() {
	c := make(chan bool)
	c <- true
	fmt.Println(<-c)
}

原因是第9行和第10行發生了死鎖,兩端都在等待對方準備就緒,運行結果會報錯:

fatal error: all goroutines are asleep - deadlock!

無緩衝信道在通信時會同步交換數據,它能確保(兩個goroutine的)計算處於確定狀態。

信道遵循FIFO原則。

2.2 帶緩衝的信道

若提供了一個可選的整型參數,它就會爲該信道設置緩衝區大小。默認值是零,表示不帶緩衝的或同步的信道。

c1 := make(chan int)            // 整型的無緩衝信道
c2 := make(chan int, 0)         // 整型的無緩衝信道
c3 := make(chan *os.File, 100)  // 指向文件指針的帶緩衝信道

僅當信道的緩衝區填滿後,向其發送數據時纔會阻塞。當緩衝區爲空時,接受方會阻塞。
以下示例展示了一個帶有緩衝區的信道:

package main

import (
	"fmt"
	"time"
)

func main() {
	c := make(chan string, 3)
	go printWord("A", c) // 開啓一個新的goroutine
	for i := 0; i < 5; i++ {
		fmt.Println(<-c)
		time.Sleep(600 * time.Millisecond)
	}
	fmt.Println("打印完畢")
}

func printWord(s string, c chan string) {
	for i := 0; i < 5; i++ {
		c <- s
		time.Sleep(300 * time.Millisecond)
	}
	fmt.Println("發送完畢")
}

我們可以看出發送的速度明顯要快於打印的速度。這種特性還可被用作信號量,例如限制請求的吞吐量等。

2.3 信道的迭代和關閉

發送者可通過內置函數close關閉一個信道來表示沒有需要發送的值了。接收者可以通過逗號ok語法來測試信道是否被關閉:若沒有值可以接收且信道已被關閉,那麼在執行完v, ok := <-ch之後ok會被設置爲false
循環 for i := range c 會不斷從信道接收值,直到它被關閉(不關閉會引發panic)。

package main

import (
	"fmt"
	"time"
)

func main() {
	c := make(chan string, 3)
	go printWord("A", c) // 開啓一個新的goroutine
	for s := range c {
		fmt.Println(s)
		time.Sleep(600 * time.Millisecond)
	}
	fmt.Println("打印完畢")
}

func printWord(s string, c chan string) {
	for i := 0; i < 5; i++ {
		c <- s
		time.Sleep(300 * time.Millisecond)
	}
	close(c) // 發送完畢,關閉信道
	fmt.Println("發送完畢")
}

注意:只有發送者才能關閉信道,而接收者不能。向一個已經關閉的信道發送數據會引發程序恐慌(panic)。另外,信道與文件不同,通常情況下無需關閉它們。只有在必須告訴接收者不再有需要發送的值時纔有必要關閉,例如終止一個for-range循環。

三 select語句

select語句是Go中另一個重要特性。與switch語法類似,不過全都是關於通信操作的。它可以使一個goroutine等待多個通信操作。select會阻塞到某個分支可以繼續執行爲止,這時就會執行該分支。當多個分支都準備好時會隨機選擇一個執行。

package main

import (
	"fmt"
	"time"
)

func main() {
	c1 := make(chan string, 3)
	c2 := make(chan string, 3)

	go printWord("A", c1) // 開啓一個新的goroutine
	go printWord("B", c2) // 開啓一個新的goroutine

	for i := 0; i < 5; i++ {
		select {
		case s1 := <-c1:
			fmt.Println(s1)
		case s2 := <-c2:
			fmt.Println(s2)
		}
	}

	fmt.Println("打印完畢")
}

func printWord(s string, c chan string) {
	for {
		c <- s
		time.Sleep(300 * time.Millisecond)
	}
}

3.1 default分支

select中的其它分支都沒有準備好時,default分支就會執行。

爲了在嘗試發送或者接收時不發生阻塞,可使用 default 分支:

package main

import (
	"fmt"
	"time"
)

func main() {
	c1 := make(chan string, 3)
	c2 := make(chan string, 3)

	go printWord("A", c1) // 開啓一個新的goroutine
	go printWord("B", c2) // 開啓一個新的goroutine

	for i := 0; i < 5; i++ {
		select {
		case s1 := <-c1:
			fmt.Println(s1)
		case s2 := <-c2:
			fmt.Println(s2)
		default:
			fmt.Println("waiting..")
			time.Sleep(300 * time.Millisecond)
		}
	}

	fmt.Println("打印完畢")
}

func printWord(s string, c chan string) {
	for {
		c <- s
		time.Sleep(300 * time.Millisecond)
	}
}

四 其他同步機制

4.1 互斥鎖

信道讓goroutine之間的通信變有趣且強大,但有時我們可能只想保證安全的訪問一個共享變量,Go同樣也提供了傳統的鎖機制方案。標準庫中提供了一個互斥鎖類型:
sync.Mutex

該類型實現了sync.Locker接口:

type Locker interface {
        Lock()
        Unlock()
}

Mutex的零值是一個解鎖狀態的互斥鎖,無需特殊初始化。

Mutex一旦使用,不能再將其複製使用。我們可以在一段代碼前調用其Lock()方法,在代碼後調用其Unlock()方法,來保證該段代碼的互斥執行。該互斥鎖不與特定的goroutine綁定,我們可以在一個goroutine中進行鎖定,並在另一個goroutine中進行解鎖。當然我們也可以用defer語句來保證互斥鎖一定會被解鎖.

我們看一下經典的賣火車票的例子在Go中是如何實現的。

以下是未進行同步的代碼示例:

package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	ticket := 10

	go sell(&ticket, "售票員1")
	go sell(&ticket, "售票員2")
	sell(&ticket, "售票員3")
	time.Sleep(1000 * time.Millisecond)
	fmt.Println("停止售票!!!")
}

func sell(ticket *int, name string) {
	for {
		// 以下操作不是原子操作
		if *ticket <= 0 {
			fmt.Printf("%v說:票賣完了\n", name)
			break
		}
		time.Sleep(time.Duration(rand.Intn(1e3)) * time.Millisecond)
		*ticket--
		fmt.Printf("%v說:還剩%v張票。\n", name, *ticket)
	}
}

上邊的例子中,第22-28行代碼沒有進行同步,不是一個原子操作,所以執行結果達不到我們的預期,會出現賣出負票的情況:

售票員3說:還剩9張票。
售票員3說:還剩8張票。
售票員3說:還剩7張票。
售票員3說:還剩6張票。
售票員2說:還剩5張票。
售票員1說:還剩4張票。
售票員3說:還剩3張票。
售票員3說:還剩2張票。
售票員1說:還剩1張票。
售票員2說:還剩0張票。
售票員2說:票賣完了
售票員1說:還剩-1張票。
售票員1說:票賣完了
售票員3說:還剩-2張票。
售票員3說:票賣完了
停止售票!!!

加鎖解決:

package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

var m sync.Mutex

func main() {
	ticket := 10

	go sell(&ticket, "售票員1")
	go sell(&ticket, "售票員2")
	sell(&ticket, "售票員3")
	time.Sleep(1000 * time.Millisecond)
	fmt.Println("停止售票!!!")
}

func sell(ticket *int, name string) {
	for {
		m.Lock() // 賣票前加鎖同步
		if *ticket <= 0 {
			fmt.Printf("%v說:票賣完了\n", name)
			defer m.Unlock() // 全部賣完記得解鎖
			break
		}
		time.Sleep(time.Duration(rand.Intn(1e3)) * time.Millisecond)
		*ticket--
		fmt.Printf("%v說:還剩%v張票。\n", name, *ticket)
		m.Unlock() // 每賣完一張解鎖
	}
}

結果輸出:

售票員3說:還剩9張票。
售票員3說:還剩8張票。
售票員1說:還剩7張票。
售票員2說:還剩6張票。
售票員3說:還剩5張票。
售票員1說:還剩4張票。
售票員2說:還剩3張票。
售票員3說:還剩2張票。
售票員1說:還剩1張票。
售票員2說:還剩0張票。
售票員3說:票賣完了
售票員1說:票賣完了
售票員2說:票賣完了
停止售票!!!

我們看到,這次恰到好處的賣完了所有票。

4.1.1 RWMutex

sync包中還有一些其他鎖,比如讀寫鎖RWMutex,它是一個基於Mutex的互斥鎖,區別在於RWMutex允許持有多個讀鎖,但只能有一個寫鎖。同樣,RWMutex的零值是一個未加鎖的互斥鎖。且一旦用過也是不能再次複製使用。其方法如下:

func (rw *RWMutex) Lock()
func (rw *RWMutex) RLock()
func (rw *RWMutex) RLocker() Locker
func (rw *RWMutex) RUnlock()
func (rw *RWMutex) Unlock()

4.2 Once

Once類型可以保證被調用的函數只執行一次。它只有一個方法:

func (o *Once) Do(f func())

其中f只有在第一次被調用時有效。該特性通常用於只能執行一次的初始化中。

package main

import (
	"fmt"
	"sync"
)

func main() {
	var once sync.Once
	c := make(chan int)

	for i := 0; i < 5; i++ {
		i := i
		go func() {
			once.Do(info)
			fmt.Printf("第%v次調用info()\n", i)
			c <- 1
		}()
	}

	for i := 0; i < 5; i++ {
		<-c
	}
}

func info() {
	fmt.Println("====But the info() func Only executes once on the first call.")
}

輸出如下:

====But the info() func Only executes once on the first call.
第4次調用info()
第0次調用info()
第1次調用info()
第2次調用info()
第3次調用info()

4.3 WaitGroup

WaitGroup會等待一批goroutine執行完成。主goroutine在使用前先調用其Add方法設置要等待的goroutine數;然後每個goroutine運行並在運行完後調用其Done函數告知執行完畢;同時,主goroutine中可以調用Wait函數來等待所有其他goroutine執行完成,在此之前,Wait函數會一直阻塞。

在Java中,java.util.concurrent.CountDownLatch具有類似的功能。

WaitGroup方法如下:

// 向WaitGroup的計數器中增加delta個計數。在計數器變爲0之前,Wait函數一直阻塞.
// 當計數器爲0時(例如剛初始化,)此函數必須在Wait函數調用前調用。
// delta可以爲負,但可能會導致運行時恐慌。
func (wg *WaitGroup) Add(delta int)

// 每次調用會使計數器減1
func (wg *WaitGroup) Done()

// 在計數器變爲0之前一直阻塞
func (wg *WaitGroup) Wait()

WaitGroup 可以複用於多個獨立的等待事件,前提是之前所有的等待必須都已返回。

在前面賣火車票的例子中,main函數最後一行代碼如下:

time.Sleep(1000 * time.Millisecond)

我們加入這行代碼的原因是讓主函數等待儘可能長的時間,以便其他goroutine能順利執行完畢。但是這種操作顯然不夠優雅,因爲我們不知道其他goroutine到底什麼時候執行完畢,只能猜測。現在我們藉助WaitGroup來做這件事情了:

package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

var m sync.Mutex
var wg sync.WaitGroup

func main() {
	ticket := 10

	wg.Add(3) // 設置需要等待幾個goroutine。
	
	go sell(&ticket, "售票員1")
	go sell(&ticket, "售票員2")
	sell(&ticket, "售票員3")

	wg.Wait() // 開始等待
	fmt.Println("停止售票!!!")
}

func sell(ticket *int, name string) {
	defer wg.Done() // 任務完成時,通知主goroutine
	for {
		m.Lock() // 賣票前加鎖同步
		if *ticket <= 0 {
			fmt.Printf("%v說:票賣完了\n", name)
			defer m.Unlock() // 全部賣完記得解鎖
			break
		}
		time.Sleep(time.Duration(rand.Intn(1e3)) * time.Millisecond)
		*ticket--
		fmt.Printf("%v說:還剩%v張票。\n", name, *ticket)
		m.Unlock() // 每賣完一張解鎖
	}
}

現在,一旦某個賣票的goroutine執行完畢,就會立即通知主goroutine,當主goroutine等待所有goroutine完成任務時,能立即發現。

五 指定可用的CPU核心數

Go語言提供了一些函數,可以讓我們精確的控制程序所使用的CPU資源,例如,函數runtime.NumCPU可以返回機器上可用的硬件CPU核數:

var cpus = runtime.NumCPU()

另外,還有一個函數runtime.GOMAXPROCS,可以獲取或設置Go程序可以同時運行的用戶指定的CPU核心數。它默認等於runtime.NumCPU的值,但是可以通過設置同名的shell環境變量或用一個正數參數調用該函數來覆蓋它。用0調用該函數則可以查詢當前值。因此,如果我們尊重用戶的資源請求,我們應該這麼寫:

var cpus = runtime.GOMAXPROCS(0)

嘗試修改並運行以下示例:

package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	runtime.GOMAXPROCS(1) // 將該值修改爲2看一下結果有什麼不同
	go printWord("A")     // 開啓一個新的goroutine
	printWord("C")
}

func printWord(s string) {
	for i := 0; i < 5; i++ {
		fmt.Println(s)
		time.Sleep(500 * time.Millisecond)
	}
}

注意不要混淆併發和並行的概念:併發是用可獨立執行的組件構造程序的方法,而並行則是爲了效率在多CPU上平行地進行計算。儘管Go的併發特性能夠讓某些問題更易構造成並行計算,但Go仍然是種併發而非並行的語言,且Go的模型並不適合所有的並行問題。一張圖簡單描述併發與並行的區別:
併發與並行的區別
                  (圖 By Erlang 之父 Joe Armstrong)

關於併發和並行區別的詳細討論,見此博文該幻燈片

參考:
https://golang.org/doc/effective_go.html#concurrency
https://golang.org/pkg/sync/
https://golang.org/ref/spec#Select_statements
https://golang.org/ref/spec#Channel_types

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章