golang學習筆記(二)—— 深入golang中的協程

yuxiaoliang發表於2019-03-04

小白一枚,最近在研究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();
  ... do something use DB or other source
  defer m.Unlock();
}

func printTwo(m *sync.Mutex){
  m.Lock();
  ... the same thing as printOne do something use DB or other source 
  defer m.Unlock();
}

func main() {
  m:= new(sync.Mutex);
  go printOne(m);
  go printTwo(m);
  time.Sleep(5 * 1e9);
}
複製程式碼

通過互斥鎖,printOne和printTwo不會競爭同一個相同的資源

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執行緒進入休眠

相關文章