golang學習筆記(二)—— 深入golang中的協程
小白一枚,最近在研究golang,記錄自己學習過程中的一些筆記,以及自己的理解。
- go中協程的實現
- go中協程的sync同步鎖
- go中通道channel
- go中的range
- go中的select切換協程
- go中帶快取的channel
- go中協程排程
原文的地址為:github.com/fortheallli…
歡迎star
介紹go中的協程之前,首先看以下go中的defer函式,defer函式不是普通的函式,defer函式會在普通函式返回之後執行。defer函式中可以釋放函式內部變數、關閉資料庫連線等等操作,舉例來說:
func print(){
fmt.Println(2);
}
func main() {
defer print();
fmt.Println(1);
}
複製程式碼
上述的例子中先輸出1後輸出2,說明defer確實是在普通函式呼叫結束之後執行的。
go中使用協程的方式來處理併發,協程可以理解成更小的執行緒,佔用空間小且執行緒上下文切換的成本少。
可以再為具體的描述以下協程的好處,協程比執行緒更加輕量,使用4K棧的記憶體就可以建立它們,可以用很小的記憶體佔用就可以處理大量的任務。
在go中,攜程是通過go關鍵字來呼叫,從關鍵字可以看出,golang的一個十分重要的特點就是協程,有句話叫“協程在手,說go就go”。
1、go中協程的實現
下面我們來看一個例子:
func printOne(){
fmt.Println(1);
}
func printTwo(){
fmt.Println(2);
}
func printThree(){
fmt.Println(3);
}
func main() {
go printOne();
go printTwo();
go printThree();
}
複製程式碼
執行上述的main函式,我們發現並沒有像我們想的那樣輸出有123的輸出,原因在於雖然協程是併發的,但是如果在協程呼叫前退出了呼叫協程的函式後,協程會隨著程式的消亡而消亡。
因此我們可以在main函式中,將主函式掛起,增加等待協程呼叫的事件。
func main() {
go printOne();
go printTwo();
go printThree();
time.Sleep(5 * 1e9);
}
複製程式碼
這樣會有相應的go關鍵字修飾的協程函式的呼叫。我們來看分別執行3次的結果。
- 第一次 1 3 2
- 第二次 3 2 1
- 第三次 3 1 2
我們發現因為協程是併發執行的,我們無法確定其呼叫的順序,因此 每次的呼叫主函式的返回結果都是不確定的。
從協程的上述例子中,我們可以看出使用協程的時候必須還要考慮兩個問題:
- 如何控制協程的呼叫順序,特別是當不同的協程同時訪問同一個資源。
- 如何實現不同協程間的通訊
問題1,可以通過sync的同步鎖來實現,問題2,go中提供了channel來實現不同協程間的通訊。
2、go中協程的sync同步鎖
go中sync包提供了2個鎖,互斥鎖sync.Mutex和讀寫鎖sync.RWMutex.我們用互斥鎖來解決上述的同步問題,改寫上述的例子:
func printOne(m *sync.Mutex){
m.Lock();
fmt.Println(1);
defer m.Unlock();
}
func printTwo(m *sync.Mutex){
m.Lock();
fmt.Println(2);
defer m.Unlock();
}
func printThree(m *sync.Mutex){
m.Lock();
fmt.Println(3);
defer m.Unlock();
}
func main() {
m:= new(sync.Mutex);
go printOne(m);
go printTwo(m);
go printThree(m);
time.Sleep(5 * 1e9);
}
複製程式碼
通過互斥鎖,可以發現每次執行,確實都依次輸出了1,2,3
3、go中通道channel
go中有一種特殊的型別通道channel,可以通過channel來發送型別化的資料,實現在協程之間的通訊,通過通道的通訊方式也保證了同步性。
channel的宣告方式很簡單:
var ch1 chan string
ch1 = make(chan string)
複製程式碼
我們用ch表示通道,通道的符號包括了流向通道(傳送): ch <- int1 和從通道流出(接收) int2 = <- ch。
同時go中也支援宣告單向通道:
var ch1 chan int //普通的channel
var ch2 chan <- int //只用於寫int資料
var ch3 <- chan int //只用於讀int資料
複製程式碼
上述定義的都是不帶快取區,或者說長度為1的channel,這種channel的特點就是:
一旦有資料被放入channel,那麼該資料必須被取走才能讓另一條資料放入,這就是同步的channel,channel的傳送者和接受者在同一時間只交流一條資料,然後必須等待另一邊完成相應的傳送和接受動作。
我們還是用上述的輸出123的例子,用同步channel來實現同步的輸出。
func printOne(cs chan int){
fmt.Println(1);
cs <- 1
}
func printTwo(cs chan int){
<-cs
fmt.Println(2);
defer close(cs);
}
func main() {
cs := make(chan int);
go printOne(cs);
go printTwo(cs);
time.Sleep(5 * 1e9);
}
複製程式碼
上述的例子中會依次輸出12,這樣我們通過同步channel的方式實現了同步的輸出。
我們前面講到用為了等待go協程執行完成,我們在main函式中用time.sleep來掛起主函式,其實main函式本身也可以看成一個協程,如果使用channel,就不用在main函式中用time.sleep來掛起。
我們改寫上述的例子:
func printOne(cs chan int){
fmt.Println(1);
cs <- 1
}
func main() {
cs := make(chan int);
go printOne(cs);
<-cs;
close(cs);
}
複製程式碼
上述的例子中,會輸出 1 ,我們並沒有在主函式中通過time.sleep的方式來掛起,轉而用一個等待寫入的channel來代替。
注意:通道可以被顯式的關閉,當需要告訴接受者不會種子提供新的值的時候,就需要關閉通道。
4、go中的range
上面我們也講到要及時的關閉channel,但是持續的訪問資料來源並檢查channel是否已經關閉,並不高效。go中提供了range關鍵字。
range關鍵字在使用channel的時候,會自動等待channel的動作一直到channel關閉。通俗點將就是可以channel可以自動開關。
同樣的來舉例:
func input(cs chan int,count int){
for i:=1;i<=count;i++ {
cs <- i
}
}
func output(cs chan int){
for s:= range cs {
fmt.Println(s);
}
}
func main() {
cs := make(chan int);
go input(cs,5);
go output(cs);
time.Sleep(3*1e9)
}
複製程式碼
上述的例子會依次的輸出1,2,3,4,5. 通過使用range關鍵字,當channel被關閉時,接受者的for迴圈也就自動停止了。
5、go中的select切換協程
從不同的併發執行過程中獲取值可以通過關鍵字select來完成,它和switch控制語句非常相似,也被稱為通訊開關。
首先要明確select做了什麼??
select中存在著一種輪詢機制,select監聽進入通道的資料,也可以是通道傳送值的時候,監聽到相應的行為後就執行case裡面的操作。
select的宣告:
select {
case u:= <- ch1:
...
case v:= <- ch2;
...
}
複製程式碼
同樣的來看一下具體使用select的例子:
func channel1(cs chan int,count int){
for i:=1;i<=count;i++ {
cs <- i
}
}
func channel2(cs chan int,count int){
for i:=1;i<=count;i++ {
cs <- i
}
}
func selectTest(cs1 ,cs2 chan int){
for i:=1;i<10;i++ {
select {
case u:=<-cs1:
fmt.Println(u);
case v:=<-cs2:
fmt.Println(v);
}
}
}
func main() {
cs1 := make(chan int);
cs2 := make(chan int);
go channel1(cs1,5);
go channel2(cs2,3);
go selectTest(cs1,cs2);
time.Sleep(3*1e9)
}
輸出結果為:1,2,1,2,3,3,4,5 總共8個數據。且因為沒有做同步控制,因此執行幾次後的輸出結果是不相同的。
複製程式碼
6、go中帶快取的channel
前面講到的都是不帶快取的channel或者說長度為1的channel,實際上channel也是可以帶快取的,我們可以在宣告的時候執行channel的長度。
ch = make(chan string,3)
複製程式碼
比如上述的例子中,指定了ch這個channel的長度為3,長度不為1的channel,就可以稱之為帶快取的channel.
帶快取的channel可以連續寫入,直到長度佔滿為止。
ch <- 1
ch <- 2
ch <- 3
複製程式碼
7、go中協程排程
講到併發,就要提到go中的協程排程。go中的runtime包,提供了排程器的功能。runtime包提供了以下幾個方法:
- Gosched:讓當前執行緒讓出 cpu 以讓其它執行緒執行,它不會掛起當前執行緒,因此當前執行緒未來會繼續執行
- NumCPU:返回當前系統的 CPU 核數量
- GOMAXPROCS:設定最大的可同時使用的 CPU 核數
- Goexit:退出當前 goroutine(但是defer語句會照常執行)
- NumGoroutine:返回正在執行和排隊的任務總數
- GOOS:目標作業系統
對於多核CPU的機器,go可以顯示的指定編譯器將go的協程排程到多個CPU上執行
import "runtime"
...
cpuNum:=runtime.NumCPU;
runtime.GOMAXPROCS(cpuNum)
複製程式碼
來聊聊GO中的排程原理,首先定義以下模型的概念:
M:核心中的執行緒的數目 G:go中的協程,併發的最小單元,在go中通過go關鍵字來建立 P:處理器,即協程G的上下文,每個P會維護一個本地的協程佇列。
接著來看解釋GO中協程排程的經典圖:
我們來解釋上圖:
- P是處理器的個數,我們經常將排程器的GOMAXPROCS設定成CPU的個數,因此這裡P一般來說是機器CPU的個數。
- M是執行緒,在P處理器上關聯一個執行緒,P和M的一組配對組成了區域性的協程佇列
- G就是協程,需要被新增到由P和M組成的區域性佇列中依次處理
- 除了區域性的協程外,在全域性還維護了一個協程佇列。
- 如果區域性協程佇列中處理完了所有佇列,且沒有新佇列,那麼M執行緒會取消對於CPU的佔用,M執行緒進入休眠