有關golang通道的面試筆記

2022-07-11 15:01:20

通道是一個goroutine之間很關鍵的通訊媒介。

理解golang的通道很重要,這裡記錄平時易忘記的、易混淆的點。

1. 基本使用

剛宣告的通道,零值為nil,無法直接使用,需配合make函數進行初始化

   ic :=  make(chan int)
   ic  <-22   // 向無緩衝通道寫入資料
   v := <-ic  // 從無緩衝通道讀取資料
  • 無緩衝通道: 一手交錢,一手交貨, sender、receiver必須同時做好動作,才能完成傳送->接收;否則,先準備好的一方將會阻塞等待。
  • 有緩衝通道 make(chan int,10):滑軌流水線,因為存在緩衝空間,故並不強制sender、receiver必須同時準備好;當通道空或滿時, 一方會阻塞。

通道存在三種狀態: nil, active, closed

針對這三種狀態,sender、receiver有一些行為,我也不知道如何強行記憶這些行為 ☹️:

動作 nil active closed
close panic 成功 panic
ch <- 死鎖 阻塞或成功 panic
<-ch 死鎖 阻塞或成功 零值

2. 從1個例子看chan的實質

package main
 
import (
    "fmt"
)
 
func SendDataToChannel(ch chan int, value int) {
    fmt.Printf("ch's value:%v, chan's type: %T \n", ch, ch) // %v 顯示struct的值;%T 顯示型別
    ch <- value
}
 
func main() {
    var v int
    ch := make(chan int)     
    fmt.Printf("ch's value:%v, chan's type: %T \n", ch, ch) 
    go SendDataToChannel(ch, 101)         // 通過通道傳送資料
    v = <-ch                              //  從通道接受資料
    fmt.Println(v)       // 101
}

能正確列印101。

Q1: 剛學習golang的時候,一直給我們灌輸golang函數是值傳遞,那上例在另外一個協程內部對形參的操作,為什麼會影響外部的實參?

請關注格式化字元的紀錄檔輸出:

ch's value:0xc000018180, chan's type: chan int 
ch's value:0xc000018180, chan's type: chan int 
101

A: 上面的紀錄檔顯示傳遞的ch是一個指標值0xc000018180,型別是chan int( 這並不是說ch是指向chan int型別的指標)。

chan int本質就是指向hchan結構體的指標。

內建函數make建立通道: func makechan(t *chantype, size int) *hchan返回了指向hchan結構體的指標:

type hchan struct {
	qcount   uint           // 佇列中已有的快取元素的長度
	dataqsiz uint           // 環形佇列的長度
	buf      unsafe.Pointer // 環形佇列的地址
	elemsize uint16
	closed   uint32
	elemtype *_type // 元素型別
	sendx    uint   // 待傳送的元素索引
	recvx    uint   // 待接受元素索引
	recvq    waitq  // 阻塞等待的goroutine
	sendq    waitq  // 阻塞等待的gotoutine

	// lock protects all fields in hchan, as well as several
	// fields in sudogs blocked on this channel.
	//
	// Do not change another G's status while holding this lock
	// (in particular, do not ready a G), as this can deadlock
	// with stack shrinking.
	lock mutex
}

Q2: 緩衝通道內部為什麼要使用環形佇列?

A:golang是使用陣列來實現通道佇列,在不移動元素的情況下, 佇列會出現「假滿」的情況,


在做成環形佇列的情況下, 所有的入隊出隊操作依舊是 O(1)的時間複雜度,同時元素空間可以重複利用。
需要使用sendIndex,receIndex來標記實際的待插入/拉取位置,顯而易見會出現 sendIndex<=receIndex 的情況。

recvq,receq是由連結串列實現的佇列,用於儲存阻塞等待的goroutine和待傳送/待接收值,
這兩個結構也是阻塞goroutine被喚醒的準備條件。

3. 傳送/接收的細節

不要使用共用記憶體來通訊,而是使用通訊來共用記憶體

元素值從外界進入通道會被複制,也就是說進入通道的是元素值的副本,並不是元素本身進入通道 (出通道類似)。

