iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 18
1
Software Development

入吾 Go 中:走訪 Go 語言內部實作系列 第 18

第十八天:GO 語言運行模型的三項之力

前情提要


昨日我們簡單路過 systemstack 函式之後開始了 newproc1 函式。

暫停回顧 P-M-G 關係

雖然在第 10 天時有簡單介紹註解中的關係,但至今為止筆者覺得這些名詞還是太讓人困惑了,因此還是找些資料來輔佐理解 GO 語言的這三個重要的抽象物件吧。

首先當然還是不得不提今年 COSCUP 由 Ken-Yi Lee 帶來的演講:從原始碼看 GO 語言的並行與排程實現。除了主題與筆者的系列高度相關之外,他所使用的投影片也是相當圖文並茂,適合用來對照 P-M-G 之間的角色。

GO Scheduler

但若是可以接受省略更多細節以求高抽象層的理解的話,我推薦這篇部落格。作者一開始就將這些基本名詞定義出來。GO 語言的執行期環境(runtime)管理三種東西,排程,垃圾回收,以及 goroutine runtime 管理。這篇的主體當然是以排程器為主。

排程的課題很多,但最普遍的程度來講,就是如何把抽象的工作配給具有實體運算能力的單元之上。以 GO runtime 的尺度來說,就是將 goroutine 這種輕量級的工作對應到作業系統的執行緒上面。GO routine 的定義在 runtime/runtime2.go 之中的 struct g

看起來有數十個成員的 struct g,為何總是被稱作輕量?是與什麼比較之後的結果嗎?

GO 執行期環境會負責紀錄 goroutine 與邏輯執行單元(Logical Processor)之間的對應關係,後者就是我們之前常常看到的 P。P 應該被視為是一種抽象資源,或是 context。使用這些資源之前必須先取得使用權,這麼一來作業系統執行緒(M)才能夠執行 goroutine。

至於這三者之間具體是如何互動的呢?本篇部落格的作者也另外引用了一份投影片來解說一個自 go fn() 起始的流程。最一開始的系統狀態裡面有兩種工作佇列(queue):一種是全域佇列(global queue),另外一種則是 P 佇列(per-P queue),是每個 P 所獨有的佇列。要執行一個 G 的話,M 必須要取得 P 作為 context,然後從 P 的佇列中取出 goroutine 來執行。若是執行完了的話,會有另外一套 job stealing 機制,以求運算支援不至於閒置。

這當然是過度簡化的一種描述,要是有非同步的 signal 會如何處理?如果是阻塞型的系統呼叫又會如何處理?

runtime/proc.go 的起始註解

這裡講解完 G、M、P 之後,就描述排程器的設計理念。其中有一個很重要的平衡,原題為 Worker thread parking/unparking,中文也許翻作執行緒的啟動與休眠較好?無論如何,這個平衡的兩端分別是:使用足夠多的執行緒(M)來確保運算資源的使用率足夠高;過多執行緒運行時需強迫休眠以節省運算資源與功耗。這兩者的平衡之所以困難,是因為以下兩個理由:

  • 整個排程器的狀態被刻意設計成分散管理的模式,這裡指的是每個 P 都有自己獨有的佇列。所以不太可能取得整體的工作量負荷情況來決定最佳的排程方式。
  • 要達到最佳的排程的話必須要能夠一定程度的預測未來的工作量分配,比方說如果有個新的 goroutine 很快就要生成的話,就不要休眠已經閒置的 M。

過去已經拋棄的設計模式包含:集中化的排程器狀態,這會需要至少一個全域的鎖來保護,因而限制了平行化的擴展性(scalibility)。第二個是直接轉移(direct goroutine handoff)機制,當有一個新的 goroutine 出現且有一個閒置的 P 存在時,立刻啟動一個 M,並將 M 與 G 的運行排入 P 的佇列中。這樣會造成 M 的狀態過於快速的更迭(thrashing),因為也許 G 很快就會運行至一個段落。另一個缺點則是這會破壞 G 可能存在的局域性(locality),因為它被直接交付到位於不同實體核心的執行緒上。第三個是不做直接轉移,但是仍然在有新的 G 生成時生成 M,這樣同樣會造成過多的執行緒 thrashing。

所以現在的設計引入一個聰明的概念,叫做空轉(spinning)。如果一個 M 在所屬的 P 佇列與全域佇列都找不到工作,它就被稱為是空轉的,以 m.spinningsched.nmspinning 兩個成員變數表示之。如果一個 GO 程式在任何時間點符合

  • 有個閒置的 P
  • 沒有任何空轉的 M

的時候,就多生成一個 M。如果它發現有多的 goroutine 可以執行的話就執行,沒有的話,就準備從初始化的空轉狀態再回到休眠狀態。如果至少有一個空轉的 M,那麼就不多生成 M;如果最後一個空轉的 M 找到工作執行了,就立刻再生成一個 M。這麼一來就可以確保不會在某些執行的時候有執行緒的爆炸成長,同時又可以確定運算資源的利用率足夠高。

難道這樣就不會 thrashing 嗎?之後應該要來監控一下 M 的生成與空轉。

這在設計上的困難點在於,處理執行緒的狀態轉移(空轉到非空轉)時必須非常小心,尤其可能與 goroutine 的生成、或是新喚醒的執行緒事件一起形成 race condition。如果這兩者都沒有做好的話,就可能造成運算資源利用率的低下。

疑問


  • goroutine 被認為輕量的理由?
  • 執行期排程器的整個運作中,一個 M 是如何面對非同步事件或是阻塞型的系統呼叫?
  • 為何現在偏好的 approach 就不會有空轉?還是會有額外的 M 生成,並且在空轉找不到工作之後還是得休眠不是嗎?

本日小結


查了些資料,也試著去讀懂註解,接下來就是與程式碼交叉印證的部份了。明日就繼續理解 newproc1 吧!


上一篇
第十七天:看看 systemstack 函式呼叫
下一篇
第十九天:G 的取得路徑
系列文
入吾 Go 中:走訪 Go 語言內部實作30

尚未有邦友留言

立即登入留言