0.1、索引
https://blog.waterflow.link/articles/1662974432717
1、進程
一個進程包含可以由任何進程分配的公共資源。這些資源包括但不限於內存地址空間、文件句柄、設備和線程。
一個進程會包含下面一些屬性:
- Process ID:進程ID
- Process State:進程狀態
- Process Priority:進程優先級
- Program Counter:程序計數器
- General purpose register:通用寄存器
- List of open files:打開的文件列表
- List of open devices:打開的設備列表
- Protection information:保護信息
- List of the child process:子進程列表
- Pending alarms:待定警告
- Signals and signal handlers:信號和信號處理程序
- Accounting information:記賬信息
2、線程
線程是輕量級的進程,一個線程將在進程內的所有線程之間共享進程的資源,如代碼、數據、全局變量、文件和內存地址空間。但是棧和寄存器不會共享,每個線程都有自己的棧和寄存器
線程的優點:
- 提高系統的吞吐量
- 提高響應能力
- 由於屬性更少,上下文切換更快
- 多核CPU的有效利用
- 資源共享(代碼、數據、地址空間、文件、全局變量)
3、用户級線程
用户級線程也稱為綠色線程,如:C 中的coroutine、Go 中的 goroutine 和 Ruby 中的 Fiber
該進程維護一個內存地址空間,處理文件,以及正在運行的應用程序的設備和線程。操作系統調度程序決定哪些線程將在任何給定的 CPU 上接收時間
因此,與耗時和資源密集型的進程創建相比,在一個進程中創建多個用户線程(goroutine)效率更高。
4、goroutine
在Go中用户級線程被稱作Goroutine,在創建goroutine時需要做到:
- 易於創建
- 輕量級
- 併發執行
- 可擴展
- 無限堆棧(最大堆棧大小在 64 位上為 1 GB,在 32 位上為 250 MB。)
- 處理阻塞調用
- 高效 (work stealing)
其中阻塞調用可能是下面一些原因:
- 在channel中收發數據
- 網絡IO調用
- 阻塞的系統調用
- 計時器
- 互斥操作(Mutex)
為什麼go需要調度goroutine?
Go 使用稱為 goroutine 的用户級線程,它比內核級線程更輕且更便宜。 例如,創建一個初始 goroutine 將佔用 2KB 的堆棧大小,而內核級線程將佔用 8KB 的堆棧大小。 還有,goroutine 比內核線程有更快的創建、銷燬和上下文切換,所以 go 調度器 需要退出來調度 goroutine。OS 不能調度用户級線程,OS 只知道內核級線程。 Go 調度器 將 goroutine 多路複用到內核級線程,這些線程將在不同的 CPU 內核上運行
什麼時候會調度goroutine?
如果有任何操作應該或將會影響 goroutine 的執行,比如 goroutine 的啓動、等待執行和阻塞調用等……
go調度 如何將 goroutine 多路複用到內核線程中?
1、1:1調度(1個線程對應一個goroutine)
- 並行執行(每個線程可以在不同的內核上運行)
- 可以工作但是代價太高
- 內存至少〜32k(用户堆棧和內核堆棧的內存)
- 性能問題(系統調用)
- 沒有無限堆棧
2、N:1調度(在單個內核線程上多路複用所有 goroutine)
- 沒有並行性(即使有更多 CPU 內核可用,也只能使用單個 CPU 內核)
我們看下下面的例子,只為go分配了1個processer去處理2個goroutine:
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
// 分配 1 個邏輯處理器供調度程序使用
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
wg.Add(2)
fmt.Println("Starting Goroutines")
// 開一個go協程打印字母
go func() {
defer wg.Done()
time.Sleep(time.Second)
// 打印3次字母
for count := 0; count < 3; count++ {
for ch := 'a'; ch < 'a'+26; ch++ {
fmt.Printf("%c ", ch)
}
fmt.Println()
}
}()
// 開一個go協程打印數字
go func() {
defer wg.Done()
// 打印3次數字
for count := 0; count < 3; count++ {
for n := 1; n <= 26; n++ {
fmt.Printf("%d ", n)
}
fmt.Println()
}
}()
// 等待返回
fmt.Println("Waiting To Finish")
wg.Wait()
fmt.Println("\nTerminating Program")
}
看下結果:
go run main.go
Starting Goroutines
Waiting To Finish
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
a b c d e f g h i j k l m n o p q r s t u v w x y z
a b c d e f g h i j k l m n o p q r s t u v w x y z
a b c d e f g h i j k l m n o p q r s t u v w x y z
Terminating Program
可以看到這倆個goroutine是串行執行的,要麼先完成第一個goroutine,要麼先完成第二個goroutine,並不是併發執行的。
那如何去實現併發執行呢?
我們同樣設置runtime.GOMAXPROCS為1,但是在goroutine中我們在不同的時機加入阻塞goroutine的時間函數time.Sleep,我們看下會有什麼不同的結果。
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
// 分配 1 個邏輯處理器供調度程序使用
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
wg.Add(2)
fmt.Println("Starting Goroutines")
// 開一個go協程打印字母
go func() {
defer wg.Done()
time.Sleep(time.Second)
// 打印3次字母
for count := 0; count < 3; count++ {
for ch := 'a'; ch < 'a'+26; ch++ {
if count == 0 {
time.Sleep(10 * time.Millisecond)
}
if count == 1 {
time.Sleep(30 * time.Millisecond)
}
if count == 2 {
time.Sleep(50 * time.Millisecond)
}
fmt.Printf("%c ", ch)
}
fmt.Println()
}
}()
// 開一個go協程打印數字
go func() {
defer wg.Done()
// 打印3次數字
for count := 0; count < 3; count++ {
for n := 1; n <= 26; n++ {
if count == 0 {
time.Sleep(20 * time.Millisecond)
}
if count == 1 {
time.Sleep(40 * time.Millisecond)
}
if count == 2 {
time.Sleep(60 * time.Millisecond)
}
fmt.Printf("%d ", n)
}
fmt.Println()
}
}()
// 等待返回
fmt.Println("Waiting To Finish")
wg.Wait()
fmt.Println("\nTerminating Program")
}
看下結果:
go run main.go
Starting Goroutines
Waiting To Finish
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
1 2 3 4 5 6 7 8 9 10 11 a 12 b c d e 13 f g h i 14 j k l m 15 n o p 16 q r s t 17 u v w x 18 y z
19 a b 20 c 21 d 22 e f 23 g 24 h 25 i j 26
k l 1 m n 2 o p 3 q r 4 s t 5 u v 6 w x 7 y z
8 a 9 b 10 c 11 d 12 e f 13 g 14 h 15 i 16 j 17 k l 18 m 19 n 20 o 21 p 22 q r 23 s 24 t 25 u 26
v w x y z
Terminating Program
通過上面的結果我們可以看到,當goroutine1阻塞時,go調度器會調度goroutine2執行。
我們可以得出:
- 即使我們將 runtime.GOMAXPROCS(1) 設置為 1,程序也在併發運行
- Running 狀態的 Goroutine 數量最大為 1,Block Goroutine 可以多於一個,其他所有 Goroutine 都處於 Runnable 狀態
3、線程池
- 在需要時創建一個線程,這意味着如果有 goroutine 要運行但所有其他線程都忙,則創建一個線程
- 一旦線程完成其執行而不是銷燬重用它
- 這可以更快的創建goroutine,因為我們可以重用線程
- 但是還有更多的內存消耗,性能問題,並且沒有無限堆棧。
4、M: N 線程共享運行隊列調度(GMP)
- M代表系統線程的數量
- N代表goroutine的數量
- goroutine 的創建成本很低,我們可以完全控制 goroutine 的整個生命週期,因為它是在用户空間中創建的
- 創建一個操作系統線程很昂貴,我們無法控制它,但是使用多個線程我們可以實現並行
- 在這個模型中,多個 goroutine 被多路複用到內核線程中
我們上面提到過導致goroutine阻塞調用可能是下面一些原因:
- 在channel中收發數據
- 網絡IO調用
- 阻塞的系統調用
- 計時器
- 互斥操作(Mutex)
下面看一些goroutine阻塞的例子:
package main
import (
"time"
"fmt"
"sync"
"os"
"net/http"
"io/ioutil"
)
// 全局變量
var worker int
func writeToFile(wg *sync.WaitGroup,){
defer wg.Done()
file, _ := os.OpenFile("file.txt", os.O_RDWR|os.O_CREATE, 0755) // 系統調用阻塞
resp, _ := http.Get("https://blog.waterflow.link/articles/1662706601117") // 網絡IO阻塞
body, _ := ioutil.ReadAll(resp.Body) // 系統調用阻塞
file.WriteString(string(body))
}
func workerCount(wg *sync.WaitGroup, m *sync.Mutex, ch chan string) {
// Lock() 給共享資源上鎖
// 獨佔訪問狀態,
// 增加worker的值,
// Unlock() 釋放鎖
m.Lock() // Mutex阻塞
worker = worker + 1
ch <- fmt.Sprintf("Worker %d is ready",worker)
m.Unlock()
// 返回, 通知WaitGroup完成
wg.Done()
}
func printWorker(wg *sync.WaitGroup, done chan bool, ch chan string){
for i:=0;i<100;i++{
fmt.Println(<-ch) // Channel阻塞
}
wg.Done()
done <-true
}
func main() {
ch :=make(chan string)
done :=make(chan bool)
var mu sync.Mutex
var wg sync.WaitGroup
for i:=1;i<=100;i++{
wg.Add(1)
go workerCount(&wg,&mu,ch)
}
wg.Add(2)
go writeToFile(&wg)
go printWorker(&wg,done,ch)
wg.Wait()
<-done // Channel阻塞
<-time.After(1*time.Second) // Timer阻塞
close(ch)
close(done)
}
下面我們看看go調度器在上面這些例子中是如何工作的:
- 如果一個 goroutine 在通道上被阻塞,則通道有等待隊列,所有阻塞的 goroutine 都列在等待隊列中,並且很容易跟蹤。 在阻塞調用之後,它們將被放入 schedular 的全局運行隊列中,OS Thread 將再次按照 FIFO 的順序選擇 goroutine。
- M1,M2,M3嘗試從全局G隊列中獲取G
- M1獲取鎖並拿到G1,然後釋放鎖
- M3獲取鎖拿到G2,然後釋放鎖
- M2獲取鎖拿到G3,然後釋放鎖
- G1在ch1的channel中阻塞,然後添加到ch1的等待隊列。導致M1空閒
- M1不能閒着,從全局隊列獲取鎖拿到G4,然後釋放鎖
- G3阻塞在ch2的channel中,然後被放到ch2的等待隊列。導致M2空閒
- M2獲取鎖拿到G5,然後釋放鎖
- 此時G3在ch2結束阻塞,被放到全局隊列尾部等待執行
- G1在ch1結束阻塞,被放到全局隊列尾部等待執行
- G4,G5,G2執行完成
- M1,M2,M3重複步驟1-4
- 互斥鎖、定時器和網絡 IO 使用相同的機制
- 如果一個 goroutine 在系統調用中被阻塞,那麼情況就不同了,因為我們不知道內核空間發生了什麼。 通道是在用户空間中創建的,因此我們可以完全控制它們,但在系統調用的情況下,我們沒法控制它們。
- 阻塞系統調用不僅會阻塞 goroutine 還會阻塞內核線程。
- 假設一個 goroutine 被安排在一個內核線程上的系統調用,當一個內核線程完成執行時,它將喚醒另一個內核線程(線程重用),該線程將拾取另一個 goroutine 並開始執行它。 這是一個理想的場景,但在實際情況下,我們不知道系統調用將花費多少時間,因此我們不能依賴內核線程來喚醒另一個線程,我們需要一些代碼級邏輯來決定何時 在系統調用的情況下喚醒另一個線程。 這個邏輯在 golang 中實現為 runtime·entersyscall()和 runtime·exitsyscall()。 這意味着內核線程的數量可以超過核心的數量。
-
當對內核進行系統調用時,它有兩個關鍵點,一個是進入時機,另一個是退出時機。
- M1,M2試着從全局隊列拿G
- M1獲取鎖並拿到G1,然後釋放鎖
- M2獲取鎖並拿到G2,然後釋放鎖
- M2阻塞在系統調用,沒有可用的內核線程,所以go調度器創建一個新的線程M3
- M3獲取鎖並拿到G3,然後釋放鎖
- 此時M2結束阻塞狀態,重新把G2放到全局隊列(G2由阻塞變為可執行狀態)。M2雖然是空閒狀態,但是go調度器不會銷燬它,而是自旋發現新的可執行的goroutine。
- G1,G3執行結束
- M1,M3重複步驟1-3
操作系統可以支持多少內核線程?
在 Linux 內核中,此參數在文件 /proc/sys/kernel/threads-max 中定義,該文件用於特定內核。
sh:~$ cat /proc/sys/kernel/threads-max 94751
這裏輸出94751表示內核最多可以執行94751個線程
每個 Go 程序可以支持多少個 goroutine?
調度中沒有內置對 goroutine 數量的限制。
每個 GO程序 可以支持多少個內核線程?
默認情況下,運行時將每個程序限制為最多 10,000 個線程。可以通過調用 runtime/debug 包中的 SetMaxThreads 函數來更改此值。
總結:
- 內核線程數可以多於內核數
- 輕量級 goroutine
- 處理 IO 和系統調用
- goroutine並行執行
- 不可擴展(所有內核級線程都嘗試使用互斥鎖訪問全局運行隊列。因此,由於競爭,這不容易擴展)
5、M:N 線程分佈式運行隊列調度器
為了解決每個線程同時嘗試訪問互斥鎖的可擴展問題,維護每個線程的本地運行隊列
- 每個線程狀態(本地運行隊列)
- 仍然有一個全局運行隊列
- M1,M2,M3,M4掃描本地可運行隊列
- M1,M2,M3,M4從各自的本地隊列取出G4,G6,G1,G3
從上面的動圖可以看到:
- 從本地隊列拿G是不需要加鎖的
- 可運行 goroutine 的全局隊列需要鎖
結論:
- 輕量級 goroutine
- 處理 IO 和 SystemCalls
- goroutine 並行執行
- 可擴展
- 高效
如果線程數大於內核數,那麼會有什麼問題呢?
在分佈式運行隊列調度中,我們知道每個線程都有自己的本地運行隊列,其中包含有關接下來將執行哪個 goroutine 的信息。 同樣由於系統調用,線程數會增加,並且大多數時候它們的本地運行隊列是空的。 因此,如果線程數大於核心數,則每個線程必須掃描所有線程本地運行隊列,並且大部分時間它們是空的,所以如果線程過多,這個過程是耗時的並且解決方案 效率不高,因此我們需要將線程掃描限制為使用 M:P:N 線程模型求解的常數。
6、M:P: N 線程
- P 代表處理器,它是運行 go 代碼所需的資源。 處理器結構詳細信息 https://github.com/golang/go/...
- M 代表工作線程或機器。 機器線程結構詳細信息 https://github.com/golang/go/...
- G 代表 goroutine。 Goroutine 結構細節 https://github.com/golang/go/...
- 通常,P的數量與邏輯處理器的數量相同
- 邏輯處理器與物理處理器不同(比如我的mac邏輯處理器是8,無力處理器是4)
- 在啓動main goroutine之前創建P
如何檢查邏輯處理器的數量?
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println(runtime.NumCPU())
}
分佈式 M:P:N 調度例子
- M1,M2各自掃描P1,P2的隊列
- M1,M2從各自的P1,P2中取出G3,G1執行
在系統調用期間執行P的切換
- M1,M2各自掃描P1,P2的隊列
- M1,M2從各自的P1,P2中取出G3,G1執行
- G1即將進入系統調用,所以在這之前G1會喚醒另一個線程M3,並將P2切換到M3
- M3掃描P2並取出G2運行
- 一旦G1變為非阻塞,它將被推送到全局隊列等待運行
在work-stealing期間,只需要掃描固定數量的隊列,因為邏輯處理器的數量是有限的。
如何選擇下一個要運行的 goroutine ?
Go 調度器 將按以下順序檢查以選擇下一個要執行的 goroutine
-
本地運行隊列
-
全局運行隊列
- M1,M2,M3各自掃描本地隊列P1,P2,P3
- M1,M2,M3各自從P1,P2,P3取出G3,G1,G5
- G5完成,M3掃描本地隊列P3發現空,然後掃描全局隊列
- M3將從全局隊列獲取一定數量的G(G6,G7),保存到本地隊列P3
- 現在M3從本地隊列P3取出G6執行
-
Network poller
- M1,M2,M3各自掃描本地隊列P1,P2,P3
- M1,M2,M3各自從P1,P2,P3取出G3,G1,G6
- G6執行完成,M3掃描P3發現是空的,然後掃描全局隊列
- 但是全局隊列也是空的,然後就檢查網絡輪詢中已就緒的G
- 網絡輪詢中有一個已就緒的G2,所以M3取出G2並執行
-
Work Stealing
- M1,M2,M3各自掃描本地隊列P1,P2,P3
- M1,M2,M3各自從P1,P2,P3取出G3,G1,G6
- G6執行完成,M3掃描P3發現是空的,然後掃描全局隊列
- 但是全局隊列也是空的,然後就檢查網絡輪詢中已就緒的G
- 但是網絡輪詢中沒有已就緒的G,所以M3隨機的從其他P中竊取一半的G到P3
- 如果隨機選中的P中沒有要執行的G,就會重試4次,從其他P獲取
總結:
- 輕量級 goroutine
- 處理 IO 和系統調用
- goroutine 的並行執行
- 可擴展
- 高效/工作竊取
Go 調度的侷限性
- FIFO 對局部性原則不利
- 沒有 goroutine 優先級的概念(不像 Linux 內核)
- 沒有強搶佔 -> 沒有強公平或延遲保證
- 它沒有意識到系統拓撲 -> 沒有真實的位置。有一箇舊的 NUMA 感知調度程序提案。此外,建議使用 LIFO 隊列,這樣 CPU 內核緩存中更有可能有數據。
翻譯自:
https://mukeshpilaniya.github...