併發基礎
併發包含如下幾種主流的實現模型:
- 多進程
- 多線程
- 基於回到的非阻塞/異步IO
- 協程
協程
與傳統的系統級線程和進程相比,協程最大的優勢在於“輕量級”,可以輕鬆創建上百萬個而不會導致系統資源枯竭,而線程和進程通常最多不超過1萬個。
Golang在語言級別支持協程,叫goroutine
。
goroutine
goroutine是Golang中輕量級線程的實現,由Go運行時管理,使用go
關鍵字來觸發一個新的goroutine執行。
具體來說,在一個函數調用前加上關鍵字go
,這次調用就會在一個新的goroutine中併發執行。
當被調用的函數返回時,這個goroutine也自動結束了。需要注意的是:如果這個函數有返回值,那麼這個返回值會被丟棄。
func Add(a, b int) {
z := a + b
fmt.Println("z=", z)
}
func main() {
for i := 0; i < 10; i++ {
go Add(1, 1) // 在函數調用前使用關鍵字go,使得函數的調用是在goroutine中執行
}
}
上述代碼演示瞭如何在Golang中使用goroutine。
但是上述代碼運行時並沒有任何輸出!原因:Go程序從初始化main package並執行main()函數開始,當main()函數返回時,程序退出,且程序並不會等待其他goroutine(非主goroutine)結束。
併發通信
在工程上,有2種最常見的併發通信模型:共享數據和消息。
被共享的數據可能有多種形式,如:內存數據塊,磁盤文件,網絡數據等。
如果是通過共享內存來實現併發通信,那就只能使用鎖了。
Golang以併發編程作爲語言的最核心優勢,提供了另一種通信模型,即:以消息機制而非共享內存作爲併發通信方式。
Golang提供的消息機制被稱爲channel。
channel
channel是Golang在語言級別提供的goroutine間通信方式,可以使用channel在兩個或多個goroutine之間傳遞消息。
channel是進程內的通信方式,因此通過channel傳遞對象的過程和調用函數時的參數傳遞行爲比較一致,比如也可以傳遞指針等。
channel是類型相關的,即:一個channel只能傳遞一種類型的值,這個類型需要在聲明channel時指定。
基本語法
一般channel的聲明形式爲:
// 與聲明一般變量的不同在於需要在類型前面加了關鍵字chan
// ElementType指定這個channel所能傳遞的元素類型
var chanName chan ElementType
示例:
// 聲明一個傳遞類型爲int的channel
var ch chan int
// 聲明一個map,元素類型爲bool的channel,即:這個channel傳遞的元素類型爲map,map的值類型爲bool
var m map[string] chan bool
定義一個channel也很簡單,使用內置的函數make()
即可:
// 聲明並初始化了一個傳遞類型爲int的channel
ch := make(chan int)
在channel的用法中,最常見的包括寫入和讀取。
將一個數據寫入channel的語法:ch <- value
,向channel寫入數據通常會導致程序阻塞,直到有其他goroutine從這個channle中讀取數據。
從channel中讀取數據的語法是:value := <- ch
,如果channel之前沒有寫入數據,那麼從channel讀取數據也會導致程序阻塞,直到channel中被寫入數據爲止。
select
Golang在語言級別支持select
關鍵字,用於處理異步IO問題。
select
與用法結構如下:
select {
case <-ch1:
// 如果從ch1成功讀取到數據,執行該case處理語句
case ch2 <- 1:
// 如果成功向ch2寫入數據,執行該case處理語句
default:
// 如果上面都沒有成功,則進入default處理流程
}
在select
的用法中,要求:每個case語句都必須是一個面向channel的操作。
如下是基於select
的一段有趣的代碼:
c := 0
ch := make(chan int, 1)
for {
// 使用select隨機向ch中寫入0或1
select {
case ch <- 0:
case ch <- 1:
}
i := <-ch
fmt.Println("Received: ", i)
c++
if c > 10 {
break
}
}
緩衝機制
不帶緩衝的channel,對於傳遞單個數據的場景可以接受,但是對於需要傳遞大量數據的場景就不合適了。
創建一個帶緩衝的channel:
// 在調用make()時將緩衝區大小作爲第二個參數傳入即可
c := make(chan int, 1024)
帶緩衝區的channel即使沒有讀取方,寫入方也可以一直往channel中寫入數據,在緩衝區填滿之前都不會阻塞。
從帶緩衝區的channel中讀取數據可以使用與常規非緩衝channel完全一致的方法,但是也可以使用range
關鍵字來實現更簡便的循環讀取。
// 使用range關鍵字來實現帶緩衝區channel的循環讀取
for v := range ch {
fmt.Println("Received:", v)
}
超時機制
如果不能很好地處理超時問題,可能會導致goroutine永遠阻塞而沒有挽回的機會!
Golang中沒有提供直接的超時處理機制,但是可以使用select
很方便地解決超時問題(因爲select
的特點是隻要其中一個case已經完成,程序就會繼續往下執行,而不會考慮其他case的情況)。
ch := make(chan int, 1024)
// 首先,實現並執行一個匿名的超時等待函數
timeout := make(chan bool, 1)
go func() {
time.Sleep(1e9) // 等待一秒鐘
timeout <- true
}()
// 然後,把timeout這個channel利用起來
select {
case <-ch:
// 從目標channel中讀取數據
case <-timeout:
// 如果從目標channel中一直沒有讀取到數據,但是從timeout這個channel上讀取到了數據
// 這樣就使用select機制可以避免永久等待的問題
// 這是在Golang開發中避免channel通信超時的最有效辦法
}
channel的傳遞
在Golang中channel本身也是一種原生類型,與map之類的類型地位一樣,因此channel本身在定義後也可以通過channel來傳遞。
可以使用這個特性來實現管道,管道也是使用非常廣泛的一種設計模式。
type PipeData struct {
value int
handler func(int) int
next chan int
}
首先限定一個基本的數據結構PipeData
,然後寫一個常規的處理函數。只要定義一系列PipeData
的數據結構並一起傳遞給這個函數,就可以達到流式處理數據的目的。
func handle(queue chan *PipeData) {
for data := range queue {
data.next <- data.handler(data.value)
}
}
單向channel
單向channel只能用於發送或接收數據。
可以在將一個channel變量傳遞給一個函數時,通過指定其爲單向channel變量,從而限制在該函數中可以對此channel執行的操作,比如只能往這個channel寫,或者只能從這個channel讀。
單向channel的聲明非常簡單,如下:
var ch1 chan int // ch1是一個正常的channel,不是單向的
var ch2 chan<- float64 // ch2是一個用於只寫float64數據單項channel
var ch3 <-chan int // ch3是一個用於只讀int數據的channel
單向channel的初始化:
ch4 := make(chan int)
ch5 := <-chan int(ch4) // ch5是一個單向讀取的channel
ch6 := chan<- int(ch4) // ch6是一個單向寫入的channel
如上,基於一個正常的channel可以實現單向channel的初始化。
即類型轉換對於channel的意義:在單向channel和雙向channel之間進行轉換。
使用單向channel可以起到一種契約的作用:
func parse(ch <-chan int) {
for value := range ch {
fmt.Println("Received:", value)
}
}
如上,除非這個函數的實現者使用了類型轉換,否則這個函數就不會因爲各種原因而對ch
變量執行寫操作,因而避免在ch
中出現非期望的數據,從而很好地實踐最小權限原則。
關閉channel
使用內置函數close()
關閉channel。
close(ch)
如何判斷一個channel是否已經關閉?可以通過在讀取的時候使用多重返回值進行判斷:
// 使用多重返回值檢查channel是否已經關閉
val, ok := <-ch
if ok {
// channel未關閉,可以正常使用返回值
fmt.Println("Received:", val)
}
多核並行化
多核並行化是指儘量利用CPU多核特性來將任務並行化執行。
具體到Golang中,就是要知道CPU核心的數量,並針對性地將計算任務分解到多個goroutine中並行運行。
// 獲取CPU核心數量
runtime.NumCPU()
出讓時間片
使用runtime.Gosched()
在每個goroutine中控制何時主動出讓時間片給其他goroutine。
同步
同步鎖
Golang的sync
包中提供了兩種鎖類型:sync.Mutex
和sync.RWMutex
。
Mutex
是最簡單的鎖類型,同時也比較暴力,當一個goroutine獲得Mutex
後,其他goroutine就只能等待這個goroutine釋放該Mutex
。
RWMutex
相對友好,是經典的單寫多讀模型。在讀鎖佔用的情況下,會阻止寫,但不阻止讀。也就是多個goroutine可同時獲取讀鎖,而寫鎖會阻止任何其他goroutine進來,整個鎖相當於由該goroutine獨佔。獲取讀鎖:sync.RWMutex.RLock()
,獲取寫鎖:sync.RWMutex.Lock()
。
對於這兩種鎖類型,任何一個Lock()
或RLock()
均需要保證對應有Unlock()
或RUnlock()
調用與之對應,否則可能導致等待該鎖的所有goroutine處於飢餓狀態,甚至可能導致死鎖。
鎖的典型使用模式如下:
// 先聲明一個鎖
var lock sync.Mutex
func foo() {
lock.Lock()
defer lock.Unlock() // defer關鍵字的方便之處
// 獲得鎖之後需要執行的操作
}
全局唯一性操作
對於從全局的角度只需要運行一次的代碼,比如全局初始化,Golang提供了一個Once
類型來保證全局的唯一性操作。
var a string
var once sync.Once
func setup() {
a = "Hello, World!"
fmt.Println("初始化a")
}
func doPrint() {
once.Do(setup) // 使用Once來控制函數在全局角度只會執行一次
fmt.Println(a)
}
func twoPrint() {
go doPrint()
go doPrint()
}
如上示例代碼,once
的Do()
方法可以保證在全局範圍內只調用指定的函數一次,而且其他所有goroutine在調用到此語句時,將會先被阻塞,直到全局唯一的once.Do()
調用結束之後才繼續。
原子性操作
如果Golang中沒有提供Once
類型來保證全局唯一性操作,對於那些需要控制在全局只執行一次的操作來說,只能通過別的辦法來處理了。
// 設置一個全局變量表示初始化操作是否完畢
var done bool = false
func setup() {
a = "Hello, World!"
done = true
fmt.Println("初始化a")
}
func doPrint() {
if !done {
setup()
}
fmt.Println(a)
}
這段代碼看起來合理,但是細看還是會有問題,因爲setup()
並不是一個原子性操作。這種寫法可能會導致setup()
被調用多次,從而無法達到全局只執行一次的目標。
爲了更好地控制並行中的原子性操作,sync
包中還包含了一個atomic
子包,它提供了對於一些基礎數據類型的原子操作函數。
// 比較和交換2個uint64類型數據
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
有了這些原子操作函數,開發者就無需再爲這樣的操作專門添加Lock
控制。
總結
關於Golang中併發編程有如下總結。
1.核心內容:協程
2.重要的關鍵字:chan
,go
,select
,defer
。