博客 / 詳情

返回

Golang 協程/線程/進程 區別以及 GMP 詳解

Golang 協程/線程/進程 區別詳解

轉載請註明來源:https://janrs.com/mffp

概念

進程 每個進程都有自己的獨立內存空間,擁有自己獨立的地址空間、獨立的堆和棧,既不共享堆,亦不共享棧。一個程序至少有一個進程,一個進程至少有一個線程。進程切換隻發生在內核態。

線程 線程擁有自己獨立的棧和共享的堆,共享堆,不共享棧,是由操作系統調度,是操作系統調度(CPU調度)執行的最小單位。對於進程和線程,都是有內核進行調度,有 CPU 時間片的概念, 進行搶佔式調度。內核由系統內核進行調度, 系統為了實現併發,會不斷地切換線程執行, 由此會帶來線程的上下文切換。

協程 協程線程一樣共享堆,不共享棧,協程是由程序員在協程的代碼中顯示調度。協程(用户態線程)是對內核透明的, 也就是系統完全不知道有協程的存在, 完全由用户自己的程序進行調度。在棧大小分配方便,且每個協程佔用的默認佔用內存很小,只有 2kb ,而線程需要 8mb,相較於線程,因為協程是對內核透明的,所以棧空間大小可以按需增大減小。

併發 多線程程序在單核上運行
並行 多線程程序在多核上運行

協程與線程主要區別是它將不再被內核調度,而是交給了程序自己而線程是將自己交給內核調度,所以golang中就會有調度器的存在。


詳解

進程

在計算機中,單個 CPU 架構下,每個 CPU 同時只能運行一個任務,也就是同時只能執行一個計算。如果一個進程跑着,就把唯一一個 CPU 給完全佔住,顯然是不合理的。而且很大概率上,CPU 被阻塞了,不是因為計算量大,而是因為網絡阻塞。如果此時 CPU 一直被阻塞着,其他進程無法使用,那麼計算機資源就是浪費了。
這就出現了多進程調用了。多進程就是指計算機系統可以同時執行多個進程,從一個進程到另外一個進程的轉換是由操作系統內核管理的,一般是同時運行多個軟件。

線程

有了多進程,為什麼還要線程?原因如下:

  • 進程間的信息難以共享數據,父子進程並未共享內存,需要通過進程間通信(IPC),在進程間進行信息交換,性能開銷較大。
  • 創建進程(一般是調用 fork 方法)的性能開銷較大。

在一個進程內,可以設置多個執行單元,這個執行單元都運行在進程的上下文中,共享着同樣的代碼和全局數據,由於是在全局共享的,就不存在像進程間信息交換的性能損耗,所以性能和效率就更高了。這個運行在進程中的執行單元就是線程。

協程

官方的解釋:鏈接:goroutines説明

Goroutines 是使併發易於使用的一部分。 這個想法已經存在了一段時間,就是將獨立執行的函數(協程)多路複用到一組線程上。 當協程阻塞時,例如通過調用阻塞系統調用,運行時會自動將同一操作系統線程上的其他協程移動到不同的可運行線程,這樣它們就不會被阻塞。 程序員看不到這些,這就是重點。 我們稱之為 goroutines 的結果可能非常便宜:除了堆棧的內存之外,它們的開銷很小,只有幾千字節。

為了使堆棧變小,Go 的運行時使用可調整大小的有界堆棧。 一個新創建的 goroutine 被賦予幾千字節,這幾乎總是足夠的。 如果不是,運行時會自動增加(和縮小)用於存儲堆棧的內存,從而允許許多 goroutine 存在於適度的內存中。 每個函數調用的 CPU 開銷平均約為三個廉價指令。 在同一個地址空間中創建數十萬個 goroutine 是很實際的。 如果 goroutines 只是線程,系統資源會以更少的數量耗盡。

從官方的解釋中可以看到,協程是通過多路複用到一組線程上,所以本質上,協程就是輕量級的線程。但是必須要區分的一點是,協程是用用户態的,進程跟線程都是內核態,這點非常重要,這也是協程為什麼高效的原因。

協程的優勢如下:

  • 節省 CPU:避免系統內核級的線程頻繁切換,造成的 CPU 資源浪費。協程是用户態的線程,用户可以自行控制協程的創建於銷燬,極大程度避免了系統級線程上下文切換造成的資源浪費。
  • 節約內存:在 64 位的 Linux 中,一個線程需要分配 8MB 棧內存和 64MB 堆內存,系統內存的制約導致我們無法開啓更多線程實現高併發。而在協程編程模式下,只需要幾千字節(執行Go協程只需要極少的棧內存,大概4~5KB,默認情況下,線程棧的大小為1MB)可以輕鬆有十幾萬協程,這是線程無法比擬的。
  • 開發效率:使用協程在開發程序之中,可以很方便的將一些耗時的 IO 操作異步化,例如寫文件、耗時 IO 請求等。並且它們並不是被操作系統所調度執行,而是程序員手動可以進行調度的。
  • 高效率:協程之間的切換髮生在用户態,在用户態沒有時鐘中斷,系統調用等機制,因此效率高。

