- 傳統cpu調度背景 #操作系統原理#
- 線程切換的時機和代價
- go是怎樣的思路:
- 將調度維持在用戶態
- 推出用戶態runtime代碼實現的輕量級線程
- go 調度策略
- 常規: 本地隊列- 其他隊列、全局隊列
- 協作式調度: 基於用戶態事件
-- 異步io: 網絡: 基於netpoller
-- 同步io: 產生M
- goroutine的生命周期的實現
只想關注golang goroutine背景、調度方式、生命周期的請關注3,4,5部分。
1.傳統cpu調度背景: 應用不能既當運動員,又當裁判員
操作系統分為用戶態和內核態(或者叫用戶空間和內核空間), 那內核態究竟是什么呢?
計算機是多進程操作系統,多個用戶進程同時都在利用顯性的物理資源:cpu、內存、io, 不能讓用戶進程既當運動員,又當裁判員。
於是操作系統抽象出了內核對象,用於實現進程同步、進程通信、內存管理等系統資源的分配和管理。操作系統維護着內核對象表,每個內核對象都在這張表上記錄了狀態和屬性。
內核對象由內核態代碼創建,用戶可以系統調用、api函數來操作和管理內核對象,與此同時用戶進程墜入內核態。
eg: C# Thread類表示一個線程(是托管代碼),而線程是內核對象,故C#使用Thread創建線程,實際是由操作系統創建了一個內核對象來實現線程。
畫外音:
os作為裁判員,內核態是一種特殊的調度程序,統籌計算機的硬件資源,例如協調CPU資源、分配內存資源、並且提供穩定的環境供應用程序運行`。

2. 線程切換的時機和代價?
- 線程是cpu調度的基本單位,進程是資源占有的基本單位。
- 線程中的代碼是在用戶態運行,而線程的調度是在內核態。
| ① 自發性上下文切換 | 線程受用戶代碼指令導致的切出 |
|---|---|
| Thread.sleep() | 線程主動休眠 |
| object.wait() | 線程等待鎖 |
| Thread.yield() | 當前線程主動讓出CPU,如果有其他就緒線程就執行其他線程,如果沒有則繼續當前線程 |
| oThread.join() | 阻塞發起調用的線程,直到oThread執行完畢 |
| ② 非自發性上下文切換: 來自內核線程調度器管控 |
|---|
| 線程的時間片用完 ,cpu時間片雨露均沾 |
| 高優先級線程搶占 |
| 虛擬機的垃圾回收動作 |
線程上下文切換的代價是高昂的:上下文切換的延遲取決於不同的因素,大概是50到100ns左右,考慮到硬件平均在每個核心上每ns執行12條指令,那么一次上下文切換可能會花費600到1200條指令的延遲時間。
| ① 直接開銷 |
|---|
| 保存/恢復上下文所需的開銷 |
| 線程調度器調度線程的開銷 |
| ② 間接開銷 |
|---|
| 重新加載高速緩存 |
| 上下文切換可能導致 一級緩存被沖刷,寫入下一級緩存或內存 |
3. go的協程輕量級體現在哪?
如上面所述,常規線程切換會導致用戶態程序和內核態調度程序的切換。
大佬們思考了另外一個思路:將調度維持在用戶態。
go推出了用戶態runtime實現的輕量級線程goroutine, go將goroutine的調度維持在用戶態, 這是由GPM中的P Process來完成的, 功能類比於常規的操作系統線程調度器。
(1) 上下文切換代價小: P 是G、M之間的橋梁,調度器對於goroutine的調度,很明顯也會有切換,這個切換是很輕量的:
只涉及
- PC (程序計數器,標記當前執行的代碼的位置)
- SP (當前執行的函數堆棧棧頂指針)
- BP 三個寄存器的值的修改;
而對比線程的上下文切換則需要陷入內核模式、以及16個寄存器的刷新。
(2) 內存占用小: 線程棧空間通常是2M, Goroutine棧空間最小是2k, golang可以輕松支持1w+的goroutine運行,而線程數量到達1k(此時基本就達到單機瓶頸了), 內存占用就到2G。
4. GO 協程調度時機
通常情況下:
go關鍵字產生的一個常規執行邏輯的goroutine,由P調度進隊列,等到被M執行完之后,調度器P繼續從本地隊列調出G給到M執行,若沒有則從其他隊列/全局隊列偷取G。

存在P的本地隊列、全局隊列、parked goroutines(阻塞的協程)
Go scheduler is not a preemptive scheduler but a cooperating scheduler. 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. The followings are the opportunities for scheduling:
GO調度器是協作式,非搶占式,這意味着調度器是基於用戶空間的事件來做出 調度決策。下面是調度的時機。
① The use of the keyword go
This is how we create a new goroutine, scheduler gain an opportunity when a new goroutine was created.
② Synchronization and Orchestration
If an mutex, or channel operation call will cause the Goroutine to block, the scheduler can context-switch a new Goroutine to run. Once the Goroutine can run again, it will be re-queued automatically.
③ System calls
Including async and sync system calls, go has different way to deal with them. With async type like network request, a network poller would be used, goroutine that might block is moved to net poller, let the proccesor can execute the next one.
With sync type like file I/O, the current pair of G and M will be seperated from G, P, M model. Meawhile, a new machine would be created in order to keep the original G, P, M model working, and the block goroutine would be take back while system call finished.
④ Garbage collection
Since the GC runs using its own set of Goroutines, those Goroutines need time on an M to run, scheduler needs a opportunitt to handle that
系統調用(system call)又分為兩種,同步和異步系統調用。
同步和異步系統調用是指在系統調用過程中,用戶程序和操作系統之間的交互方式。
同步系統調用(Synchronous System Call)是指用戶程序在進行系統調用時,必須等待系統完成操作並返回結果后才能繼續執行。在進行同步系統調用時,用戶程序會阻塞,直到系統調用完成。同步系統調用的優點是操作簡單,易於實現,但缺點是會造成用戶程序的阻塞,影響程序的響應性能。
異步系統調用(Asynchronous System Call)是指用戶程序在進行系統調用時,可以在系統調用的同時繼續執行其他操作,無需等待系統調用的完成。在進行異步系統調用時,用戶程序不會阻塞,而是會通過回調函數等機制在系統調用完成后再進行處理。異步系統調用的優點是可以提高程序的並發性和響應性能,但缺點是實現較為復雜。
在實際的系統編程中,通常需要根據具體的需求和場景選擇使用同步或異步系統調用。例如,在需要進行文件IO等較為簡單的操作時,可以使用同步系統調用;在需要進行網絡通信等較為復雜的操作時,可以使用異步系統調用,以提高程序的並發性和響應性能。
5. goroutine生命周期
Go 必須對每個運行着的線程上的 Goroutine 進行調度和管理。
這個調度的功能被委托給了一個叫做 g0 的特殊的 goroutine, g0 是每個 OS 線程創建的第一個goroutine。
g0為新創建的goroutine
- 設置PC/SP寄存器
- 更新goroutine內部的 ID和status

Go 需要一種方法來了解 goroutine的結束。
這個控制是在 goroutine 的創建過程中,在創建 goroutine 時,Go在開啟實際go執行片段之前,通過PC寄存器設置了SP寄存器的首個函數棧幀(名為goexit的函數),這個技巧強制goroutine在結束工作后調用函數goexit。
newg.sched.pc = funcPC(goexit) + sys.PCQuantum
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)
//------------- ......
// adjust Gobuf as if it executed a call to fn with context ctxt
// and then did an immediate gosave.
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
sp := buf.sp
...
sp -= sys.PtrSize
*(*uintptr)(unsafe.Pointer(sp)) = buf.pc
buf.sp = sp
buf.pc = uintptr(fn)
buf.ctxt = ctxt
}
https://www.sobyte.net/post/2022-02/where-is-goexit-from/
5. goroutine的常規實踐
-
在一個函數前放置go即可開啟一個go的 協程,如其他函數一樣,可以有形參,不過函數返回值會被忽略。
-
在golang中, 大家習慣使用一個封裝了業務邏輯的閉包來啟動一個goroutine, 該閉包負責管理並發的數據和狀態,例如閉包從信道中讀取數據並傳遞給業務邏輯, 業務邏輯完全不知道它是在一個goroutine中,
然后函數的結果被寫回另外一個信道,這種職責分離使代碼模塊化、可測試,並使得api調用簡單,無需關注並發問題。
func process(val int) int {
}
func runningConcurrently(in <-chan int, out chan <- int) {
go func() { // 業務邏輯協程
for val := range in {
result := process(val)
out<- result // 利用信道來在協程間通信
}
}
}
