1. 程式人生 > >golang學習筆記(二)—— 深入golang中的協程

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中協程排程的經典圖:

1141545827812_ pic_hd

我們來解釋上圖:

  • P是處理器的個數,我們經常將排程器的GOMAXPROCS設定成CPU的個數,因此這裡P一般來說是機器CPU的個數。
  • M是執行緒,在P處理器上關聯一個執行緒,P和M的一組配對組成了區域性的協程佇列
  • G就是協程,需要被新增到由P和M組成的區域性佇列中依次處理
  • 除了區域性的協程外,在全域性還維護了一個協程佇列。
  • 如果區域性協程佇列中處理完了所有佇列,且沒有新佇列,那麼M執行緒會取消對於CPU的佔用,M執行緒進入休眠