乾貨分享丨從MPG 線程模型,探討Go語言的併發程序

摘要:Go 語言的併發特性是其一大亮點,今天咱們來帶着你們一塊兒看看如何使用 Go 更好地開發併發程序。

咱們都知道計算機的核心爲 CPU,它是計算機的運算和控制核心,承載了全部的計算任務。最近半個世紀以來,因爲半導體技術的高速發展,集成電路中晶體管的數量也在大幅度增加,這大大提高了 CPU 的性能。著名的摩爾定律——「集成電路芯片上所集成的電路的數目,每隔18個月就翻一番」,描述的就是該種情形。數據庫

過於密集的晶體管雖然提升了 CPU 的處理性能,但也帶來了單個芯片發熱太高和成本太高的問題,與此同時,受限於材料技術的發展,芯片中晶體管數量密度的增長速度已經放緩。也就是說,程序已經沒法簡單地依賴硬件的提高而提高運行速度。這時,多核 CPU 的出現讓咱們看到了提高程序運行速度的另外一個方向:將程序的執行過程分爲多個可並行或併發執行的步驟,讓它們分別在不一樣的 CPU 核心中同時執行,最後將各部分的執行結果進行合併獲得最終結果。express

並行和併發是計算機程序執行的常見概念,它們的區別在於:編程

  • 並行,指兩個或多個程序在同一個時刻執行;
  • 併發,指兩個或多個程序在同一個時間段內執行。

並行執行的程序,不管從宏觀仍是微觀的角度觀察,同一時刻內都有多個程序在 CPU 中執行。這就要求 CPU 提供多核計算能力,多個程序被分配到 CPU 的不一樣的核中被同時執行。緩存

併發執行的程序,僅須要在宏觀角度觀察到多個程序在 CPU 中同時執行。即便是單核 CPU 也能夠經過分時複用的方式,給多個程序分配必定的執行時間片,讓它們在 CPU 上被快速輪換執行,從而在宏觀上模擬出多個程序同時執行的效果。但從微觀角度來看,這些程序實際上是在 CPU 中被串行執行。安全

Go 的 MPG 線程模型

Go 被認爲是一門高性能併發語言,得益於它在原生態支持協程併發。這裏咱們首先了解進程、線程和協程這三者的聯繫和區別。併發

在多道程序系統中,進程是一個具備獨立功能的程序關於某個數據集合的一次動態執行過程,是操做系統進行資源分配和調度的基本單位,是應用程序運行的載體。函數

線程則是程序執行過程當中一個單一的順序控制流程,是 CPU 調度和分派的基本單位。線程是比進程更小的獨立運行基本單位,一個進程中能夠擁有一個或者以上的線程,這些線程共享進程所持有的資源,在 CPU 中被調度執行,共同完成進程的執行任務。工具

在 Linux 系統中,根據資源訪問權限的不一樣,操做系統會把內存空間分爲內核空間和用戶空間:內核空間的代碼可以直接訪問計算機的底層資源,如 CPU 資源、I/O 資源等,爲用戶空間的代碼提供計算機底層資源訪問能力;用戶空間爲上層應用程序的活動空間,沒法直接訪問計算機底層資源,須要藉助「系統調用」「庫函數」等方式調用內核空間提供的資源。性能

一樣,線程也能夠分爲內核線程和用戶線程。內核線程由操做系統管理和調度,是內核調度實體,它可以直接操做計算機底層資源,能夠充分利用 CPU 多核並行計算的優點,可是線程切換時須要 CPU 切換到內核態,存在必定的開銷,可建立的線程數量也受到操做系統的限制。用戶線程由用戶空間的代碼建立、管理和調度,沒法被操做系統感知。用戶線程的數據保存在用戶空間中,切換時無須切換到內核態,切換開銷小且高效,可建立的線程數量理論上只與內存大小相關。學習

協程是一種用戶線程,屬於輕量級線程。協程的調度,徹底由用戶空間的代碼控制;協程擁有本身的寄存器上下文和棧,並存儲在用戶空間;協程切換時無須切換到內核態訪問內核空間,切換速度極快。但這也給開發人員帶來較大的技術挑戰:開發人員須要在用戶空間處理協程切換時上下文信息的保存和恢復、棧空間大小的管理等問題。

