1. 程式人生 > >協程的實現原理(轉)

協程的實現原理(轉)

我們都知道Go語言是原生支援語言級併發的,這個併發的最小邏輯單元就是goroutine。goroutine就是Go語言提供的一種使用者態執行緒,當然這種使用者態執行緒是跑在核心級執行緒之上的。當我們建立了很多的goroutine,並且它們都是跑在同一個核心執行緒之上的時候,就需要一個排程器來維護這些goroutine,確保所有的goroutine都使用cpu,並且是儘可能公平的使用cpu資源。

這個排程器的原理以及實現值得我們去深入研究一下。支撐整個排程器的主要有4個重要結構,分別是M、G、P、Sched,前三個定義在runtime.h中,Sched定義在proc.c中。

  • Sched結構就是排程器,它維護有儲存M和G的佇列以及排程器的一些狀態資訊等。
  • M代表核心級執行緒,一個M就是一個執行緒,goroutine就是跑在M之上的;M是一個很大的結構,裡面維護小物件記憶體cache(mcache)、當前執行的goroutine、隨機數發生器等等非常多的資訊。
  • P全稱是Processor,處理器,它的主要用途就是用來執行goroutine的,所以它也維護了一個goroutine佇列,裡面儲存了所有需要它來執行的goroutine,這個P的角色可能有一點讓人迷惑,一開始容易和M衝突,後面重點聊一下它們的關係。
  • G就是goroutine實現的核心結構了,G維護了goroutine需要的棧、程式計數器以及它所在的M等資訊。

理解M、P、G三者的關係對理解整個排程器非常重要,我從網路上找了一個圖來說明其三者關係:

 

地鼠(gopher)用小車運著一堆待加工的磚。M就可以看作圖中的地鼠,P就是小車,G就是小車裡裝的磚。一圖勝千言啊,弄清楚了它們三者的關係,下面我們就開始重點聊地鼠是如何在搬運磚塊的。

#####啟動過程

在關心絕大多數程式的內部原理的時候,我們都試圖去弄明白其啟動初始化過程,弄明白這個過程對後續的深入分析至關重要。在asm_amd64.s檔案中的彙編程式碼_rt0_amd64就是整個啟動過程,核心過程如下:

CALL	runtime·args(SB)
CALL	runtime·osinit(SB)
CALL	runtime·hashinit(SB)
CALL	runtime·schedinit(SB)

// create a new goroutine to start program
PUSHQ	$runtime·main·f(SB)		// entry
PUSHQ	$0			// arg size
CALL	runtime·newproc(SB)
POPQ	AX
POPQ	AX

// start this M
CALL	runtime·mstart(SB)

啟動過程做了排程器初始化runtime·schedinit後,呼叫runtime·newproc創建出第一個goroutine,這個goroutine將執行的函式是runtime·main,這第一個goroutine也就是所謂的主goroutine。我們寫的最簡單的Go程式”hello,world”就是完全跑在這個goroutine裡,當然任何一個Go程式的入口都是從這個goroutine開始的。最後呼叫的runtime·mstart就是真正的執行上一步建立的主goroutine。

啟動過程中的排程器初始化runtime·schedinit函式主要根據使用者設定的GOMAXPROCS值來建立一批小車(P),不管GOMAXPROCS設定為多大,最多也只能建立256個小車(P)。這些小車(p)初始建立好後都是閒置狀態,也就是還沒開始使用,所以它們都放置在排程器結構(Sched)的pidle欄位維護的連結串列中儲存起來了,以備後續之需。

檢視runtime·main函式可以瞭解到主goroutine開始執行後,做的第一件事情是建立了一個新的核心執行緒(地鼠M),不過這個執行緒是一個特殊執行緒,它在整個執行期專門負責做特定的事情——系統監控(sysmon)。接下來就是進入Go程式的main函式開始Go程式的執行。

至此,Go程式就被啟動起來開始運行了。一個真正幹活的Go程式,一定建立有不少的goroutine,所以在Go程式開始執行後,就會向排程器新增goroutine,排程器就要負責維護好這些goroutine的正常執行。

#####建立goroutine(G)

在Go程式中,時常會有類似程式碼:

go do_something()

