1. 程式人生 > >深度解密Go語言之 scheduler

深度解密Go語言之 scheduler

目錄

  • 前置知識
    • os scheduler
    • 執行緒切換
    • 函式呼叫過程分析
  • goroutine 是怎麼工作的
    • 什麼是 goroutine
    • goroutine 和 thread 的區別
    • M:N 模型
  • 什麼是 scheduler
  • 為什麼要 scheduler
  • scheduler 底層原理
    • 總覽
    • goroutine 排程時機
    • work stealing
    • 同步/非同步系統呼叫
    • scheduler 的陷阱
  • 總結
  • 參考資料

好久不見,你還好嗎?距離上一篇文章已經過去了一個多月了,遲遲未更新文章,我也很著急啊。

跟大家彙報一下,這段時間我在看 proc.go 的原始碼,其實就是排程器的原始碼。程式碼有幾千行之多,不像以往的 map,channel 等等。想把這些程式碼都看明白,是一個龐大的工程。到今天為止,我也不敢說我都看明白了。

要深挖下去的話,會無窮無盡,所以階段性的探索就到這裡。接下來就把這段時間的探索分享出來。

其實,今天這篇文章僅僅算是一個引子,接下來會連續釋出十篇系列文章。目錄如下:

而這個系列的文章主要是受公眾號“go 語言核心程式設計技術”的啟發,它有一個 Go 排程器的系列教程,寫得非常贊,強烈推薦大家去看,後面會經常引用到它的文章。我忍不住在這貼上公眾號的二維碼,一定要去關注啊。這是我在找資料的過程中發現的一個寶藏,本來想私藏著,但是好東西還是要分享給大家,不能固步自封。

開始我們今天的正題。

一個月前,《Go 語言高階程式設計》作者柴樹杉老師在 CSDN 上發表了一篇《Go 語言十年而立,Go2 蓄勢待發》,視角十分巨集大。我們既要低頭看路,有時也要擡頭看天,這篇文章就屬於“擡頭”看天類的,推薦閱讀。

文章中提到了第一本寫 Go 的小說《胡文 Go》。我找來看了下,嬉笑怒罵,還挺有意思的。書中有這樣一句話:

在 Go 語言裡,go func 是併發的單元,chan 是協調併發單元的機制,panic 和 recover 是出錯處理的機制,而 defer 是神來之筆,大大簡化了出錯的管理。

Goroutines 在同一個使用者空間裡同時獨立執行 functions,channels 則用於 goroutines 間的通訊和同步訪問控制。

上一篇文章裡我們講了 channel,並且提到,goroutine 和 channel 是 Go 併發程式設計的兩大基石,那這篇文章就聚焦到 goroutine,以及排程 goroutine 的 go scheduler。

前置知識

os scheduler

從作業系統角度看,我們寫的程式最終都會轉換成一系列的機器指令,機器只要按順序執行完所有的指令就算完成了任務。完成“按順序執行指令”任務的實體就是執行緒,也就是說,執行緒是 CPU 排程的實體,執行緒是真正在 CPU 上執行指令的實體。

每個程式啟動的時候,都會建立一個初始程序,並且啟動一個執行緒。而執行緒可以去建立更多的執行緒,這些執行緒可以獨立地執行,CPU 在這一層進行排程,而非程序。

OS scheduler 保證如果有可以執行的執行緒時,就不會讓 CPU 閒著。並且它還要保證,所有可執行的執行緒都看起來在同時執行。另外,OS scheduler 在保證高優先順序的執行緒執行機會大於低優先順序執行緒的同時,不能讓低優先順序的執行緒始終得不到執行的機會。OS scheduler 還需要做到迅速決策,以降低延時。

執行緒切換

OS scheduler 排程執行緒的依據就是它的狀態,執行緒有三種狀態(簡化模型):Waiting, Runnable or Executing

狀態 解釋
Waiting 等待狀態。執行緒在等待某件事的發生。例如等待網路資料、硬碟;呼叫作業系統 API;等待記憶體同步訪問條件 ready,如 atomic, mutexes
Runnable 就緒狀態。只要給 CPU 資源我就能執行
Executing 執行狀態。執行緒在執行指令,這是我們想要的

