《Go 語言併發之道》讀後感 - 第三章
《Go 語言併發之道》讀後感目錄
《Go 語言併發之道》讀後感 - 第三章
前兩章我們介紹了併發之苦,CSP 理論。這一章作者詳細的介紹了 Go 是如何支援併發的。
goroutine
goroutine 是 Go 語言程式中最基本的組織單位之一。每個 Go 語言程式至少有一個 goroutine: main goroutine , 它再程式開始時自動建立並啟動。我們經常聽人說到 goroutine ,它是什麼呢?
- goroutine 是一個併發的函式。
- goroutine 是協程,非系統執行緒,非綠色執行緒。
goroutine 並沒有定義自己的暫停方法或再執行點。Go 程式的 goroutine 排程機制決定,當 goroutine 阻塞的時候自動把它掛起,然後在它們不被阻塞時恢復它們。在 gouroutine 阻塞的時後會觸發搶佔。
上述這種託管機制是一個名為 M:N 排程器的實現,這意味著它將 M 各綠色執行緒對映到 N 個系統執行緒。然後 goroutine 執行在綠色執行緒上。當我們的 goroutine 數量超過可用的綠色執行緒時,排程程式處理分佈在可用執行緒上的 goroutine,並確保這些 goroutine 被阻塞時,其他 goroutine 可以執行。
Go 語言遵循 fork-join 的併發模型:
- fork 指程式在任意節點,可以將子節點於父節點同時執行
- join 將來在某個節點時,分支將會合並在一起
我們一起來看一個例子:
var wg sync.WaitGroup
sayHello := func(){
defer wg.Done()
fmt.Println("hello")
}
wg.Add(1)
go sayHello()
wg.Wait()
fmt.Println("bye")
這裡我們引入了,sync 用作同步,wg.Add() 到 wg.Wait() 定義了一個臨界區。臨界區內的操作完成後,才會繼續下面的 fmt.Println("bye")
輕如鴻毛
記憶體
開闢一個新的程式,或執行緒都需要消耗系統的資源。開闢一個執行緒需要消耗大概 8 MB 資源,通過下面的命令,可以檢視:
ulimit -s
在上一章結尾的我寫到可以認為 goroutine 是沒有任何代價的,下面我們來看一個例子,以下內容會開啟空的 goroutine :
memConsumed := func() uint64{
runtime.GC()
var s runtime.MemStats
runtime.ReadMemStats(&s)
return s.Sys
}
var c <-chan interface{}
var wg sync.WaitGroup
noop := func(){ wg.Done(); <-c }
const numGoroutines = 1e4
wg.Add(numGoroutines)
before := memConsumed()
for i := numGoroutines; i > 0 ; i--{
go noop()
}
wg.Wait()
after := memConsumed()
fmt.Printf("%.3fkb",float64(after - before) / numGoroutines /1000)
Windows 10 下的執行結果:
Linux CentOS 7.4 下的執行結果:
上下文切換
taskset -c 0 perf bench sched pipe -T
# 如果你的機器沒有安裝 perf ,可以用如下命令
yum install perf
apt install perf
goroutine 上下文切換
func Ben(b *testing.B) {
var wg sync.WaitGroup
begin := make(chan struct{})
c := make(chan struct{})
var token struct{}
sender := func() {
defer wg.Done()
<-begin
for i := 0; i < b.N; i++ {
c <- token
}
}
receiver := func() {
defer wg.Done()
<-begin
for i := 0; i < b.N; i++ {
<-c
}
}
wg.Add(2)
go sender()
go receiver()
b.StartTimer()
close(begin)
wg.Wait()
}
我們可以看到上下文切換,執行緒需要花費 2s 左右的時間,goroutine 上下文切換隻需要 0.002s。
sync
sync 包包含對低階別記憶體訪問同步最有用的併發原語,
WaitGroup
當你不關心併發操作的結果,或者你有其他方法來收集它們的結果時,WaitGroup 是等待一組併發操作完成的好方法。
var wg sync.WaitGroup
wg.Add(1) // 引數為 1 ,表示一個 goroutine 開始了
go func() {
defer wg.Done() // 退出前執行 Done 操作,我們向 WaitGroup 表明我們已經退出了
fmt.Println("1st goroutine sleeping...")
time.Sleep(1)
}()
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("2nd goroutine sleeping...")
time.Sleep(2)
}()
wg.Wait() // 等待所有 goroutine 都執行完,再繼續下面的邏輯
fmt.Println("All goroutines complete.")
互斥鎖和讀寫鎖
Mutex 是 "互斥" 的意思,是保護程式中臨界區的以重方式。它提供了一種安全的方式來表示對這些共享資源的獨佔訪問。
var lock sync.Mutex
n := 1
plus := func(){
lock.Lock() // 加鎖
defer lock.Unlock // 函式執行完成釋放鎖
n++
fmt.Println("plus n =>",n)
}
subtr := func(){
lock.Lock()
defer locl.Unlock
n--
fmt.Println("subtr n =>",n)
}
go subtr()
go plus()
fmt.Println(n)
Mutex 互斥鎖,對臨界區強限制,goroutine 必須先獲得鎖然後再進行臨界區操作。
有的時候我們希望下游讀取臨界區操作可以併發,以便提升程式碼讀操作的效能,畢竟一旦加鎖整體都需要等待鎖釋放,如果 Lock() 和 Unlock() 之間的邏輯阻塞,大家都的等待。RWMutex() 就應運而生了。
var rwLock sync.RWMutex
// 獲取鎖,讀寫鎖,其他 goroutine 不可對臨界區內容進行讀寫操作
rwLock.Lock()
// 釋放鎖
rwLock.Unlock()
// 獲取讀鎖,限制其他 goroutine 寫,但不限制讀
rwLock.RLock()
// 釋放讀鎖
rwLock.RUnlock()
sync.NewCond
在 Golang 原始碼中很好的描述了,cond 型別的用途:
一個 goroutine 的集合點,等待或釋出一個 event。
使用方式如下:
c := sync.NewCond(&sync.Mutex{})
c.L.Lock()
for !condition(){
c.Wait()
}
c.L.Unlock()
sync.Once
sync 包為我們提供了一個專門的方案解決一次性初始化的問題: sync.One。
使用方式如下:
var count int
increment := func(){
count++
}
var once sync.Once
var increments sync.WaitGroup
increments.Add(100)
for i := 0; i < 100; i++{
go func(){
defer increments.Done()
once.Do(increment)
}
}
increments.Wait()
fmt.Println("Count is %d \n",count)
sync.Pool { }
Pool 池 是併發安全實現。用於約束建立昂貴的場景,例如: 連結 Redis,MySQL,或其他呼叫遠端服務的時候。只建立固定數量的例項,保障對端服務可用。
myPool := &sync.Pool{
New: func() interface{} {
fmt.Println("Create new connection")
return struct{}{}
},
}
instance := myPool.Get() // 獲取例項
....
myPool.Put(instance) // 釋放例項,供其他人使用
當你使用 Pool 工作是,記住以下幾點:
- 當例項化 sync.Pool,使用 new 方法建立一個成員變數,在呼叫時時執行緒安全的。
- 當你收到一個來自 Get 的例項時,不要對所接收的物件的狀態做出任何假設。
- 當你用完一個從 Pool 中取出來的物件時,一定要呼叫 Put,否則,Pool 就無法複用這個例項了。通常情況下,這是用 defer 完成的。
- Pool 內的分佈必須大致均勻。
channel
channel 是由 Hoare 的 CSP 派生的同步原語之一。
// 定義雙向 channel
var ds chan interface{}
ds = make(chan interface{})
// 定義 只讀 channel
var or <-chan interface{}
or = make(<-chan interface{})
// 定義只寫 channel
var ow chan<- interface{}
ow = make(chan<- interface{})
// 建立緩衝 channel。
bufferChan := make(chan interface{} ,4)
goroutine 是被動排程的,沒有辦法保證它會在程式退出之前執行。Go 語言中的 channel 是阻塞的,這樣在不同的 goroutine 操作同一個 channel 的時候就會被 channel 阻塞,我們還需要注意,不要試圖從一個空 channel 中讀取資料,如果只讀取將會觸發死鎖,讀資料的 goroutine 將等待至少一條資料被寫入 channel 後才行。
個人對於緩衝 channel 的一些看法
- 當生產者速度遠大於消費者速度,建立緩衝 channel 是一種正向優化
- 當消費者具有阻塞性質或 syscall 時(例如:資料寫入磁碟,請求外部介面,遠端服務)
- 當消費者速度大於生產者速度,消費者側無阻塞性質,設定緩衝 channel 可能是一種負優化
對於只讀只寫 channel 的一些個人經驗:
我們的函式往往是一層一層的呼叫的,當我們需要使用 channel 構建併發的時候,我們需要知道當前操作的函式對需要操作的 channel 是生產者,或消費者。這樣構建時就可以防止一些死鎖,channel 未關閉的問題。這是我個人的使用經驗。
prod := func(n chan<- int){
defer close(n)
n <- 1
}
consum := func(n <-chan int) <-chan int{
m := make(chan int)
tmp := <-n
fmt.Println(tmp)
go func(){
defer close(m)
m <- tmp
}()
return
}
num := make(chan int)
go prod(num)
mm := consum(num)
for i := range mm{
fmt.Println(i)
}
從上方的程式碼段可以看出一些技巧
- channel 的輸入向都需要一個 goroutine.
- 在 consum 函式內部定義 channel 返回一個只讀 channel ,有效的管理了臨界區
- 全域性定義的 num ,在傳入 函式時轉換了性質,防止在一個 goroutine 種對同一 channel 既讀又寫
這裡要說明一下單向 channel 無法向雙向 channel 轉換,雙向 channel 可以向單向 channel 轉換。
channel 狀態機
從 channel 的所有者說起。當一個 goroutine 擁有一個 channel 時應該:
- 初始化該 channel
- 執行寫入操作,或將所有權交給另一個 goroutine
- 關閉該通道
- 將此前列入的三件事封裝在一個列表中,並通過訂閱 channel 將其公開
通過將這些責任分配給 channel 的所有者,會發生一些事情:
- 因為我們是初始化 channel 的人,所以我們要了解寫入空 channel 會帶來死鎖的風險
- 因為我們是初始化 channel 的人,所以我們要了解關閉空 channel 會帶來 panic 的風險
- 因為我們是決定 channel 何時關閉的人,所以我們要了解寫入已關閉的 channel 會帶來 panic 的風險
- 因為我們是決定何時關閉 channel 的人,所以我們要了解多次關閉 channel 會帶來 panic 的風險
- 我們在編譯時使用型別檢查器來防止對 channel 進行不正確的寫入
作為一個消費者,需要只需要擔心兩件事:
- channel 什麼時候會被關閉
- 處理基於任何原因出現的阻塞
Select
channel 將 goroutine 粘合在一起,讓我們構建起一條非常健壯,高效能的生產線。那麼程式中有多條生產線,select 語句就是幫我們多個 channel 組合在一起。
// 一起看一下 select 的用法
var ca,cb,cc <-chan interface{}
var cd chan<- string
select {
case <- ca:
// 業務邏輯
case <- cb:
// 監控邏輯
case <- cc:
// 告警邏輯
case cd <- "Hello Sober":
// 佛系邏輯
}
關於 select-case 排程疑問
乍一看 select 於 switch 類似根據不同 case 判斷並執行邏輯。 我們知道既然是 channel ,那麼一定是有資料需要傳遞的,不能簡單的條件判斷而已,例如我想讓 cb 執行需要什麼條件呢?其實 select 內部實現一種均衡排程,保證每個 case 都會被執行,所有 case 執行次數相對均衡,你可以用如下程式碼測試一下:
c1 := make(chan int)
close(c1)
c2 := make(chan int)
close(c2)
var c1n ,c2n int
for i := 100; i > 0; i-- {
select {
case <- c1:
c1n++
case <- c2:
c2n++
}
}
fmt.Printf("c1n: %d \nc2n: %d\n",c1n,c2n)
如何關閉已經我們認為已完成的工作流?
我們來看這個例子,讓我們記住它,這將是高併發程式的核心一環,併發控制的根基:
var c <-chan int
select {
case <- c: // 這裡我們幹一件愚蠢的事,從空的 channel 中讀取資料,如果沒有 select 它將觸發死鎖,在select 中他將永遠不被執行
case <-time.After(1*time.Second): // 1秒後關閉整個工作流
fmt.Println("The pipeline is end")
}
預設值
select {
case <- c1:
// 神鬼邏輯
case <- c2:
// 鬼神邏輯
default:
fmt.Println("沒有可用的 channel,觸發預設操作 ....")
}
永久阻塞
select {}
GOMAXPROCS 控制
這裡需要提一下 GMP 模型,M 就是 GOMAXPROCS 的配置,通常為當前計算節點最大 OS 執行緒數。
// 由 runtime 包控制
runtime.GOMAXPROCS(runtime.NumCPU())
結束語
雖然是第三章,但是我認為這是全書技巧篇第一章,接下會有更精彩的技巧,例如:如何構建一個 pipeline ;如何控制併發中大規模層級呼叫,訊息傳遞。
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- 《Go 語言併發之道》讀後感 - 第二章Go
- 《Go 語言併發之道》讀後感 - 第一章Go
- 《Go 語言併發之道》讀後感 - 第四章Go
- GO語言併發Go
- 《七週七語言》讀後感
- 第09章 Go語言併發,Golang併發Golang
- Go語言併發程式設計Go程式設計
- Go 語言的組合之道Go
- 十九、Go語言基礎之併發Go
- 《C語言入門經典》讀後感(一)C語言
- Java併發程式設計實戰——讀後感Java程式設計
- LINUX核心修煉之道–讀後感薦Linux
- Go語言是徹底的面向組合的併發語言Go
- 【資源分享】Go語言併發之道 [美] 凱瑟琳(Katherine Cox-Buday)著 PDF 下載Go
- Go語言專案實戰:併發爬蟲Go爬蟲
- GO 語言的併發模式你瞭解多少?Go模式
- 程式設計師修煉之道讀後感02程式設計師
- 程式設計師修煉之道讀後感(1)程式設計師
- 程式設計師修煉之道讀後感(3)程式設計師
- 程式設計師修煉之道讀後感(2)程式設計師
- Go語言 | CSP併發模型與Goroutine的基本使用Go模型
- Go語言併發程式設計簡單入門Go程式設計
- 《快學 Go 語言》第 13 課 —— 併發與安全Go
- Go 語言讀寫 Excel 文件GoExcel
- 《Go 語言程式設計》讀書筆記 (六) 基於共享變數的併發Go程式設計筆記變數
- Go 為什麼不在語言層面支援 map 併發?Go
- 讀後感
- Go高效併發 11 | 併發模式:Go 語言中即學即用的高效併發模式Go模式
- Go語言之併發示例(Runner)Go
- Go語言中的併發模式Go模式
- go語言安卓開發Go安卓
- 帶讀 |《Go in Action》(中文:Go語言實戰)(一)Go
- nodejs開發指南讀後感NodeJS
- 高效程式設計師的45個習慣-敏捷開發修煉之道(讀後感)程式設計師敏捷
- 【Go 語言入門專欄】Go 語言的起源與發展Go
- 帶讀 |《Go in Action》(中文:Go語言實戰)語法和語言結構概覽 (二)Go
- 帶讀 |《Go in Action》(中文:Go語言實戰) 語法和語言結構概覽(三)Go
- Go語言 | 併發設計中的同步鎖與waitgroup用法GoAI