Go 是爲數很少在語言層次實現協程併發的語言,它採用了一種特殊的兩級線程模型:MPG 線程模型(以下圖)。

MPG 線程模型

  • M,即 machine,至關於內核線程在 Go 進程中的映射,它與內核線程一一對應,表明真正執行計算的資源。在 M 的生命週期內,它只會與一個內核線程關聯。
  • P,即 processor,表明 Go 代碼片斷執行所需的上下文環境。M 和 P 的結合可以爲 G 提供有效的運行環境,它們之間的結合關係不是固定的。P 的最大數量決定了 Go 程序的併發規模,由 runtime.GOMAXPROCS 變量決定。
  • G,即 goroutine,是一種輕量級的用戶線程,是對代碼片斷的封裝,擁有執行時的棧、狀態和代碼片斷等信息。

在實際執行過程當中,M 和 P 共同爲 G 提供有效的運行環境(以下圖),多個可執行的 G 順序掛載在 P 的可執行 G 隊列下面,等待調度和執行。當 G 中存在一些 I/O 系統調用阻塞了 M時,P 將會斷開與 M 的聯繫,從調度器空閒 M 隊列中獲取一個 M 或者建立一個新的 M 組合執行, 保證 P 中可執行 G 隊列中其餘 G 獲得執行,且因爲程序中並行執行的 M 數量沒變,保證了程序 CPU 的高利用率。

M 和 P 結合示意圖

當 G 中系統調用執行結束返回時,M 會爲 G 捕獲一個 P 上下文,若是捕獲失敗,就把 G 放到全局可執行 G 隊列等待其餘 P 的獲取。新建立的 G 會被放置到全局可執行 G 隊列中,等待調度器分發到合適的 P 的可執行 G 隊列中。M 和 P 結合後,會從 P 的可執行 G 隊列中無鎖獲取 G 執行。當 P 的可執行 G 隊列爲空時,P 纔會加鎖從全局可執行 G 隊列獲取 G。當全局可執行 G 隊列中也沒有 G 時,P 會嘗試從其餘 P 的可執行 G 隊列中「剽竊」G 執行。

goroutine 和 channel

併發程序中的多個線程同時在 CPU 執行,因爲資源之間的相互依賴和競態條件,須要必定的併發模型協做不一樣線程之間的任務執行。Go 中倡導使用CSP 併發模型來控制線程之間的任務協做,CSP 倡導使用通訊的方式來進行線程之間的內存共享。

Go是經過 goroutine 和 channel 來實現 CSP 併發模型的:

  • goroutine,即協程,Go 中的併發實體,是一種輕量級的用戶線程,是消息的發送和接收方;
  • channel,即通道, goroutine 使用通道發送和接收消息。

CSP併發模型相似經常使用的同步隊列,它更加關注消息的傳輸方式,解耦了發送消息的 goroutine 和接收消息的 goroutine,channel 能夠獨立建立和存取,在不一樣的 goroutine 中傳遞使用。

使用關鍵字 go 便可使用 goroutine 併發執行代碼片斷,形式以下:

go expression

而 channel 做爲一種引用類型,聲明時須要指定傳輸數據類型,聲明形式以下:

var name chan T // 雙向 channel
var name chan <- T // 只能發送消息的 channel
var name T <- chan // 只能接收消息的 channel

其中,T 即爲 channel 可傳輸的數據類型。channel 做爲隊列,遵循消息先進先出的順序,同時保證同一時刻只能有一個 goroutine 發送或者接收消息。
使用 channel 發送和接收消息形式以下:

channel <- val // 發送消息
val := <- channel // 接收消息
val, ok := <- channel // 非阻塞接收消息

goroutine 向已經填滿信息的 channel 發送信息或從沒有數據的 channel 接收信息會阻塞自身。goroutine 接收消息時可使用非阻塞的方式,不管 channel 中是否存在消息都會當即返回,經過 ok 布爾值判斷是否接收成功。

建立一個 channel 須要使用 make 函數對 channel 進行初始化,形式以下所示:

ch := make(chan T, sizeOfChan)

初始化 channel 時能夠指定 channel 的長度,表示 channel 最多能夠緩存多少條信息。下面咱們經過一個簡單例子演示 goroutine 和 channel 的使用:

