《Go語言四十二章經(jīng)》第二十一章 協(xié)程(goroutine)
作者:李驍
Concurrency is about dealing with lots of things at once.
Parallelism is about doing lots of things at once.并發(fā): 指的是程序的邏輯結(jié)構(gòu)。如果程序代碼結(jié)構(gòu)中的某些函數(shù)邏輯上可以同時(shí)運(yùn)行溯泣,但物理上未必會(huì)同時(shí)運(yùn)行虐秋。
并行: 并行是指程序的運(yùn)行狀態(tài)。并行則指的就是在物理層面也就是使用了不同CPU在執(zhí)行不同或者相同的任務(wù)垃沦。
21.1 并發(fā)
并發(fā)是在同一時(shí)間處理(dealing with)多件事情客给。并行是在同一時(shí)間做(doing)多件事情。并發(fā)的目的在于把當(dāng)個(gè) CPU 的利用率使用到最高肢簿。并行則需要多核 CPU 的支持靶剑。
Go 語言在語言層面上支持了并發(fā),goroutine是Go語言提供的一種用戶態(tài)線程池充,有時(shí)我們也稱之為協(xié)程桩引。所謂的協(xié)程,某種程度上也可以叫做輕量線程收夸,它不由os而由應(yīng)用程序創(chuàng)建和管理坑匠,因此使用開銷較低(一般為4K)。我們可以創(chuàng)建很多的goroutine卧惜,并且它們跑在同一個(gè)內(nèi)核線程之上的時(shí)候厘灼,就需要一個(gè)調(diào)度器來維護(hù)這些goroutine,確保所有的goroutine都能使用cpu序苏,并且是盡可能公平地使用cpu資源手幢。
調(diào)度器的主要有4個(gè)重要部分,分別是M忱详、G围来、P、Sched匈睁,前三個(gè)定義在runtime.h中监透,Sched定義在proc.c中。
M (work thread) 代表了系統(tǒng)線程OS Thread航唆,由操作系統(tǒng)管理胀蛮。
P (processor) 銜接M和G的調(diào)度上下文,它負(fù)責(zé)將等待執(zhí)行的G與M對(duì)接糯钙。P的數(shù)量可以通過GOMAXPROCS()來設(shè)置粪狼,它其實(shí)也就代表了真正的并發(fā)度退腥,即有多少個(gè)goroutine可以同時(shí)運(yùn)行。
G (goroutine) goroutine的實(shí)體再榄,包括了調(diào)用棧狡刘,重要的調(diào)度信息,例如channel等困鸥。
在操作系統(tǒng)的OS Thread和編程語言的User Thread之間嗅蔬,實(shí)際上存在3種線程對(duì)應(yīng)模型,也就是:1:1疾就,1:N澜术,M:N。
N:1 多個(gè)(N)用戶線程始終在一個(gè)內(nèi)核線程上跑猬腰,context上下文切換很快鸟废,但是無法真正的利用多核。
1:1 一個(gè)用戶線程就只在一個(gè)內(nèi)核線程上跑漆诽,這時(shí)可以利用多核侮攀,但是上下文切換很慢,切換效率很低厢拭。
M:N 多個(gè)goroutine在多個(gè)內(nèi)核線程上跑兰英,這個(gè)可以集齊上面兩者的優(yōu)勢(shì),但是無疑增加了調(diào)度的難度供鸠。
M:N 綜合兩種方式(N:1畦贸,1:1)的優(yōu)勢(shì)。多個(gè) goroutines 可以在多個(gè) OS threads 上處理楞捂。既能快速切換上下文薄坏,也能利用多核的優(yōu)勢(shì),而Go正是選擇這種實(shí)現(xiàn)方式寨闹。
Go 語言中的goroutine是運(yùn)行在多核CPU中的(通過runtime.GOMAXPROCS(1)設(shè)定CPU核數(shù))胶坠。 實(shí)際中運(yùn)行的CPU核數(shù)未必會(huì)和實(shí)際物理CPU數(shù)相吻合。
每個(gè)goroutine都會(huì)被一個(gè)特定的P(某個(gè)CPU)選定維護(hù)繁堡,而M(物理計(jì)算資源)每次挑選一個(gè)有效P沈善,然后執(zhí)行P中的goroutine。
每個(gè)P會(huì)將自己所維護(hù)的goroutine放到一個(gè)G隊(duì)列中椭蹄,其中就包括了goroutine堆棧信息闻牡,是否可執(zhí)行信息等等。
默認(rèn)情況下绳矩,P的數(shù)量與實(shí)際物理CPU的數(shù)量相等罩润。當(dāng)我們通過循環(huán)來創(chuàng)建goroutine時(shí),goroutine會(huì)被分配到不同的G隊(duì)列中翼馆。 而M的數(shù)量又不是唯一的割以,當(dāng)M隨機(jī)挑選P時(shí)金度,也就等同隨機(jī)挑選了goroutine。
所以拳球,當(dāng)我們碰到多個(gè)goroutine的執(zhí)行順序不是我們想象的順序時(shí)就可以理解了审姓,因?yàn)間oroutine進(jìn)入P管理的隊(duì)列G是帶有隨機(jī)性的。
P的數(shù)量由runtime.GOMAXPROCS(1)所設(shè)定祝峻,通常來說它是和內(nèi)核數(shù)對(duì)應(yīng),例如在4Core的服務(wù)器上會(huì)啟動(dòng)4個(gè)線程扎筒。G會(huì)有很多個(gè)莱找,每個(gè)P會(huì)將goroutine從一個(gè)就緒的隊(duì)列中做Pop操作,為了減小鎖的競(jìng)爭(zhēng)嗜桌,通常情況下每個(gè)P會(huì)負(fù)責(zé)一個(gè)隊(duì)列奥溺。
runtime.NumCPU() // 返回當(dāng)前CPU內(nèi)核數(shù)
runtime.GOMAXPROCS(2) // 設(shè)置運(yùn)行時(shí)最大可執(zhí)行CPU數(shù)
runtime.NumGoroutine() // 當(dāng)前正在運(yùn)行的goroutine 數(shù)
P維護(hù)著這個(gè)隊(duì)列(稱之為runqueue),Go語言里骨宠,啟動(dòng)一個(gè)goroutine很容易:go function 就行浮定,所以每有一個(gè)go語句被執(zhí)行,runqueue隊(duì)列就在其末尾加入一個(gè)goroutine层亿,在下一個(gè)調(diào)度點(diǎn)桦卒,就從runqueue中取出一個(gè)goroutine執(zhí)行。
假如有兩個(gè)M匿又,即兩個(gè)OS Thread線程方灾,分別對(duì)應(yīng)一個(gè)P,每一個(gè)P調(diào)度一個(gè)G隊(duì)列碌更。如此一來裕偿,就組成的goroutine運(yùn)行時(shí)的基本結(jié)構(gòu):
當(dāng)有一個(gè)M返回時(shí),它必須嘗試取得一個(gè)P來運(yùn)行g(shù)oroutine痛单,一般情況下嘿棘,它會(huì)從其他的OS Thread線程那里竊取一個(gè)P過來,如果沒有拿到旭绒,它就把goroutine放在一個(gè)global runqueue里鸟妙,然后自己進(jìn)入線程緩存里。
如果某個(gè)P所分配的任務(wù)G很快就執(zhí)行完了快压,這會(huì)導(dǎo)致多個(gè)隊(duì)列存在不平衡圆仔,會(huì)從其他隊(duì)列中截取一部分goroutine到P上進(jìn)行調(diào)度。一般來說蔫劣,如果P從其他的P那里要取任務(wù)的話坪郭,一般就取run queue的一半,這就確保了每個(gè)OS線程都能充分的使用脉幢。
當(dāng)一個(gè)OS Thread線程被阻塞時(shí)歪沃,P可以轉(zhuǎn)而投奔另一個(gè)OS線程嗦锐。
下面是G、 M沪曙、 P的具體結(jié)構(gòu)奕污,這不是Go代碼:
struct G
{
uintptr stackguard0;// 用于棧保護(hù),但可以設(shè)置為StackPreempt液走,用于實(shí)現(xiàn)搶占式調(diào)度
uintptr stackbase; // 棧頂
Gobuf sched; // 執(zhí)行上下文碳默,G的暫停執(zhí)行和恢復(fù)執(zhí)行,都依靠它
uintptr stackguard; // 跟stackguard0一樣缘眶,但它不會(huì)被設(shè)置為StackPreempt
uintptr stack0; // 棧底
uintptr stacksize; // 棧的大小
int16 status; // G的六個(gè)狀態(tài)
int64 goid; // G的標(biāo)識(shí)id
int8* waitreason; // 當(dāng)status==Gwaiting有用嘱根,等待的原因,可能是調(diào)用time.Sleep之類
G* schedlink; // 指向鏈表的下一個(gè)G
uintptr gopc; // 創(chuàng)建此goroutine的Go語句的程序計(jì)數(shù)器PC巷懈,通過PC可以獲得具體的函數(shù)和代碼行數(shù)
};
struct P
{
Lock; // plan9 C的擴(kuò)展語法该抒,相當(dāng)于Lock lock;
int32 id; // P的標(biāo)識(shí)id
uint32 status; // P的四個(gè)狀態(tài)
P* link; // 指向鏈表的下一個(gè)P
M* m; // 它當(dāng)前綁定的M,Pidle狀態(tài)下顶燕,該值為nil
MCache* mcache; // 內(nèi)存池
// Grunnable狀態(tài)的G隊(duì)列
uint32 runqhead;
uint32 runqtail;
G* runq[256];
// Gdead狀態(tài)的G鏈表(通過G的schedlink)
// gfreecnt是鏈表上節(jié)點(diǎn)的個(gè)數(shù)
G* gfree;
int32 gfreecnt;
};
struct M
{
G* g0; // M默認(rèn)執(zhí)行G
void (*mstartfn)(void); // OS線程執(zhí)行的函數(shù)指針
G* curg; // 當(dāng)前運(yùn)行的G
P* p; // 當(dāng)前關(guān)聯(lián)的P凑保,要是當(dāng)前不執(zhí)行G,可以為nil
P* nextp; // 即將要關(guān)聯(lián)的P
int32 id; // M的標(biāo)識(shí)id
M* alllink; // 加到allm涌攻,使其不被垃圾回收(GC)
M* schedlink; // 指向鏈表的下一個(gè)M
};
我們可以運(yùn)行下面代碼體驗(yàn)下Go語言中通過設(shè)定runtime.GOMAXPROCS(2) 欧引,也即手動(dòng)指定CPU運(yùn)行的核數(shù),來體驗(yàn)多核CPU在并發(fā)處理時(shí)的威力癣漆。不得不提维咸,遞歸函數(shù)的計(jì)算很費(fèi)CPU和內(nèi)存,運(yùn)行時(shí)可以根據(jù)電腦配置修改循環(huán)或遞歸數(shù)量惠爽。
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
var quit chan int = make(chan int)
func loop() {
for i := 0; i < 1000; i++ {
Factorial(uint64(1000))
}
quit <- 1
}
func Factorial(n uint64) (result uint64) {
if n > 0 {
result = n * Factorial(n-1)
return result
}
return 1
}
var wg1, wg2 sync.WaitGroup
func main() {
fmt.Println("1:", time.Now())
fmt.Println(runtime.NumCPU()) // 默認(rèn)CPU核數(shù)
a := 5000
for i := 1; i <= a; i++ {
wg1.Add(1)
go loop()
}
for i := 0; i < a; i++ {
select {
case <-quit:
wg1.Done()
}
}
fmt.Println("2:", time.Now())
wg1.Wait()
fmt.Println("3:", time.Now())
runtime.GOMAXPROCS(2) // 設(shè)置執(zhí)行使用的核數(shù)
a = 5000
for i := 1; i <= a; i++ {
wg2.Add(1)
go loop()
}
for i := 0; i < a; i++ {
select {
case <-quit:
wg2.Done()
}
}
fmt.Println("4:", time.Now())
wg2.Wait()
fmt.Println("5:", time.Now())
}
我的測(cè)試電腦CPU默認(rèn)是4核癌蓖,對(duì)比手動(dòng)設(shè)置CPU在2核時(shí)的運(yùn)行耗時(shí),4核耗時(shí)約8秒婚肆,2核約14秒租副,當(dāng)然這是一種比較理想化的測(cè)試,因?yàn)殡A乘很快導(dǎo)致unit64為0较性,所以這個(gè)測(cè)試并不嚴(yán)謹(jǐn)用僧,但從中我們?nèi)匀豢梢泽w驗(yàn)到Go語言在處理并發(fā)(cpu)時(shí)代碼之簡(jiǎn)單,控制之方便赞咙。
在實(shí)際中運(yùn)行速度延緩可能不一定僅僅是由于CPU的競(jìng)爭(zhēng)责循,可能還有內(nèi)存或者I/O的原因?qū)е碌模覀冃枰鶕?jù)情況仔細(xì)分析攀操。
最后院仿,runtime.Gosched()用于讓出CPU時(shí)間片,讓出當(dāng)前goroutine的執(zhí)行權(quán)限,調(diào)度器安排其他等待的任務(wù)運(yùn)行歹垫,并在下次某個(gè)時(shí)候從該位置恢復(fù)執(zhí)行剥汤。
21.2 goroutine
在Go語言中,協(xié)程(goroutine)的使用很簡(jiǎn)單排惨,直接在函數(shù)(代碼塊)前加上關(guān)鍵字 go 即可吭敢。go關(guān)鍵字就是用來創(chuàng)建一個(gè)協(xié)程(goroutine)的,后面的代碼塊就是這個(gè)協(xié)程(goroutine)需要執(zhí)行的代碼邏輯暮芭。
package main
import (
"fmt"
"time"
)
func main() {
for i := 1; i < 10; i++ {
go func(i int) {
fmt.Println(i)
}(i)
}
// 暫停一會(huì)鹿驼,保證打印全部結(jié)束
time.Sleep(1e9)
}
time.Sleep(1e9)讓主程序不會(huì)馬上退出,以便讓協(xié)程(goroutine)運(yùn)行完成谴麦,避免主程序退出時(shí)協(xié)程(goroutine)未處理完成甚至沒有開始運(yùn)行蠢沿。
有關(guān)于協(xié)程(goroutine)之間的通信以及協(xié)程(goroutine)與主線程的控制以及多個(gè)協(xié)程(goroutine)的管理和控制,我們后續(xù)通過channel匾效、context以及鎖來進(jìn)一步說明。
本書《Go語言四十二章經(jīng)》內(nèi)容在github上同步地址:https://github.com/ffhelicopter/Go42
本書《Go語言四十二章經(jīng)》內(nèi)容在簡(jiǎn)書同步地址: http://www.reibang.com/nb/29056963雖然本書中例子都經(jīng)過實(shí)際運(yùn)行恤磷,但難免出現(xiàn)錯(cuò)誤和不足之處面哼,煩請(qǐng)您指出;如有建議也歡迎交流扫步。
聯(lián)系郵箱:roteman@163.com