go關鍵字就是用來建立一個goroutine的,後面的函式就是這個goroutine需要執行的程式碼邏輯。go關鍵字對應到排程器的介面就是runtime·newproc。runtime·newproc乾的事情很簡單,就負責製造一塊磚(G),然後將這塊磚(G)放入當前這個地鼠(M)的小車(P)中。

每個新的goroutine都需要有一個自己的棧,G結構的sched欄位維護了棧地址以及程式計數器等資訊,這是最基本的排程資訊,也就是說這個goroutine放棄cpu的時候需要儲存這些資訊,待下次重新獲得cpu的時候,需要將這些資訊裝載到對應的cpu暫存器中。

假設這個時候已經建立了大量的goroutne,就輪到排程器去維護這些goroutine了。

#####建立核心執行緒(M)

 

Go程式中沒有語言級的關鍵字讓你去建立一個核心執行緒,你只能建立goroutine,核心執行緒只能由runtime根據實際情況去建立。runtime什麼時候建立執行緒?以地鼠運磚圖來講,磚(G)太多了,地鼠(M)又太少了,實在忙不過來,剛好還有空閒的小車(P)沒有使用,那就從別處再借些地鼠(M)過來直到把小車(p)用完為止。這裡有一個地鼠(M)不夠用,從別處借地鼠(M)的過程,這個過程就是建立一個核心執行緒(M)。建立M的介面函式是:

void newm(void (*fn)(void), P *p)

newm函式的核心行為就是呼叫clone系統呼叫建立一個核心執行緒,每個核心執行緒的開始執行位置都是runtime·mstart函式。引數p就是一輛空閒的小車(p)。

每個建立好的核心執行緒都從runtime·mstart函式開始執行了,它們將用分配給自己小車去搬磚了。

#####排程核心

newm介面只是給新建立的M分配了一個空閒的P,也就是相當於告訴借來的地鼠(M)——“接下來的日子,你將使用1號小車搬磚,記住是1號小車;待會自己到停車場拿車。”,地鼠(M)去拿小車(P)這個過程就是acquirep。runtime·mstart在進入schedule之前會給當前M裝配上P,runtime·mstart函式中的程式碼:

} else if(m != &runtime·m0) {
	acquirep(m->nextp);
	m->nextp = nil;
}
schedule();

if分支的內容就是為當前M裝配上P,nextp就是newm分配的空閒小車(P),只是到這個時候才真正拿到手罷了。沒有P,M是無法執行goroutine的,就像地鼠沒有小車無法運磚一樣的道理。對應acquirep的動作是releasep,把M裝配的P給載掉;活幹完了,地鼠需要休息了,就把小車還到停車場,然後睡覺去。

地鼠(M)拿到屬於自己的小車(P)後,就進入工場開始幹活了,也就是上面的schedule呼叫。簡化schedule的程式碼如下:

static void
schedule(void)
{
	G *gp;

	gp = runqget(m->p);
	if(gp == nil)
		gp = findrunnable();

	if (m->p->runqhead != m->p->runqtail &&
		runtime·atomicload(&runtime·sched.nmspinning) == 0 &&
		runtime·atomicload(&runtime·sched.npidle) > 0)  // TODO: fast atomic
		wakep();

	execute(gp);
}

schedule函式被我簡化了太多,主要是我不喜歡貼大段大段的程式碼,因此只保留主幹程式碼了。這裡涉及到4大步邏輯:

 
  1. runqget, 地鼠(M)試圖從自己的小車(P)取出一塊磚(G),當然結果可能失敗,也就是這個地鼠的小車已經空了,沒有磚了。
  2. findrunnable, 如果地鼠自己的小車中沒有磚,那也不能閒著不幹活是吧,所以地鼠就會試圖跑去工場倉庫取一塊磚來處理;工場倉庫也可能沒磚啊,出現這種情況的時候,這個地鼠也沒有偷懶停下幹活,而是悄悄跑出去,隨機盯上一個小夥伴(地鼠),然後從它的車裡試圖偷一半磚到自己車裡。如果多次嘗試偷磚都失敗了,那說明實在沒有磚可搬了,這個時候地鼠就會把小車還回停車場,然後睡覺休息了。如果地鼠睡覺了,下面的過程當然都停止了,地鼠睡覺也就是執行緒sleep了。
  3. wakep, 到這個過程的時候,可憐的地鼠發現自己小車裡有好多磚啊,自己根本處理不過來;再回頭一看停車場居然有閒置的小車,立馬跑到宿舍一看,你妹,居然還有小夥伴在睡覺,直接給屁股一腳,“你妹,居然還在睡覺,老子都快累死了,趕緊起來幹活,分擔點工作。”,小夥伴醒了,拿上自己的小車,乖乖幹活去了。有時候,可憐的地鼠跑到宿舍卻發現沒有在睡覺的小夥伴,於是會很失望,最後只好向工場老闆說——”停車場還有閒置的車啊,我快乾不動了,趕緊從別的工場借個地鼠來幫忙吧。”,最後工場老闆就搞來一個新的地鼠幹活了。
  4. execute,地鼠拿著磚放入火種歡快的燒練起來。