package main
import (
"fmt"
"time"
)
//生產者
func Producer(begin, end int, queue chan<- int) {
for i:= begin ; i < end ; i++ {
fmt.Println("produce:", i)
queue <- i
}
}
//消費者
func Consumer(queue <-chan int) {
for val := range queue  { //當前的消費者循環消費
fmt.Println("consume:", val)
}
}
func main() {
queue := make(chan int)
defer close(queue)
for i := 0; i < 3; i++ {
go Producer(i * 5, (i+1) * 5, queue) //多個生產者
}
go Consumer(queue) //單個消費者
time.Sleep(time.Second) // 避免主 goroutine 結束程序
}

這是一個簡單的多生產者和單消費的代碼例子,生產 goroutine 將生產的數字經過 channel 發送給消費 goroutine。上述例子中,消費 goroutine 使用 for:range 從 channel 中循環接收消息,只有當相應的 channel 被內置函數 close 後,該循環纔會結束。channel 在關閉以後不能夠再用於發送消息,可是能夠繼續用於接收消息,從關閉的 channel 中接收消息或者正在被阻塞的 goroutine 將會接收零值並返回。還有一個須要注意的點是,main 函數由主 goroutine 啓動,當主 goroutine 即 main 函數執行結束,整個 Go 程序也會直接執行結束,不管是否存在其餘未執行完的 goroutine。

  • select 多路複用

當須要從多個 channel 中接收消息時,可使用 Go 提供的 select 關鍵字,它提供相似多路複用的能力,使得 goroutine 能夠同時等待多個 channel 的讀寫操做。select 的形式與 switch 相似,可是要求 case 語句後面必須爲 channel 的收發操做,一個簡單的例子以下:

package main
import (
"fmt"
"time"
)
func send(ch chan int, begin int )  {
// 循環向 channel 發送消息
for i :=begin ; i< begin + 10 ;i++{
ch <- i
}
}
func receive(ch <-chan int)  {
val := <- ch
fmt.Println("receive:", val)
}
func main()  {
ch1 := make(chan int)
ch2 := make(chan int)
go send(ch1, 0)
go receive(ch2)
// 主 goroutine 休眠 1s,保證調度成功
time.Sleep(time.Second)
for {
select {
case val := <- ch1: // 從 ch1 讀取數據
fmt.Printf("get value %d from ch1\n", val)
case ch2 <- 2 : // 使用 ch2 發送消息
fmt.Println("send value by ch2")
case <-time.After(2 * time.Second): // 超時設置
fmt.Println("Time out")
return
}
}
}

在上述例子中,咱們使用 select 關鍵字同時從 ch1 中接收數據和使用 ch2 發送數據,輸出的一種可能結果爲:

get value 0 from ch1
get value 1 from ch1
send value by ch2
receive: 2
get value 2 from ch1
get value 3 from ch1
get value 4 from ch1
get value 5 from ch1
get value 6 from ch1
get value 7 from ch1
get value 8 from ch1
get value 9 from ch1
Time out

因爲 ch2 中的消息僅被接收一次,因此僅出現一次「send value by ch2」,後續消息的發送將被阻塞。select 語句分別從 3 個 case 中選取返回的 case 進行處理,當有多個 case 語句同時返回時,select 將會隨機選擇一個 case 進行處理。若是 select 語句的最後包含 default 語句,該 select 語句將會變爲非阻塞型,即當其餘全部的 case 語句都被阻塞沒法返回時,select 語句將直接執行 default 語句返回結果。在上述例子中,咱們在最後的 case 語句使用了 <-time.After(2 * time.Second) 的方式指定了定時返回的 channel,這是一種有效從阻塞的 channel 中超時返回的小技巧。

  • Context 上下文

當須要在多個 goroutine 中傳遞上下文信息時,可使用 Context 實現。Context 除了用來傳遞上下文信息,還能夠用於傳遞終結執行子任務的相關信號,停止多個執行子任務的 goroutine。Context 中提供如下接口:

type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
  • Deadline 方法,返回 Context 被取消的時間,也就是完成工做的截止日期;
  • Done,返回一個 channel,這個channel 會在當前工做完成或者上下文被取消以後關閉,屢次調用 Done 方法會返回同一個 channel;
  • Err 方法,返回 Context 結束的緣由,它只會在 Done 返回的 channel 被關閉時纔會返回非空的值,若是 Context 被取消,會返回 Canceled 錯誤;若是 Context 超時,會返回 DeadlineExceeded 錯誤。
  • Value 方法,可用於從 Context 中獲取傳遞的鍵值信息。