Golang GMP 調度器

注: 以下相關知識摘自劉丹冰(AceLd)的博文:[[Golang三關-典藏版] Golang 調度器 GMP 原理與調度全分析](https://learnku.com/articles/41728 "[Golang三關-典藏版] Golang 調度器 GMP 原理與調度全分析")

簡介

  • G 表示:goroutine,即 Go 協程,每個 go 關鍵字都會創建一個協程。
  • M 表示:thread 內核級線程,所有的 G 都要放在 M 上才能運行。
  • P 表示:processor 處理器,調度 GM 上,其維護了一個隊列,存儲了所有需要它來調度的G

Goroutine 調度器 POS 調度器是通過 M 結合起來的,每個 M 都代表了 1 個內核線程,OS 調度器負責把內核線程分配到 CPU 的核上執行,

線程和協程的映射關係

在上面的 Golang 官方關於協程的解釋中提到:

將獨立執行的函數(協程)多路複用到一組線程上。 當協程阻塞時,例如通過調用阻塞系統調用,運行時會自動將同一操作系統線程上的其他協程移動到不同的可運行線程,這樣它們就不會被阻塞。

也就是説,協程的執行是需要通過線程來先實現的。下圖表示的映射關係:

在協程和線程的映射關係中,有以下三種:

  • N:1 關係
  • 1:1 關係
  • M:N 關係

N:1 關係

N 個協程綁定 1 個線程,優點就是協程在用户態線程即完成切換,不會陷入到內核態,這種切換非常的輕量快速。但也有很大的缺點,1 個進程的所有協程都綁定在 1 個線程上。

缺點:

  • 某個程序用不了硬件的多核加速能力
  • 一旦某協程阻塞,造成線程阻塞,本進程的其他協程都無法執行了,根本就沒有併發的能力了。

1:1 關係

1 個協程綁定 1 個線程,這種最容易實現。協程的調度都由 CPU 完成了,不存在 N:1 缺點。

缺點:

  • 協程的創建、刪除和切換的代價都由 CPU 完成,有點略顯昂貴了。

M:N 關係

M 個協程綁定 1 個線程,是 N:11:1 類型的結合,克服了以上 2 種模型的缺點,但實現起來最為複雜。
協程跟線程是有區別的,線程由 CPU 調度是搶佔式的,協程由用户態調度是協作式的,一個協程讓出 CPU 後,才執行下一個協程。

調度器實現原理

注: Go 目前使用的調度器是 2012 年重新設計的。

2012 之前的調度原理,如下圖所示:

M 想要執行、放回 G 都必須訪問全局 G 隊列,並且 M 有多個,即多線程訪問同一資源需要加鎖進行保證互斥 / 同步,所以全局 G 隊列是有互斥鎖進行保護的。

缺點:

  • 創建、銷燬、調度 G 都需要每個 M 獲取鎖,這就形成了激烈的鎖競爭。
  • M 轉移 G 會造成延遲和額外的系統負載。比如當 G 中包含創建新協程的時候,M 創建了 G,為了繼續執行 G,需要把 G 交給 M 執行,也造成了很差的局部性,因為 GG 是相關的,最好放在 M 上執行,而不是其他 M
  • 系統調用 (CPU 在 M 之間的切換) 導致頻繁的線程阻塞和取消阻塞操作增加了系統開銷。

2012 年之後的調度器實現原理,如下圖所示:

在新調度器中,除了 M (thread) G (goroutine),又引進了 P (Processor)Processor,它包含了運行 goroutine 的資源,如果線程想運行 goroutine,必須先獲取 PP 中還包含了可運行的 G 隊列。

Go 中,線程是運行 goroutine 的實體,調度器的功能是把可運行的 goroutine 分配到工作線程上。調度過程如下:

  1. 全局隊列(Global Queue):存放等待運行的 G
  2. P 的本地隊列:同全局隊列類似,存放的也是等待運行的 G,存的數量有限,不超過 256 個。新建 G 時,G 優先加入到 P 的本地隊列,如果隊列滿了,則會把本地隊列中一半的 G 移動到全局隊列。
  3. P 列表:所有的 P 都在程序啓動時創建,並保存在數組中,最多有 GOMAXPROCS(可配置) 個。
  4. M:線程想運行任務就得獲取 P,從 P 的本地隊列獲取 GP 隊列為空時,M 也會嘗試從全局隊列拿一批 G 放到 P 的本地隊列,或從其他 P 的本地隊列偷一半放到自己 P 的本地隊列。M 運行 GG 執行之後,M 會從 P 獲取下一個 G,不斷重複下去。

Goroutine 調度器和 OS 調度器是通過 M 結合起來的,每個 M 都代表了 1 個內核線程,OS 調度器負責把內核線程分配到 CPU 的核上執行。

調度器設計策略

複用線程: 避免頻繁的創建、銷燬線程,而是對線程的複用。

  1. work stealing 機制

當本線程無可運行的 G 時,嘗試從其他線程綁定的 P 偷取 G,而不是銷燬線程。

  1. hand off 機制

當本線程因為 G 進行系統調用阻塞時,線程釋放綁定的 P,把 P 轉移給其他空閒的線程執行。

  • 利用並行:GOMAXPROCS 設置 P 的數量,最多有 GOMAXPROCS 個線程分佈在多個 CPU 上同時運行。GOMAXPROCS 也限制了併發的程度,比如 GOMAXPROCS = 核數/2,則最多利用了一半的 CPU 核進行並行。
  • 搶佔:在 coroutine 中要等待一個協程主動讓出 CPU 才執行下一個協程,在 Go 中,一個 goroutine 最多佔用 CPU 10ms,防止其他 goroutine 被餓死,這就是 goroutine 不同於 coroutine 的一個地方。
  • 全局 G 隊列:在新的調度器中依然有全局 G 隊列,但功能已經被弱化了,當 M 執行 work stealing 從其他 P 偷不到 G 時,它可以從全局 G 隊列獲取 G

go func () 調度流程

流程如下:

  1. 通過 go func () 來創建一個 goroutine
  2. 有兩個存儲 G 的隊列,一個是局部調度器 P 的本地隊列、一個是全局 G 隊列。新創建的 G 會先保存在 P 的本地隊列中,如果 P 的本地隊列已經滿了就會保存在全局的隊列中;
  3. G 只能運行在 M 中,一個 M 必須持有一個 PMP1:1 的關係。M 會從 P 的本地隊列彈出一個可執行狀態的 G 來執行,如果 P 的本地隊列為空,就會向其他的 MP 組合偷取一個可執行的 G 來執行;
  4. 一個 M 調度 G 執行的過程是一個循環機制;
  5. M 執行某一個 G 時候如果發生了 syscall 或則其餘阻塞操作,M 會阻塞,如果當前有一些 G 在執行,runtime 會把這個線程 MP 中摘除 (detach),然後再創建一個新的操作系統的線程 (如果有空閒的線程可用就複用空閒線程) 來服務於這個 P
  6. M 系統調用結束時候,這個 G 會嘗試獲取一個空閒的 P 執行,並放入到這個 P 的本地隊列。如果獲取不到 P,那麼這個線程 M 變成休眠狀態, 加入到空閒線程中,然後這個 G 會被放入全局隊列中。

調度器的生命週期

特殊的 M0G0

M0

M0 是啓動程序後的編號為 0 的主線程,這個 M 對應的實例會在全局變量 runtime.m0 中,不需要在 heap 上分配,M0 負責執行初始化操作和啓動第一個 G, 在之後 M0 就和其他的 M 一樣了。

G0

G0 是每次啓動一個 M 都會第一個創建的 goroutineG0 僅用於負責調度的 GG0 不指向任何可執行的函數,每個 M 都會有一個自己的 G0。在調度或系統調用時會使用 G0 的棧空間,全局變量的 G0M0G0

我們來跟蹤一段代碼:

package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}

