Go的CSP併發模型(goroutine + channel)

參考Go的CSP併發模型實現:M, P, G

Go語言是爲併發而生的語言,Go語言是爲數不多的在語言層面實現併發的語言。

併發(concurrency):多個任務在同一段時間內運行。

並行(parallellism):多個任務在同一時刻運行。

Go的CSP併發模型(goroutine + channel)

Go實現了兩種併發形式。

  • 多線程共享內存:Java或者C++等語言中的多線程開發。
  • CSP(communicating sequential processes)併發模型:Go語言特有且推薦使用的。

不同於傳統的多線程通過共享內存來通信,CSP講究的是“以通信的方式來共享內存”。

普通的線程併發模型,就是像Java、C++、或者Python,他們線程間通信都是通過共享內存的方式來進行的。非常典型的方式就是,在訪問共享數據(例如數組、Map、或者某個結構體或對象)的時候,通過鎖來訪問,因此,在很多時候,衍生出一種方便操作的數據結構,叫做“線程安全的數據結構”。

Go的CSP併發模型,是通過goroutine和channel來實現的。

  • goroutine 是Go語言中併發的執行單位。可以理解爲用戶空間的線程。
  • channel是Go語言中不同goroutine之間的通信機制,即各個goroutine之間通信的”管道“,有點類似於Linux中的管道。

1、goroutine

Go語言最大的特色就是從語言層面支持併發(goroutine),goroutine是Go中最基本的執行單元。事實上每一個Go程序至少有一個goroutine:主goroutine。當程序啓動時,它會自動創建。我們在使用Go語言進行開發時,一般會使用goroutine來處理併發任務。

goroutine機制有點像線程池:

  1. go 內部有三個對象: P(processor) 代表上下文(M所需要的上下文環境,也就是處理用戶級代碼邏輯的處理器),M(work thread)代表內核線程,G(goroutine)協程。
  2. 正常情況下一個cpu核運行一個內核線程,一個內核線程運行一個goroutine協程。當一個goroutine阻塞時,會啓動一個新的內核線程來運行其他goroutine,以充分利用cpu資源。所以線程往往會比cpu核數更多。

example

在單核情況下,所有goroutine運行在同一個內核線程(M0)中,每一個內核線程維護一個上下文(P),任何時刻,一個上下文中只有一個goroutine,其他goroutine在runqueue中等待。一個goroutine運行完自己的時間片後,讓出上下文,自己回到runqueue中。如下圖左邊所示,只有一個G0在運行,而其他goroutine都掛起了。

img

當正在運行的G0阻塞的時候(IO之類的),會再創建一個新的內核線程(M1),P轉到新的內核線程中去運行。

當M0返回時(不再阻塞),它會嘗試從其他線程中“偷”一個上下文(cpu)過來,如果沒有偷到,會把goroutine放到global runqueue中去,然後把自己放入線程緩存中。上下文會定時檢查global runqueue切換goroutine運行。

goroutine的優點

1、創建與銷燬的開銷小

線程創建時需要向操作系統申請資源,並且在銷燬時將資源歸還,因此它的創建和銷燬的開銷比較大。相比之下,goroutine的創建和銷燬是由go語言在運行時自己管理的,因此開銷更低。所以一個Golang的程序中可以支持10w級別的Goroutine。每個 goroutine (協程) 默認佔用內存遠比 Java 、C 的線程少(*goroutine:*2KB ,線程:8MB)

2、切換開銷小

這是goroutine於線程的主要區別,也是golang能夠實現高併發的主要原因。

線程的調度方式是搶佔式的,如果一個線程的執行時間超過了分配給它的時間片,就會被其它可執行的線程搶佔。在線程切換的過程中需要保存/恢復所有的寄存器信息,比如16個通用寄存器,PC(Program Counter),SP(Stack Pointer),段寄存器等等。

而goroutine的調度是協同式的,沒有時間片的概念,由Golang完成,它不會直接地與操作系統內核打交道。當goroutine進行切換的時候,之後很少量的寄存器需要保存和恢復(PC和SP)。因此gouroutine的切換效率更高。

總的來說,操作系統的一個線程下可以併發執行上千個goroutine,每個goroutine所佔用的資源和切換開銷都很小,因此,goroutine是golang適合高併發場景的重要原因。

生成一個goroutine的方法十分簡單,直接使用go關鍵字即可:

go func();

2、channel

參考由淺入深剖析 go channel

channel的使用方法:聲明之後,傳數據用channel <- data,取數據用<-channel。channel分爲無緩衝和有緩衝,無緩衝會同步阻塞,即每次生產消息都會阻塞到消費者將消息消費;有緩衝的不會立刻阻塞。

無緩存channel

ch := make(chan int)

// write to channel
ch <- x

// read from channel
x <- ch

// another way to read
x = <- ch

