Go 并發(fā)模型
1. 調(diào)度模型
CPU執(zhí)行指令的速度是非常快的。在 3.0GHz 主頻的單核 CPU 核心上,大部分簡單指令的執(zhí)行僅需 1 個(gè)時(shí)鐘周期,也就是三分之一納秒。在僅考慮執(zhí)行,不考慮讀取數(shù)據(jù)耗時(shí)的情況下,1s 可以執(zhí)行 30 億條簡單指令,速度極快。CPU 慢的原因是慢在對外部數(shù)據(jù)的讀/寫上。外部 I/O 的速度慢和阻塞是導(dǎo)致 CPU 使用效率不高的最大原因。其實(shí)在大部分真實(shí)系統(tǒng)中,CPU 都不是瓶頸,CPU 的大部分時(shí)間被白白浪費(fèi)了,所以增加 CPU 的吞吐量是程序員的重要指標(biāo)。
所謂提高 CPU 的有效吞吐量,就是讓 CPU 盡量多干活,而不是在空跑或等待。理想的狀態(tài)是機(jī)器的每個(gè) CPU 核心都有事情做,盡可能快地做一些事情。
-
盡可能讓每個(gè) CPU 核心都有事情做。
這就要求工作的線程要大于 CPU 的核心數(shù),單進(jìn)程的程序最多使用一個(gè) CPU 干活,沒有辦法有效利用機(jī)器資源。由于 CPU 要和外部設(shè)備通信,單個(gè)線程經(jīng)常會(huì)被阻塞,這其中包括 I/O 等待、缺頁中斷、等待網(wǎng)絡(luò)等。所以 CPU 和線程的比例是 1:1,大部分情況下不能充分發(fā)揮 CPU 的威力。實(shí)際上依據(jù)程序的特性,合理地調(diào)整 CPU 和線程的關(guān)系,一般情況下,線程數(shù)要大于 CPU 的個(gè)數(shù),才能發(fā)揮機(jī)器的價(jià)值。
-
盡可能提高每個(gè) CPU 核心做事情的效率
現(xiàn)在操作系統(tǒng)雖然能夠進(jìn)行并行調(diào)度,但當(dāng)進(jìn)程數(shù)大于 CPU 核心的時(shí)候,就存在進(jìn)程切換的問題。該切換需要保存上下文,恢復(fù)堆棧。頻繁的切換也很耗時(shí),我們的目標(biāo)是盡量讓程序減少阻塞和切換,盡量讓進(jìn)程跑滿操作系統(tǒng)分配的時(shí)間片。
上面是從整個(gè)系統(tǒng)的角度來看程序的運(yùn)行效率問題,但具體到應(yīng)用程序又有所不同。應(yīng)用程序的并發(fā)模型是多樣的,主要看以下三種。
-
多進(jìn)程模型
進(jìn)程都能被多核 CPU 并發(fā)調(diào)度,優(yōu)點(diǎn)是每個(gè)進(jìn)程都有自己獨(dú)立的內(nèi)存空間,隔離性好、健壯性高;缺點(diǎn)是進(jìn)程比較重,進(jìn)程的切換消耗較大,進(jìn)程間的通信需要多次在內(nèi)核區(qū)和用戶區(qū)之間復(fù)制數(shù)據(jù)。
-
多線程模型
這里的多線程是指啟動(dòng)多個(gè)內(nèi)核線程進(jìn)行處理,線程的優(yōu)點(diǎn)是通過共享內(nèi)存進(jìn)行通信更快捷,切換代價(jià)小;缺點(diǎn)是多個(gè)線程共享內(nèi)存空間,很容易導(dǎo)致數(shù)據(jù)訪問混亂,某個(gè)線程誤操作內(nèi)存掛掉可能危及整個(gè)線程組,健壯性不高。
-
用戶級(jí)多線程模型
用戶級(jí)多線程分為兩種情況,一種是 M:1 的方式,M 個(gè)用戶線程對應(yīng)一個(gè)內(nèi)核線程,這種情況很容易因?yàn)橐粋€(gè)系統(tǒng)阻塞,其他用戶線程都會(huì)阻塞,不能利用機(jī)器多核的優(yōu)勢。還有一種是 M:N 的方式,M 個(gè)用戶線程對應(yīng) N 個(gè)內(nèi)核線程,這種模式一般需要語言運(yùn)行時(shí)或庫的支持,效率最高。
程序并發(fā)處理的要求越來越高,但是不能無限制地增加系統(tǒng)線程數(shù),線程數(shù)過多會(huì)導(dǎo)致操作系統(tǒng)的調(diào)度開銷大,單個(gè)線程的單位時(shí)間內(nèi)被分配的運(yùn)行時(shí)間片減少,單個(gè)線程的運(yùn)行速度降低,單靠增加系統(tǒng)線程數(shù)不能滿足要求。為了不讓系統(tǒng)線程無限膨脹,于是就有了協(xié)程的概念。
協(xié)程是一種用戶態(tài)的輕量級(jí)線程,協(xié)程的調(diào)度完全由用戶態(tài)程序控制,協(xié)程擁有自己的寄存器上下文和棧。協(xié)程調(diào)度切換時(shí),將寄存器上下文和棧保存到其他地方,在切回來的時(shí)候,恢復(fù)先前保存的寄存器上下文和棧,每個(gè)內(nèi)核線程可以對應(yīng)多個(gè)用戶協(xié)程,當(dāng)一個(gè)協(xié)程執(zhí)行體阻塞了,調(diào)度器會(huì)調(diào)度另一個(gè)協(xié)程執(zhí)行,最大效率地利用操作系統(tǒng)分給系統(tǒng)線程的時(shí)間片。我們提到的用戶級(jí)多線程模型就是一種協(xié)程模型,尤其以 M:N 模型最為高效。好處就是:
-
控制了系統(tǒng)線程數(shù),保證每個(gè)線程的運(yùn)行時(shí)間片充足。
-
調(diào)度層能進(jìn)行用戶態(tài)的切換,不會(huì)導(dǎo)致單個(gè)協(xié)程阻塞整個(gè)程序的情況,盡量減少上下文切換,提升運(yùn)行效率。
所以,協(xié)程是一種非常高效、理想的執(zhí)行模型。Go 的并發(fā)執(zhí)行模型就是變種的協(xié)程模型。
2. 并發(fā)和調(diào)度
Go 語言在語言層面引入了 goroutine。
-
goroutine 可以在用戶空間調(diào)度,避免了內(nèi)核態(tài)和用戶態(tài)切換導(dǎo)致的成本。
-
goroutine 是語言原生支持的,提供了非常簡潔的語法,屏蔽了大部分復(fù)雜底層實(shí)現(xiàn)。
-
goroutine 更小的棧空間允許用戶創(chuàng)建成千上萬的實(shí)例。
Go 的調(diào)度模型中抽象出三個(gè)實(shí)體:M、P、G。
G(Goroutine)
G 是 Go 運(yùn)行時(shí)對 goroutine 的抽象描述,G 中存放并發(fā)執(zhí)行的代碼入口地址、上下文、運(yùn)行環(huán)境(關(guān)聯(lián)的 P 和 M)、運(yùn)行棧等執(zhí)行相關(guān)的元信息。
G 的新建、休眠、恢復(fù)、停止都受到 Go 運(yùn)行時(shí)的管理。Go 運(yùn)行時(shí)的監(jiān)控線程會(huì)監(jiān)控 G 的調(diào)度,G 不會(huì)長久地阻塞系統(tǒng)線程,運(yùn)行時(shí)的調(diào)度器會(huì)自動(dòng)切換到其他 G 上繼續(xù)執(zhí)行。G 新建或恢復(fù)時(shí)會(huì)添加到運(yùn)行隊(duì)列,等待 M 取出并運(yùn)行。
M(Machine)
M 代表 OS 內(nèi)核線程,是操作系統(tǒng)層面調(diào)度和執(zhí)行的實(shí)體。M 僅負(fù)責(zé)執(zhí)行,M 不停地被喚醒或創(chuàng)建,然后執(zhí)行。M 啟動(dòng)時(shí)進(jìn)入的是運(yùn)行時(shí)的管理代碼,由這段代碼獲取 G 和 P 資源,然后執(zhí)行調(diào)度。另外,Go 語言運(yùn)行時(shí)會(huì)單獨(dú)創(chuàng)建一個(gè)監(jiān)控線程,負(fù)責(zé)對程序的內(nèi)存、調(diào)度等信息進(jìn)行監(jiān)控和控制。
P(Processor)
P 代表 M 運(yùn)行 G 所需要的資源,是對資源的一種抽象和管理,P 不是一段代碼實(shí)體,而是一個(gè)管理的數(shù)據(jù)結(jié)構(gòu),P 主要是降低 M 管理調(diào)度 G 的復(fù)雜性,增加一個(gè)間接的控制層數(shù)據(jù)結(jié)構(gòu)。把 P 看作資源,而不是處理器,P 控制 Go代碼的并行度,它不是運(yùn)行實(shí)體。P 持有 G 的隊(duì)列,P 可以隔離調(diào)度,解除 P 和 M 的綁定就解除了 M 對一串 G 的調(diào)用。P 在運(yùn)行模型中只是一個(gè)數(shù)據(jù)模型,而不是程序控制模型。
M 和 P 一起構(gòu)成一個(gè)運(yùn)行時(shí)環(huán)境,每個(gè) P 有一個(gè)本地的可調(diào)度 G 隊(duì)列,隊(duì)列里的 G 會(huì)被 M 依次調(diào)度執(zhí)行,如果本地隊(duì)列空了,則會(huì)去全局隊(duì)列偷取一部分 G,如果全局隊(duì)列也是空的,則去其他的 P 中偷取一部分 G。下面是調(diào)度結(jié)構(gòu)。