接下來我們來針對上面的代碼對調度器裏面的結構做一個分析,也會經歷如上圖所示的過程:

  1. runtime 創建最初的線程 m0goroutine g0,並把 2 者關聯。
  2. 調度器初始化:初始化 m0、棧、垃圾回收,以及創建和初始化由 GOMAXPROCSP 構成的 P 列表。
  3. 示例代碼中的 main 函數是 main.mainruntime 中也有 1main 函數 ——runtime.main,代碼經過編譯後,runtime.main 會調用 main.main,程序啓動時會為 runtime.main 創建 goroutine,稱它為 main goroutine 吧,然後把 main goroutine 加入到 P 的本地隊列。
  4. 啓動 m0m0 已經綁定了 P,會從 P 的本地隊列獲取 G,獲取到 main goroutine
  5. G 擁有棧,M 根據 G 中的棧信息和調度信息設置運行環境。
  6. M 運行 G
  7. G 退出,再次回到 M 獲取可運行的 G,這樣重複下去,直到 main.main 退出,runtime.main 執行 DeferPanic 處理,或調用 runtime.exit 退出程序。

調度器的生命週期幾乎佔滿了一個 Go 程序的一生,runtime.maingoroutine 執行之前都是為調度器做準備工作,runtime.maingoroutine 運行,才是調度器的真正開始,直到 runtime.main 結束而結束。


轉載請註明來源:https://janrs.com/mffp
user avatar yinggaozhen 頭像 roseduan 頭像
2 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.