百度一下Go語(yǔ)言優(yōu)勢(shì),幾乎所有文章都包含并發(fā)性好,作為一名老PHPer,一番學(xué)習(xí)實(shí)踐下來(lái),真香。
在當(dāng)今這個(gè)多核時(shí)代,并發(fā)編程的意義不言而喻。當(dāng)然,很多語(yǔ)言都支持多線程、多進(jìn)程編程,但遺憾的是,實(shí)現(xiàn)和控制起來(lái)并不是那么令人感覺(jué)輕松和愉悅。Golang不同的是,語(yǔ)言級(jí)別支持協(xié)程(goroutine)并發(fā)(協(xié)程又稱微線程,比線程更輕量、開(kāi)銷更小,性能更高),操作起來(lái)非常簡(jiǎn)單,語(yǔ)言級(jí)別提供關(guān)鍵字(go)用于啟動(dòng)協(xié)程,并且在同一臺(tái)機(jī)器上可以啟動(dòng)成千上萬(wàn)個(gè)協(xié)程。
不管作為初學(xué)者還是久經(jīng)沙場(chǎng)的老Gopher,Golang的協(xié)程調(diào)度器原理及 GMP 設(shè)計(jì)思想都是有必要去掌握的,而且面試必問(wèn):),推薦閱讀丹冰大佬的 Golang修養(yǎng)之路GMP章節(jié),圖文并茂非常Nice。想深入學(xué)習(xí)的推薦歐神的 并發(fā)調(diào)度,結(jié)合源碼和流程圖講解,膜拜。本文結(jié)束?Too young too simple,真正的重頭戲才剛剛開(kāi)始。
G 的數(shù)量:
無(wú)限制,理論上受內(nèi)存的影響,創(chuàng)建一個(gè) G 的初始棧大小為2-4K,配置一般的機(jī)器也能簡(jiǎn)簡(jiǎn)單單開(kāi)啟數(shù)十萬(wàn)個(gè) Goroutine ,而且Go語(yǔ)言在 G 退出的時(shí)候還會(huì)把 G 清理之后放到 P 本地或者全局的閑置列表 gFree 中以便復(fù)用。
那是不是為了提高“并發(fā)能力”,就可以為所欲為的開(kāi)啟 Goroutine 呢,答案是否定的。
如果 Goroutine 中只有簡(jiǎn)單的邏輯,比如輸出Hello world:),那肯定是沒(méi)什么問(wèn)題,但是如果Goroutine 中存在頻繁請(qǐng)求 HTTP,MySQL,打開(kāi)文件等,那假設(shè)短時(shí)間內(nèi)有幾十萬(wàn)個(gè)協(xié)程在跑,那肯定就不大合理了(可能會(huì)導(dǎo)致 too many files open)。常見(jiàn)的 Goroutine 泄露所導(dǎo)致的 CPU、Memory 上漲等,所以還是得看你的 Goroutine 里具體在跑什么東西。
一開(kāi)始寫(xiě) GO ,不管多大數(shù)據(jù)處理全部丟進(jìn)去進(jìn)行循環(huán),認(rèn)為全部都并發(fā)使用 Goroutine 去做一件事情,效率比較高,但這樣的話,噩夢(mèng)般的事情就開(kāi)始了,服務(wù)器系統(tǒng)資源利用率不斷上漲,到最后程序自動(dòng)killed。這里比較好的解決方案是,引入線程池,限制 Goroutine 的數(shù)量,復(fù)用資源,保障系統(tǒng)穩(wěn)定的同時(shí)提高處理能力。避免重復(fù)造輪子,推薦使用 ants,源碼也不多可以學(xué)習(xí)學(xué)習(xí),親測(cè)好用- -
M 的數(shù)量:
限制10000,Go語(yǔ)言運(yùn)行時(shí)初始化的時(shí)候在runtime.schedinit()中通過(guò)下面代碼設(shè)置了 M 的最大數(shù)。
sched.maxmcount = 10000
通過(guò)debug.SetMaxThreads(n),可以調(diào)整。如果超出(手動(dòng)調(diào)成10)會(huì)panic:
runtime: program exceeds 10-thread limit
fatal error: thread exhaustion
因?yàn)?M 必須持有 P 才能運(yùn)行 G,通常情況 P 的數(shù)量很有限,如果 M 還超過(guò) 10000,基本上就是程序?qū)懙挠袉?wèn)題。
P 的數(shù)量:
有限制,默認(rèn)是CPU核心數(shù),由啟動(dòng)時(shí)環(huán)境變量$GOMAXPROCS或者是由runtime.GOMAXPROCS()決定,runtime初始化建議閱讀煎魚(yú)大佬的 詳解 Go 程序的啟動(dòng)流程,你知道 g0,m0 是什么嗎?,以及 Go語(yǔ)言調(diào)度器之創(chuàng)建main goroutine
func schedinit() {
...
procs := ncpu // osinit 中獲取 CPU 核心數(shù)
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
// P 初始化
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}
...
}
func GOMAXPROCS(n int) int {
...
stopTheWorldGC("GOMAXPROCS")
// newprocs will be processed by startTheWorld
newprocs = int32(n) // 重新設(shè)置的 P 數(shù)量
startTheWorldGC()
return ret
}
startTheWorldGC() -> startTheWorld() -> startTheWorldWithSema()
func startTheWorldWithSema(emitTraceEvent bool) int64 {
...
procs := gomaxprocs
if newprocs != 0 {
procs = newprocs
newprocs = 0
}
// 擴(kuò)容或者縮容全局的處理器
p1 := procresize(procs)
...
}
在任何情況下,Go運(yùn)行時(shí)并行執(zhí)行(注意,不是并發(fā))的 goroutines 數(shù)量是小于等于 P 的數(shù)量的。為了提高系統(tǒng)的性能,P 的數(shù)量肯定不是越小越好,所以官方默認(rèn)值就是 CPU 的核心數(shù),設(shè)置的過(guò)小的話,如果一個(gè)持有 P 的 M,由于 P 當(dāng)前執(zhí)行的 G 調(diào)用了 syscall 而導(dǎo)致 M 被阻塞,那么此時(shí)關(guān)鍵點(diǎn):GO 的調(diào)度器是遲鈍的,它很可能什么都沒(méi)做,直到 M 阻塞了相當(dāng)長(zhǎng)時(shí)間以后,才會(huì)發(fā)現(xiàn)有一個(gè) P/M 被 syscall 阻塞了。然后,才會(huì)用空閑的 M 來(lái)強(qiáng)這個(gè) P。通過(guò) sysmon 監(jiān)控實(shí)現(xiàn)的搶占式調(diào)度,最快在20us,最慢在10-20ms才會(huì)發(fā)現(xiàn)有一個(gè) M 持有 P 并阻塞了。操作系統(tǒng)在 1ms 內(nèi)可以完成很多次線程調(diào)度(一般情況1ms可以完成幾十次線程調(diào)度),Go 發(fā)起 IO/syscall 的時(shí)候執(zhí)行該 G 的 M 會(huì)阻塞然后被OS調(diào)度走,P什么也不干,sysmon 最慢要10-20ms才能發(fā)現(xiàn)這個(gè)阻塞,說(shuō)不定那時(shí)候阻塞已經(jīng)結(jié)束了,寶貴的P資源就這么被阻塞的M浪費(fèi)了。
補(bǔ)充說(shuō)明:調(diào)度器遲鈍不是 M 遲鈍,M 也就是操作系統(tǒng)線程,是非常的敏感的,只要阻塞就會(huì)被操作系統(tǒng)調(diào)度(除了極少數(shù)自旋的情況)。但是 GO 的調(diào)度器會(huì)等待一個(gè)時(shí)間間隔才會(huì)行動(dòng),這也是為了減少調(diào)度器干預(yù)的次數(shù)。也就是說(shuō),如果一個(gè) M 調(diào)用了什么 API 導(dǎo)致了操作系統(tǒng)線程阻塞了,操作系統(tǒng)立刻會(huì)把這個(gè)線程M調(diào)度走,掛起等阻塞解除。這時(shí)候,Go 調(diào)度器不會(huì)馬上把這個(gè) M 持有的 P 搶走。這就會(huì)導(dǎo)致一定的 P 被浪費(fèi)了。
開(kāi)源數(shù)據(jù)庫(kù)項(xiàng)目https://github.com/dgraph-io/dgraph中,特意將 GOMAXPROCS 調(diào)整到 128 增加 IO 處理能力,提高吞吐量。
https://github.com/dgraph-io/dgraph/blob/master/dgraph/main.go
func main() {
rand.Seed(time.Now().UnixNano())
// Setting a higher number here allows more disk I/O calls to be scheduled, hence considerably
// improving throughput. The extra CPU overhead is almost negligible in comparison. The
// benchmark notes are located in badger-bench/randread.
runtime.GOMAXPROCS(128)
}
那 P 的數(shù)量太大會(huì)有什么影響呢?
一個(gè)runtime findrunnable 時(shí)產(chǎn)生的損耗,另一個(gè)是線程引起的上下文切換。如果是cpu密集的業(yè)務(wù),增加多個(gè)processor也沒(méi)用,畢竟cpu計(jì)算資源就這些,來(lái)回切換反而拖慢程序。
runtime的 findrunnable 方法是解決 M 找可用的協(xié)程的函數(shù),當(dāng)從綁定 P 本地runq上找不到可執(zhí)行的goroutine后,嘗試從全局鏈表中拿,再拿不到從 netpoll 和事件池里拿,最后會(huì)從別的 P 里偷任務(wù)。全局 runq 是有鎖操作,其他偷任務(wù)使用了atomic 原子操作來(lái)規(guī)避futex競(jìng)爭(zhēng)下陷入切換等待問(wèn)題,但 lock free 在競(jìng)爭(zhēng)下也會(huì)有忙輪詢的狀態(tài),比如不斷的嘗試(自旋)。
隨著調(diào)多 runtime processor 數(shù)量,相關(guān)的 M 線程自然也就跟著多了起來(lái)。linux 內(nèi)核為了保證可執(zhí)行的線程在調(diào)度上雨露均沾,按照內(nèi)核調(diào)度算法來(lái)切換就緒狀態(tài)的線程,切換又引起上下文切換。上下文切換也是性能的一大殺手。findrunnable 的某些鎖競(jìng)爭(zhēng)也會(huì)觸發(fā)上下文切換。
結(jié)論:常規(guī)項(xiàng)目直接使用默認(rèn)的核心數(shù)就好了,GOMAXPROCS 開(kāi)太多的時(shí)候,針對(duì)計(jì)算密集型的處理性能提升反而沒(méi)那么大,IO 密集(或者 syscall 較多)的 Go 程序,至少應(yīng)該配置到CPU核心數(shù)目的5倍以上, 最大1024。
個(gè)人學(xué)習(xí)筆記,方便自己復(fù)習(xí),有不對(duì)的地方歡迎評(píng)論哈!
參考資料:
Golang修養(yǎng)之路GMP章節(jié)
并發(fā)調(diào)度
詳解 Go 程序的啟動(dòng)流程,你知道 g0,m0 是什么嗎?
Go語(yǔ)言調(diào)度器之創(chuàng)建main goroutine
Go 群友提問(wèn):Goroutine 數(shù)量控制在多少合適,會(huì)影響 GC 和調(diào)度?
Go開(kāi)發(fā)中,如何有效控制Goroutine的并發(fā)數(shù)量
golang gomaxprocs調(diào)高引起調(diào)度性能損耗
[GO語(yǔ)言]合理配置GOMAXPROCS提升一倍以上的性能