大話通道

pardon110發表於2019-10-16

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

記憶體順序

編譯器(在編譯時刻)最佳化和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 {}   // 主協程永久阻塞,防止退出 
  }  
  • 對戰

其它

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

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章