G 并不是執(zhí)行體,而是用于存放并發(fā)執(zhí)行體的元信息,包括并發(fā)執(zhí)行的入口函數(shù)、堆棧、上下文等信息。G 對象是可以復(fù)用的,只需將相關(guān)元信息初始化為新值即可。M 僅負(fù)責(zé)執(zhí)行,M 啟動(dòng)時(shí)進(jìn)入運(yùn)行時(shí)的管理代碼,這段管理代碼必須拿到可用的 P 后,才能執(zhí)行調(diào)度。P 的數(shù)目默認(rèn)是 CPU 核心的數(shù)量,可以通過 runtime.GOMAXPROCS 函數(shù)設(shè)置或查詢,M 和 P 的數(shù)目差不多,運(yùn)行時(shí)會(huì)根據(jù)當(dāng)前的狀態(tài)動(dòng)態(tài)地創(chuàng)建 M,M 有一個(gè)最大值上限,目前是 10000;G 與 P 是一種 M:N 的關(guān)系,M 可以成千上萬,遠(yuǎn)遠(yuǎn)大于 N。
Go 啟動(dòng)初始化過程
-
分配和檢查棧空間
-
初始化參數(shù)和環(huán)境變量
-
當(dāng)前運(yùn)行線程標(biāo)記為 m0,m0 是程序啟動(dòng)的主線程
-
調(diào)用運(yùn)行時(shí)初始化函數(shù) runtime.schedinit 進(jìn)行初始化。主要是初始化內(nèi)存空間分配器、GC、生成空閑 P 列表
-
在 m0 上調(diào)度第一個(gè) G,這個(gè) G 運(yùn)行 runtime.main 函數(shù)
runtime.main 會(huì)拉起運(yùn)行時(shí)的監(jiān)控線程,然后調(diào)用 main 包的 init() 初始化函數(shù),最后執(zhí)行 main 函數(shù)。
什么時(shí)候創(chuàng)建 M、P、G
程序啟動(dòng)過程中會(huì)初始化空閑 P 列表,P 是在這個(gè)時(shí)候被創(chuàng)建的,同時(shí)第一個(gè) G 也是在初始化過程中被創(chuàng)建的。后續(xù)在有 go 并發(fā)調(diào)用的地方都有可能創(chuàng)建 G。
每個(gè)并發(fā)調(diào)用都會(huì)初始化一個(gè)新的 G 任務(wù),然后喚醒 M 執(zhí)行任務(wù)。先嘗試獲取當(dāng)前線程 M,如果無法獲取,則從全局調(diào)度的空閑 M 列表中獲取可用的 M,如果沒有可用的,則新建 M,然后綁定 P 和 G 進(jìn)行運(yùn)行。
M 線程里有管理調(diào)度和切換堆棧的邏輯,但是 M 必須拿到 P 后才能運(yùn)行,可以看到 M 是自驅(qū)動(dòng)的,但是需要 P 的配合。