金玉良言落到實處:不同的執行緒不共用記憶體、不用鎖,執行緒之間通訊用channel同步也用channel。
傳送/接收資料的兩個動作(G1,G2,G3)沒有共用的記憶體,底層通過hchan結構體的buf,使用copy記憶體的方式進行通訊,最後達到了共用記憶體的目的。

② 根據第①點,傳送操作包括:複製待傳送值,放置到通道內;
接收操作包括:複製元素值, 放置副本到接收方,刪除原值,以上行為在全部完成之前都不會被打斷
所以第①點所說的無鎖,其實指的業務程式碼無鎖,通道底層實現還是靠鎖。

以send操作為例,下面程式碼擷取自 https://github.com/golang/go/blob/master/src/runtime/chan.go

if c.qcount < c.dataqsiz {
  	// Space is available in the channel buffer. Enqueue the element to send.
  	qp := chanbuf(c, c.sendx)         // 計算出buf中待插入位置的地址
  	if raceenabled {
  		racenotify(c, c.sendx, nil)
  	}
  	typedmemmove(c.elemtype, qp, ep)  // 將元素copy進指定的qp地址
  	c.sendx++                         // 重新計算待插入位置的索引
  	if c.sendx == c.dataqsiz {
  		c.sendx = 0                      
  	}
  	c.qcount++
  	unlock(&c.lock)
  	return true
  }

一個常規的send動作:

  • 計算環形佇列的待插入位置的地址
  • 將元素copy進指定的qp地址
  • 重新計算待插入位置的索引sendx
  • 如果待插入位置==佇列長度,說明插入位置已到尾部,需要插入首部。
  • 以上動作加鎖

進入等待狀態的goroutine會進入hchan的sendq/recvq列表

排程器將G1、G2置為waiting狀態,G1、G2進入sendq列表,同時與邏輯處理器分離;

直到有G3嘗試讀取通道內recvx元素,之後將喚醒隊首G1進入runnable狀態,加入排程器的runqueue。

這裡面涉及gopark, goready兩個函數。

如果是無緩衝通道引起的阻塞,將會直接拷貝G1的待傳送值到G2的儲存位置

✍️ https://github.com/golang/go/blob/master/src/runtime/chan.go

package main

import (
	"fmt"
	"time"
)

func SendDataToChannel(ch chan int, value int) {
	time.Sleep(time.Millisecond * time.Duration(value))
	ch <- value
}

func main() {
	var v int
	var ch chan int = make(chan int)
	go SendDataToChannel(ch, 104) // 通過通道傳送資料
	go SendDataToChannel(ch, 100) // 通過通道傳送資料
	go SendDataToChannel(ch, 95)  // 通過通道傳送資料
	go SendDataToChannel(ch, 120) // 通過通道傳送資料

	time.Sleep(time.Second)
	v = <-ch       //  從通道接受資料
	fmt.Println(v)  

	time.Sleep(time.Second * 10)
}

Q3:上述程式碼大概率穩定輸出95

A:雖然4個goroutine被啟動的順序不定,但是肯定都阻塞了,阻塞的時機不一樣,被喚醒的是sendq隊首的goroutine,基本可認為第三個goroutine被首先捕獲進sendq ,因為是無緩衝通道,將會直接拷貝G3的95給到待接收地址。

4. 業內總結的通道的常規姿勢

無緩衝、緩衝通道的特徵,已經在golang領域形成了特定的套路。

  • 當容量為0時,說明通道中不能存放資料,在傳送資料時,必須要求立馬有人接收,此時的通道稱之為無緩衝通道。

  • 當容量為1時,說明通道只能快取一個資料,若通道中已有一個資料,此時再往裡傳送資料,會造成程式阻塞,利用這點可以利用通道來做鎖。

  • 當容量大於1時,通道中可以存放多個資料,可以用於多個協程之間的通訊管道,共用資源。

Q4: 為什麼無緩衝通道不適合做鎖?

A: 我們先思考一下鎖的業務實質: 獲取獨佔標識,並能夠繼續執行; 無緩衝通道雖然可以獲取獨佔標識,但是他阻塞了自身goroutine的執行,所以並不適合實現業務鎖。