# 6.5 線程管理
Go 語言既然專門將線程進一步抽象為 Goroutine,自然也就不希望我們對線程做過多的操作,事實也是如此, 大部分的用戶代碼并不需要線程級的操作。但某些情況下,當需要 使用 cgo 調用 C 端圖形庫(如 GLib)時,甚至需要將某個 Goroutine 用戶態代碼一直在主線程上執行。 我們已經知道了`runtime.LockOSThread`會將當前 Goroutine 鎖在一個固定的 OS 線程上執行, 但是一旦開放了鎖住某個 OS 線程后,會連帶產生一些副作用。比如當系統級的編程實踐總是需要對線程進行操作, 尤其是當用戶態代碼通過系統調用將 OS 線程所在的 Linux namespace 進行修改、把線程私有化時(系統調用`unshare`和標志位`CLONE_NEWNS`), 其他 Goroutine 已經不再適合在此 OS 線程上執行。這時候不得不將 M 永久的從運行時中移出, 通過[6.3 調度循環](https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/exec)一節的介紹,我們知道`LockOSThread/UnlockOSThread`也是目前唯一一個能夠讓 M 退出的做法(將 Goroutine 鎖在 OS 線程上,且在 Goroutine 死亡退出時不調用 Unlock 方法)。 本節便進一步研究 Go 語言對用戶態線程操作的支持和與之相關的運行時線程的管理。
## 6.5.1 LockOSThread
LockOSThread 和 UnlockOSThread 在運行時包中分別提供了私有和公開的方法。 運行時私有的 lockOSThread 非常簡單:
```
//go:nosplit
func lockOSThread() {
getg().m.lockedInt++
dolockOSThread()
}
```
因為整個運行時只有在`runtime.main`調用`main.init`、和 cgo 的 C 調用 Go 時候才會使用, 其中`main.init`其實也是為了 cgo 里 Go 調用某些 C 圖形庫時需要主線程支持才使用的。 因此不需要做過多復雜的處理,直接在 m 上進行計數 (計數的原因在于安全性和時鐘上的一些處理,防止用戶態代碼誤用, 例如只調用了 Unlock 而沒有先調用 Lock \[Mills, 2017\]), 而后調用`dolockOSThread`將 g 與 m 互相鎖定:
```
// dolockOSThread 在修改 m.locked 后由 LockOSThread 和 lockOSThread 調用。
// 在此調用期間不允許搶占,否則此函數中的 m 可能與調用者中的 m 不同。
//go:nosplit
func dolockOSThread() {
if GOARCH == "wasm" {
return // no threads on wasm yet
}
_g_ := getg()
_g_.m.lockedg.set(_g_)
_g_.lockedm.set(_g_.m)
}
```
而用戶態的公開方法則不同,還額外增加了一個模板線程的處理(隨后解釋),這也解釋了運行時其實并不希望 模板線程的存在,只有當需要時才會懶加載:
```
func LockOSThread() {
if atomic.Load(&newmHandoff.haveTemplateThread) == 0 && GOOS != "plan9" {
// 如果我們需要從鎖定的線程啟動一個新線程,我們需要模板線程。
// 當我們處于一個已知良好的狀態時,立即啟動它。
startTemplateThread()
}
_g_ := getg()
_g_.m.lockedExt++
if _g_.m.lockedExt == 0 {
_g_.m.lockedExt--
panic("LockOSThread nesting overflow")
}
dolockOSThread()
}
```
## 6.5.2 UnlockOSThread
Unlock 的部分非常簡單,減少計數,再實際 dounlock:
```
func UnlockOSThread() {
_g_ := getg()
if _g_.m.lockedExt == 0 {
return
}
_g_.m.lockedExt--
dounlockOSThread()
}
//go:nosplit
func unlockOSThread() {
_g_ := getg()
if _g_.m.lockedInt == 0 {
systemstack(badunlockosthread)
}
_g_.m.lockedInt--
dounlockOSThread()
}
```
而且并無特殊處理,只是簡單的將`lockedg`和`lockedm`兩個字段清零:
```
// dounlockOSThread 在更新 m->locked 后由 UnlockOSThread 和 unlockOSThread 調用。
// 在此調用期間不允許搶占,否則此函數中的 m 可能與調用者中的 m 不同。
//go:nosplit
func dounlockOSThread() {
if GOARCH == "wasm" {
return // no threads on wasm yet
}
_g_ := getg()
if _g_.m.lockedInt != 0 || _g_.m.lockedExt != 0 {
return
}
_g_.m.lockedg = 0
_g_.lockedm = 0
}
```
## 6.5.3 lockedg/lockedm 與調度循環
一個很自然的問題,為什么簡單的設置 lockedg 和 lockedm 之后就能保證 g 只在一個 m 上執行了? 其實我們已經在調度循環中見過與之相關的代碼了:
```
// 調度器的一輪:找到 runnable Goroutine 并進行執行且永不返回
func schedule() {
_g_ := getg()
if _g_.m.locks != 0 {
throw("schedule: holding locks")
}
// m.lockedg 會在 lockosthread 下變為非零
if _g_.m.lockedg != 0 {
stoplockedm()
execute(_g_.m.lockedg.ptr(), false) // 永不返回
}
...
}
```
調度循環在發現當前的 m 存在請求鎖住執行的 g 時,不會進入后續 g 的偷取過程, 相反會直接調用`stoplockedm`,將當前的 m 和 p 解綁,并 park 當前的 m, 直到可以再次調度 lockedg 為止,獲取 p 并通過`execute`直接調度 lockedg , 從而再次進入調度循環:
```
// 停止當前正在執行鎖住的 g 的 m 的執行,直到 g 重新變為 runnable。
// 返回獲得的 P
func stoplockedm() {
_g_ := getg()
if _g_.m.lockedg == 0 || _g_.m.lockedg.ptr().lockedm.ptr() != _g_.m {
throw("stoplockedm: inconsistent locking")
}
if _g_.m.p != 0 {
// 調度其他 M 來運行此 P
_p_ := releasep()
handoffp(_p_)
}
incidlelocked(1)
// 等待直到其他線程可以再次調度 lockedg
notesleep(&_g_.m.park)
noteclear(&_g_.m.park)
status := readgstatus(_g_.m.lockedg.ptr())
if status&^_Gscan != _Grunnable {
print("runtime:stoplockedm: g is not Grunnable or Gscanrunnable\n")
dumpgstatus(_g_)
throw("stoplockedm: not runnable")
}
acquirep(_g_.m.nextp.ptr())
_g_.m.nextp = 0
}
```
## 6.5.4 模板線程
前面已經提到過,鎖住系統線程帶來的隱患就是某個線程的狀態可能被用戶態代碼過分的修改, 從而不再具有產出新線程的能力,模板線程就提供了一個備用線程,不會執行 g,只用于創建安全的 m。
模板線程會在第一次調用`LockOSThread`的時候被創建,并將`haveTemplateThread`標記為已經存在模板線程:
```
// 如果模板線程尚未運行,則startTemplateThread將啟動它。
//
// 調用線程本身必須處于已知良好狀態。
func startTemplateThread() {
if GOARCH == "wasm" { // no threads on wasm yet
return
}
if !atomic.Cas(&newmHandoff.haveTemplateThread, 0, 1) {
return
}
newm(templateThread, nil)
}
```
`tempalteThread`這個函數會在 m 正式啟動時被調用:
```
// 創建一個新的 m. 它會啟動并調用 fn 或調度器
// fn 必須是靜態、非堆上分配的閉包
// 它可能在 m.p==nil 時運行,因此不允許 write barrier
//go:nowritebarrierrec
func newm(fn func(), _p_ *p) {
// 分配一個 m
mp := allocm(_p_, fn)
...
}
//go:yeswritebarrierrec
func allocm(_p_ *p, fn func()) *m {
...
mp := new(m)
mp.mstartfn = fn
...
}
func mstart1() {
...
// 執行啟動函數
if fn := _g_.m.mstartfn; fn != nil {
fn()
}
...
}
```
這個`newmHandoff`負責并串聯了所有新創建的 m:
```
// newmHandoff 包含需要新 OS 線程的 m 的列表。
// 在 newm 本身無法安全啟動 OS 線程的情況下,newm 會使用它。
var newmHandoff struct {
lock mutex
// newm 指向需要新 OS 線程的M結構列表。 該列表通過 m.schedlink 鏈接。
newm muintptr
// waiting 表示當 m 列入列表時需要通知喚醒。
waiting bool
wake note
// haveTemplateThread 表示 templateThread 已經啟動。沒有鎖保護,使用 cas 設置為 1。
haveTemplateThread uint32
}
```
而模板線程本身不會退出,只會在需要的時,創建 m:
```
// templateThread是處于已知良好狀態的線程,僅當調用線程可能不是良好狀態時,
// 該線程僅用于在已知良好狀態下啟動新線程。
//
// 許多程序不需要這個,所以當我們第一次進入可能導致在未知狀態的線程上運行的狀態時,
// templateThread會懶啟動。
//
// templateThread 在沒有 P 的 M 上運行,因此它必須沒有寫障礙。
//
//go:nowritebarrierrec
func templateThread() {
lock(&sched.lock)
sched.nmsys++
checkdead()
unlock(&sched.lock)
for {
lock(&newmHandoff.lock)
for newmHandoff.newm != 0 {
newm := newmHandoff.newm.ptr()
newmHandoff.newm = 0
unlock(&newmHandoff.lock)
for newm != nil {
next := newm.schedlink.ptr()
newm.schedlink = 0
newm1(newm)
newm = next
}
lock(&newmHandoff.lock)
}
// 等待新的創建請求
newmHandoff.waiting = true
noteclear(&newmHandoff.wake)
unlock(&newmHandoff.lock)
notesleep(&newmHandoff.wake)
}
}
```
當創建好 m 后,模板線程會休眠,直到創建新的 m 時候會被喚醒,這個我們在分析調度循環的時候已經看到過了:
```
// 創建一個新的 m. 它會啟動并調用 fn 或調度器
// fn 必須是靜態、非堆上分配的閉包
// 它可能在 m.p==nil 時運行,因此不允許 write barrier
//go:nowritebarrierrec
func newm(fn func(), _p_ *p) {
...
if gp := getg(); gp != nil && gp.m != nil && (gp.m.lockedExt != 0 || gp.m.incgo) && GOOS != "plan9" {
// 我們處于一個鎖定的 M 或可能由 C 啟動的線程。這個線程的內核狀態可能
// 很奇怪(用戶可能已將其鎖定)。我們不想將其克隆到另一個線程。
// 相反,請求一個已知狀態良好的線程來創建給我們的線程。
//
// 在 plan9 上禁用,見 golang.org/issue/22227
//
// TODO: This may be unnecessary on Windows, which
// doesn't model thread creation off fork.
lock(&newmHandoff.lock)
if newmHandoff.haveTemplateThread == 0 {
throw("on a locked thread with no template thread")
}
mp.schedlink = newmHandoff.newm
newmHandoff.newm.set(mp)
if newmHandoff.waiting {
newmHandoff.waiting = false
// 喚醒 m, spinning -> non-spinning
notewakeup(&newmHandoff.wake)
}
unlock(&newmHandoff.lock)
return
}
newm1(mp)
}
```
## 小結
LockOSThread 并不是什么優秀的特性,相反它卻給 Go 運行時調度器帶來了諸多管理上的難題。 它的存在僅僅只是需要提供對上個世紀 C 編寫的諸多遺產提供必要支持,倘若 Go 的基礎庫能夠更加豐富, 這項特性可能不復存在。
- 第一部分 :基礎篇
- 第1章 Go語言的前世今生
- 1.2 Go語言綜述
- 1.3 順序進程通訊
- 1.4 Plan9匯編語言
- 第2章 程序生命周期
- 2.1 從go命令談起
- 2.2 Go程序編譯流程
- 2.3 Go 程序啟動引導
- 2.4 主Goroutine的生與死
- 第3 章 語言核心
- 3.1 數組.切片與字符串
- 3.2 散列表
- 3.3 函數調用
- 3.4 延遲語句
- 3.5 恐慌與恢復內建函數
- 3.6 通信原語
- 3.7 接口
- 3.8 運行時類型系統
- 3.9 類型別名
- 3.10 進一步閱讀的參考文獻
- 第4章 錯誤
- 4.1 問題的演化
- 4.2 錯誤值檢查
- 4.3 錯誤格式與上下文
- 4.4 錯誤語義
- 4.5 錯誤處理的未來
- 4.6 進一步閱讀的參考文獻
- 第5章 同步模式
- 5.1 共享內存式同步模式
- 5.2 互斥鎖
- 5.3 原子操作
- 5.4 條件變量
- 5.5 同步組
- 5.6 緩存池
- 5.7 并發安全散列表
- 5.8 上下文
- 5.9 內存一致模型
- 5.10 進一步閱讀的文獻參考
- 第二部分 運行時篇
- 第6章 并發調度
- 6.1 隨機調度的基本概念
- 6.2 工作竊取式調度
- 6.3 MPG模型與并發調度單
- 6.4 調度循環
- 6.5 線程管理
- 6.6 信號處理機制
- 6.7 執行棧管理
- 6.8 協作與搶占
- 6.9 系統監控
- 6.10 網絡輪詢器
- 6.11 計時器
- 6.12 非均勻訪存下的調度模型
- 6.13 進一步閱讀的參考文獻
- 第7章 內存分配
- 7.1 設計原則
- 7.2 組件
- 7.3 初始化
- 7.4 大對象分配
- 7.5 小對象分配
- 7.6 微對象分配
- 7.7 頁分配器
- 7.8 內存統計
- 第8章 垃圾回收
- 8.1 垃圾回收的基本想法
- 8.2 寫屏幕技術
- 8.3 調步模型與強弱觸發邊界
- 8.4 掃描標記與標記輔助
- 8.5 免清掃式位圖技術
- 8.6 前進保障與終止檢測
- 8.7 安全點分析
- 8.8 分代假設與代際回收
- 8.9 請求假設與實務制導回收
- 8.10 終結器
- 8.11 過去,現在與未來
- 8.12 垃圾回收統一理論
- 8.13 進一步閱讀的參考文獻
- 第三部分 工具鏈篇
- 第9章 代碼分析
- 9.1 死鎖檢測
- 9.2 競爭檢測
- 9.3 性能追蹤
- 9.4 代碼測試
- 9.5 基準測試
- 9.6 運行時統計量
- 9.7 語言服務協議
- 第10章 依賴管理
- 10.1 依賴管理的難點
- 10.2 語義化版本管理
- 10.3 最小版本選擇算法
- 10.4 Vgo 與dep之爭
- 第12章 泛型
- 12.1 泛型設計的演進
- 12.2 基于合約的泛型
- 12.3 類型檢查技術
- 12.4 泛型的未來
- 12.5 進一步閱讀的的參考文獻
- 第13章 編譯技術
- 13.1 詞法與文法
- 13.2 中間表示
- 13.3 優化器
- 13.4 指針檢查器
- 13.5 逃逸分析
- 13.6 自舉
- 13.7 鏈接器
- 13.8 匯編器
- 13.9 調用規約
- 13.10 cgo與系統調用
- 結束語: Go去向何方?