天天看點

Golang 之協程詳解

一、Golang 線程和協程的差別

備注:需要區分程序、線程(核心級線程)、協程(使用者級線程)三個概念。

程序、線程 和 協程 之間概念的差別

對于 程序、線程,都是有核心進行排程,有 CPU 時間片的概念,進行 搶占式排程(有多種排程算法)

對于 協程(使用者級線程),這是對核心透明的,也就是系統并不知道有協程的存在,是完全由使用者自己的程式進行排程的,因為是由使用者程式自己控制,那麼就很難像搶占式排程那樣做到強制的 CPU 控制權切換到其他程序/線程,通常隻能進行 協作式排程,需要協程自己主動把控制權轉讓出去之後,其他協程才能被執行到。

goroutine 和協程差別

本質上,goroutine 就是協程。 不同的是,Golang 在 runtime、系統調用等多方面對goroutine 排程進行了封裝和處理,當遇到長時間執行或者進行系統調用時,會主動把目前goroutine 的CPU (P) 轉讓出去,讓其他 goroutine 能被排程并執行,也就是 Golang 從語言層面支援了協程。Golang 的一大特色就是從語言層面原生支援協程,在函數或者方法前面加 go關鍵字就可建立一個協程。

其他方面的比較

1. 記憶體消耗方面

每個 goroutine (協程) 預設占用記憶體遠比 Java 、C 的線程少。

goroutine:2KB

線程:8MB

2. 線程和 goroutine 切換排程開銷方面

線程/goroutine 切換開銷方面,goroutine 遠比線程小

線程:涉及模式切換(從使用者态切換到核心态)、16個寄存器、PC、SP...等寄存器的重新整理等。

goroutine:隻有三個寄存器的值修改 - PC / SP / DX.

二、協程底層實作原理

線程是作業系統的核心對象,多線程程式設計時,如果線程數過多,就會導緻頻繁的上下文切換,這些 cpu 時間是一個額外的耗費。是以在一些高并發的網絡伺服器程式設計中,使用一個線程服務一個 socket 連接配接是很不明智的。于是作業系統提供了基于事件模式的異步程式設計模型。用少量的線程來服務大量的網絡連接配接和I/O操作。但是采用異步和基于事件的程式設計模型,複雜化了程式代碼的編寫,非常容易出錯。因為線程穿插,也提高排查錯誤的難度。

協程,是在應用層模拟的線程,他避免了上下文切換的額外耗費,兼顧了多線程的優點。簡化了高并發程式的複雜度。舉個例子,一個高并發的網絡伺服器,每一個socket連接配接進來,伺服器用一個協程來對他進行服務。代碼非常清晰。而且兼顧了性能。

那麼,協程是怎麼實作的呢?

他和線程的原理是一樣的,當 a線程 切換到 b線程 的時候,需要将 a線程 的相關執行進度壓入棧,然後将 b線程 的執行進度出棧,進入 b線程 的執行序列。協程隻不過是在 應用層 實作這一點。但是,協程并不是由作業系統排程的,而且應用程式也沒有能力和權限執行 cpu 排程。怎麼解決這個問題?

答案是,協程是基于線程的。内部實作上,維護了一組資料結構和 n 個線程,真正的執行還是線程,協程執行的代碼被扔進一個待執行隊列中,由這 n 個線程從隊列中拉出來執行。這就解決了協程的執行問題。那麼協程是怎麼切換的呢?答案是:golang 對各種 io函數 進行了封裝,這些封裝的函數提供給應用程式使用,而其内部調用了作業系統的異步 io函數,當這些異步函數傳回 busy 或 bloking 時,golang 利用這個時機将現有的執行序列壓棧,讓線程去拉另外一個協程的代碼來執行,基本原理就是這樣,利用并封裝了作業系統的異步函數。包括 linux 的 epoll、select 和 windows 的 iocp、event 等。

由于golang是從編譯器和語言基礎庫多個層面對協程做了實作,是以,golang的協程是目前各類有協程概念的語言中實作的最完整和成熟的。十萬個協程同時運作也毫無壓力。關鍵我們不會這麼寫代碼。但是總體而言,程式員可以在編寫 golang 代碼的時候,可以更多的關注業務邏輯的實作,更少的在這些關鍵的基礎構件上耗費太多精力。

三、協程的曆史以及特點

