一文搞懂Go中select的隨機(jī)公平策略:并發(fā)編程的黃金法則
一、引言
今天呢,咱們來聊聊 Go 語言的那點(diǎn)事兒,尤其是咱們?cè)诓l(fā)處理中常用的 select 語句,它可是處理并發(fā)時(shí)的一把利劍!
Go 語言的 select 語句,仿佛是編程世界中的一位冷靜的裁判,當(dāng)多個(gè)通道(channel)全都爭(zhēng)著搶話語權(quán)的時(shí)候,它就會(huì)站出來,公平地判決誰應(yīng)當(dāng)先發(fā)聲。
換句話說,select 可以在多個(gè)通道之間等待并選擇可用的通道執(zhí)行操作。
你得這么看select語句——它是并發(fā)編程領(lǐng)域里的一塊重要的拼圖,沒有這塊,你畫出的并發(fā)圖景就不完整。
首先,我們來看一個(gè)簡(jiǎn)單的示例:
select {
case <-chan1:
// 操作1
case data := <-chan2:
// 操作2
case chan3 <- data:
// 操作3
default:
// 默認(rèn)操作
}
還別說,這幾行代碼,簡(jiǎn)單明了,但它背后可是隱藏著深邃的并發(fā)處理智慧:
- select 可以在 channel 上進(jìn)行非阻塞的收發(fā)操作;
- 當(dāng)存在可以收發(fā)的 channel 時(shí),會(huì)直接處理該 channel 對(duì)應(yīng)的 case;
- 如果沒有任何通道準(zhǔn)備好,它就會(huì)執(zhí)行 default 子句(如果有的話);
- 如果連 default 都沒得,那它就會(huì)那么靜靜站著,不厭其煩地等待,直到有一個(gè)通道準(zhǔn)備好。
優(yōu)雅!這是使用過 select 語句后,我心中的感嘆。就像你有了一塊功能強(qiáng)大的瑞士軍刀,可以靈活地應(yīng)對(duì)各種野外求生的情況。
在代碼中,select 語句也可以靈活地處理多個(gè)通道的并發(fā)操作,避免使用復(fù)雜的同步工具實(shí)現(xiàn)并發(fā)操作。
二、select 機(jī)制
講科技,不能光有干巴巴的代碼堆砌,還得有歷史沉淀(反正以前歷史老師是這么教的 :)。
而我們現(xiàn)在探討的是 Go 語言里的 select 思想,它最初源自于網(wǎng)絡(luò) IO 模型中的 select,其精華在于 IO 多路復(fù)用。
想象一下,有那么一刻,你需要同時(shí)傾聽來自世界各地的廣播,這可不是一件簡(jiǎn)單的事兒。然而,這正是 go 中的 channel 和 select 的日常所在:致力于協(xié)調(diào)多個(gè)渠道的信息流,也只有在這里,才有 “通道爭(zhēng)鳴” 的景象。
1. Go select 特性
讓我們像切洋蔥一樣,一層層地剝開 select 神秘的外衣:
- 每個(gè)案例必須是個(gè)通道:這是規(guī)定,沒得商量,像是一場(chǎng)形式各異的對(duì)話,總得有人發(fā)聲,對(duì)吧?
- 所有被發(fā)送的通道表達(dá)式先被求值:這就像是通道們排著隊(duì),等待裁判 select 逐一審視。
- 如果有多個(gè)符合條件,select 公平地隨機(jī)挑一個(gè)執(zhí)行:這也是 select 魅力之一所在,我們下文會(huì)從代碼層面探討這個(gè)特性。
- 如果沒有通道準(zhǔn)備好,執(zhí)行 default 子句(如果有的話):和網(wǎng)絡(luò)選擇一樣,咱不能干等著,得找點(diǎn)事做。
- 沒有 default,select 就等著,也許數(shù)秒,也許是永恒:如果沒有 default,那也只能干等著了,考驗(yàn)裁判耐心的時(shí)刻。
- 通道關(guān)閉時(shí),讀取會(huì)導(dǎo)致死循環(huán):這像是一個(gè)已經(jīng)倒閉的電臺(tái),但你的收音機(jī)還在不斷嘗試調(diào)頻接收信號(hào)。
- 空的 select 會(huì)造成死鎖:這就是沒有對(duì)話的對(duì)話框,靜默的無聲世界。
2. 特性驗(yàn)證
接下來,咱們通過一系列實(shí)驗(yàn)來檢驗(yàn)真實(shí)世界中 select 的行為。
(1) select 已關(guān)閉通道和空通道場(chǎng)景
再來看以下代碼:
func main() {
c1, c2, c3 := make(chan bool), make(chan bool), make(chan bool)
go func() {
for {
select {
// 保證c1一定不會(huì)關(guān)閉,否則會(huì)死循環(huán)此case
case <-c1:
fmt.Println("case c1")
// c2可以防止出現(xiàn)c1死循環(huán)的情況
// 如果c2關(guān)閉了(ok==false),將其置為nil,下次就會(huì)忽略此case
case _, ok := <-c2:
if !ok {
fmt.Println("c2 is closed")
c2 = nil
}
// 如果c3已關(guān)閉,則panic(不能往已關(guān)閉的channel里寫數(shù)據(jù))
// 如果c3為nil,則ignore該case
case c3 <- true:
fmt.Println("case c3")
case v <- c4:
fmt.Println(v)
}
}
}()
time.Sleep(10 * time.Second)
}
當(dāng) channel 關(guān)閉以后,case <- chan 會(huì)接收該通信對(duì)應(yīng)數(shù)據(jù)類型的零值,所以會(huì)出現(xiàn)死循環(huán)。
(2) 帶 default 語句實(shí)現(xiàn)非阻塞讀寫
select {
case <- c1:
fmt.Println(":case c3")
// 當(dāng)c1沒有消息時(shí),不會(huì)一直阻塞,而是進(jìn)入default
default:
fmt.Println(":select default")
}
注意,Go 語言的 select 和 Java 或者 C 語言的 switch 還不太一樣:switch 中一般會(huì)帶有 default 判斷分支,但 select 使用時(shí),外層的 for 循環(huán)和 default 不會(huì)同時(shí)出現(xiàn),否則會(huì)發(fā)生死鎖。
(3) select 實(shí)現(xiàn)定時(shí)任務(wù)
func main() {
done := make(chan bool)
var selectTest = func() {
for {
select {
case <-time.After(1 * time.Second):
fmt.Println("Working...")
case <-done:
fmt.Println("Job done!")
}
}
}
go selectTest()
time.Sleep(3 * time.Second)
done <- true
time.Sleep(500 * time.Microsecond)
}
這個(gè)例子模擬的是一個(gè)簡(jiǎn)易的定時(shí)器,每隔一秒鐘它都會(huì)打印 "Working..." 直到我們通過關(guān)閉 done 通道告訴它 "任務(wù)完成"。
這樣的模式在你需要定時(shí)檢查或者定時(shí)執(zhí)行一些任務(wù)時(shí)非常有用!
代碼運(yùn)行結(jié)果:
Working...
Working...
Job done!
注意,如果定時(shí)器的另外 case 分支是上面已關(guān)閉 channel 場(chǎng)景,可能會(huì)出現(xiàn)異常,如下所示:
func main() {
done := make(chan bool)
t := time.Now()
var selectTest = func() {
for {
select {
case <-time.After(100 * time.Microsecond):
fmt.Println(time.Since(t), " time.After exec, return!")
return
case <-done:
fmt.Println("over")
}
}
}
// 關(guān)閉 chan
close(done)
go selectTest()
time.Sleep(2 * time.Second)
}
我們?cè)诓l(fā)執(zhí)行之前就 close(done) 關(guān)閉了 Channel,不妨猜一下這段代碼會(huì)輸出什么,答案是:
...
over
over
over
601.3938ms time.After exec, return!
這是因?yàn)椋篸one 已經(jīng)被關(guān)閉了,所以當(dāng)執(zhí)行 case <-done 語句時(shí)會(huì)死循環(huán)此 case 分支。
但是,為什么還會(huì)執(zhí)行退出 case,而且 return 時(shí),時(shí)間來到了 601.3938ms 呢?
從上面代碼中定時(shí)器 case 100 ms 執(zhí)行一次,我們不難得知,程序退出時(shí)是第 6 次執(zhí)行 select 語句,這里面究竟有什么魔法呢?
讓我們接著往下看!
3.多個(gè) case 滿足讀寫條件
上文已經(jīng)描述過,如果多個(gè) case 滿足讀取條件時(shí),select 會(huì)隨機(jī)選擇一個(gè)語句執(zhí)行。
讓我們用代碼來詳細(xì)描述一下:
func main() {
done := make(chan int, 1024)
tick := time.NewTicker(time.Second)
var selectTest = func() {
for i := 0; i < 10; i++ {
select {
case done <- i:
fmt.Println(i, ", done exec")
case <- tick.C:
fmt.Println(i, ", time.After exec")
}
time.Sleep(500 * time.Millisecond)
}
}
go selectTest()
time.Sleep(5 * time.Second)
}
這個(gè)例子開啟了一個(gè) goroutine 協(xié)程來運(yùn)行 selectTest 函數(shù),在函數(shù)里面 for 循環(huán) 10 次執(zhí)行 select 語句。并且,select 的兩個(gè)分支 case done <- i 和 case <- tick.C 都是可以執(zhí)行的。
這時(shí)候,我們看一下執(zhí)行結(jié)果:
0 , done exec
1 , done exec
2 , time.After exec
3 , done exec
4 , time.After exec
5 , done exec
6 , done exec
7 , done exec
8 , time.After exec
9 , done exec
注意,以上結(jié)果多次運(yùn)行的打印順序可能不一致,是正?,F(xiàn)象!
我們可以發(fā)現(xiàn),原本寫入 done 通道的 2、4 和 8 不見了,說明在循環(huán)的過程中,select 的兩個(gè)分支 case done <- i 和 case <- tick.C 都是執(zhí)行了的。
因此,這就驗(yàn)證了當(dāng)多個(gè) case 同時(shí)滿足時(shí),select 會(huì)隨機(jī)選擇一個(gè)執(zhí)行。這個(gè)設(shè)計(jì)是為了避免某個(gè) case 出現(xiàn)饑餓問題,保證公平競(jìng)爭(zhēng)而引入的。
試想一下,如果某個(gè) case 一直執(zhí)行,而某些 case 一直得不到執(zhí)行,這和 select 公平選擇的初衷就沖突了。
所以,Go 在十多年前新增 select 提交時(shí)就用了這種隨機(jī)策略并保留至今,雖然中途有過細(xì)微的變更,但整體語義一直沒有變化。
三、底層原理
Go語言中 select用于處理多個(gè)通道(channel)的發(fā)送和接收操作,但在 Go 語言的源代碼中沒有直接對(duì)應(yīng)的結(jié)構(gòu)體。
因此,select通過runtime.scase結(jié)構(gòu)體表示其中的每個(gè) case,該結(jié)構(gòu)體包含指向通道和數(shù)據(jù)元素的指針:
type scase struct {
c *hchan // chan
elem unsafe.Pointer // data element
}
1.編譯器
編譯時(shí),select 語句被轉(zhuǎn)換成 OSELECT 節(jié)點(diǎn),持有 OCASE 節(jié)點(diǎn)集合,每個(gè) OCASE 代表一個(gè)可能的操作,包括空(對(duì)應(yīng) default)。
根據(jù)情況不同,編譯器會(huì)優(yōu)化select的處理過程。優(yōu)化處理的情況分為:
- 不存在任何 case 的 select:直接阻塞,使用 runtime.block 函數(shù)使當(dāng)前協(xié)程(goroutine)進(jìn)入永久休眠狀態(tài)。
- 只有一個(gè) case 的 select:改寫成單個(gè) if 條件語句。
- 有一個(gè) case 是 default 的兩個(gè) case 的 select:被視為非阻塞操作,分別優(yōu)化為非阻塞發(fā)送或接收。
- 多個(gè) case:通過運(yùn)行時(shí)函數(shù) runtime.selectgo 處理,從幾個(gè)待執(zhí)行的 case 中選擇一個(gè)。
非阻塞操作進(jìn)行相應(yīng)的編譯器重寫,發(fā)送使用 runtime.selectnbsend 函數(shù)進(jìn)行非阻塞發(fā)送,接收方面有兩種函數(shù)處理單值和雙值接收。
2.運(yùn)行時(shí)
運(yùn)行時(shí),runtime.selectgo函數(shù)通過以下幾個(gè)步驟處理 select:
- 初始化階段,決定 case 的處理順序。
- 遍歷 case,查找立即就緒的 Channel,如果有則立即處理。
- 如果沒有立即就緒的 Channel,將當(dāng)前 Goroutine 加入到所有相關(guān) Channel 的收發(fā)隊(duì)列中,并掛起。
- 當(dāng)某個(gè) case 就緒時(shí)(Channel 收到數(shù)據(jù)或有空間發(fā)送數(shù)據(jù)),調(diào)度器喚醒掛起的 Goroutine,查找并處理對(duì)應(yīng)的 case。
四、小結(jié)
本文中,我們談到了 Go 語言里 select 的基本特性和實(shí)現(xiàn),提到了select與直接 Channel 操作的相似性,以及通過 default 支持非阻塞收發(fā)操作。
我們還揭示了select 底層實(shí)現(xiàn)的復(fù)雜性——需要編譯器和運(yùn)行時(shí)支持。
通過以上不難得知,Go 的 select 語句在不同場(chǎng)景下的行為和實(shí)現(xiàn)是比較奇妙的,這也是 Go 獨(dú)特的數(shù)據(jù)結(jié)構(gòu),其背后的設(shè)計(jì)與優(yōu)化策略都需要我們對(duì) Go 底層有著比較完善的認(rèn)知。