1. 程式人生 > 實用技巧 >goroutine基於執行緒池的P:M:G協程模型

goroutine基於執行緒池的P:M:G協程模型

goroutine基於執行緒池的P:M:G協程模型

首先說明一下go可以有兩種併發方式

  • csp

    也就是最常使用的go併發模式,這中模式無資訊的直接交換,所以go中運用了chanel來交換資料

  • 共享記憶體

    通常意義上可以理解為通過共享了記憶體進而來通訊的併發方式,例如加lock 這種模式可以直接交換資料,但是為了併發安全需要加鎖

只有cpu中執行緒之間的資料交換才可以是共享記憶體,程序之間是無法進行資訊交換的

首先我們談談關於cpu和作業系統執行緒,程序的那些事: 我們通常都聽過這個一個詞,cpu 4核8執行緒,這裡的意思就是cpu實際核心是4核,但是在作業系統看來是8核cpu,這裡的8執行緒就是指的是8個虛擬核心。然後再作業系統層面,劃分為程序和執行緒,這裡的程序是cpu資源分配(io儲存等)的基本單位,線上程出現之前它也是cpu進行排程分配的基本單位,注意這裡的執行緒是作業系統的概念,跟4核8執行緒裡的概念不是一回事,而協程也就是coroutine是程式語言層面上的最近才有的一個東西,go裡面的goroutine也可以看做是能實現併發的協程

我們知道,在作業系統的層面上而言,實現併發就是多個執行緒在一個cpu核心裡接替執行,如果是並行呢,就是多個執行緒在多個cpu核心裡同時執行,這裡的同時才是真同時,而併發是"肉眼可見的同時但 是光速裡的交替執行"如果是高cpu密集計算形式的任務其實不需要那麼多個執行緒,只需要幾個執行緒然後將他們分配到多個核心進行計算,這樣上下文排程的時間就少了非常多了,多io形式的不需要多核,單核多執行緒就足夠了,因為在跨執行緒之間的資料交換上下文切換花費的時間也不少。

go實現併發的模式是PMG

如圖:

p就是上下文context m就是machine也就是對應著作業系統執行緒 g就是goroutine 我們來看看對應的關係

首先m對應了一個kse也就是作業系統中的一個執行緒,然後這個m下面有一個p就是上下文排程,然後這個p上面有一個初始的g goroutine和一個佇列,這個佇列裡是一批的goroutine,這個就是PMG模型。這裡有無數個想這樣的單位,如果誰的活幹完了那麼它就會去搶奪別的佇列的東西,並且分走一半的任務。

其實 goroutine 用到的就是執行緒池的技術,當 goroutine 需要執行時,會從 thread pool 中選出一個可用的 M 或者新建一個 M。而 thread pool 中如何選取執行緒,擴建執行緒,回收執行緒,Go 排程器 進行了封裝,對程式透明,只管呼叫就行,從而簡化了thread pool 的使用,它是定義在proc.c中,它維護有儲存M和G的佇列以及排程器的一些狀態資訊

  • 問 什麼時候會建立另一個kse呢?(作業系統的執行緒)

我們可以這麼看這個模型,一個地鼠推著一個車子,車子上是磚頭,地鼠就是m車子就是p磚頭就是g而m對應了一個kse,那麼什麼時候會建立另一個m呢?runtime什麼時候建立執行緒?磚(G)太多了,地鼠(M)又太少了,實在忙不過來,剛好還有空閒的小車(P)沒有使用 當這個地鼠發現自己的活太多的時候,排程器就會再啟動一個m也就是執行緒池的概念,在作業系統層面從這個池中重新分配一個kse讓他繼續幹活。如果一個m發現自己沒活了,那麼它會主動去攬活兒,如果發現沒活了那麼它就會去偷取同伴m的g,直接拿走一半,如果同伴也沒了,那麼它就去睡覺了,也就是sleep了,

  • go的GOMAXPROCS 是幹嘛的?

在go語言啟動的時候會首先檢視gomaxprocs,它會根據設定的數量來建立一批p,然後將他們儲存在排程器裡,以連結串列的方式儲存。它就是小推車呀

  • 解析goroutine協程和kse的關係