從無緩存的 channel 中讀取消息會阻塞,直到有 goroutine 向該 channel 中發送消息;同理,向無緩存的 channel 中發送消息也會阻塞,直到有 goroutine 從 channel 中讀取消息。

example

c := make(chan int)  // Allocate a channel.

// Start the sort in a goroutine; when it completes, signal on the channel.
go func() {
    list.Sort()
    c <- 1  // Send a signal; value does not matter.
}()

doSomethingForAWhile()
<-c

主goroutine定義一個無緩存的channel,然後開啓一個新的goroutine執行排序任務,接着主goroutine繼續向下執行doSomethingForAWhile,接着要從channel中取值,但是channel是空的,因此主goroutine阻塞。等到新goroutine排序完畢,向channel中寫值後,主goroutine從channel中取到值,然後才能繼續向下執行。

有緩存channel

有緩存的 channel 的聲明方式爲指定 make 函數的第二個參數,該參數爲 channel 緩存的容量

ch := make(chan int, 10)

當緩存未滿時,向 channel 中發送消息時不會阻塞,當緩存滿時,發送操作將被阻塞,直到有其他 goroutine 從中讀取消息

ch := make(chan int, 3)

// blocked, read from empty buffered channel
<- ch

相應的,當 channel 中消息不爲空時,讀取消息不會出現阻塞,當 channel 爲空時,讀取操作會造成阻塞,直到有 goroutine 向 channel 中寫入消息。

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3

// blocked, send to full buffered channel
ch <- 4

通過 len 函數可以獲得 chan 中的元素個數,通過 cap 函數可以得到 channel 的緩存長度。

channel 也可以使用 range 取值,並且會一直從 channel 中讀取數據,直到有 goroutine 對改 channel 執行 close 操作,循環纔會結束。

// consumer worker
ch := make(chan int, 10)
for x := range ch{
    fmt.Println(x)
}

等價於

for {
    x, ok := <- ch
    if !ok {
        break
    }
    
    fmt.Println(x)
}

3、Go併發模型的底層實現原理

參考Golang CSP併發模型

無論在語言層面用的是何種併發模型,到了操作系統層面,一定是以線程的形態存在的。而操作系統根據資源訪問權限的不同,體系架構可分爲用戶空間和內核空間。

  • 內核空間主要操作訪問CPU資源、I/O資源、內存資源等硬件資源,爲上層應用程序提供最基本的基礎資源。
  • 用戶空間就是上層應用程序的固定活動空間,用戶空間不可以直接訪問資源,必須通過“系統調用”、“庫函數”或“Shell腳本”來調用內核空間提供的資源。

golang使用goroutine做爲最小的執行單位,但是這個執行單位還是在用戶空間,實際上最後被處理器執行的還是內核中的線程,用戶線程和內核線程的調度方法有:

  • 1:1,即一個內核線程對應一個用戶級線程(併發度低,浪費cpu資源,上下文切換需要消耗額外的資源)。
  • 1:N,即一個內核線程對應N個用戶級線程(併發度高,但是隻用一個內核線程,不能有效利用多核CPU)。
  • M:N,即M個內核線程對應N個用戶級線程(上述兩種方式的折中,缺點是線程調度會複雜一些)

golang 通過爲goroutine提供語言層面的調度器,來實現了高效率的M:N線程對應關係

img

img

M:是內核線程

P : 是調度協調,用於協調M和G的執行,內核線程只有拿到了 P才能對goroutine繼續調度執行,一般都是通過限定P的個數來控制golang的併發度

G : 是待執行的goroutine,包含這個goroutine的棧空間

Gn : 灰色背景的Gn 是已經掛起的goroutine,它們被添加到了執行隊列中,然後需要等待網絡IO的goroutine,當P通過 epoll查詢到特定的fd的時候,會重新調度起對應的,正在掛起的goroutine。

Golang爲了調度的公平性,在調度器加入了steal working 算法 ,在一個P自己的執行隊列,處理完之後,它會先到全局的執行隊列中偷G進行處理,如果沒有的話,再會到其他P的執行隊列中搶G來進行處理。

4、一個CSP例子

參考golang中的CSP併發模型

生產者-消費者Sample:

package main
import (
   "fmt" 
   "time"
)

// 生產者
func Producer (queue chan<- int){
        for i:= 0; i < 10; i++ {
                queue <- i
        }
}
// 消費者
func Consumer( queue <-chan int){
        for i :=0; i < 10; i++{
                v := <- queue
                fmt.Println("receive:", v)
        }
}

func main(){
        queue := make(chan int, 1)
        go Producer(queue)
        go Consumer(queue)
        time.Sleep(1e9) //讓Producer與Consumer完成

}

生產者goroutine往channel傳值,消費者goroutine往channel取值,這兩個goroutine通過channel完成通信。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章