1. 程式人生 > >《GO併發程式設計實戰》—— 臨時物件池

《GO併發程式設計實戰》—— 臨時物件池

宣告:本文是《Go併發程式設計實戰》的樣章,感謝圖靈授權併發程式設計網站釋出樣章,

本章要講解的是sync.Pool型別。我們可以把sync.Pool型別值看作是存放可被重複使用的值的容器。此類容器是自動伸縮的、高效的,同時也是併發安全的。為了描述方便,我們也會把sync.Pool型別的值稱為臨時物件池,而把存於其中的值稱為物件值。至於為什麼要加“臨時“這兩個字,我們稍後再解釋。
我們在用複合字面量初始化一個臨時物件池的時候可以為它唯一的公開欄位New賦值。該欄位的型別是func() interface{},即一個函式型別。可以猜到,被賦給欄位New的函式會被臨時物件池用來建立物件值。不過,實際上,該函式幾乎僅在池中無可用物件值的時候才會被呼叫。
型別sync.Pool有兩個公開的方法。一個是Get,另一個是Put。前者的功能是從池中獲取一個interface{}型別的值,而後者的作用則是把一個interface{}型別的值放置於池中。

 通過Get方法獲取到的值是任意的。如果一個臨時物件池的Put方法未被呼叫過,且它的New欄位也未曾被賦予一個非nil的函式值,那麼它的Get方法返回的結果值就一定會是nil。我們稍後會講到,Get方法返回的不一定就是存在於池中的值。不過,如果這個結果值是池中的,那麼在該方法返回它之前就一定會把它從池中刪除掉。
這樣一個臨時物件池在功能上看似與一個通用的快取池相差無幾。但是實際上,臨時物件池本身的特性決定了它是一個“個性”非常鮮明的同步工具。我們在這裡說明它的兩個非常突出的特性。
第一個特性是,臨時物件池可以把由其中的物件值產生的儲存壓力進行分攤。更進一步說,它會專門為每一個與操作它的Goroutine相關聯的P都生成一個本地池。在臨時物件池的Get方法被呼叫的時候,它一般會先嚐試從與本地P對應的那個本地池中獲取一個物件值。如果獲取失敗,它就會試圖從其他P的本地池中偷一個物件值並直接返回給呼叫方。如果依然未果,那它只能把希望寄託於當前的臨時物件池的New欄位代表的那個物件值生成函數了。注意,這個物件值生成函式產生的物件值永遠不會被放置到池中。它會被直接返回給呼叫方。另一方面,臨時物件池的Put方法會把它的引數值存放到與當前P對應的那個本地池中。每個P的本地池中的絕大多數物件值都是被同一個臨時物件池中的所有本地池所共享的。也就是說,它們隨時可能會被偷走。
臨時物件池的第二個突出特性是對垃圾回收友好。垃圾回收的執行一般會使臨時物件池中的物件值被全部移除。也就是說,即使我們永遠不會顯式的從臨時物件池取走某一個物件值,該物件值也不會永遠待在臨時物件池中。它的生命週期取決於垃圾回收任務下一次的執行時間。
請讀者閱讀一下這段程式碼:

package main

import (
    "fmt"
    "runtime"
    "runtime/debug"
    "sync"
    "sync/atomic"
)

func main() {
    // 禁用GC,並保證在main函式執行結束前恢復GC
    defer debug.SetGCPercent(debug.SetGCPercent(-1))
    var count int32
    newFunc := func() interface{} {
        return atomic.AddInt32(&count, 1)
    }
    pool := sync.Pool{New: newFunc}

    // New 欄位值的作用
    v1 := pool.Get()
    fmt.Printf("v1: %v\n", v1)

    // 臨時物件池的存取
    pool.Put(newFunc())
    pool.Put(newFunc())
    pool.Put(newFunc())
    v2 := pool.Get()
    fmt.Printf("v2: %v\n", v2)

    // 垃圾回收對臨時物件池的影響
    debug.SetGCPercent(100)
    runtime.GC()
    v3 := pool.Get()
    fmt.Printf("v3: %v\n", v3)
    pool.New = nil
    v4 := pool.Get()
    fmt.Printf("v4: %v\n", v4)
}

在這裡,我們使用runtime/debug程式碼包的SetGCPercent函式來禁用、恢復GC以及指定垃圾收集比率(詳見第7章的第1節中的相關說明),以保證我們的演示能夠如願進行。
我們把這段程式碼存放在gocp專案的sync1/pool程式碼包的檔案pool_demo.go中,並使用go run命令執行它。就像下面這樣:
[email protected]:~/golang/goc2p/src/sync1/pool$ go run pool_demo.go
而後,我們會在標準輸出上看到如下內容:

v1: 1
v2: 2
v3: 5
v4: <nil>

請讀者注意第3行和第4行的內容,也就是我們在手動的進行垃圾回收之後的輸出內容。在把nil賦給pool的New欄位之前,即使手動的執行了垃圾回收,我們也是可以從臨時物件池獲取到一個物件值的。而在這之後,我們卻只能取出nil。讀者應該可以依據我們剛剛描述的那兩個特性想明白如此輸出的原因。
看到這裡,讀者可能會隱約的感覺到,我們在使用臨時物件池的時候應該依照一些方式方法,否則就會很容易邁入陷坑。實際情況確實如此。
首先,我們不能對通過Get方法獲取到的物件值有任何假設。到底哪一個值會被取出是完全不確定的。這是因為我們總是不能得知操作臨時物件池的Goroutine在哪一時刻會與哪一個P相關聯,尤其是在比上述示例更加複雜的程式的執行過程中。在這種情況下,我們也就無從知曉我們放入的物件值會被存放到哪一個本地池中,以及哪一個Goroutine執行的Get方法會返回該物件值。所以,我們給予臨時物件池的物件值生成函式所產生的值以及通過呼叫它的Put方法放入到池中的值都應該是無狀態的或者狀態一致的。從另一方面說,我們在取出並使用這些值的時候也不應該以其中的任何狀態作為先決條件。這一點非常的重要。
第二個需要注意的地方實際上與我們前面講到的第二個特性緊密相關。臨時物件池中的任何物件值都有可能在任何時候被移除掉,並且根本不會通知該池的使用方。這種情況常常會發生在垃圾回收器即將開始回收記憶體垃圾的時候。如果這時臨時物件池中的某個物件值僅被該池引用,那麼它還可能會在垃圾回收的時候被回收掉。因此,我們也就不能假設之前放入到臨時物件池的某個物件值會一直待在池中,即使我們沒有顯式的把它從池中取出。甚至一個物件值可以在臨時物件池中待多久,我們也無法假設。除非我們像前面的示例那樣手動的控制GC的啟停。不過,我們並不推薦這種方式。這會帶來一些其他問題。
依據我們剛剛講述的臨時物件池特性和使用注意事項,讀者應該可以想象得出臨時物件池的一些適用場景(比如作為臨時且狀態無關的資料的暫存處),以及一些不適用的場景(比如用來存放資料庫連線的例項)。如果我們在做實現技術的選型的時候把臨時物件池作為了候選之一,那麼就應該好好想想它的“個性”是不是符合你的需要。如果真的適合,那麼它的特性一定會為你的程式增光添彩,無論在功能上還是在效能上。而如果它被用在了不恰當的地方,那麼就只能適得其反了。