大話通道

稍微有編碼常識的同學,都會意識到程序並非完全按照純代碼邏輯順序執行。有多線程多進程經驗,知道程序執行往往表現的像無規律的交叉,而且每次重新來過,還體現不一樣。 本文以通道爲引子,意直白講述併發同步

內存順序

編譯器(在編譯時刻)優化和CPU處理器優化(運行時),會調整指令的執行地順序。這導致指令執行順序與代碼指定的順序不完全一致。所以當你認爲你的代碼是按照書寫的邏輯執行時,事實有可能並非如此,尤其是在高併發的情況下。在go語言中表現爲,指令執行順序(指令順序,稱之爲內存順序)的調整,可能會影響到其它協程行爲。

併發同步

爲了應對這些調整,需要同步技術。換而言之,對於某些調整代碼會影響到最終輸出結果的情況,必須作出內存順序保證。非go語言通常會用到鎖和原子操作,以及適用於多個進程之間或多個主機之間,用網絡或文件讀寫來實現併發同步。
鎖基本上有這幾種。互斥鎖有點獨的味道,無論是讀還是寫,都會阻塞,即有人在讀其它人別說寫了,想看看(讀)都沒門。讀寫鎖,單寫多讀模型,除了寫時阻塞,你讀時,我可讀,他可讀,大家誰讀都沒問題,不阻塞。更進一步,其實站在鎖的立場,它需要知道,誰在用他,用完後給哪些需要他的人。換而言之,得通知可能得鎖人,讓那些沒得鎖的人繼續等待。條件鎖出現了,它依賴於前面兩種鎖,通常用來協調想要訪問共享資源的線程,在對應的共享資源狀態發送變化時,通知其它因此而阻塞(在等待)的線程。

中斷 代碼從運行狀態切換到非運行狀態稱之爲中斷
原子操作通常是 CPU 和操作系統提供支持,執行過程中不會中斷

原子操作 互斥鎖只能保證臨界區代碼的串行執行,不能保證代碼執行的原子性。
而原子執行過程中不中斷,可以完全消除競態條件,從而絕對保證併發安全性,無鎖直接通過CPU指令直接實現。

順序保證

上面說了那麼多,就一目的實現內存模型(指令執行)順序保證,即確保某些情況下順序不被調整(或即便調整了也不影響最終的結果正確性)。Go內存模型除了提供主流的鎖或原子操作做出順序保證外,還提供了通道操作順序保證。

通道

通道簡單理解,就是由讀,寫,緩衝三個隊列組成的數據類型。其實它的設計邏輯,以無緩衝通道爲例,假定有一空杯,大家喝水都用它,加鎖那套是要喝水的人時不時要看看,有沒有人在用那個杯子,沒有人用它用完則放回原地。而通道不一樣,它是杯子到手了,我用完了,直接傳遞給下一個要用的人,當然你得保證喝完之後杯子裏有水。二者的區別在於,前者需要鎖防止大家爭搶水杯,而後者則你不需要去找水,你只需要告訴水杯我要喝水,上一個喝水的人,喝完之後,會灌滿遞給你。前者強調共享,後者重在傳遞。所以不要讓計算通過共享內存來通訊,而應該讓它們通過通訊來共享內存

最快到達

現實生活中,發出請求並不總是及時響應,有時面對多源數據,我們會發出多個請求,只採用其中響應最快的那個。

import (
     "fmt"
     "math/rand"
     "time"
 )

 func main() {
     rand.Seed(time.Now().UnixNano())
     startTime := time.Now()
	 // 採用緩衝通道,模擬同步發出多個請求
     c := make(chan int32, 5)
     for i := 0; i < cap(c); i++ {
         go source(c)
     }
    // 只取一個最快的響應結果
     rnd := <-c
	 // 測量最快時間差
     fmt.Println(time.Since(startTime))
     fmt.Println(rnd)
 }

 func source(c chan<- int32) {
     ra, rb := rand.Int31(), rand.Intn(3)+1
	 // 隨機模擬請求的響應時間
     time.Sleep(time.Duration(rb) * time.Second)
     c <- ra
 }

Future/Promise

Future/promise 常常用在請求/迴應場合,以下示例 sumSquares 函數調用的兩個實參請求併發進行。 每個通道讀取操作將阻塞到請求返回結果爲止。 兩個實參總共需要大約3秒鐘(而不是6秒鐘) 準備完畢(以較慢的一個爲準)

package main

 import (
     "fmt"
     "math/rand"
     "time"
 )

 func longTimeRequest() <-chan int32 {
     r := make(chan int32)
     go func() {
         time.Sleep(time.Second * 3)   // 模擬一個工作負載
         r <- rand.Int31n(100)     // 隨機正整數範圍
     }()
     return r
 }

 func sumSquares(a, b int32) int32 {
     return a*a + b*b
 }

 func main() {
     rand.Seed(time.Now().UnixNano())   // 準備隨機初始種子
     start := time.Now()   // 計時
     a, b := longTimeRequest(), longTimeRequest()  // goroutine分發,併發執行
     fmt.Println(sumSquares(<-a, <-b), time.Since(start))  	 // 輸出類似 10084 3.000541298s
 }

  • 通知

互斥鎖

將容量爲1的緩衝通道,作爲互斥鎖,下面示例發送操作加鎖

package main
import "fmt"

func main() {
	mutex := make(chan struct{}, 1) // 容量必須爲1,二元信號
	counter := 0
	increase := func() {
		mutex <- struct{}{} // 發送通道加鎖
		counter++
		<-mutex // 解鎖
	}
	increase10 := func(done chan<- struct{}) {
		for i := 0; i < 10; i++ {
			increase()
		}
		done <- struct{}{}
	}
	done := make(chan struct{})
	go increase10(done)
	go increase10(done)
	<-done; <-done
	fmt.Println(counter) // 20
}

計數信號量

計數信號量經常被使用於限制最大併發數,下面以酒吧喝酒示例

package main

 import (
     "log"
     "math/rand"
     "time"
 )

 type Seat int
 type Bar chan Seat

 func (bar Bar) ServeCustomer(c int, seat Seat) {
     log.Print("顧客#", c, "進酒吧了")
     log.Print("++ 顧客", c, "坐在",seat,"號位開始喝酒#", )
     time.Sleep(time.Second * time.Duration(2+rand.Intn(6)))
     log.Print("-- 顧客#", c, "離開了", seat, "號座位")
     bar <- seat    // 離開座位
 }

 func main() {
     rand.Seed(time.Now().UnixNano())
     bar24x7 := make(Bar, 3)    // 此酒吧最多能同時服務3個客人
     for seatId := 0; seatId < cap(bar24x7); seatId++ {
         bar24x7 <- Seat(seatId)    // 酒吧放置3把椅子,此處不會阻塞
     }

     for customerId := 0; ; customerId++ {
         time.Sleep(time.Second)
         seat := <-bar24x7 // 等待,當有空位時允許進
		  go bar24x7.ServeCustomer(customerId, seat)  
	 } 
	  select {}   // 主協程永久阻塞,防止退出 
  }  

  • 對戰

其它

用通道實現請求/應答模式,使用緩衝,並不能保證結果順序與分發順序一致。道理很簡單,在同步發出多個請求,最先響應的並不一定是第一個請求(各個請求響應耗時不一),你不能根據響應的結果來斷定通道是否讀取完畢。讀寫一致是最好的保證。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章