很久不見,你還好嗎?距離上一篇文章已通過去了一個多月了,遲遲未更新文章,我也很着急啊。html
跟你們彙報一下,這段時間我在看 proc.go
的源碼,其實就是調度器的源碼。代碼有幾千行之多,不像以往的 map,channel 等等。想把這些代碼都看明白,是一個龐大的工程。到今天爲止,我也不敢說我都看明白了。python
要深挖下去的話,會無窮無盡,因此階段性的探索就到這裏。接下來就把這段時間的探索分享出來。ios
其實,今天這篇文章僅僅算是一個引子,接下來會連續發佈十篇系列文章。目錄以下:git
而這個系列的文章主要是受公衆號「go 語言核心編程技術」的啓發,它有一個 Go 調度器的系列教程,寫得很是贊,強烈推薦你們去看,後面會常常引用到它的文章。我忍不住在這貼上公衆號的二維碼,必定要去關注啊。這是我在找資料的過程當中發現的一個寶藏,原本想私藏着,可是好東西仍是要分享給你們,不能固步自封。github
開始咱們今天的正題。golang
一個月前,《Go 語言高級編程》做者柴樹杉老師在 CSDN 上發表了一篇《Go 語言十年而立,Go2 蓄勢待發》,視角十分宏大。咱們既要低頭看路,有時也要擡頭看天,這篇文章就屬於「擡頭」看天類的,推薦閱讀。shell
文章中提到了第一本寫 Go 的小說《胡文 Go》。我找來看了下,嬉笑怒罵,還挺有意思的。書中有這樣一句話:編程
在 Go 語言裏,go func 是併發的單元,chan 是協調併發單元的機制,panic 和 recover 是出錯處理的機制,而 defer 是神來之筆,大大簡化了出錯的管理。
Goroutines 在同一個用戶空間裏同時獨立執行 functions,channels 則用於 goroutines 間的通訊和同步訪問控制。segmentfault
上一篇文章裏咱們講了 channel,而且提到,goroutine 和 channel 是 Go 併發編程的兩大基石,那這篇文章就聚焦到 goroutine,以及調度 goroutine 的 go 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 能夠看做對 thread 加的一層抽象,它更輕量級,能夠單獨執行。由於有了這層抽象,Gopher 不會直接面對 thread,咱們只會看到代碼裏滿天飛的 goroutine。操做系統卻相反,管你什麼 goroutine,我纔沒空理會。我安心地執行線程就能夠了,線程纔是我調度的基本單位。
談到 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 要小得多。
咱們都知道,Go runtime 會負責 goroutine 的生老病死,從建立到銷燬,都一手包辦。Runtime 會在程序啓動的時候,建立 M 個線程(CPU 執行調度的單位),以後建立的 N 個 goroutine 都會依附在這 M 個線程上執行。這就是 M:N 模型:
在同一時刻,一個線程上只能跑一個 goroutine。當 goroutine 發生阻塞(例如上篇文章提到的向一個 channel 發送數據,被阻塞)時,runtime 會把當前 goroutine 調度走,讓其餘 goroutine 來執行。目的就是不讓一個線程閒着,榨乾 CPU 的每一滴油水。
Go 程序的執行由兩層組成:Go Program,Runtime,即用戶程序和運行時。它們之間經過函數調用來實現內存管理、channel 通訊、goroutines 建立等功能。用戶程序進行的系統調用都會被 Runtime 攔截,以此來幫助它進行調度以及垃圾回收相關的工做。
一個展示了全景式的關係以下圖:
Go scheduler 能夠說是 Go 運行時的一個最重要的部分了。Runtime 維護全部的 goroutines,並經過 scheduler 來進行調度。Goroutines 和 threads 是獨立的,可是 goroutines 要依賴 threads 才能執行。
Go 程序執行的高效和 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 的核心思想是:
爲何須要 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 可能會發生調度,但也並不必定會發生,只是說 Go scheduler 有機會進行調度。
情形 | 說明 |
---|---|
使用關鍵字 go |
go 建立一個新的 goroutine,Go scheduler 會考慮調度 |
GC | 因爲進行 GC 的 goroutine 也須要在 M 上運行,所以確定會發生調度。固然,Go scheduler 還會作不少其餘的調度,例如調度不涉及堆訪問的 goroutine 來運行。GC 無論棧上的內存,只會回收堆上的內存 |
系統調用 | 當 goroutine 進行系統調用時,會阻塞 M,因此它會被調度走,同時一個新的 goroutine 會被調度上來 |
內存同步訪問 | atomic,mutex,channel 操做等會使 goroutine 阻塞,所以會被調度走。等條件知足後(例如其餘 goroutine 解鎖了)還會被調度上來繼續運行 |
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.
因爲 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 內部,又沒有調用一些諸如 channel
,time.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/questio...
【從零開始學架構 Reactor與Proactor】https://book.douban.com/subje...
【思否上 goalng 排名第二的大佬譯文】https://segmentfault.com/a/11...
【ardan labs】https://www.ardanlabs.com/blo...
【論文 Analysis of the Go runtime scheduler】http://www.cs.columbia.edu/~a...
【譯文傳播很廣的】https://morsmachine.dk/go-sch...
【碼農翻身文章】https://mp.weixin.qq.com/s/BV...
【goroutine 資料合集】https://github.com/ardanlabs/...
【大彬調度器系列文章】http://lessisbetter.site/2019...
【Scalable scheduler design doc 2012】https://docs.google.com/docum...
【Go scheduler blog post】https://morsmachine.dk/go-sch...
【work stealing】https://rakyll.org/scheduler/
【Tony Bai 也談goroutine調度器】https://tonybai.com/2017/06/2...
【Tony Bai 調試實例分析】https://tonybai.com/2017/11/2...
【Tony Bai goroutine 是如何工做的】https://tonybai.com/2014/11/1...
【How Goroutines Work】https://blog.nindalf.com/post...
【知乎回答 什麼是阻塞,非阻塞,同步,異步?】https://www.zhihu.com/questio...
【知乎文章 徹底理解同步/異步與阻塞/非阻塞】https://zhuanlan.zhihu.com/p/...
【The Go netpoller】https://morsmachine.dk/netpoller
【知乎專欄 Head First of Golang Scheduler】https://zhuanlan.zhihu.com/p/...
【鳥窩 五種 IO 模型】https://colobu.com/2019/07/26...
【Go Runtime Scheduler】https://speakerdeck.com/reter...
【go-scheduler】https://povilasv.me/go-schedu...
【追蹤 scheduler】https://www.ardanlabs.com/blo...
【go tool trace 使用】https://making.pusher.com/go-...
【goroutine 之旅】https://medium.com/@riteeksri...
【介紹 concurreny 和 parallelism 區別的視頻】https://www.youtube.com/watch...
【scheduler 的陷阱】http://www.sarathlakshman.com...
【boya 源碼閱讀】https://github.com/zboya/gola...
【阿波張調度器系列教程】http://mp.weixin.qq.com/mp/ho...
【曹大 asmshare】https://github.com/cch123/asm...
【Go調度器介紹和容易忽視的問題】https://www.cnblogs.com/CodeW...
【最近發現的一位大佬的源碼分析】https://github.com/changkun/g...