協程(Coroutine)是在1963年由Melvin E. Conway USAF, Bedford, MA等人提出的一個概念。而且協程的概念是早于線程(Thread)提出的。但是由于協程是非搶占式的排程,無法實作公平的任務調用。也無法直接利用多核優勢。是以,我們不能武斷地說協程是比線程更進階的技術。

盡管,在任務排程上,協程是弱于線程的。但是在資源消耗上,協程則是極低的。一個線程的記憶體在 MB 級别,而協程隻需要 KB 級别。而且線程的排程需要核心态與使用者的頻繁切入切出,資源消耗也不小。

我們把協程的基本特點歸納為:

1. 協程排程機制無法實作公平排程

2. 協程的資源開銷是非常低的,一台普通的伺服器就可以支援百萬協程。

那麼,近幾年為何協程的概念可以大熱。我認為一個特殊的場景使得協程能夠廣泛的發揮其優勢,并且屏蔽掉了劣勢 --> 網絡程式設計。與一般的計算機程式相比,網絡程式設計有其獨有的特點。

1. 高并發(每秒鐘上千數萬的單機通路量)

2. Request/Response。程式生命期端(毫秒,秒級)

3. 高IO,低計算(連接配接資料庫,請求API)。

最開始的網絡程式其實就是一個線程一個請求設計的(Apache)。後來,随着網絡的普及,誕生了C10K問題。Nginx 通過單線程異步 IO 把網絡程式的執行流程進行了亂序化,通過IO 事件機制最大化的保證了CPU的使用率。

至此,現代網絡程式的架構已經形成。基于IO事件排程的異步程式設計。其代表作恐怕就屬NodeJS 了吧。

異步程式設計的槽點

異步程式設計為了追求程式的性能,強行的将線性的程式打亂,程式變得非常的混亂與複雜。對程式狀态的管理也變得異常困難。寫過Nginx C Module的同學應該知道我說的是什麼。我們開始吐槽 NodeJS 那惡心的層層Callback。

Golang

在我們瘋狂被 NodeJS 的層層回調惡心到的時候,Golang 作為名門之後開始走入我們的視野。并且迅速的在Web後端極速的跑馬圈地。其代表者 Docker 以及圍繞這 Docker 展開的整個容器生态圈欣欣向榮起來。其最大的賣點 – 協程 開始真正的流行與讨論起來。

我們開始向寫PHP一樣來寫全異步IO的程式。看上去美好極了,仿佛世界就是這樣了。

在網絡程式設計中,我們可以了解為 Golang 的協程本質上其實就是對 IO 事件的封裝,并且通過語言級的支援讓異步的代碼看上去像同步執行的一樣。

四、Golang 協程的應用

我們知道,協程(coroutine)是Go語言中的輕量級線程實作,由Go運作時(runtime)管理。

在一個函數調用前加上go關鍵字,這次調用就會在一個新的goroutine中并發執行。當被調用的函數傳回時,這個goroutine也自動結束。需要注意的是,如果這個函數有傳回值,那麼這個傳回值會被丢棄。

先看一下下面的程式代碼:

1func Add(x, y int) {

2 z := x + y

3 fmt.Println(z)

4}

5

6func main() {

7 for i:=0; i<10; i++ {

8 go Add(i, i)

9 }

10}

執行上面的代碼,會發現螢幕什麼也沒列印出來,程式就退出了。

對于上面的例子,main()函數啟動了10個goroutine,然後傳回,這時程式就退出了,而被啟動的執行 Add() 的 goroutine 沒來得及執行。我們想要讓 main() 函數等待所有goroutine 退出後再傳回,但如何知道 goroutine 都退出了呢?這就引出了多個goroutine之間通信的問題。

在工程上,有兩種最常見的并發通信模型:共享記憶體和消息。

下面的例子,使用了鎖變量(屬于一種共享記憶體)來同步協程,事實上 Go 語言主要使用消息機制(channel)來作為通信模型。

1package main

2

3import (

4 "fmt"

5 "sync"

6 "runtime"

7)

8

9var counter int = 0

10

11func Count(lock *sync.Mutex) {

12 lock.Lock() // 上鎖

13 counter++

14 fmt.Println("counter =", counter)

15 lock.Unlock() // 解鎖

16}

17