在 Web 請求的處理過程當中,一個請求可能啓動多個 goroutine 協同工做,這些 goroutine 之間可能須要共享請求的信息,且當請求被取消或者執行超時時,該請求對應的全部 goroutine 都須要快速結束,釋放資源。Context 就是爲了解決上述場景而開發的,咱們經過下面一個例子來演示:

package main
import (
"context"
"fmt"
"time"
)
const DB_ADDRESS  = "db_address"
const CALCULATE_VALUE  = "calculate_value"
func readDB(ctx context.Context, cost time.Duration)  {
fmt.Println("db address is", ctx.Value(DB_ADDRESS))
select {
case <- time.After(cost): //  模擬數據庫讀取
fmt.Println("read data from db")
case <-ctx.Done():
fmt.Println(ctx.Err()) // 任務取消的緣由
// 一些清理工做
}
}
func calculate(ctx context.Context, cost time.Duration)  {
fmt.Println("calculate value is", ctx.Value(CALCULATE_VALUE))
select {
case <- time.After(cost): //  模擬數據計算
fmt.Println("calculate finish")
case <-ctx.Done():
fmt.Println(ctx.Err()) // 任務取消的緣由
// 一些清理工做
}
}
func main()  {
ctx := context.Background(); // 建立一個空的上下文
// 添加上下文信息
ctx = context.WithValue(ctx, DB_ADDRESS, "localhost:10086")
ctx = context.WithValue(ctx, CALCULATE_VALUE, 1234)
// 設定子 Context 2s 後執行超時返回
ctx, cancel := context.WithTimeout(ctx, time.Second * 2)
defer cancel()
// 設定執行時間爲 4 s
go readDB(ctx, time.Second * 4)
go calculate(ctx, time.Second * 4)

// 充分執行
time.Sleep(time.Second * 5)
}

在上述例子中,咱們模擬了一個請求中同時進行數據庫訪問和邏輯計算的操做,在請求執行超時時,及時關閉還沒有執行結束 goroutine。咱們首先經過 context.WithValue 方法爲 context 添加上下文信息,Context 在多個 goroutine 中是併發安全的,能夠安全地在多個 goroutine 中對 Context 中的上下文數據進行讀取。接着使用 context.WithTimeout 方法設定了 Context 的超時時間爲 2s,並傳遞給 readDB 和 calculate 兩個 goroutine 執行子任務。在 readDB 和 calculate 方法中,使用 select 語句對 Context 的 Done 通道進行監控。

因爲咱們設定了子 Context 將在 2s 以後超時,因此它將在 2s 以後關閉 Done 通道;然而預設的子任務執行時間爲 4s,對應的 case 語句還沒有返回,執行被取消,進入到清理工做的 case 語句中,結束掉當前的 goroutine 所執行的任務。預期的輸出結果以下:

calculate value is 1234
db address is localhost:10086
context deadline exceeded
context deadline exceeded

使用 Context,可以有效地在一組 goroutine 中傳遞共享值、取消信號、deadline 等信息,及時關閉不須要的 goroutine。

小結

本文咱們主要介紹了 Go 語言併發特性,主要包含:

  • Go 的 MPG 線程模型;
  • goroutine 和 channel;
  • select 多路複用;
  • Context 上下文。

除了支持 CSP 的併發模型,Go 一樣支持傳統的線程與鎖併發模型,提供了互斥鎖、讀寫鎖、併發等待組、同步等待條件等一系列同步工具,這些同步工具的結構體位於 sync 包中,與其餘語言的同步工具使用方式相差無幾。Go 在語言層次支持協程併發,在併發性能上表現卓越,可以充分挖掘多核 CPU 的運算性能。但願本節課的學習,可以有效提高你對 Go 併發設計和編程的認知。

本文分享自華爲雲社區《如何使用 Go 更好地開發併發程序》,原文做者:aoho 。

 

點擊關注,第一時間瞭解華爲雲新鮮技術~

相關文章
相關標籤/搜索