注: “地鼠偷磚”叫work stealing,一種排程演算法。

到這裡,貌似整個工場都正常的運轉起來了,無懈可擊的樣子。不對,還有一個疑點沒解決啊,假設地鼠的車裡有很多磚,它把一塊磚放入火爐中後,何時把它取出來,放入第二塊磚呢?難道要一直把第一塊磚燒練好,才取出來嗎?那估計後面的磚真的是等得花兒都要謝了。這裡就是要真正解決goroutine的排程,上下文切換問題。

#####排程點 當我們翻看channel的實現程式碼可以發現,對channel讀寫操作的時候會觸發呼叫runtime·park函式。goroutine呼叫park後,這個goroutine就會被設定位waiting狀態,放棄cpu。被park的goroutine處於waiting狀態,並且這個goroutine不在小車(P)中,如果不對其呼叫runtime·ready,它是永遠不會再被執行的。除了channel操作外,定時器中,網路poll等都有可能park goroutine。

除了park可以放棄cpu外,呼叫runtime·gosched函式也可以讓當前goroutine放棄cpu,但和park完全不同;gosched是將goroutine設定為runnable狀態,然後放入到排程器全域性等待佇列(也就是上面提到的工場倉庫,這下就明白為何工場倉庫會有磚塊(G)了吧)。

除此之外,就輪到系統呼叫了,有些系統呼叫也會觸發重新排程。Go語言完全是自己封裝的系統呼叫,所以在封裝系統呼叫的時候,可以做不少手腳,也就是進入系統呼叫的時候執行entersyscall,退出後又執行exitsyscall函式。 也只有封裝了entersyscall的系統呼叫才有可能觸發重新排程,它將改變小車(P)的狀態為syscall。還記一開始提到的sysmon執行緒嗎?這個系統監控執行緒會掃描所有的小車(P),發現一個小車(P)處於了syscall的狀態,就知道這個小車(P)遇到了goroutine在做系統呼叫,於是系統監控執行緒就會建立一個新的地鼠(M)去把這個處於syscall的小車給搶過來,開始幹活,這樣這個小車中的所有磚塊(G)就可以繞過之前系統呼叫的等待了。被搶走小車的地鼠等系統呼叫返回後,發現自己的車沒,不能繼續幹活了,於是只能把執行系統呼叫的goroutine放回到工場倉庫,自己睡覺去了。

從goroutine的排程點可以看出,排程器還是挺粗暴的,排程粒度有點過大,公平性也沒有想想的那麼好。總之,這個排程器還是比較簡單的。

#####現場處理 goroutine在cpu上換入換出,不斷上下文切換的時候,必須要保證的事情就是儲存現場恢復現場,儲存現場就是在goroutine放棄cpu的時候,將相關暫存器的值給儲存到記憶體中;恢復現場就是在goroutine重新獲得cpu的時候,需要從記憶體把之前的暫存器資訊全部放回到相應暫存器中去。

goroutine在主動放棄cpu的時候(park/gosched),都會涉及到呼叫runtime·mcall函式,此函式也是彙編實現,主要將goroutine的棧地址和程式計數器儲存到G結構的sched欄位中,mcall就完成了現場儲存。恢復現場的函式是runtime·gogocall,這個函式主要在execute中呼叫,就是在執行goroutine前,需要重新裝載相應的暫存器。

