Golang 之協程詳解
一、Golang 執行緒和協程的區別
備註:需要區分程序、執行緒(核心級執行緒)、協程(使用者級執行緒)三個概念。
程序、執行緒 和 協程 之間概念的區別
對於 程序、執行緒,都是有核心進行排程,有 CPU 時間片的概念,進行 搶佔式排程(有多種排程演算法)
對於 協程(使用者級執行緒),這是對核心透明的,也就是系統並不知道有協程的存在,是完全由使用者自己的程式進行排程的,因為是由使用者程式自己控制,那麼就很難像搶佔式排程那樣做到強制的 CPU 控制權切換到其他程序/執行緒,通常只能進行 協作式排程,需要協程自己主動把控制權轉讓出去之後,其他協程才能被執行到。
goroutine 和協程區別
本質上,goroutine 就是協程。 不同的是,Golang 在 runtime、系統呼叫等多方面對 goroutine 排程進行了封裝和處理,當遇到長時間執行或者進行系統呼叫時,會主動把當前 goroutine 的CPU (P) 轉讓出去,讓其他 goroutine 能被排程並執行,也就是 Golang 從語言層面支援了協程。Golang 的一大特色就是從語言層面原生支援協程,在函式或者方法前面加 go關鍵字就可建立一個協程。
其他方面的比較
1. 記憶體消耗方面
每個 goroutine (協程) 預設佔用記憶體遠比 Java 、C 的執行緒少。 goroutine:
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 3 |
|
最開始的網路程式其實就是一個執行緒一個請求設計的(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也自動結束。需要注意的是,如果這個函式有返回值,那麼這個返回值會被丟棄。
先看一下下面的程式程式碼:
1 2 3 4 5 6 7 8 9 10 |
|
執行上面的程式碼,會發現螢幕什麼也沒打印出來,程式就退出了。 對於上面的例子,main()函式啟動了10個goroutine,然後返回,這時程式就退出了,而被啟動的執行 Add() 的 goroutine 沒來得及執行。我們想要讓 main() 函式等待所有 goroutine 退出後再返回,但如何知道 goroutine 都退出了呢?這就引出了多個goroutine之間通訊的問題。
在工程上,有兩種最常見的併發通訊模型:共享記憶體 和 訊息。
下面的例子,使用了鎖變數(屬於一種共享記憶體)來同步協程,事實上 Go 語言主要使用訊息機制(channel)來作為通訊模型
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 27 28 29 30 31 32 33 34 35 |
|
channel
訊息機制認為每個併發單元是自包含的、獨立的個體,並且都有自己的變數,但在不同併發單元間這些變數不共享。每個併發單元的輸入和輸出只有一種,那就是訊息。
channel 是 Go 語言在語言級別提供的 goroutine 間的通訊方式,我們可以使用 channel 在多個 goroutine 之間傳遞訊息。channel是程序內的通訊方式,因此通過 channel 傳遞物件的過程和呼叫函式時的引數傳遞行為比較一致,比如也可以傳遞指標等。channel 是型別相關的,一個 channel 只能傳遞一種型別的值,這個型別需要在宣告 channel 時指定。
channel的宣告形式為:
1 |
|
舉個例子,宣告一個傳遞int型別的channel:
var ch chan int
使用內建函式 make() 定義一個channel:
ch := make(chan int)
在channel的用法中,最常見的包括寫入和讀出:
// 將一個數據value寫入至channel,這會導致阻塞,直到有其他goroutine從這個channel中讀取資料 ch <- value // 從channel中讀取資料,如果channel之前沒有寫入資料,也會導致阻塞,直到channel中被寫入資料為止 value := <-ch
預設情況下,channel的接收和傳送都是阻塞的,除非另一端已準備好。
我們還可以建立一個帶緩衝的channel:
c := make(chan int, 1024) // 從帶緩衝的channel中讀資料 for i:=range c { ... }
此時,建立一個大小為1024的int型別的channel,即使沒有讀取方,寫入方也可以一直往channel裡寫入,在緩衝區被填完之前都不會阻塞。
可以關閉不再使用的channel:
close(ch)
應該在生產者的地方關閉channel,如果在消費者的地方關閉,容易引起panic;
現在利用channel來重寫上面的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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問題,大致結構如下:
1 2 3 4 5 6 7 8 9 10 |
|
select預設是阻塞的,只有當監聽的channel中有傳送或接收可以進行時才會執行,當多個channel都準備好的時候,select是隨機的選擇一個執行的。
Go語言沒有對channel提供直接的超時處理機制,但我們可以利用select來間接實現,例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
這樣使用select就可以避免永久等待的問題,因為程式會在timeout中獲取到一個數據後繼續執行,而無論對ch的讀取是否還處於等待狀態。