執行緒能做的事一般分為兩種:計算型、IO 型。

計算型主要是佔用 CPU 資源,一直在做計算任務,例如對一個大數做質數分解。這種型別的任務不會讓執行緒跳到 Waiting 狀態。

IO 型則是要獲取外界資源,例如通過網路、系統呼叫等方式。記憶體同步訪問控制原語:mutexes 也可以看作這種型別。共同特點是需要等待外界資源就緒。IO 型的任務會讓執行緒跳到 Waiting 狀態。

執行緒切換就是作業系統用一個處於 Runnable 的執行緒將 CPU 上正在執行的處於 Executing 狀態的執行緒換下來的過程。新上場的執行緒會變成 Executing 狀態,而下場的執行緒則可能變成 Waiting 或 Runnable 狀態。正在做計算型任務的執行緒,會變成 Runnable 狀態;正在做 IO 型任務的執行緒,則會變成 Waiting 狀態。

因此,計算密集型任務和 IO 密集型任務對執行緒切換的“態度”是不一樣的。由於計算型密集型任務一直都有任務要做,或者說它一直有指令要執行,執行緒切換的過程會讓它停掉當前的任務,損失非常大。

相反,專注於 IO 密集型的任務的執行緒,如果它因為某個操作而跳到 Waiting 狀態,那麼把它從 CPU 上換下,對它而言是沒有影響的。而且,新換上來的執行緒可以繼續利用 CPU 完成任務。從整個作業系統來看,“工作進度”是往前的。

記住,對於 OS scheduler 來說,最重要的是不要讓一個 CPU 核心閒著,儘量讓每個 CPU 核心都有任務可做。

If you have a program that is focused on IO-Bound work, then context switches are going to be an advantage. Once a Thread moves into a Waiting state, another Thread in a Runnable state is there to take its place. This allows the core to always be doing work. This is one of the most important aspects of scheduling. Don’t allow a core to go idle if there is work (Threads in a Runnable state) to be done.

函式呼叫過程分析

要想理解 Go scheduler 的底層原理,對於函式呼叫過程的理解是必不可少的。它涉及到函式引數的傳遞,CPU 的指令跳轉,函式返回值的傳遞等等。這需要對組合語言有一定的瞭解,因為只有組合語言才能進行像暫存器賦值這樣的底層操作。之前的一些文章裡也有說明,這裡再來複習一遍。

函式棧幀的空間主要由函式引數和返回值、區域性變數和被呼叫其它函式的引數和返回值空間組成。

巨集觀看一下,Go 語言中函式呼叫的規範,引用曹大部落格裡的一張圖:

Go plan9 彙編通過棧傳遞函式引數和返回值。

呼叫子函式時,先將引數在棧頂準備好,再執行 CALL 指令。CALL 指令會將 IP 暫存器的值壓棧,這個值就是函式呼叫完成後即將執行的下一條指令。

然後,就會進入被呼叫者的棧幀。首先會將 caller BP 壓棧,這表示棧基址,也就是棧底。棧頂和棧基址定義函式的棧幀。

CALL 指令類似 PUSH IP 和 JMP somefunc 兩個指令的組合,首先將當前的 IP 指令暫存器的值壓入棧中,然後通過 JMP 指令將要呼叫函式的地址寫入到 IP 暫存器實現跳轉。

而 RET 指令則是和 CALL 相反的操作,基本和 POP IP 指令等價,也就是將執行 CALL 指令時儲存在 SP 中的返回地址重新載入到 IP 暫存器,實現函式的返回。

首先是呼叫函式前準備的輸入引數和返回值空間。然後 CALL 指令將首先觸發返回地址入棧操作。在進入到被呼叫函式內之後,彙編器自動插入了 BP 暫存器相關的指令,因此 BP 暫存器和返回地址是緊挨著的。再下面就是當前函式的區域性變數的空間,包含再次呼叫其它函式需要準備的呼叫引數空間。被呼叫的函式執行 RET 返回指令時,先從棧恢復 BP 和 SP 暫存器,接著取出的返回地址跳轉到對應的指令執行。