我們都知道Go語言是原生支援語言級併發的,這個併發的最小邏輯單元就是goroutine。goroutine就是Go語言提供的一種使用者態執行緒,當然這種使用者態執行緒是跑在核心級執行緒之上的。當我們建立了很多的goroutine,並且它們都是跑在同一個核心執行緒之上的時候,就需要一個排程器來維護這些goroutine,確保所有的goroutine都使用cpu,並且是儘可能公平的使用cpu資源。

這個排程器的原理以及實現值得我們去深入研究一下。支撐整個排程器的主要有4個重要結構,分別是M、G、P、Sched,前三個定義在runtime.h中,Sched定義在proc.c中。

  • Sched結構就是排程器,它維護有儲存M和G的佇列以及排程器的一些狀態資訊等。
  • M代表核心級執行緒,一個M就是一個執行緒,goroutine就是跑在M之上的;M是一個很大的結構,裡面維護小物件記憶體cache(mcache)、當前執行的goroutine、隨機數發生器等等非常多的資訊。
  • P全稱是Processor,處理器,它的主要用途就是用來執行goroutine的,所以它也維護了一個goroutine佇列,裡面儲存了所有需要它來執行的goroutine,這個P的角色可能有一點讓人迷惑,一開始容易和M衝突,後面重點聊一下它們的關係。
  • G就是goroutine實現的核心結構了,G維護了goroutine需要的棧、程式計數器以及它所在的M等資訊。

理解M、P、G三者的關係對理解整個排程器非常重要,我從網路上找了一個圖來說明其三者關係:

 

地鼠(gopher)用小車運著一堆待加工的磚。M就可以看作圖中的地鼠,P就是小車,G就是小車裡裝的磚。一圖勝千言啊,弄清楚了它們三者的關係,下面我們就開始重點聊地鼠是如何在搬運磚塊的。

#####啟動過程

在關心絕大多數程式的內部原理的時候,我們都試圖去弄明白其啟動初始化過程,弄明白這個過程對後續的深入分析至關重要。在asm_amd64.s檔案中的彙編程式碼_rt0_amd64就是整個啟動過程,核心過程如下:

CALL	runtime·args(SB)
CALL	runtime·osinit(SB)
CALL	runtime·hashinit(SB)
CALL	runtime·schedinit(SB)

// create a new goroutine to start program
PUSHQ	$runtime·main·f(SB)		// entry
PUSHQ	$0			// arg size
CALL	runtime·newproc(SB)
POPQ	AX
POPQ	AX

// start this M
CALL	runtime·mstart(SB)

啟動過程做了排程器初始化runtime·schedinit後,呼叫runtime·newproc創建出第一個goroutine,這個goroutine將執行的函式是runtime·main,這第一個goroutine也就是所謂的主goroutine。我們寫的最簡單的Go程式”hello,world”就是完全跑在這個goroutine裡,當然任何一個Go程式的入口都是從這個goroutine開始的。最後呼叫的runtime·mstart就是真正的執行上一步建立的主goroutine。

啟動過程中的排程器初始化runtime·schedinit函式主要根據使用者設定的GOMAXPROCS值來建立一批小車(P),不管GOMAXPROCS設定為多大,最多也只能建立256個小車(P)。這些小車(p)初始建立好後都是閒置狀態,也就是還沒開始使用,所以它們都放置在排程器結構(Sched)的pidle欄位維護的連結串列中儲存起來了,以備後續之需。

檢視runtime·main函式可以瞭解到主goroutine開始執行後,做的第一件事情是建立了一個新的核心執行緒(地鼠M),不過這個執行緒是一個特殊執行緒,它在整個執行期專門負責做特定的事情——系統監控(sysmon)。接下來就是進入Go程式的main函式開始Go程式的執行。

至此,Go程式就被啟動起來開始運行了。一個真正幹活的Go程式,一定建立有不少的goroutine,所以在Go程式開始執行後,就會向排程器新增goroutine,排程器就要負責維護好這些goroutine的正常執行。

#####建立goroutine(G)

在Go程式中,時常會有類似程式碼:

go do_something()

