商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。
鏈接:http://www.zhihu.com/question/20862617
來源:知乎
12 個回答
Go runtime的調度器:
在瞭解Go的運行時的scheduler之前,需要先了解爲什麼需要它,因爲我們可能會想,OS內核不是已經有一個線程scheduler了嘛?
熟悉POSIX API的人都知道,POSIX的方案在很大程度上是對Unix process進場模型的一個邏輯描述和擴展,兩者有很多相似的地方。 Thread有自己的信號掩碼,CPU affinity等。但是很多特徵對於Go程序來說都是累贅。 尤其是context上下文切換的耗時。另一個原因是Go的垃圾回收需要所有的goroutine停止,使得內存在一個一致的狀態。垃圾回收的時間點是不確定的,如果依靠OS自身的scheduler來調度,那麼會有大量的線程需要停止工作。
單獨的開發一個GO得調度器,可以是其知道在什麼時候內存狀態是一致的,也就是說,當開始垃圾回收時,運行時只需要爲當時正在CPU核上運行的那個線程等待即可,而不是等待所有的線程。
用戶空間線程和內核空間線程之間的映射關係有:N:1,1:1和M:N
N:1是說,多個(N)用戶線程始終在一個內核線程上跑,context上下文切換確實很快,但是無法真正的利用多核。
1:1是說,一個用戶線程就只在一個內核線程上跑,這時可以利用多核,但是上下文switch很慢。
M:N是說, 多個goroutine在多個內核線程上跑,這個看似可以集齊上面兩者的優勢,但是無疑增加了調度的難度。
Go的調度器內部有三個重要的結構:M,P,S
M:代表真正的內核OS線程,和POSIX裏的thread差不多,真正幹活的人
G:代表一個goroutine,它有自己的棧,instruction pointer和其他信息(正在等待的channel等等),用於調度。
P:代表調度的上下文,可以把它看做一個局部的調度器,使go代碼在一個線程上跑,它是實現從N:1到N:M映射的關鍵。
圖中看,有2個物理線程M,每一個M都擁有一個context(P),每一個也都有一個正在運行的goroutine。
P的數量可以通過GOMAXPROCS()來設置,它其實也就代表了真正的併發度,即有多少個goroutine可以同時運行。
圖中灰色的那些goroutine並沒有運行,而是出於ready的就緒態,正在等待被調度。P維護着這個隊列(稱之爲runqueue),
Go語言裏,啓動一個goroutine很容易:go function 就行,所以每有一個go語句被執行,runqueue隊列就在其末尾加入一個
goroutine,在下一個調度點,就從runqueue中取出(如何決定取哪個goroutine?)一個goroutine執行。
爲何要維護多個上下文P?因爲當一個OS線程被阻塞時,P可以轉而投奔另一個OS線程!
圖中看到,當一個OS線程M0陷入阻塞時,P轉而在OS線程M1上運行。調度器保證有足夠的線程來運行所以的context P。
圖中的M1可能是被創建,或者從線程緩存中取出。
當MO返回時,它必須嘗試取得一個context P來運行goroutine,一般情況下,它會從其他的OS線程那裏steal偷一個context過來,
如果沒有偷到的話,它就把goroutine放在一個global runqueue裏,然後自己就去睡大覺了(放入線程緩存裏)。Contexts們也會週期性的檢查global runqueue,否則global runqueue上的goroutine永遠無法執行。
另一種情況是P所分配的任務G很快就執行完了(分配不均),這就導致了一個上下文P閒着沒事兒幹而系統卻任然忙碌。但是如果global runqueue沒有任務G了,那麼P就不得不從其他的上下文P那裏拿一些G來執行。一般來說,如果上下文P從其他的上下文P那裏要偷一個任務的話,一般就‘偷’run queue的一半,這就確保了每個OS線程都能充分的使用。
《go中的調度分析》
《goroutine背後的系統知識》
還有一個是Columbia University的三個傢伙發表的一篇paper,
《Analysis of the Go runtime scheduler》
最後還有Golang核心成員寫一個Goroutine Scheduler的設計。《 Scalable Go Scheduler Design Doc》以及對其詳細解釋的《The Go scheduler》
Goroutines are part of making concurrency easy to use. The idea, which has been around for a while, is to multiplex independently executing functions—coroutines—onto a set of threads. When a coroutine blocks, such as by calling a blocking system call, the run-time automatically moves other coroutines on the same operating system thread to a different, runnable thread so they won't be blocked. The programmer sees none of this, which is the point. The result, which we call goroutines, can be very cheap: unless they spend a lot of time in long-running system calls, they cost little more than the memory for the stack, which is just a few kilobytes.
To make the stacks small, Go's run-time uses segmented stacks. A newly minted goroutine is given a few kilobytes, which is almost always enough. When it isn't, the run-time allocates (and frees) extension segments automatically. The overhead averages about three cheap instructions per function call. It is practical to create hundreds of thousands of goroutines in the same address space. If goroutines were just threads, system resources would run out at a much smaller number.
----------------- 我是分割線----------------------------------
我對goroutine的理解類似於C/C++下常用的線程池技術。但是goroutine要在這基礎上大大的前進了好多。首先,go關鍵字極大的簡化了C/C++下往線程池投遞任務的操作。雖然C++11引入了lambda,但是因爲沒有GC的緣故用起來還是稍微蛋疼的。其次就是goroutine的調度器解決了一般線程池常見的問題,就是遇到阻塞或者同步動作時,怎麼讓線程池更容易擴展,不會因爲其中一個任務的阻塞或者同步獨佔線程,甚至怎麼避免由此問題帶來的死鎖。而在C/C++語言裏,想做到這點非常的困難,沒有類似Golang的runtime,做起來會非常痛苦。 Golang在這點上做的也是非常的漂亮。發起的同步或者channel動作,哪怕網絡操作,都會把自身goroutine切換出去,讓下一個預備好的goroutine去運行。而且Golang其本身還在此基礎上很容易的做到對線程池的擴展,根據程序行爲自動擴展或者收縮線程,儘可能的讓線程保持在一個合適的數目。
但是線程又老貴了,花不起那個錢,所以go發明了goroutine。大致就是說給每個goroutine弄一個分配在heap裏面的棧來模擬線程棧。比方說有3個goroutine,A,B,C,就在heap上弄三個棧出來。然後Go讓一個單線程的scheduler開始跑他們仨。相當於 { A(); B(); C() },連續的,串行的跑。
和操作系統不太一樣的是,操作系統可以隨時隨地把你線程停掉,切換到另一個線程。這個單線程的scheduler沒那個能力啊,他就是user space的一段樸素的代碼,他跑着A的時候控制權是在A的代碼裏面的。A自己不退出誰也沒辦法。
所以A跑一小段後需要主動說,老大(scheduler),我不想跑了,幫我把我的所有的狀態保存在我自己的棧上面,讓我歇一會吧。這時候你可以看做A返回了。A返回了B就可以跑了,然後B跑一小段說,跑夠了,保存狀態,返回,然後C再跑。C跑一段也返回了。
這樣跑完{A(); B(); C()}之後,我們發現,好像他們都只跑了一小段啊。所以外面要包一個循環,大致是:
goroutine_list = [A, B, C]
while(goroutine):
for goroutine in goroutine_list:
r = goroutine()
if r.finished():
goroutine_list.remove(r)
def A:
上次跑到的地方 = 找到上次跑哪兒了
讀取所有臨時變量
goto 上次跑到的地方
a = 1
print("do something")
go.scheduler.保存程序指針 // 設置"這次跑哪兒了"
go.scheduler.保存臨時變量們
go.scheduler.跑夠了_換人 //相當於return
print("do something again")
print(a)
所以你看出來了,這個關鍵就在於每個goroutine跑一跑就要讓一讓。一般支持這種玩意(叫做coroutine)的語言都是讓每個coroutine自己說,我跑夠了,換人。goroutine比較文藝的地方就在於,他可以來幫你判斷啥時候“跑夠了”。
其中有一大半就是靠的你說的“異步併發”。go把每一個能異步併發的操作,像你說的文件訪問啦,網絡訪問啦之類的都包包好,包成一個看似樸素的而且是同步的“方法”,比如string readFile(我瞎舉得例子)。但是神奇的地方在於,這個方法裏其實會調用“異步併發”的操作,比如某操作系統提供的asyncReadFile。你也知道,這種異步方法都是很快返回的。
所以你自己在某個goroutine裏寫了
string s = go.file.readFile("/root")
其實go偷偷在裏面執行了某操作系統的API asyncReadFIle。跑起來之後呢,這個方法就會說,我當前所在的goroutine跑夠啦,把剛剛跑的那個異步操作的結果保存下下,換人:
// 實際上
handler h = someOS.asyncReadFile("/root") //很快返回一個handler
while (!h.finishedAsyncReadFile()): //很快返回Y/N
go.scheduler.保存現狀()
go.scheduler.跑夠了_換人() // 相當於return,不過下次會從這裏的下一句開始執行
string s = h.getResultFromAsyncRead()
然後scheduler就換下一個goroutine跑了。等下次再跑回剛纔那個goroutine的時候,他就看看,說那個asyncReadFile到底執行完沒有啊,如果沒有,就再換個人吧。如果執行完了,那就把結果拿出來,該幹嘛幹嘛。所以你看似寫了個同步的操作,已經被go替換成異步操作了。
還有另外一種情況是,某個goroutine執行了某個不能異步調用的會blocking的系統調用,這個時候goroutine就沒法玩那種異步調用的把戲了。他會把你挪到一個真正的線程裏讓你在那個縣城裏等着,他接茬去跑別的goroutine。比如A這麼定義
def A:
print("do something")
go.os.InvokeSomeReallyHeavyAndBlockingSystemCall()
print("do something 2")
def 真實的A:
print("do something")
Thread t = new Thread( () => {
SomeReallyHeavyAndBlockingSystemCall();
})
t.start()
while !t.finished():
go.scheduler.保存現狀
go.scheduler.跑夠了_換人
print("finished")
當然會有一種情況就是A完全沒有調用任何可能的“異步併發”的操作,也沒有調用任何的同步的系統調用,而是一個勁的用CPU做運算(比如用個死循環調用a++)。在早期的go裏,這個A就把整個程序block住了。後面新版本的go好像會有一些處理辦法,比如如果你A裏面call了任意一個別的函數的話,就有一定機率被踢下去換人。好像也可以自己主動說我要換人的,可以去查查新的go的spec
另外,請不要在意語言細節,技術細節。會意即可
其實實現原理很簡單,就是利用C(嵌入彙編)語言可以直接修改寄存器(setcontext/setjmp/longjmp均是類似原理,修改程序指針eip實現跳轉,棧指針實現上線文切換)來實現從func_a調進去,從func_b返回出來這種行爲。對於golang來說,func_a/func_b屬於不同的goroutine,從而就實現了goroutine的調度切換。
另外對於所有可能阻塞的syscall,golang對其進行了封裝,底層實際是epoll方式做的,註冊回調後切換到另一個runnable的goroutine。
源代碼可參考libtask的實現,golang原理類似:
libtask/asm.S at master 路 llhe/libtask 路 GitHub
libtask/fd.c at master 路 llhe/libtask 路 GitHub
一、go 內部有三個對象: P對象(processor) 代表cpu,M(work thread)代表工作線程,G對象(goroutine).
二、正常情況下一個cpu對象啓一個工作線程對象,線程去檢查並執行goroutine對象。碰到goroutine對象阻塞的時候,會啓動一個新的工作線程,以充分利用cpu資源。所有有時候線程對象會比處理器對象多很多。
三、go大部分棧信息存在goroutine中,線程之間切換速度很快,成本很低(介於線程和協程之間)。