上面說了輕量級kse(作業系統執行緒)才是cpu排程的基本單位,你goroutine算什麼?,然後剛說了mpg,m就是這個kse p就是上下文,g就是goruntine,當然除此之外,go還有一個排程器然後go在這個kse中模擬了執行緒執行的過程,讓p負責管理,這個時候p沒辦法主動去取消g,只能g執行完了主動告訴p說我執行完了然後p把狀態儲存到棧上,然後執行另一個g,等到所有的g都執行完都返回了,就跟剛才說的一樣這個m開始去取新的任務了,或者就是將p還給排程器然後自己休息了。

  • cpu執行的時候能同時執行多個程序嗎?

答案是不行,cpu去執行程序的時候只能去操作這個程序中的多個執行緒,然後將這些個執行緒分配到不同的cpu核心中。它是無法去執行多個程序的 因為cpu同時只能執行一個程序。

  • cpu同時只能執行一個程序,那麼我的計算機裡為什麼可以同時執行多個程式呢?

作業系統排程器(區分於go中的排程器哦) 拆分CPU為一段段時間的執行片,輪流分配給不同的程式。這樣的話就彷彿可以同時執行不同的程式程序了,還記得你使用kill命令的時候嗎?殺的就是程序,這些程序不能同時執行,他們被分配到不同的cpu片段裡。

  • 為什麼除了作業系統排程器go還有一個自己的排程器

單獨的開發一個GO得排程器,可以是其知道在什麼時候記憶體狀態是一致的,也就是說,當開始垃圾回收時,執行時只需要為當時 正在CPU核上執行的那個執行緒等待即可,而不是等待所有的執行緒。不然如果只有os的排程器,那gc的時候就要全部停下了。

綜上所述:

  • cpu同時只能執行一個程式(作業系統kse程序)(例如同時執行一個go程式然後接下來再單獨執行一個qq)但是作業系統將cpu分為了多時間片段,並且速度夠快,所以你看起來就是同時執行嘍

  • cpu可以將這個程序中的很多執行緒分配到不同的cpu核心裡。這樣就實現了並行,因為它只能識別一個程序,但是程序中有很多執行緒。

  • 執行緒池的概念在go中主要是用於分配那個m,並且是go自己的排程器來分配的

  • GOMAXPROCS的功能是為了分配p也就是車子,分配好了車子就儲存在排程器中,m可多可少,但是車是一定的。

  • 在這個車子中g的數量可多可少,可以非常多,那麼m也就是這個kse是根據GOMAXPROCS來定的,他的數量<= GOMAXPROCS指定的數量,但是最多不能超過256(不論你的gomaxpocs設定的是多少)

  • cpu將這個m也就是作業系統執行緒分配到多個cpu核心中

  • 如果GOMAXPROCS設定是1 那麼只能講這個執行緒分配到一個cpu核心中了,因為只有一個執行緒,(p只有一個,當然m也是隻有一個)所以就是併發不能並行了

  • 上下文context 也就是p 管理著這裡面一個佇列的的很多個的goroutine,所以這麼說來,p是控制區域性G的排程器也可以這麼說,但是它不能主動控制g,是g說我執行完了,告訴它而已。但是和go自己的本身的排程器不是一回事兒。go本身的排程器實現了很多功能例如說分配M.

  • go單獨於os的排程器也就是區別於os的排程器 它是管理這個執行緒池的 --- 管理如何分配m

channel 基於生產者消費者模型的無鎖佇列

首先解釋一下什麼是生產者消費者模式: 有三個東西,生產資料的一個任務(可以是執行緒,程序函式等)一個快取區域,一個使用資料的任務這個模式基本上可以類比:廚師做飯+把飯放到前臺+你去端飯。

還記得上文談的csp模型嗎?這裡的channel就是csp中通訊的部分,上文的goroutine是併發實體

channel的建立是使用的make,那麼這說明了什麼?說明了channel的初始值肯定是nil,channel變數是一個引用變數(具體是一個struct的指標)