go關鍵字就是用來建立一個goroutine的,後面的函式就是這個goroutine需要執行的程式碼邏輯。go關鍵字對應到排程器的介面就是runtime·newproc。runtime·newproc乾的事情很簡單,就負責製造一塊磚(G),然後將這塊磚(G)放入當前這個地鼠(M)的小車(P)中。

每個新的goroutine都需要有一個自己的棧,G結構的sched欄位維護了棧地址以及程式計數器等資訊,這是最基本的排程資訊,也就是說這個goroutine放棄cpu的時候需要儲存這些資訊,待下次重新獲得cpu的時候,需要將這些資訊裝載到對應的cpu暫存器中。

假設這個時候已經建立了大量的goroutne,就輪到排程器去維護這些goroutine了。

#####建立核心執行緒(M)

 

Go程式中沒有語言級的關鍵字讓你去建立一個核心執行緒,你只能建立goroutine,核心執行緒只能由runtime根據實際情況去建立。runtime什麼時候建立執行緒?以地鼠運磚圖來講,磚(G)太多了,地鼠(M)又太少了,實在忙不過來,剛好還有空閒的小車(P)沒有使用,那就從別處再借些地鼠(M)過來直到把小車(p)用完為止。這裡有一個地鼠(M)不夠用,從別處借地鼠(M)的過程,這個過程就是建立一個核心執行緒(M)。建立M的介面函式是:

void newm(void (*fn)(void), P *p)

newm函式的核心行為就是呼叫clone系統呼叫建立一個核心執行緒,每個核心執行緒的開始執行位置都是runtime·mstart函式。引數p就是一輛空閒的小車(p)。

每個建立好的核心執行緒都從runtime·mstart函式開始執行了,它們將用分配給自己小車去搬磚了。

#####排程核心

newm介面只是給新建立的M分配了一個空閒的P,也就是相當於告訴借來的地鼠(M)——“接下來的日子,你將使用1號小車搬磚,記住是1號小車;待會自己到停車場拿車。”,地鼠(M)去拿小車(P)這個過程就是acquirep。runtime·mstart在進入schedule之前會給當前M裝配上P,runtime·mstart函式中的程式碼:

} else if(m != &runtime·m0) {
	acquirep(m->nextp);
	m->nextp = nil;
}
schedule();

if分支的內容就是為當前M裝配上P,nextp就是newm分配的空閒小車(P),只是到這個時候才真正拿到手罷了。沒有P,M是無法執行goroutine的,就像地鼠沒有小車無法運磚一樣的道理。對應acquirep的動作是releasep,把M裝配的P給載掉;活幹完了,地鼠需要休息了,就把小車還到停車場,然後睡覺去。

地鼠(M)拿到屬於自己的小車(P)後,就進入工場開始幹活了,也就是上面的schedule呼叫。簡化schedule的程式碼如下:

static void
schedule(void)
{
	G *gp;

	gp = runqget(m->p);
	if(gp == nil)
		gp = findrunnable();

	if (m->p->runqhead != m->p->runqtail &&
		runtime·atomicload(&runtime·sched.nmspinning) == 0 &&
		runtime·atomicload(&runtime·sched.npidle) > 0)  // TODO: fast atomic
		wakep();

	execute(gp);
}

schedule函式被我簡化了太多,主要是我不喜歡貼大段大段的程式碼,因此只保留主幹程式碼了。這裡涉及到4大步邏輯:

 
  1. runqget, 地鼠(M)試圖從自己的小車(P)取出一塊磚(G),當然結果可能失敗,也就是這個地鼠的小車已經空了,沒有磚了。
  2. findrunnable, 如果地鼠自己的小車中沒有磚,那也不能閒著不幹活是吧,所以地鼠就會試圖跑去工場倉庫取一塊磚來處理;工場倉庫也可能沒磚啊,出現這種情況的時候,這個地鼠也沒有偷懶停下幹活,而是悄悄跑出去,隨機盯上一個小夥伴(地鼠),然後從它的車裡試圖偷一半磚到自己車裡。如果多次嘗試偷磚都失敗了,那說明實在沒有磚可搬了,這個時候地鼠就會把小車還回停車場,然後睡覺休息了。如果地鼠睡覺了,下面的過程當然都停止了,地鼠睡覺也就是執行緒sleep了。
  3. wakep, 到這個過程的時候,可憐的地鼠發現自己小車裡有好多磚啊,自己根本處理不過來;再回頭一看停車場居然有閒置的小車,立馬跑到宿舍一看,你妹,居然還有小夥伴在睡覺,直接給屁股一腳,“你妹,居然還在睡覺,老子都快累死了,趕緊起來幹活,分擔點工作。”,小夥伴醒了,拿上自己的小車,乖乖幹活去了。有時候,可憐的地鼠跑到宿舍卻發現沒有在睡覺的小夥伴,於是會很失望,最後只好向工場老闆說——”停車場還有閒置的車啊,我快乾不動了,趕緊從別的工場借個地鼠來幫忙吧。”,最後工場老闆就搞來一個新的地鼠幹活了。
  4. execute,地鼠拿著磚放入火種歡快的燒練起來。

