Go select實現原理剖析 頂 原 薦

1. 前言

select是Golang在語言層面提供的多路IO複用的機制,其可以檢測多個channel是否ready(即是否可讀或可寫),使用起來非常方便。

本章試圖根據源碼總結其實現原理,從而發現一些使用誤區或解釋一些不太常見的現象。

2. 熱身環節

我們先看幾個題目,用於測試對select的瞭解程度,每個題目代表一個知識點,本章後面的部分會進行略爲詳細的介紹。

2.1 題目1

下面的程序輸出是什麼?

package main

import (
    "fmt"
    "time"
)

func main() {
    chan1 := make(chan int)
    chan2 := make(chan int)

    go func() {
        chan1 <- 1
        time.Sleep(5 * time.Second)
    }()

    go func() {
        chan2 <- 1
        time.Sleep(5 * time.Second)
    }()

    select {
    case <-chan1:
        fmt.Println("chan1 ready.")
    case <-chan2:
        fmt.Println("chan2 ready.")
    default:
        fmt.Println("default")
    }

    fmt.Println("main exit.")
}

程序中聲明兩個channel,分別爲chan1和chan2,依次啓動兩個協程,分別向兩個channel中寫入一個數據就進入睡眠。select語句兩個case分別檢測chan1和chan2是否可讀,如果都不可讀則執行default語句。

參考答案:
select中各個case執行順序是隨機的,如果某個case中的channel已經ready,則執行相應的語句並退出select流程,如果所有case中的channel都未ready,則執行default中的語句然後退出select流程。另外,由於啓動的協程和select語句並不能保證執行順序,所以也有可能select執行時協程還未向channel中寫入數據,所以select直接執行default語句並退出。所以,以下三種輸出都有可能:

可能的輸出一:

chan1 ready.
main exit.

可能的輸出二:

chan2 ready.
main exit.

可能的輸出三:

default
main exit.

2.2 題目2

下面的程序執行到select時會發生什麼?

package main

import (
    "fmt"
    "time"
)

func main() {
    chan1 := make(chan int)
    chan2 := make(chan int)

    writeFlag := false
    go func() {
        for {
            if writeFlag {
                chan1 <- 1
            }
            time.Sleep(time.Second)
        }
    }()

    go func() {
        for {
            if writeFlag {
                chan2 <- 1
            }
            time.Sleep(time.Second)
        }
    }()

    select {
    case <-chan1:
        fmt.Println("chan1 ready.")
    case <-chan2:
        fmt.Println("chan2 ready.")
    }

    fmt.Println("main exit.")
}

程序中聲明兩個channel,分別爲chan1和chan2,依次啓動兩個協程,協程會判斷一個bool類型的變量writeFlag來決定是否要向channel中寫入數據,由於writeFlag永遠爲false,所以實際上協程什麼也沒做。select語句兩個case分別檢測chan1和chan2是否可讀,這個select語句不包含default語句。

參考答案:select會按照隨機的順序檢測各case語句中channel是否ready,如果某個case中的channel已經ready則執行相應的case語句然後退出select流程,如果所有的channel都未ready且沒有default的話,則會阻塞等待各個channel。所以上述程序會一直阻塞。

2.3 題目3

下面程序有什麼問題?

package main

import (
    "fmt"
)

func main() {
    chan1 := make(chan int)
    chan2 := make(chan int)

    go func() {
        close(chan1)
    }()

    go func() {
        close(chan2)
    }()

    select {
    case <-chan1:
        fmt.Println("chan1 ready.")
    case <-chan2:
        fmt.Println("chan2 ready.")
    }

    fmt.Println("main exit.")
}

程序中聲明兩個channel,分別爲chan1和chan2,依次啓動兩個協程,協程分別關閉兩個channel。select語句兩個case分別檢測chan1和chan2是否可讀,這個select語句不包含default語句。

