gopl 使用共享變量實現併發

競態

併發,如果無法確定一個事件先於另外一個事件,那麼這兩個事件就是併發的。
併發安全(concurrency-safe),如果一個函數在併發調用時仍然能正確工作,那麼這個函數就是併發安全的。如果一個類型的所有可訪問方法和操作都是併發安全的,則它可稱爲併發安全的類型。

併發安全的類型是特例而不是普遍存在的,對於絕大部分變量,如要回避併發訪問,只有下面幾種辦法:

  • 限制變量只存在於一個 goroutine 內。
  • 維護一個更高層的互斥不變量

競態是指在多個 goroutine 按某些交錯順序執行時程序無法給出正確的結果。
數據競態(data race)是競態的一種。數據競態發生於兩個 goroutine 併發讀寫同一個變量並且至少其中一個是寫入時。有三種方法來避免數據競態:

  • 不要修改變量
  • 避免從多個 goroutine 訪問同一個變量。就是限制
  • 允許多個 goroutine 訪問同一個變量,但在同一時間只有一個 goroutine 可以訪問。這種方法稱爲互斥機制

Go 箴言:“不要通過共享內存來通信,而應該通過通信來共享內存”。

互斥鎖:sync.Mutex

使用緩衝通道可以實現一個計數信號量,可以用於同時發起的 goroutine 的數量。一個計數上限爲 1 的信號量稱爲二進制信號量(binary semaphore)。
使用二進制信號量就可以實現互斥鎖:

var (
    sema    = make(chan struct{}, 1) // 用來保護 balance 的二進制信號量
    balance int
)

func Deposit(amount int) {
    sema <- struct{}{} // 獲取令牌
    balance = balance + amount
    <-sema // 釋放令牌
}

func Balance() int {
    sema <- struct{}{} // 獲取令牌
    b := balance
    <-sema // 釋放令牌
    return b
}

互斥鎖模式應用非常廣泛,所以 sync 包有一個單獨的 Mutex 類型來支持這種模式:

import "sync"

var (
    mu      sync.Mutex // 保護 balance
    balance int
)

func Deposit(amount int) {
    mu.Lock()
    balance = balance + amount
    mu.Unlock()
}

func Balance() int {
    mu.Lock()
    b := balance
    mu.Unlock()
    return b
}

互斥量保護共享變量。按照慣例,被互斥量保護的變量聲明應當緊接在互斥量的聲明之後。如果實際情況不是如此,請加註釋說明。

臨界區域,在 Lock 和 Unlock 之間的代碼,可以自由地讀取和修改共享變量,這一部分稱爲臨界區域。

封裝,即通過在程序中減少對數據結構的非預期交互,來幫助我們保證數據結構中的不變量。類似的原因,封裝也可以用來保持併發中的不變性。所以無論是爲了保護包級別的變量,還是結構中的字段,當使用一個互斥量時,都請確保互斥量本身以及被保護的變量都沒有導出。

讀寫互斥鎖:sync.RWMutex

多讀單寫鎖,允許只讀操作可以併發執行,但寫操作需要獲得完全獨享的訪問權限。Go 語言中的 sync.RWMutex 提供了這種功能:

var mu sync.RWMutex
var balance int

func Balance() int {
    mu.RLock() // 讀取
    defer mu.RUnlock()
    return balance
}

Balance 函數可以調用 mu.RLock 和 mu.RUnlock 方法來分別獲取和釋放一個讀鎖(也稱爲共享鎖)。而之前的 mu.Lock 和 mu.Unlock 方法則是分別獲取和釋放一個寫鎖(也稱爲互斥鎖)。
一般情況下,不應該假定那些邏輯上只讀的函數和方法不會更新一些變量。比如,一個看起來只是簡單訪問的方法,可能會遞增內部使用的計數器,或者更新一個緩存來讓重複的調用更快。如果不確定,就應該使用互斥鎖。

讀鎖的應用場景
僅在絕大部分 goroutine 都在獲取讀鎖並且鎖競爭比較激烈時,RWMutex 纔有優勢。因爲 RWMutex 需要更復雜的內部實現,所以在競爭不激烈時它比普通的互斥鎖慢。

內存同步