上面兩段描述來自《Go 語言高階程式設計》一書的組合語言章節,說得很好,再次推薦閱讀。

goroutine 是怎麼工作的

什麼是 goroutine

Goroutine 可以看作對 thread 加的一層抽象,它更輕量級,可以單獨執行。因為有了這層抽象,Gopher 不會直接面對 thread,我們只會看到程式碼裡滿天飛的 goroutine。作業系統卻相反,管你什麼 goroutine,我才沒空理會。我安心地執行執行緒就可以了,執行緒才是我排程的基本單位。

goroutine 和 thread 的區別

談到 goroutine,繞不開的一個話題是:它和 thread 有什麼區別?

參考資料【How Goroutines Work】告訴我們可以從三個角度區別:記憶體消耗、建立與銷毀、切換。

  • 記憶體佔用

建立一個 goroutine 的棧記憶體消耗為 2 KB,實際執行過程中,如果棧空間不夠用,會自動進行擴容。建立一個 thread 則需要消耗 1 MB 棧記憶體,而且還需要一個被稱為 “a guard page” 的區域用於和其他 thread 的棧空間進行隔離。

對於一個用 Go 構建的 HTTP Server 而言,對到來的每個請求,建立一個 goroutine 用來處理是非常輕鬆的一件事。而如果用一個使用執行緒作為併發原語的語言構建的服務,例如 Java 來說,每個請求對應一個執行緒則太浪費資源了,很快就會出 OOM 錯誤(OutOfMermoryError)。

  • 建立和銷毀

Thread 建立和銷毀都會有巨大的消耗,因為要和作業系統打交道,是核心級的,通常解決的辦法就是執行緒池。而 goroutine 因為是由 Go runtime 負責管理的,建立和銷燬的消耗非常小,是使用者級。

  • 切換

當 threads 切換時,需要儲存各種暫存器,以便將來恢復:

16 general purpose registers, PC (Program Counter), SP (Stack Pointer), segment registers, 16 XMM registers, FP coprocessor state, 16 AVX registers, all MSRs etc.

而 goroutines 切換隻需儲存三個暫存器:Program Counter, Stack Pointer and BP。

一般而言,執行緒切換會消耗 1000-1500 納秒,一個納秒平均可以執行 12-18 條指令。所以由於執行緒切換,執行指令的條數會減少 12000-18000。

Goroutine 的切換約為 200 ns,相當於 2400-3600 條指令。

因此,goroutines 切換成本比 threads 要小得多。

M:N 模型

我們都知道,Go runtime 會負責 goroutine 的生老病死,從建立到銷燬,都一手包辦。Runtime 會在程式啟動的時候,建立 M 個執行緒(CPU 執行排程的單位),之後建立的 N 個 goroutine 都會依附在這 M 個執行緒上執行。這就是 M:N 模型:

在同一時刻,一個執行緒上只能跑一個 goroutine。當 goroutine 發生阻塞(例如上篇文章提到的向一個 channel 傳送資料,被阻塞)時,runtime 會把當前 goroutine 排程走,讓其他 goroutine 來執行。目的就是不讓一個執行緒閒著,榨乾 CPU 的每一滴油水。

什麼是 scheduler

Go 程式的執行由兩層組成:Go Program,Runtime,即使用者程式和執行時。它們之間通過函式呼叫來實現記憶體管理、channel 通訊、goroutines 建立等功能。使用者程式進行的系統呼叫都會被 Runtime 攔截,以此來幫助它進行排程以及垃圾回收相關的工作。

一個展現了全景式的關係如下圖:

為什麼要 scheduler

Go scheduler 可以說是 Go 執行時的一個最重要的部分了。Runtime 維護所有的 goroutines,並通過 scheduler 來進行排程。Goroutines 和 threads 是獨立的,但是 goroutines 要依賴 threads 才能執行。

Go 程式執行的高效和 scheduler 的排程是分不開的。

scheduler 底層原理

實際上在作業系統看來,所有的程式都是在執行多執行緒。將 goroutines 排程到執行緒上執行,僅僅是 runtime 層面的一個概念,在作業系統之上的層面。

