本篇文章是我對(duì) Go 語(yǔ)言并發(fā)性的理解總結(jié),適合初步了解并發(fā)疯兼,對(duì) Go 語(yǔ)言的并發(fā)編程與調(diào)度器原理有興趣的讀者。
你真的了解并發(fā)嗎贫途?
相信讀者都對(duì)并發(fā)有著一定的理解吧彪,也都對(duì) Go 語(yǔ)言感興趣,Go 最吸引人的地方可能就是它的內(nèi)建并發(fā)支持丢早,使用 go
關(guān)鍵字姨裸,就可以輕松的實(shí)現(xiàn)并發(fā)。但是怨酝,你真正的了解并發(fā)嗎傀缩?
并發(fā)這個(gè)詞,你去問編程領(lǐng)域中不同的人农猬,會(huì)給出不同的答案赡艰。對(duì)于 WEB 領(lǐng)域的開發(fā)人員來說,并發(fā)通常是指同一時(shí)刻的請(qǐng)求量盛险,WEB 領(lǐng)域的面試官經(jīng)常會(huì)問到的問題:“做過多少并發(fā)的項(xiàng)目瞄摊?”或“接觸過高并發(fā)的項(xiàng)目嗎?”就是應(yīng)用的這個(gè)概念苦掘。高并發(fā)在這里還有個(gè)可能的概念是:同時(shí)應(yīng)對(duì)許多請(qǐng)求所使用的技術(shù)换帜,這通常與分布式、并行等概念掛鉤鹤啡,需要結(jié)合上下文語(yǔ)境來判斷惯驼。
并發(fā)是一個(gè)有趣的詞,因?yàn)樗鼘?duì)編程領(lǐng)域中的不同人員意味著不同的事情递瑰,在廣義概念下祟牲,有著許多狹義概念。除了“并發(fā)”之外抖部,你可能還聽過“并行”说贝、“多線程”、“異步”等詞匯慎颗,有些人認(rèn)為這些詞意思相同乡恕,而其他人則在每個(gè)詞之間劃清界限言询。
下面,讓我們看看 Go 語(yǔ)言編程中傲宜,“并發(fā)”這個(gè)詞的概念运杭。
Go 語(yǔ)言中的并發(fā)性
Go 語(yǔ)言的并發(fā)性并不是 WEB 領(lǐng)域的并發(fā)概念,很多人對(duì)此有所混淆函卒。在 Go 語(yǔ)言發(fā)布之初辆憔,大家對(duì) Go 的并發(fā)特性都有所疑問:
- 為什么要有并發(fā)?
- 什么是并發(fā)报嵌?
- 這個(gè)想法源自哪里虱咧?
- 并發(fā)有什么好處?
- 我該如何使用它沪蓬?
面對(duì)這些問題彤钟,Rob Pike(Go 語(yǔ)言作者之一)在2012年的 Google I/O 上做了一次精彩的演講:《Go Concurrency Patterns》来候,在這場(chǎng)演講中跷叉,他回答了上述問題,并通過詳細(xì)的示例講解了 goroutine营搅、channel 與 select 的使用云挟,建議大家都去看一看這場(chǎng)演講。
簡(jiǎn)單的總結(jié)一下并發(fā)在 Go 語(yǔ)言編程中的概念:
“并發(fā)是一種將程序分解成小片段獨(dú)立執(zhí)行的程度設(shè)計(jì)方法”转质,它是一種結(jié)構(gòu)化程序的方式园欣,獨(dú)立執(zhí)行計(jì)算的組合。
在上述的演講中可以看出休蟹,Go 語(yǔ)言推薦使用并發(fā)沸枯,我們也應(yīng)該遵循這種編程方式。對(duì)于程序員來說赂弓,代碼更有說服力绑榴,我們可以通過這個(gè)素?cái)?shù)篩選程序來理解 Go 的并發(fā)編程:
// A concurrent prime sieve
package main
// Send the sequence 2, 3, 4, ... to channel 'ch'.
func Generate(ch chan<- int) {
for i := 2; ; i++ {
ch <- i // Send 'i' to channel 'ch'.
}
}
// Copy the values from channel 'in' to channel 'out',
// removing those divisible by 'prime'.
func Filter(in <-chan int, out chan<- int, prime int) {
for {
i := <-in // Receive value from 'in'.
if i%prime != 0 {
out <- i // Send 'i' to 'out'.
}
}
}
// The prime sieve: Daisy-chain Filter processes.
func main() {
ch := make(chan int) // Create a new channel.
go Generate(ch) // Launch Generate goroutine.
for i := 0; i < 10; i++ {
prime := <-ch
print(prime, "\n")
ch1 := make(chan int)
go Filter(ch, ch1, prime)
ch = ch1
}
}
它并不是復(fù)雜度最低的算法,特別是尋找大素?cái)?shù)方面盈魁,但卻是最能體現(xiàn) Go 并發(fā)編程翔怎、通過通信共享內(nèi)存的理念,而且非常優(yōu)雅杨耙。
在這段代碼中赤套,通過 goroutine 的組合,實(shí)現(xiàn)一層層的篩選器珊膜,篩選器之間通過 channel 通信容握,每一個(gè)篩選器就是一個(gè)素?cái)?shù),每個(gè)給 main goroutine 通信的內(nèi)容也是素?cái)?shù)车柠,簡(jiǎn)直精妙剔氏。
通過下面的 gif 動(dòng)畫能清晰的看到程序運(yùn)行過程:
并發(fā)不是并行
Go 的并行
只需要 GOMAXPROCS 的值大于1脖旱,就可以讓 Go 程序在多核機(jī)器上實(shí)現(xiàn)以并行的形式運(yùn)行。但并發(fā)的程序一定可以并行嗎介蛉?
我們需要明確一個(gè)觀點(diǎn):并發(fā)不是為了效率萌庆,并發(fā)的程序不一定可以并行。還是上面素?cái)?shù)的例子币旧,這段代碼是并發(fā)的践险,但不可以并行,因?yàn)樗拿恳粋€(gè)執(zhí)行片段都需要上一個(gè)片段的篩選與通信吹菱。
正交概念
正交概念:從數(shù)學(xué)上引進(jìn)正交這個(gè)詞巍虫,用于表示指相互獨(dú)立,相互間不可替代鳍刷,并且可以組合起來占遥。
在廣義概念上來講,并發(fā)與并行是正交概念输瓜,對(duì)于 Go 語(yǔ)言的并發(fā)性來講也是如此瓦胎。
《Concurrency is not Parallelism》
同樣,在 Go 語(yǔ)言發(fā)布之初尤揣,有很多人混淆了并發(fā)與并行的概念搔啊,對(duì)此,Rob Pike 發(fā)表了另一篇演講《Concurrency is not Parallelism》北戏,通過地鼠燒書的比喻與簡(jiǎn)單負(fù)載均衡器的示例负芋,詳細(xì)的闡述了并發(fā)與并行的區(qū)別。
這里不再?gòu)?fù)述地鼠例子嗜愈,只是簡(jiǎn)單的總結(jié)旧蛾,感興趣的建議去看演講:
并行是指同時(shí)能執(zhí)行多個(gè)事情。
并發(fā)關(guān)乎結(jié)構(gòu)蠕嫁,是一種結(jié)構(gòu)化程序的方式锨天。
并行關(guān)乎執(zhí)行,表述的是程序的運(yùn)行狀態(tài)拌阴。
Go 語(yǔ)言是如何支持并發(fā)的绍绘?
上面一直在講 Go 語(yǔ)言的并發(fā)性,接下來看下 Go 語(yǔ)言是如何做到的并發(fā)支持迟赃。
我們?cè)谑褂?Go 編寫并發(fā)程序的過程中陪拘,無需關(guān)心線程的維護(hù)、調(diào)度等一系列問題纤壁,只需要關(guān)心程序結(jié)構(gòu)的分解與組合左刽、goroutine 之間的通信就可以寫出良好的并發(fā)程序,這全部都要依賴于 Go 語(yǔ)言內(nèi)建的 G-P-M 模型酌媒。
模型演化過程
在 Go 語(yǔ)言1.0版本時(shí)欠痴,只有 G-M 模型迄靠,Google 工程師 Dmitry Vyukov 在《Scalable Go Scheduler Design Doc》中指出了該模型在并發(fā)伸縮性方面的問題:
- 所有對(duì) G 的操作:創(chuàng)建、重新調(diào)用等由單個(gè)全局鎖(Sched.Lock)保護(hù)喇辽,浪費(fèi)時(shí)間掌挚。
- 當(dāng) M 阻塞時(shí),G 需要傳遞給別的 M 執(zhí)行菩咨,這導(dǎo)致調(diào)度延遲增大以及額外的性能損耗吠式;
- M 用到的
mCache
屬于內(nèi)核線程,當(dāng) M 阻塞后相應(yīng)的內(nèi)存資源仍被占用抽米,導(dǎo)致內(nèi)存占用過高特占;- 由于 syscall 導(dǎo)致 M 的阻塞和恢復(fù),導(dǎo)致了額外性能損耗云茸。
并且親自下場(chǎng)是目,重新設(shè)計(jì)、改進(jìn)了 Go scheduler标捺,在 Go1.1 版本中實(shí)現(xiàn)了 G-P-M 模型:
G-P-M 模型
那么這套模型與調(diào)度是怎樣的呢懊纳?先來簡(jiǎn)單的說一下 G、P宜岛、M 的定義:
- G:表示 Goroutine长踊,G 存儲(chǔ)了 goroutine 執(zhí)行的棧信息功舀,狀態(tài)萍倡,任務(wù)函數(shù),可重用辟汰。
- P:Processor列敲,表示邏輯處理器,擁有一個(gè)本地隊(duì)列帖汞。對(duì)于 G 來說戴而,P 相當(dāng)于 CPU 內(nèi)核,只有進(jìn)入到 P 的隊(duì)列中翩蘸,才可以被調(diào)度所意。對(duì)于 M 來說,P 提供了相關(guān)的執(zhí)行環(huán)境(Context)催首,如內(nèi)存分配狀態(tài)扶踊,任務(wù)隊(duì)列等。P 的數(shù)量就是程序可最大可并行的 G 的數(shù)量(前提:物理CPU核數(shù) >= P的數(shù)量)郎任,由用戶設(shè)置的 GOMAXPROCS 決定秧耗。
- M:Machine,是對(duì)系統(tǒng)線程的抽象舶治,是真正執(zhí)行計(jì)算的部分分井。M 在綁定 P 后會(huì)在隊(duì)列中獲取 G车猬,切換到 G 的執(zhí)行棧并執(zhí)行 G 的函數(shù)。M 數(shù)量不定尺锚,但同時(shí)只有 P 個(gè) M 在執(zhí)行珠闰,為了防止創(chuàng)建過多系統(tǒng)線程導(dǎo)致系統(tǒng)調(diào)度出現(xiàn)問題,目前默認(rèn)最大限制10000個(gè)瘫辩。
接下來了解這套模型的基本調(diào)度铸磅,在調(diào)度過程中還有一個(gè) work-stealing 的算法:
- 每個(gè) P 維護(hù)一個(gè)本地隊(duì)列;
- 當(dāng)一個(gè) G 被創(chuàng)建后杭朱,放入當(dāng)前 P 的本地隊(duì)列中阅仔,如果隊(duì)列已滿,放入全局隊(duì)列弧械;
- 當(dāng) M 執(zhí)行完一個(gè) G 后八酒,會(huì)在 當(dāng)前 P 的隊(duì)列中取出新的 G,隊(duì)列為空則在全局隊(duì)列中加鎖獲热刑啤羞迷;
- 如果全局隊(duì)列也為空,則去其他的 P 的隊(duì)列中偷出一半的 G画饥,放入自己的本地隊(duì)列衔瓮。
Go 語(yǔ)言就是憑借著這套優(yōu)秀的并發(fā)模型與調(diào)度,實(shí)現(xiàn)了內(nèi)建的并發(fā)支持抖甘。
Goroutine 調(diào)度器的深入
讓我們深入的了解一下 goroutine 調(diào)度器热鞍。
調(diào)度器解決了什么問題?
阻塞問題
如果任務(wù)G陷入到阻塞的系統(tǒng)調(diào)用中衔彻,內(nèi)核線程M將一起阻塞薇宠,于是實(shí)際的運(yùn)行線程少了一個(gè)。更嚴(yán)重的艰额,如果所有M都阻塞了澄港,那些本可以運(yùn)行的任務(wù)G將沒有系統(tǒng)資源運(yùn)行。
Go 在執(zhí)行阻塞的系統(tǒng)調(diào)用時(shí)會(huì)調(diào)用 entersyscallblock
柄沮,然后通過 handoffp
解綁 M 對(duì)應(yīng)的 P回梧。如果此時(shí) P 的本地隊(duì)列中還有 G,P 會(huì)去尋找別的 M 或創(chuàng)建新的 M 繼續(xù)執(zhí)行祖搓,若本地隊(duì)列為空狱意,則進(jìn)入 pidle
鏈表,等待有需要時(shí)被取出棕硫。
如果是調(diào)用的 entersyscall
髓涯,會(huì)將 P 的狀態(tài)置為 _Psyscall
。監(jiān)控線程 sysmon
會(huì)通過 retake
循環(huán)所有的 P哈扮,發(fā)現(xiàn)是 _Psyscall
狀態(tài)纬纪,就會(huì)調(diào)用 handoffp
來釋放蚓再。
搶占調(diào)度
在 Go1.1 版本中,是沒有搶占調(diào)度的包各,當(dāng)前 G 只有涉及到鎖操作摘仅,讀寫 channel 才會(huì)觸發(fā)切換。若沒有搶占機(jī)制问畅,同一個(gè) M 上的其他任務(wù) G 有可能會(huì)長(zhǎng)時(shí)間執(zhí)行不到娃属,甚至?xí)凰姥h(huán)鎖住。
于是 Dmitry Vyukov 提出了《Go Preemptive Scheduler Design Doc》, 并在1.2版本中引入了初級(jí)的搶占护姆。
監(jiān)控線程 sysmon
會(huì)通過 retake
循環(huán)所有的 P矾端,發(fā)現(xiàn)運(yùn)行時(shí)間超出 forcePreemptNS
限制(10ms)的 P,就會(huì)通過 preemptone
發(fā)起搶占卵皂。
Goroutine 的負(fù)載均衡
內(nèi)核線程M不是從全局任務(wù)隊(duì)列中得到G秩铆,而是從M本地維護(hù)的G緩存中獲取任務(wù)。如果某個(gè)M的G執(zhí)行完了灯变,而別的M還有很多G殴玛,這時(shí)如果G不能切換將造成CPU的浪費(fèi)。
這部分的實(shí)現(xiàn)是在 M 的啟動(dòng)函數(shù) mstart
中 schedule
的調(diào)用來實(shí)現(xiàn)添祸,它會(huì)先查找本地隊(duì)列滚粟,然后查找全局隊(duì)列,最后是隨機(jī)偷取其他 P 的一半 G刃泌,直到取到 G 或停掉 M凡壤。為了防止全局隊(duì)列被“餓死”,每61次調(diào)度蔬咬,會(huì)先在全局隊(duì)列中查找鲤遥。
調(diào)度器相關(guān)源碼
調(diào)度器部分的代碼主要集中在 src/runtime/runtime2.go
與 src/runtime/proc.go
這兩個(gè)文件中。
調(diào)度器的4個(gè)基本結(jié)構(gòu):g林艘、m、p混坞、schedt狐援,都在 runtime2.go
中,schedt
可能有些陌生究孕,它是調(diào)度器的核心結(jié)構(gòu)啥酱,也是全局資源池,用來存儲(chǔ) G 的全局隊(duì)列厨诸,空閑的 P 鏈表 pidle
镶殷,空閑的 M 鏈表 midle
等等。
調(diào)度器的具體實(shí)現(xiàn)函數(shù)都在 proc.go
中微酬,用戶的所有代碼都是運(yùn)行在 goroutine 中绘趋,Go 在運(yùn)行時(shí)會(huì)將 main
中的代碼放入 main goroutine
中運(yùn)行颤陶,這時(shí)還會(huì)啟動(dòng)監(jiān)控系統(tǒng) sysmon
。
更多關(guān)于調(diào)度器的細(xì)節(jié)陷遮,例如加鎖滓走,與 GC 的交互等,需要通過進(jìn)一步的閱讀源碼來了解帽馋。
結(jié)束語(yǔ)
看到這里搅方,相信大家對(duì)“并發(fā)”會(huì)有全新的認(rèn)識(shí),本文旨在講清 Go 語(yǔ)言的并發(fā)性绽族,在以后的 Go 語(yǔ)言編程過程中姨涡,希望更傾向于并發(fā)編程。并發(fā)編程不僅結(jié)構(gòu)清晰吧慢,通常來說也會(huì)更容易并行運(yùn)行绣溜,使得程序運(yùn)行效率提高。