現代的計算機一般會有多個處理器,每個處理器都有內存的本地緩存。爲了提高效率,對內存的寫入是緩存在每個處理器中的,只在必要時才刷回內存。甚至刷會內存的順序都可能與 goroutine 的寫入順序不一致。像通道通信或者互斥鎖操作這樣的同步源語都會導致處理器把累積的寫操作刷回內存並提交。但這個時刻之前 goroutine 的執行結果就無法保證能被運行在其他處理器的 goroutine 觀察到。
考慮如下的代碼片段可能的輸出:

var x, y int
go func() {
    x = 1
    fmt.Print("y:", y, " ")
}
go func() {
    y = 1
    fmt.Print("x:", x, " ")
}

下面4個是顯而易見的可能的輸出結果:

y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1

但是下面的輸出也是可能出現的:

x:0 y:0
y:0 x:0

在某些特定的編譯器、CPU 或者其他情況下,這些確實可能發生。

單個 goroutine 內,每個語句的效果保證按照執行的順序發生,也就是說,goroutine 是串行一致的(sequentially consistent)。但在缺乏使用通道或者互斥量來顯式同步的情況下,並不能保證所有的 goroutine 看到的事件順序都是一致的。
上面的兩個 goroutine 儘管打印語句是在賦值另外一個變量之後,但是一個 goroutine 並不一定能觀察到另一個 goroutine 對變量的效果。所以可能輸出的是一個變量的過期值
儘管很容易把併發簡單理解爲多個 goroutine 中語句的某種交錯執行方式。如果兩個 goroutine 在不同的 CPU 上執行,每個 CPU 都有自己的緩存,那麼一個 goroutine 的寫入操作在同步到內存之前對另外一個 goroutine 的打印變量的語句是不可見的。
這些併發的問題都可以通過採用簡單、成熟的模式來避免,即在可能的情況下,把變量限制到單個 goroutine 中,對於其他變量,使用互斥鎖。

延遲初始化:sync.Once

延遲一個昂貴的初始化步驟到有實際需求的時刻是一個很好的實踐。預先初始化一個變量會增加程序的啓動延遲,並且如果實際執行時有可能根本用不上這個變量,那麼初始化也不是必需的。
sync 包提供了針對一次性初始化問題的特化解決方案:sync.Once。從概念上來講,Once 包含一個布爾變量和一個互斥量,布爾變量記錄初始化是否已經完成,互斥量則負責保護這個布爾變量和客戶端的數據結構。Once 唯一的方法 Do 以初始化函數作爲它的參數:

var loadIconsOnce sync.Once
var icons map[string]image.Image

// 這是個昂貴的初始化步驟
func loadIcons() {
    icons = map[string]image.Image{
        "spades.png":   loadIcon("spades.png"),
        "hearts.png":   loadIcon("hearts.png"),
        "diamonds.png": loadIcon("diamonds.png"),
        "clubs.png":    loadIcon("clubs.png"),
    }
}

// 併發安全
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

每次調用 Do 方法時,會先鎖定互斥量並檢查裏邊的布爾變量。在第一次調用時,這個布爾變量爲 false,Do 會調用它參數的方法,然後把布爾變量設置爲 true。之後 DO 方法的調用相當於空操作,只是通過互斥量的同步來保證初始化操作對內存產生的效果對所有的 goroutine 可見。以這種方式來使用 sync.Once,可以避免變量在構造完成之前就被其他 goroutine 訪問。

goroutine 與線程

goroutine 與操作系統(OS)線程之間的差異本質上屬於量變。但是足夠大的量變會變成質變,所以還是要區分一下兩者的差異。

可增長的棧

每個 OS 線程都有一個固定大小的棧內存(通常爲 2MB),棧內存區域用戶保存在其他函數調用期間那些正在執行或臨時暫停的函數中的局部變量。這個固定的大小對小的 goroutine 來說太大了,對於要創建數量巨大的 goroutine 來說,就會有巨大的浪費。另外,對於更復雜或者深度遞歸的函數,固定大小的棧又會不夠大。改變這個固定大小,調小了可以允許創建更多的線程,改大了則可以容許更深的遞歸,但兩者無法同時兼容。
gotouine 也用於存放那些正在執行或臨時暫停的函數中的局部變量。但棧的大小不是固定的,它可與按需增大或縮小。goroutine 的棧大小限制可以達到 1GB。當然,只有極少的 goroutine 會使用這麼大的棧。