有三個基礎的結構體來實現 goroutines 的排程。g,m,p。

g 代表一個 goroutine,它包含:表示 goroutine 棧的一些欄位,指示當前 goroutine 的狀態,指示當前執行到的指令地址,也就是 PC 值。

m 表示核心執行緒,包含正在執行的 goroutine 等欄位。

p 代表一個虛擬的 Processor,它維護一個處於 Runnable 狀態的 g 佇列,m 需要獲得 p 才能執行 g

當然還有一個核心的結構體:sched,它總覽全域性。

Runtime 起始時會啟動一些 G:垃圾回收的 G,執行排程的 G,執行使用者程式碼的 G;並且會建立一個 M 用來開始 G 的執行。隨著時間的推移,更多的 G 會被創建出來,更多的 M 也會被創建出來。

當然,在 Go 的早期版本,並沒有 p 這個結構體,m 必須從一個全域性的佇列裡獲取要執行的 g,因此需要獲取一個全域性的鎖,當併發量大的時候,鎖就成了瓶頸。後來在大神 Dmitry Vyokov 的實現裡,加上了 p 結構體。每個 p 自己維護一個處於 Runnable 狀態的 g 的佇列,解決了原來的全域性鎖問題。

Go scheduler 的目標:

For scheduling goroutines onto kernel threads.

Go scheduler 的核心思想是:

  1. reuse threads;
  2. 限制同時執行(不包含阻塞)的執行緒數為 N,N 等於 CPU 的核心數目;
  3. 執行緒私有的 runqueues,並且可以從其他執行緒 stealing goroutine 來執行,執行緒阻塞後,可以將 runqueues 傳遞給其他執行緒。

為什麼需要 P 這個元件,直接把 runqueues 放到 M 不行嗎?

You might wonder now, why have contexts at all? Can't we just put the runqueues on the threads and get rid of contexts? Not really. The reason we have contexts is so that we can hand them off to other threads if the running thread needs to block for some reason.

An example of when we need to block, is when we call into a syscall. Since a thread cannot both be executing code and be blocked on a syscall, we need to hand off the context so it can keep scheduling.

翻譯一下,當一個執行緒阻塞的時候,將和它繫結的 P 上的 goroutines 轉移到其他執行緒。

Go scheduler 會啟動一個後臺執行緒 sysmon,用來檢測長時間(超過 10 ms)執行的 goroutine,將其排程到 global runqueues。這是一個全域性的 runqueue,優先順序比較低,以示懲罰。

總覽

通常講到 Go scheduler 都會提到 GPM 模型,我們來一個個地看。

下圖是我使用的 mac 的硬體資訊,只有 2 個核。

但是配上 CPU 的超執行緒,1 個核可以變成 2 個,所以當我在 mac 上執行下面的程式時,會打印出 4。

func main() {
    // NumCPU 返回當前程序可以用到的邏輯核心數
    fmt.Println(runtime.NumCPU())
}

因為 NumCPU 返回的是邏輯核心數,而非物理核心數,所以最終結果是 4。

Go 程式啟動後,會給每個邏輯核心分配一個 P(Logical Processor);同時,會給每個 P 分配一個 M(Machine,表示核心執行緒),這些核心執行緒仍然由 OS scheduler 來排程。

總結一下,當我在本地啟動一個 Go 程式時,會得到 4 個系統執行緒去執行任務,每個執行緒會搭配一個 P。

在初始化時,Go 程式會有一個 G(initial Goroutine),執行指令的單位。G 會在 M 上得到執行,核心執行緒是在 CPU 核心上排程,而 G 則是在 M 上進行排程。

G、P、M 都說完了,還有兩個比較重要的元件沒有提到: 全域性可執行佇列(GRQ)和本地可執行佇列(LRQ)。 LRQ 儲存本地(也就是具體的 P)的可執行 goroutine,GRQ 儲存全域性的可執行 goroutine,這些 goroutine 還沒有分配到具體的 P。