18func main() {

19 lock := &sync.Mutex{}

20

21 for i:=0; i<10; i++ {

22 go Count(lock)

23 }

24 for {

25 lock.Lock() // 上鎖

26 c := counter

27 lock.Unlock() // 解鎖

28

29 runtime.Gosched() // 出讓時間片

30

31 if c >= 10 {

32 break

33 }

34 }

35}

channel

消息機制認為每個并發單元是自包含的、獨立的個體,并且都有自己的變量,但在不同并發單元間這些變量不共享。每個并發單元的輸入和輸出隻有一種,那就是消息。

channel 是 Go 語言在語言級别提供的 goroutine 間的通信方式,我們可以使用 channel在多個 goroutine 之間傳遞消息。channel是程序内的通信方式,是以通過 channel 傳遞對象的過程和調用函數時的參數傳遞行為比較一緻,比如也可以傳遞指針等。channel 是類型相關的,一個 channel 隻能傳遞一種類型的值,這個類型需要在聲明 channel 時指定。

channel的聲明形式為:

1var chanName chan ElementType
           

舉個例子,聲明一個傳遞int類型的channel:

1var ch chan int           

使用内置函數 make() 定義一個channel:

1ch := make(chan int)
           

在channel的用法中,最常見的包括寫入和讀出:

1// 将一個資料value寫入至channel,這會導緻阻塞,直到有其他goroutine從這個channel中讀取資料

2ch <- value

3

4// 從channel中讀取資料,如果channel之前沒有寫入資料,也會導緻阻塞,直到channel中被寫入資料為止

5value := <-ch

預設情況下,channel的接收和發送都是阻塞的,除非另一端已準備好。

我們還可以建立一個帶緩沖的channel:

1c := make(chan int, 1024)

2

3// 從帶緩沖的channel中讀資料

4for i:=range c {

5  ...

6}

此時,建立一個大小為1024的int類型的channel,即使沒有讀取方,寫入方也可以一直往channel裡寫入,在緩沖區被填完之前都不會阻塞。

可以關閉不再使用的channel:

1close(ch)

應該在生産者的地方關閉channel,如果在消費者的地方關閉,容易引起panic;

現在利用channel來重寫上面的例子:

1func Count(ch chan int) {

2 ch <- 1

3 fmt.Println("Counting")

4}

5

6func main() {

7

8 chs := make([] chan int, 10)

9

10 for i:=0; i<10; i++ {

11 chs[i] = make(chan int)

12 go Count(chs[i])

13 }

14

15 for _, ch := range(chs) {

16 <-ch

17 }

18}

在這個例子中,定義了一個包含10個channel的數組,并把數組中的每個channel配置設定給10個不同的goroutine。在每個goroutine完成後,向goroutine寫入一個資料,在這個channel被讀取前,這個操作是阻塞的。在所有的goroutine啟動完成後,依次從10個channel中讀取資料,在對應的channel寫入資料前,這個操作也是阻塞的。這樣,就用channel實作了類似鎖的功能,并保證了所有goroutine完成後main()才傳回。

另外,我們在将一個channel變量傳遞到一個函數時,可以通過将其指定為單向channel變量,進而限制該函數中可以對此channel的操作。

select

在UNIX中,select()函數用來監控一組描述符,該機制常被用于實作高并發的socket伺服器程式。Go語言直接在語言級别支援select關鍵字,用于處理異步IO問題,大緻結構如下:

1select {

2 case <- chan1:

3 // 如果chan1成功讀到資料

4

5 case chan2 <- 1:

6 // 如果成功向chan2寫入資料

7

8 default:

9 // 預設分支

10}

select預設是阻塞的,隻有當監聽的channel中有發送或接收可以進行時才會運作,當多個channel都準備好的時候,select是随機的選擇一個執行的。

Go語言沒有對channel提供直接的逾時處理機制,但我們可以利用select來間接實作,例如:

1timeout := make(chan bool, 1)

2

3go func() {

4 time.Sleep(1e9)

5 timeout <- true

6}()

7

8switch {

9 case <- ch:

10 // 從ch中讀取到資料

11

12 case <- timeout:

13 // 沒有從ch中讀取到資料,但從timeout中讀取到了資料

14}

這樣使用select就可以避免永久等待的問題,因為程式會在timeout中擷取到一個資料後繼續執行,而無論對ch的讀取是否還處于等待狀态。

原文釋出時間為:2018-11-27

本文來自雲栖社群合作夥伴“

Golang語言社群

”,了解相關資訊可以關注“

”。

繼續閱讀