注: “地鼠偷磚”叫work stealing,一種排程演算法。

到這裡,貌似整個工場都正常的運轉起來了,無懈可擊的樣子。不對,還有一個疑點沒解決啊,假設地鼠的車裡有很多磚,它把一塊磚放入火爐中後,何時把它取出來,放入第二塊磚呢?難道要一直把第一塊磚燒練好,才取出來嗎?那估計後面的磚真的是等得花兒都要謝了。這裡就是要真正解決goroutine的排程,上下文切換問題。

#####排程點 當我們翻看channel的實現程式碼可以發現,對channel讀寫操作的時候會觸發呼叫runtime·park函式。goroutine呼叫park後,這個goroutine就會被設定位waiting狀態,放棄cpu。被park的goroutine處於waiting狀態,並且這個goroutine不在小車(P)中,如果不對其呼叫runtime·ready,它是永遠不會再被執行的。除了channel操作外,定時器中,網路poll等都有可能park goroutine。

除了park可以放棄cpu外,呼叫runtime·gosched函式也可以讓當前goroutine放棄cpu,但和park完全不同;gosched是將goroutine設定為runnable狀態,然後放入到排程器全域性等待佇列(也就是上面提到的工場倉庫,這下就明白為何工場倉庫會有磚塊(G)了吧)。

除此之外,就輪到系統呼叫了,有些系統呼叫也會觸發重新排程。Go語言完全是自己封裝的系統呼叫,所以在封裝系統呼叫的時候,可以做不少手腳,也就是進入系統呼叫的時候執行entersyscall,退出後又執行exitsyscall函式。 也只有封裝了entersyscall的系統呼叫才有可能觸發重新排程,它將改變小車(P)的狀態為syscall。還記一開始提到的sysmon執行緒嗎?這個系統監控執行緒會掃描所有的小車(P),發現一個小車(P)處於了syscall的狀態,就知道這個小車(P)遇到了goroutine在做系統呼叫,於是系統監控執行緒就會建立一個新的地鼠(M)去把這個處於syscall的小車給搶過來,開始幹活,這樣這個小車中的所有磚塊(G)就可以繞過之前系統呼叫的等待了。被搶走小車的地鼠等系統呼叫返回後,發現自己的車沒,不能繼續幹活了,於是只能把執行系統呼叫的goroutine放回到工場倉庫,自己睡覺去了。

從goroutine的排程點可以看出,排程器還是挺粗暴的,排程粒度有點過大,公平性也沒有想想的那麼好。總之,這個排程器還是比較簡單的。

#####現場處理 goroutine在cpu上換入換出,不斷上下文切換的時候,必須要保證的事情就是儲存現場恢復現場,儲存現場就是在goroutine放棄cpu的時候,將相關暫存器的值給儲存到記憶體中;恢復現場就是在goroutine重新獲得cpu的時候,需要從記憶體把之前的暫存器資訊全部放回到相應暫存器中去。

goroutine在主動放棄cpu的時候(park/gosched),都會涉及到呼叫runtime·mcall函式,此函式也是彙編實現,主要將goroutine的棧地址和程式計數器儲存到G結構的sched欄位中,mcall就完成了現場儲存。恢復現場的函式是runtime·gogocall,這個函式主要在execute中呼叫,就是在執行goroutine前,需要重新裝載相應的暫存器。