channel分為兩種:無快取的channel和有快取的channel什麼區別呢?無快取的就是有東西就需要讀,不讀就沒法再往裡面加東西,有快取的就是不讀也能繼續加東西。就這麼個區別。Channel是Go中的一個核心型別,你可以把它看成一個管道,通過它併發核心單元就可以傳送或者接收資料進行通訊。

net.conn 基於epoll的非同步io同步阻塞模型

epoll是Linux為了替代poll模型而打造的支援高併發的產物,net.conn基於這個模型進行打造。其實就是go呼叫了Linux的epoll模型來打造的net.conn,同步阻塞 其實就是呼叫多goroutine+非快取的channel來實現。

syscall 基於作業系統的原生syscall能力

go語言裡的讀取都可以使用作業系統提供的syscall功能,幾乎所有 Linux 檔案相關係統呼叫,Go 都有封裝

net/http基於goroutine的http伺服器

在看原始碼的時候可以看出來每個請求,go都會啟動一個goroutine來進行服務。這個就是go net.Listen高併發的關鍵。

併發安全的hash map slice

在syscall.map中提供了這個併發安全的map,如果不使用這個原生的map在跨goroutine就可能發生資源搶斷的問題,沒有這個函式的時候,使用lock unlock也可以實現相關的功能。

可實現cas context基於channel的goroutine流程控制能力

context包,是go新增的一個包,這個包主要的目的是提供一個上下文,我們可以使用這個包來實現goroutine之間的一些調配 舉個例子 當a goroutine裡新增一個b groutine,那麼當a執行5分鐘後要求b結束,該怎麼做呢?

使用ctx.WitchTimeOut 就給b傳送訊號,讓它關閉,看個例子

func main(){
	ctx,cal := context.WithTimeout(context.Background(),5*time.Second)
	ctx = context.WithValue(ctx,"12","12")
	go b(ctx)
	time.Sleep(1e10)
	cal()// 執行取消命令
	time.Sleep(1e10)

}
func b(ctx context.Context) {
	for {
		time.Sleep(time.Second)
		select {
		case <-ctx.Done():
			fmt.Println(ctx.Value("12"))
			return
		default:
			fmt.Println(".")
		}
	}

}

// output:

/*

.
.
.
.
12

*/

  

以實現有限的動態性 atomic基於cpu原子操作的包裝

atomic包的所有動作都是基於cpu的原子操作,atomic 提供的原子操作能夠確保任一時刻只有一個goroutine對變數進行操作 這樣的話就可以避免在程式中出現的大量lock unlock的現象了。你可以理解為atomic是輕量級的鎖

gosched 基於阻塞的協程排程

Gosched產生處理器,允許其他goroutines執行。它不會掛起當前的goroutine,因此執行會自動恢復。 這句話的意思就是,遇到這個go.Gosched,這個goroutine就會讓出執行的機會給其它的goruntine。

go gc基於三色標記法的併發gc模型

go的垃圾回收和正在執行的邏輯goruntine不是同一個goroutine,這些goroutine是分別佔用cpu時間片段的,這就導致go 的執行中gc的時候go程式並不是都停止的,(gc需要記憶體保持一致,需要停止)如果一個記憶體物件在一次GC迴圈開始的時候無法被訪問,則將會被凍結,並在GC的最後將其回收。

中心思想就是

根節點設定咋白色區域內,然後子程式在灰色區域中,然後gc掃描記憶體物件,將其設定為黑色,當然他的子程式也是在灰色中,將白色區域中沒有任務的變量回收了,然後將灰色區域中沒有引用依賴的記憶體物件移動到黑色區域中,然後灰色區域中的不可達的的程式將會在下一次gc的時候被收回,然後 這個黑色的區域直接變成白色,進行下一次迴圈。

總結

  • 根節點在白色區域
  • 子程式在灰色區域
  • 將白色區域沒有用的變量回收
  • 將灰色區域中的沒有引用依賴的(說白了 無依無靠沒有什麼指向的)變數或者是什麼程式之類的送到黑色區域
  • 然後灰色區域的變數如果這次無法回收,就下次gc回收
  • 進行下一次迴圈。
  • gc過程和正常執行的邏輯程式碼並行執行。