博客 / 詳情

返回

goroutine調度

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
http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1662974591.png

該進程維護一個內存地址空間,處理文件,以及正在運行的應用程序的設備和線程。操作系統調度程序決定哪些線程將在任何給定的 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。
    http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1662974630.gif
  1. M1,M2,M3嘗試從全局G隊列中獲取G
  2. M1獲取鎖並拿到G1,然後釋放鎖
  3. M3獲取鎖拿到G2,然後釋放鎖
  4. M2獲取鎖拿到G3,然後釋放鎖
  5. G1在ch1的channel中阻塞,然後添加到ch1的等待隊列。導致M1空閒
  6. M1不能閒着,從全局隊列獲取鎖拿到G4,然後釋放鎖
  7. G3阻塞在ch2的channel中,然後被放到ch2的等待隊列。導致M2空閒
  8. M2獲取鎖拿到G5,然後釋放鎖
  9. 此時G3在ch2結束阻塞,被放到全局隊列尾部等待執行
  10. G1在ch1結束阻塞,被放到全局隊列尾部等待執行
  11. G4,G5,G2執行完成
  12. M1,M2,M3重複步驟1-4
  • 互斥鎖、定時器和網絡 IO 使用相同的機制
  • 如果一個 goroutine 在系統調用中被阻塞,那麼情況就不同了,因為我們不知道內核空間發生了什麼。 通道是在用户空間中創建的,因此我們可以完全控制它們,但在系統調用的情況下,我們沒法控制它們。
  • 阻塞系統調用不僅會阻塞 goroutine 還會阻塞內核線程。
  • 假設一個 goroutine 被安排在一個內核線程上的系統調用,當一個內核線程完成執行時,它將喚醒另一個內核線程(線程重用),該線程將拾取另一個 goroutine 並開始執行它。 這是一個理想的場景,但在實際情況下,我們不知道系統調用將花費多少時間,因此我們不能依賴內核線程來喚醒另一個線程,我們需要一些代碼級邏輯來決定何時 在系統調用的情況下喚醒另一個線程。 這個邏輯在 golang 中實現為 runtime·entersyscall()和 runtime·exitsyscall()。 這意味着內核線程的數量可以超過核心的數量。
  • 當對內核進行系統調用時,它有兩個關鍵點,一個是進入時機,另一個是退出時機。

    http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1662974658.gif

    1. M1,M2試着從全局隊列拿G
    2. M1獲取鎖並拿到G1,然後釋放鎖
    3. M2獲取鎖並拿到G2,然後釋放鎖
    4. M2阻塞在系統調用,沒有可用的內核線程,所以go調度器創建一個新的線程M3
    5. M3獲取鎖並拿到G3,然後釋放鎖
    6. 此時M2結束阻塞狀態,重新把G2放到全局隊列(G2由阻塞變為可執行狀態)。M2雖然是空閒狀態,但是go調度器不會銷燬它,而是自旋發現新的可執行的goroutine。
    7. G1,G3執行結束
    8. 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 函數來更改此值。

總結:

  1. 內核線程數可以多於內核數
  2. 輕量級 goroutine
  3. 處理 IO 和系統調用
  4. goroutine並行執行
  5. 不可擴展(所有內核級線程都嘗試使用互斥鎖訪問全局運行隊列。因此,由於競爭,這不容易擴展)

5、M:N 線程分佈式運行隊列調度器

為了解決每個線程同時嘗試訪問互斥鎖的可擴展問題,維護每個線程的本地運行隊列

  • 每個線程狀態(本地運行隊列)
  • 仍然有一個全局運行隊列
    http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1662974686.gif
  1. M1,M2,M3,M4掃描本地可運行隊列
  2. M1,M2,M3,M4從各自的本地隊列取出G4,G6,G1,G3

從上面的動圖可以看到:

  • 從本地隊列拿G是不需要加鎖的
  • 可運行 goroutine 的全局隊列需要鎖

結論:

  1. 輕量級 goroutine
  2. 處理 IO 和 SystemCalls
  3. goroutine 並行執行
  4. 可擴展
  5. 高效

如果線程數大於內核數,那麼會有什麼問題呢?

在分佈式運行隊列調度中,我們知道每個線程都有自己的本地運行隊列,其中包含有關接下來將執行哪個 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 調度例子
http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1662974709.gif

  1. M1,M2各自掃描P1,P2的隊列
  2. M1,M2從各自的P1,P2中取出G3,G1執行

在系統調用期間執行P的切換
http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1662974724.gif

  1. M1,M2各自掃描P1,P2的隊列
  2. M1,M2從各自的P1,P2中取出G3,G1執行
  3. G1即將進入系統調用,所以在這之前G1會喚醒另一個線程M3,並將P2切換到M3
  4. M3掃描P2並取出G2運行
  5. 一旦G1變為非阻塞,它將被推送到全局隊列等待運行

在work-stealing期間,只需要掃描固定數量的隊列,因為邏輯處理器的數量是有限的。

如何選擇下一個要運行的 goroutine ?

Go 調度器 將按以下順序檢查以選擇下一個要執行的 goroutine

  • 本地運行隊列

    http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1662974749.gif

  • 全局運行隊列

    http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1662974771.gif

    1. M1,M2,M3各自掃描本地隊列P1,P2,P3
    2. M1,M2,M3各自從P1,P2,P3取出G3,G1,G5
    3. G5完成,M3掃描本地隊列P3發現空,然後掃描全局隊列
    4. M3將從全局隊列獲取一定數量的G(G6,G7),保存到本地隊列P3
    5. 現在M3從本地隊列P3取出G6執行
  • Network poller

    http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1662974790.gif

    1. M1,M2,M3各自掃描本地隊列P1,P2,P3
    2. M1,M2,M3各自從P1,P2,P3取出G3,G1,G6
    3. G6執行完成,M3掃描P3發現是空的,然後掃描全局隊列
    4. 但是全局隊列也是空的,然後就檢查網絡輪詢中已就緒的G
    5. 網絡輪詢中有一個已就緒的G2,所以M3取出G2並執行
  • Work Stealing

    http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1662974803.gif

    1. M1,M2,M3各自掃描本地隊列P1,P2,P3
    2. M1,M2,M3各自從P1,P2,P3取出G3,G1,G6
    3. G6執行完成,M3掃描P3發現是空的,然後掃描全局隊列
    4. 但是全局隊列也是空的,然後就檢查網絡輪詢中已就緒的G
    5. 但是網絡輪詢中沒有已就緒的G,所以M3隨機的從其他P中竊取一半的G到P3
    6. 如果隨機選中的P中沒有要執行的G,就會重試4次,從其他P獲取

總結:

  • 輕量級 goroutine
  • 處理 IO 和系統調用
  • goroutine 的並行執行
  • 可擴展
  • 高效/工作竊取

Go 調度的侷限性

  • FIFO 對局部性原則不利
  • 沒有 goroutine 優先級的概念(不像 Linux 內核)
  • 沒有強搶佔 -> 沒有強公平或延遲保證
  • 它沒有意識到系統拓撲 -> 沒有真實的位置。有一箇舊的 NUMA 感知調度程序提案。此外,建議使用 LIFO 隊列,這樣 CPU 內核緩存中更有可能有數據。

翻譯自:

https://mukeshpilaniya.github...

user avatar venmos 頭像 guyan0319 頭像
2 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.