參考答案:select會按照隨機的順序檢測各case語句中channel是否ready,考慮到已關閉的channel也是可讀的,所以上述程序中select不會阻塞,具體執行哪個case語句具是隨機的。

2.4 題目4

下面程序會發生什麼?

package main

func main() {
    select {
    }
}

上面程序中只有一個空的select語句。

參考答案:對於空的select語句,程序會被阻塞,準確的說是當前協程被阻塞,同時Golang自帶死鎖檢測機制,當發現當前協程再也沒有機會被喚醒時,則會panic。所以上述程序會panic。

3. 實現原理

Golang實現select時,定義了一個數據結構表示每個case語句(含defaut,default實際上是一種特殊的case),select執行過程可以類比成一個函數,函數輸入case數組,輸出選中的case,然後程序流程轉到選中的case塊。

3.1 case數據結構

源碼包src/runtime/select.go:scase定義了表示case語句的數據結構:

type scase struct {
	c           *hchan         // chan
	kind        uint16
	elem        unsafe.Pointer // data element
}

scase.c爲當前case語句所操作的channel指針,這也說明了一個case語句只能操作一個channel。
scase.kind表示該case的類型,分爲讀channel、寫channel和default,三種類型分別由常量定義:

  • caseRecv:case語句中嘗試讀取scase.c中的數據;
  • caseSend:case語句中嘗試向scase.c中寫入數據;
  • caseDefault: default語句

scase.elem表示緩衝區地址,跟據scase.kind不同,有不同的用途:

  • scase.kind == caseRecv : scase.elem表示讀出channel的數據存放地址;
  • scase.kind == caseSend : scase.elem表示將要寫入channel的數據存放地址;

3.2 select實現邏輯

源碼包src/runtime/select.go:selectgo()定義了select選擇case的函數:

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)

函數參數:

  • cas0爲scase數組的首地址,selectgo()就是從這些scase中找出一個返回。
  • order0爲一個兩倍cas0數組長度的buffer,保存scase隨機序列pollorder和scase中channel地址序列lockorder
    • pollorder:每次selectgo執行都會把scase序列打亂,以達到隨機檢測case的目的。
    • lockorder:所有case語句中channel序列,以達到去重防止對channel加鎖時重複加鎖的目的。
  • ncases表示scase數組的長度

函數返回值:

  1. int: 選中case的編號,這個case編號跟代碼一致
  2. bool: 是否成功從channle中讀取了數據,如果選中的case是從channel中讀數據,則該返回值表示是否讀取成功。

selectgo實現僞代碼如下:

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
    //1. 鎖定scase語句中所有的channel
    //2. 按照隨機順序檢測scase中的channel是否ready
    //   2.1 如果case可讀,則讀取channel中數據,解鎖所有的channel,然後返回(case index, true)
    //   2.2 如果case可寫,則將數據寫入channel,解鎖所有的channel,然後返回(case index, false)
    //   2.3 所有case都未ready,則解鎖所有的channel,然後返回(default index, false)
    //3. 所有case都未ready,且沒有default語句
    //   3.1 將當前協程加入到所有channel的等待隊列
    //   3.2 當將協程轉入阻塞,等待被喚醒
    //4. 喚醒後返回channel對應的case index
    //   4.1 如果是讀操作,解鎖所有的channel,然後返回(case index, true)
    //   4.2 如果是寫操作,解鎖所有的channel,然後返回(case index, false)
}

特別說明:對於讀channel的case來說,如case elem, ok := <-chan1:, 如果channel有可能被其他協程關閉的情況下,一定要檢測讀取是否成功,因爲close的channel也有可能返回,此時ok == false。

4. 總結

  • select語句中除default外,每個case操作一個channel,要麼讀要麼寫
  • select語句中除default外,各case執行順序是隨機的
  • select語句中如果沒有default語句,則會阻塞等待任一case
  • select語句中讀操作要判斷是否成功讀取,關閉的channel也可以讀取
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章