Go scheduler 是 Go runtime 的一部分,它內嵌在 Go 程式裡,和 Go 程式一起執行。因此它執行在使用者空間,在 kernel 的上一層。和 Os scheduler 搶佔式排程(preemptive)不一樣,Go scheduler 採用協作式排程(cooperating)。

Being a cooperating scheduler means the scheduler needs well-defined user space events that happen at safe points in the code to make scheduling decisions.

協作式排程一般會由使用者設定排程點,例如 python 中的 yield 會告訴 Os scheduler 可以將我排程出去了。

但是由於在 Go 語言裡,goroutine 排程的事情是由 Go runtime 來做,並非由使用者控制,所以我們依然可以將 Go scheduler 看成是搶佔式排程,因為使用者無法預測排程器下一步的動作是什麼。

和執行緒類似,goroutine 的狀態也是三種(簡化版的):

狀態 解釋
Waiting 等待狀態,goroutine 在等待某件事的發生。例如等待網路資料、硬碟;呼叫作業系統 API;等待記憶體同步訪問條件 ready,如 atomic, mutexes
Runnable 就緒狀態,只要給 M 我就可以執行
Executing 執行狀態。goroutine 在 M 上執行指令,這是我們想要的

下面這張 GPM 全域性的執行示意圖見得比較多,可以留著,看完後面的系列文章之後再回頭來看,還是很有感觸的:

goroutine 排程時機

在四種情形下,goroutine 可能會發生排程,但也並不一定會發生,只是說 Go scheduler 有機會進行排程。

情形 說明
使用關鍵字 go go 建立一個新的 goroutine,Go scheduler 會考慮排程
GC 由於進行 GC 的 goroutine 也需要在 M 上執行,因此肯定會發生排程。當然,Go scheduler 還會做很多其他的排程,例如排程不涉及堆訪問的 goroutine 來執行。GC 不管棧上的記憶體,只會回收堆上的記憶體
系統呼叫 當 goroutine 進行系統呼叫時,會阻塞 M,所以它會被排程走,同時一個新的 goroutine 會被排程上來
記憶體同步訪問 atomic,mutex,channel 操作等會使 goroutine 阻塞,因此會被排程走。等條件滿足後(例如其他 goroutine 解鎖了)還會被排程上來繼續執行

work stealing

Go scheduler 的職責就是將所有處於 runnable 的 goroutines 均勻分佈到在 P 上執行的 M。

當一個 P 發現自己的 LRQ 已經沒有 G 時,會從其他 P “偷” 一些 G 來執行。看看這是什麼精神!自己的工作做完了,為了全域性的利益,主動為別人分擔。這被稱為 Work-stealing,Go 從 1.1 開始實現。

Go scheduler 使用 M:N 模型,在任一時刻,M 個 goroutines(G) 要分配到 N 個核心執行緒(M),這些 M 跑在個數最多為 GOMAXPROCS 的邏輯處理器(P)上。每個 M 必須依附於一個 P,每個 P 在同一時刻只能執行一個 M。如果 P 上的 M 阻塞了,那它就需要其他的 M 來執行 P 的 LRQ 裡的 goroutines。

個人感覺,上面這張圖比常見的那些用三角形表示 M,圓形表示 G,矩形表示 P 的那些圖更生動形象。

實際上,Go scheduler 每一輪排程要做的工作就是找到處於 runnable 的 goroutines,並執行它。找的順序如下:

runtime.schedule() {
    // only 1/61 of the time, check the global runnable queue for a G.
    // if not found, check the local queue.
    // if not found,
    //     try to steal from other Ps.
    //     if not, check the global runnable queue.
    //     if not found, poll network.
}

找到一個可執行的 goroutine 後,就會一直執行下去,直到被阻塞。

當 P2 上的一個 G 執行結束,它就會去 LRQ 獲取下一個 G 來執行。如果 LRQ 已經空了,就是說本地可執行佇列已經沒有 G 需要執行,並且這時 GRQ 也沒有 G 了。這時,P2 會隨機選擇一個 P(稱為 P1),P2 會從 P1 的 LRQ “偷”過來一半的 G。

這樣做的好處是,有更多的 P 可以一起工作,加速執行完所有的 G。