goroutine 調度

OS線程調度器
OS線程由OS內核來調度。每隔幾毫秒,一個硬件時鐘中斷髮送到CPU、CPU調用一個叫調度器的內核函數。這個函數暫停當前正在運行的線程,把它的寄存器信息保存到內存,查看線程列表並決定接下來運行哪一個線程,再從內存恢復線程的註冊表信息,最後繼續執行選中的線程。因爲OS線程由內核來調度,所以控制權限從一個線程到另外一個線程需要一個完整的上下文切換(context switch):即保存一個線程的狀態到內存,再恢復另外一個線程的狀態、最後更新調度器的數據結構。考慮這個操作涉及的內存局域性以及涉及的內存訪問數量,還有訪問內存所需的CPU週期數量的增加,這個操作其實是很慢的。

Go調度器
Go 運行時包含一個自己的調度器,這個調度器使用一個稱爲m:n 調度的技術(因爲它可以複用/調度 m 個 goroutine 到 n 個OS線程)。Go 調度器與內核調度器的工作類似,但 Go 調度器值需關心單個 Go 程序的 goroutine 調度問題。

差別
與操作系統的線程調度器不同的是,Go 調度器不是由硬件時鐘來定期觸發的,而是由特定的 Go 語言結構來觸發的。比如當一個 goroutine 調用 time.Sleep 或被通道阻塞或對互斥量操作時,調度器就會將這個 goroutine 設爲休眠模式,並運行其他 goroutine 直到前一個可重新喚醒爲止。因爲它不需要切換到內核語境,所以調用一個 goroutine 比調度一個線程成本低很多。

GOMAXPROCS

Go 調度器使用 GOMAXPROCS 參數來確定需要使用多少個OS線程來同時執行 Go 代碼,默認值是機器上的CPU數量(GOMAXPROCS 是 m:n 調度中的 n)。正在休眠或者正被通道通信阻塞的 goroutine 不需要佔用線程。阻塞在 I\/O 和其他系統調用中或調用非 Go 語言寫的函數的 goroutine 需要一個獨立的OS線程,但這個線程不計算在 GOMAXPROCS 內。
可以用 GOMAXPROCS 環境變量或者 runtime.GOMAXPROCS 函數來顯式控制這個參數。可以用一個小程序來看看 GOMAXPROCS 的效果,這個程序無止境地輸出0和1:

func main() {
    var n int
    flag.IntVar(&n, "n", 1, "GOMAXPROCS")
    flag.Parse()
    runtime.GOMAXPROCS(n)
    for {
        go fmt.Print(0)
        fmt.Print(1)
    }
}

這裏使用命令行參數來控制線程數量。
Linux 中應該可以直接設置 GOMAXPROCS 環境變量來運行程序:

$ GOMAXPROCS=1 go run main.go
$ GOMAXPROCS=2 go run main.go

GOMAXPROCS 爲1時,每次最多隻能由一個 goroutine 運行。最開始是主 goroutine,它會連續輸出很多1。在運行了一段時間之後,Go 調度器讓主 goroutine 休眠,並喚醒另一個輸出0的 goroutine,讓它有機會執行。所以執行結果能看到大段的連續的0或1。
GOMAXPROCS 爲2時,就有兩個可用的OS線程,所以兩個 goroutine 可以同時運行,輸出的0和1就會交替出現(我看到的是小段小段的交替)。

goroutine 沒有標識

在大部分支持多線程的操作系統和編程語言裏,當前線程都有一個獨特的標識,它通常可以取一個整數或者指針。這個特性讓我們可以輕鬆構建一個線程的局部存儲,它本質上就是一個全局的 map,以線程的標識爲 key,這樣各個線程都可以獨立地用這個 map 存儲和獲取值,而不受其他線程的干擾。
goroutine 沒有可供程序員訪問的表示。這個是有設計來決定的,因爲線程局部存儲有一個被濫用的的傾向。
Go 語言鼓勵一種更簡單的編程風格。其中,能影響一個函數行爲的參數應當是顯式指定的。

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