《Go語言四十二章經》第二十一章 協程(goroutine)
《Go語言四十二章經》第二十一章 協程(goroutine)
作者:李驍
Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.
併發: 指的是程式的邏輯結構。如果程式程式碼結構中的某些函式邏輯上可以同時執行,但物理上未必會同時執行。 並行: 並行是指程式的執行狀態。並行則指的就是在物理層面也就是使用了不同CPU在執行不同或者相同的任務。
21.1 併發
併發是在同一時間處理(dealing with)多件事情。並行是在同一時間做(doing)多件事情。併發的目的在於把當個 CPU 的利用率使用到最高。並行則需要多核 CPU 的支援。
Go 語言從語言層面上就支援了併發,goroutine是Go語言提供的一種使用者態執行緒,有時我們也稱之為協程。所謂的協程,某種程度上也可以叫做輕量執行緒,它不由os,而由應用程式建立和管理,因此使用開銷較低(一般為4K)。我們可以建立很多的goroutine,並且它們跑在同一個核心執行緒之上的時候,就需要一個排程器來維護這些goroutine,確保所有的goroutine都使用cpu,並且是儘可能公平的使用cpu資源。排程器的主要有4個重要部分,分別是M、G、P、Sched,前三個定義在runtime.h中,Sched定義在proc.c中。
-
M (work thread) 代表了系統執行緒OS Thread,由作業系統管理。
-
P (processor)銜接M和G的排程上下文,它負責將等待執行的G與M對接。P的數量可以通過GOMAXPROCS()來設定,它其實也就代表了真正的併發度,即有多少個goroutine可以同時執行。
-
G (goroutine)goroutine的實體,包括了呼叫棧,重要的排程資訊,例如channel等。
在作業系統的OS Thread和程式語言的User Thread之間,實際上存在3中執行緒對應模型,也就是:1:1,1:N,M:N。
N:1 多個(N)使用者執行緒始終在一個核心執行緒上跑,context上下文切換很快,但是無法真正的利用多核。 1:1 一個使用者執行緒就只在一個核心執行緒上跑,這時可以利用多核,但是上下文切換很慢,切換效率很低。 M:N 多個goroutine在多個核心執行緒上跑,這個可以集齊上面兩者的優勢,但是無疑增加了排程的難度。
M:N 綜合兩種方式(N:1,1:1)的優勢。多個 goroutines 可以在多個 OS threads 上處理。既能快速切換上下文,也能利用多核的優勢,而Go正是選擇這種實現方式。
Go 的goroutine是執行在虛擬CPU中的(通過runtime.GOMAXPROCS(1)所設定的虛擬CPU個數)。 虛擬CPU個數未必會和實際CPU個數相吻合。
每個goroutine都會被一個特定的P(虛擬CPU)選定維護,而M(物理計算資源)每次挑選一個有效P,然後執行P中的goroutine。
每個P會將自己所維護的goroutine放到一個G佇列中,其中就包括了goroutine堆疊資訊,是否可執行資訊等等。
預設情況下,P的數量與實際物理CPU的數量相等。當我們通過迴圈來建立goroutine時,goroutine會被分配到不同的G佇列中。 而M的數量又不是唯一的,當M隨機挑選P時,也就等同隨機挑選了goroutine。
所以,當我們碰到多個goroutine的執行順序不是我們想象的順序時就可以理解了,因為goroutine進入P管理的佇列G是帶有隨機性的。
P的數量由runtime.GOMAXPROCS(1)所設定,通常來說它是和核心數對應,例如在4Core的伺服器上會啟動4個執行緒。G會有很多個,每個P會將goroutine從一個就緒的佇列中做Pop操作,為了減小鎖的競爭,通常情況下每個P會負責一個佇列。
runtime.NumCPU()// 返回當前CPU核心數 runtime.GOMAXPROCS(2)// 設定執行時最大可執行CPU數 runtime.NumGoroutine() // 當前正在執行的goroutine 數
P維護著這個佇列(稱之為runqueue),Go語言裡,啟動一個goroutine很容易:go function 就行,所以每有一個go語句被執行,runqueue佇列就在其末尾加入一個goroutine,在下一個排程點,就從runqueue中取出一個goroutine執行。
假如有兩個M,即兩個OS Thread執行緒,分別對應一個P,每一個P排程一個G佇列。如此一來,就組成的goroutine執行時的基本結構:
-
當有一個M返回時,它必須嘗試取得一個P來執行goroutine,一般情況下,它會從其他的OS Thread執行緒那裡竊取一個P過來,如果沒有拿到,它就把goroutine放在一個global runqueue裡,然後自己進入執行緒快取裡。
-
如果某個P所分配的任務G很快就執行完了,這會導致多個佇列存在不平衡,會從其他佇列中擷取一部分goroutine到P上進行排程。一般來說,如果P從其他的P那裡要取任務的話,一般就取run queue的一半,這就確保了每個OS執行緒都能充分的使用。
-
當一個OS Thread執行緒被阻塞時,P可以轉而投奔另一個OS執行緒。
下面是G、 M、 P的具體結構,這不是Go程式碼:
structG { uintptr stackguard0;// 用於棧保護,但可以設定為StackPreempt,用於實現搶佔式排程 uintptr stackbase;// 棧頂 Gobufsched;// 執行上下文,G的暫停執行和恢復執行,都依靠它 uintptr stackguard; // 跟stackguard0一樣,但它不會被設定為StackPreempt uintptr stack0;// 棧底 uintptr stacksize;// 棧的大小 int16status;// G的六個狀態 int64goid;// G的標識id int8*waitreason; // 當status==Gwaiting有用,等待的原因,可能是呼叫time.Sleep之類 G*schedlink;// 指向連結串列的下一個G uintptr gopc;// 建立此goroutine的Go語句的程式計數器PC,通過PC可以獲得具體的函式和程式碼行數 }; struct P { Lock;// plan9 C的擴充套件語法,相當於Lock lock; int32id;// P的標識id uint32status;// P的四個狀態 P*link;// 指向連結串列的下一個P M*m;// 它當前繫結的M,Pidle狀態下,該值為nil MCache* mcache; // 記憶體池 // Grunnable狀態的G佇列 uint32runqhead; uint32runqtail; G*runq[256]; // Gdead狀態的G連結串列(通過G的schedlink) // gfreecnt是連結串列上節點的個數 G*gfree; int32gfreecnt; }; structM { G*g0;// M預設執行G void(*mstartfn)(void);// OS執行緒執行的函式指標 G*curg;// 當前執行的G P*p;// 當前關聯的P,要是當前不執行G,可以為nil P*nextp;// 即將要關聯的P int32id; // M的標識id M*alllink;// 加到allm,使其不被垃圾回收(GC) M*schedlink;// 指向連結串列的下一個M };
21.2 goroutine
在Go中,goroutine的使用很簡單,直接在程式碼前加上關鍵字 go 即可。go關鍵字就是用來建立一個goroutine的,後面的程式碼塊就是這個goroutine需要執行的程式碼邏輯。
package main import ( "fmt" "time" ) func main() { for i := 1; i < 10; i++ { go func(i int) { fmt.Println(i) }(i) } // 暫停一會,保證列印全部結束 time.Sleep(1e9) }
有關於goroutine 之間的通訊以及goroutine與主執行緒的控制,我們後續通過channel、context以及鎖來進一步說明。
本書《Go語言四十二章經》內容在github上同步地址:https://github.com/ffhelicopter/Go42
本書《Go語言四十二章經》內容在簡書同步地址:https://www.jianshu.com/nb/29056963
雖然本書中例子都經過實際執行,但難免出現錯誤和不足之處,煩請您指出;如有建議也歡迎交流。