同步/非同步系統呼叫

當 G 需要進行系統呼叫時,根據呼叫的型別,它所依附的 M 有兩種情況:同步非同步

對於同步的情況,M 會被阻塞,進而從 P 上排程下來,P 可不養閒人,G 仍然依附於 M。之後,一個新的 M 會被呼叫到 P 上,接著執行 P 的 LRQ 裡嗷嗷待哺的 G 們。一旦系統呼叫完成,G 還會加入到 P 的 LRQ 裡,M 則會被“雪藏”,待到需要時再“放”出來。

對於非同步的情況,M 不會被阻塞,G 的非同步請求會被“代理人” network poller 接手,G 也會被繫結到 network poller,等到系統呼叫結束,G 才會重新回到 P 上。M 由於沒被阻塞,它因此可以繼續執行 LRQ 裡的其他 G。

可以看到,非同步情況下,通過排程,Go scheduler 成功地將 I/O 的任務轉變成了 CPU 任務,或者說將核心級別的執行緒切換轉變成了使用者級別的 goroutine 切換,大大提高了效率。

The ability to turn IO/Blocking work into CPU-bound work at the OS level is where we get a big win in leveraging more CPU capacity over time.

Go scheduler 像一個非常苛刻的監工一樣,不會讓一個 M 閒著,總是會通過各種辦法讓你幹更多的事。

In Go, it’s possible to get more work done, over time, because the Go scheduler attempts to use less Threads and do more on each Thread, which helps to reduce load on the OS and the hardware.

scheduler 的陷阱

由於 Go 語言是協作式的排程,不會像執行緒那樣,在時間片用完後,由 CPU 中斷任務強行將其排程走。對於 Go 語言中執行時間過長的 goroutine,Go scheduler 有一個後臺執行緒在持續監控,一旦發現 goroutine 執行超過 10 ms,會設定 goroutine 的“搶佔標誌位”,之後排程器會處理。但是設定標誌位的時機只有在函式“序言”部分,對於沒有函式呼叫的就沒有辦法了。

Golang implements a co-operative partially preemptive scheduler.

所以在某些極端情況下,會掉進一些陷阱。下面這個例子來自參考資料【scheduler 的陷阱】。

func main() {
    var x int
    threads := runtime.GOMAXPROCS(0)
    for i := 0; i < threads; i++ {
        go func() {
            for { x++ }
        }()
    }
    time.Sleep(time.Second)
    fmt.Println("x =", x)
}

執行結果是:在死迴圈裡出不來,不會輸出最後的那條列印語句。

為什麼?上面的例子會啟動和機器的 CPU 核心數相等的 goroutine,每個 goroutine 都會執行一個無限迴圈。

建立完這些 goroutines 後,main 函式裡執行一條 time.Sleep(time.Second) 語句。Go scheduler 看到這條語句後,簡直高興壞了,要來活了。這是排程的好時機啊,於是主 goroutine 被排程走。先前建立的 threads 個 goroutines,剛好“一個蘿蔔一個坑”,把 M 和 P 都佔滿了。

在這些 goroutine 內部,又沒有呼叫一些諸如 channeltime.sleep 這些會引發排程器工作的事情。麻煩了,只能任由這些無限迴圈執行下去了。

解決的辦法也有,把 threads 減小 1:

func main() {
    var x int
    threads := runtime.GOMAXPROCS(0) - 1
    for i := 0; i < threads; i++ {
        go func() {
            for { x++ }
        }()
    }
    time.Sleep(time.Second)
    fmt.Println("x =", x)
}

執行結果:

x = 0

不難理解了吧,主 goroutine 休眠一秒後,被 go schduler 重新喚醒,排程到 M 上繼續執行,列印一行語句後,退出。主 goroutine 退出後,其他所有的 goroutine 都必須跟著退出。所謂“覆巢之下 焉有完卵”,一損俱損。

至於為什麼最後打印出的 x 為 0,之前的文章《曹大談記憶體重排》裡有講到過,這裡不再深究了。

還有一種解決辦法是在 for 迴圈里加一句:

go func() {
    time.Sleep(time.Second)
    for { x++ }
}()

同樣可以讓 main goroutine 有機會排程執行。

總結

這篇文章,從巨集觀角度來看 Go 排程器,講到了很多方面。接下來連續的 10 篇文章,我會深入原始碼,層層解析。敬請期待!

參考資料裡有很多篇英文部落格寫得很好,當你掌握了基本原理後,看這些文章會有一種熟悉的感覺,講得真好!

參考資料

【知乎回答,怎樣理解阻塞非阻塞與同步非同步的區別】?https://www.zhihu.com/question/19732473/answer/241673170

【從零開始學架構 Reactor與Proactor】https://book.douban.com/subject/30335935/

【思否上 goalng 排名第二的大佬譯文】https://segmentfault.com/a/1190000016038785

【ardan labs】https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part1.html

【論文 Analysis of the Go runtime scheduler】http://www.cs.columbia.edu/~aho/cs6998/reports/12-12-11_DeshpandeSponslerWeiss_GO.pdf

【譯文傳播很廣的】https://morsmachine.dk/go-scheduler

【碼農翻身文章】https://mp.weixin.qq.com/s/BV25ngvWgbO3_yMK7eHhew

【goroutine 資料合集】https://github.com/ardanlabs/gotraining/tree/master/topics/go/concurrency/goroutines

【大彬排程器系列文章】http://lessisbetter.site/2019/03/10/golang-scheduler-1-history/

【Scalable scheduler design doc 2012】https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw/edit#heading=h.rvfa6uqbq68u

【Go scheduler blog post】https://morsmachine.dk/go-scheduler

【work stealing】https://rakyll.org/scheduler/

【Tony Bai 也談goroutine排程器】https://tonybai.com/2017/06/23/an-intro-about-goroutine-scheduler/

【Tony Bai 除錯例項分析】https://tonybai.com/2017/11/23/the-simple-analysis-of-goroutine-schedule-examples/

【Tony Bai goroutine 是如何工作的】https://tonybai.com/2014/11/15/how-goroutines-work/

【How Goroutines Work】https://blog.nindalf.com/posts/how-goroutines-work/

【知乎回答 什麼是阻塞,非阻塞,同步,非同步?】https://www.zhihu.com/question/26393784/answer/328707302

【知乎文章 完全理解同步/非同步與阻塞/非阻塞】https://zhuanlan.zhihu.com/p/22707398

【The Go netpoller】https://morsmachine.dk/netpoller

【知乎專欄 Head First of Golang Scheduler】https://zhuanlan.zhihu.com/p/42057783

【鳥窩 五種 IO 模型】https://colobu.com/2019/07/26/IO-models/

【Go Runtime Scheduler】https://speakerdeck.com/retervision/go-runtime-scheduler?slide=32

【go-scheduler】https://povilasv.me/go-scheduler/#

【追蹤 scheduler】https://www.ardanlabs.com/blog/2015/02/scheduler-tracing-in-go.html

【go tool trace 使用】https://making.pusher.com/go-tool-trace/

【goroutine 之旅】https://medium.com/@riteeksrivastava/a-complete-journey-with-goroutines-8472630c7f5c

【介紹 concurreny 和 parallelism 區別的視訊】https://www.youtube.com/watch?v=cN_DpYBzKso&t=422s

【scheduler 的陷阱】http://www.sarathlakshman.com/2016/06/15/pitfall-of-golang-scheduler

【boya 原始碼閱讀】https://github.com/zboya/golang_runtime_reading/blob/master/src/runtime/proc.go

【阿波張排程器系列教程】http://mp.weixin.qq.com/mp/homepage?__biz=MzU1OTg5NDkzOA==&hid=1&sn=8fc2b63f53559bc0cee292ce629c4788&scene=18#wechat_redirect

【曹大 asmshare】https://github.com/cch123/asmshare/blob/master/layout.md

【Go排程器介紹和容易忽視的問題】https://www.cnblogs.com/CodeWithTxT/p/11370215.html

【最近發現的一位大佬的原始碼分析】https://github.com/changkun/go-under-the-hood/blob/master/book/zh-cn/TO