Go語言核心36講(Go語言實戰與應用十一)–學習筆記

33 | 臨時對象池sync.Pool

到目前為止,我們已經一起學習了 Go 語言標準庫中最重要的那幾個同步工具,這包括非常經典的互斥鎖、讀寫鎖、條件變數和原子操作,以及 Go 語言特有的幾個同步工具:

1、sync/atomic.Value

2、sync.Once

3、sync.WaitGroup

4、context.Context

今天,我們來講 Go 語言標準庫中的另一個同步工具:sync.Pool。

sync.Pool類型可以被稱為臨時對象池,它的值可以被用來存儲臨時的對象。與 Go 語言的很多同步工具一樣,sync.Pool類型也屬於結構體類型,它的值在被真正使用之後,就不應該再被複制了。

這裡的「臨時對象」的意思是:不需要持久使用的某一類值。這類值對於程式來說可有可無,但如果有的話會明顯更好。它們的創建和銷毀可以在任何時候發生,並且完全不會影響到程式的功能。

同時,它們也應該是無需被區分的,其中的任何一個值都可以代替另一個。如果你的某類值完全滿足上述條件,那麼你就可以把它們存儲到臨時對象池中。

你可能已經想到了,我們可以把臨時對象池當作針對某種數據的快取來用。實際上,在我看來,臨時對象池最主要的用途就在於此。

sync.Pool類型只有兩個方法——Put和Get。Put 用於在當前的池中存放臨時對象,它接受一個interface{}類型的參數;而 Get 則被用於從當前的池中獲取臨時對象,它會返回一個interface{}類型的值。

更具體地說,這個類型的Get方法可能會從當前的池中刪除掉任何一個值,然後把這個值作為結果返回。如果此時當前的池中沒有任何值,那麼這個方法就會使用當前池的New欄位創建一個新值,並直接將其返回。

sync.Pool類型的New欄位代表著創建臨時對象的函數。它的類型是沒有參數但有唯一結果的函數類型,即:func() interface{}。

這個函數是Get方法最後的臨時對象獲取手段。Get方法如果到了最後,仍然無法獲取到一個值,那麼就會調用該函數。該函數的結果值並不會被存入當前的臨時對象池中,而是直接返回給Get方法的調用方。

這裡的New欄位的實際值需要我們在初始化臨時對象池的時候就給定。否則,在我們調用它的Get方法的時候就有可能會得到nil。所以,sync.Pool類型並不是開箱即用的。不過,這個類型也就只有這麼一個公開的欄位,因此初始化起來也並不麻煩。

舉個例子。標準庫程式碼包fmt就使用到了sync.Pool類型。這個包會創建一個用於快取某類臨時對象的sync.Pool類型值,並將這個值賦給一個名為ppFree的變數。這類臨時對象可以識別、格式化和暫存需要列印的內容。

var ppFree = sync.Pool{
 New: func() interface{} { return new(pp) },
}

臨時對象池ppFree的New欄位在被調用的時候,總是會返回一個全新的pp類型值的指針(即臨時對象)。這就保證了ppFree的Get方法總能返回一個可以包含需要列印內容的值。

pp類型是fmt包中的私有類型,它有很多實現了不同功能的方法。不過,這裡的重點是,它的每一個值都是獨立的、平等的和可重用的。

更具體地說,這些對象既互不干擾,又不會受到外部狀態的影響。它們幾乎只針對某個需要列印內容的緩衝區而已。由於fmt包中的程式碼在真正使用這些臨時對象之前,總是會先對其進行重置,所以它們並不在意取到的是哪一個臨時對象。這就是臨時對象的平等性的具體體現。

另外,這些程式碼在使用完臨時對象之後,都會先抹掉其中已緩衝的內容,然後再把它存放到ppFree中。這樣就為重用這類臨時對象做好了準備。

眾所周知的fmt.Println、fmt.Printf等列印函數都是如此使用ppFree,以及其中的臨時對象的。因此,在程式同時執行很多的列印函數調用的時候,ppFree可以及時地把它快取的臨時對象提供給它們,以加快執行的速度。

而當程式在一段時間內不再執行列印函數調用時,ppFree中的臨時對象又能夠被及時地清理掉,以節省記憶體空間。

顯然,在這個維度上,臨時對象池可以幫助程式實現可伸縮性。這就是它的最大價值。

我想,到了這裡你已經清楚了臨時對象池的基本功能、使用方式、適用場景和存在意義。我們下面來討論一下它的一些內部機制,這樣,我們就可以更好地利用它做更多的事。

首先,我來問你一個問題。這個問題很可能也是你想問的。今天的問題是:為什麼說臨時對象池中的值會被及時地清理掉?

這裡的典型回答是:因為,Go 語言運行時系統中的垃圾回收器,所以在每次開始執行之前,都會對所有已創建的臨時對象池中的值進行全面地清除。

問題解析

我在前面已經向你講述了臨時對象會在什麼時候被創建,下面我再來詳細說說它會在什麼時候被銷毀。

sync包在被初始化的時候,會向 Go 語言運行時系統註冊一個函數,這個函數的功能就是清除所有已創建的臨時對象池中的值。我們可以把它稱為池清理函數。

一旦池清理函數被註冊到了 Go 語言運行時系統,後者在每次即將執行垃圾回收時就都會執行前者。

另外,在sync包中還有一個包級私有的全局變數。這個變數代表了當前的程式中使用的所有臨時對象池的匯總,它是元素類型為*sync.Pool的切片。我們可以稱之為池匯總列表。

通常,在一個臨時對象池的Put方法或Get方法第一次被調用的時候,這個池就會被添加到池匯總列表中。正因為如此,池清理函數總是能訪問到所有正在被真正使用的臨時對象池。

更具體地說,池清理函數會遍歷池匯總列表。對於其中的每一個臨時對象池,它都會先將池中所有的私有臨時對象和共享臨時對象列表都置為nil,然後再把這個池中的所有本地池列表都銷毀掉。

最後,池清理函數會把池匯總列表重置為空的切片。如此一來,這些池中存儲的臨時對象就全部被清除乾淨了。

如果臨時對象池以外的程式碼再無對它們的引用,那麼在稍後的垃圾回收過程中,這些臨時對象就會被當作垃圾銷毀掉,它們佔用的記憶體空間也會被回收以備他用。

以上,就是我對臨時對象清理的進一步說明。首先需要記住的是,池清理函數和池匯總列表的含義,以及它們起到的關鍵作用。一旦理解了這些,那麼在有人問到你這個問題的時候,你應該就可以從容地應對了。

不過,我們在這裡還碰到了幾個新的詞,比如:私有臨時對象、共享臨時對象列表和本地池。這些都代表著什麼呢?這就涉及了下面的問題。

知識擴展

問題 1:臨時對象池存儲值所用的數據結構是怎樣的?

在臨時對象池中,有一個多層的數據結構。正因為有了它的存在,臨時對象池才能夠非常高效地存儲大量的值。

這個數據結構的頂層,我們可以稱之為本地池列表,不過更確切地說,它是一個數組。這個列表的長度,總是與 Go 語言調度器中的 P 的數量相同。

還記得嗎?Go 語言調度器中的 P 是 processor 的縮寫,它指的是一種可以承載若干個 G、且能夠使這些 G 適時地與 M 進行對接,並得到真正運行的中介。

這裡的 G 正是 goroutine 的縮寫,而 M 則是 machine 的縮寫,後者指代的是系統級的執行緒。正因為有了 P 的存在,G 和 M 才能夠進行靈活、高效的配對,從而實現強大的並發編程模型。

P 存在的一個很重要的原因是為了分散並發程式的執行壓力,而讓臨時對象池中的本地池列表的長度與 P 的數量相同的主要原因也是分散壓力。這裡所說的壓力包括了存儲和性能兩個方面。在說明它們之前,我們先來探索一下臨時對象池中的那個數據結構。

在本地池列表中的每個本地池都包含了三個欄位(或者說組件),它們是:存儲私有臨時對象的欄位private、代表了共享臨時對象列表的欄位shared,以及一個sync.Mutex類型的嵌入欄位。

image

sync.Pool 中的本地池與各個 G 的對應關係

實際上,每個本地池都對應著一個 P。我們都知道,一個 goroutine 要想真正運行就必須先與某個 P 產生關聯。也就是說,一個正在運行的 goroutine 必然會關聯著某個 P。

在程式調用臨時對象池的Put方法或Get方法的時候,總會先試圖從該臨時對象池的本地池列表中,獲取與之對應的本地池,依據的就是與當前的 goroutine 關聯的那個 P 的 ID。

換句話說,一個臨時對象池的Put方法或Get方法會獲取到哪一個本地池,完全取決於調用它的程式碼所在的 goroutine 關聯的那個 P。

既然說到了這裡,那麼緊接著就會有下面這個問題。

問題 2:臨時對象池是怎樣利用內部數據結構來存取值的?

臨時對象池的Put方法總會先試圖把新的臨時對象,存儲到對應的本地池的private欄位中,以便在後面獲取臨時對象的時候,可以快速地拿到一個可用的值。

只有當這個private欄位已經存有某個值時,該方法才會去訪問本地池的shared欄位。

相應的,臨時對象池的Get方法,總會先試圖從對應的本地池的private欄位處獲取一個臨時對象。只有當這個private欄位的值為nil時,它才會去訪問本地池的shared欄位。

一個本地池的shared欄位原則上可以被任何 goroutine 中的程式碼訪問到,不論這個 goroutine 關聯的是哪一個 P。這也是我把它叫做共享臨時對象列表的原因。

相比之下,一個本地池的private欄位,只可能被與之對應的那個 P 所關聯的 goroutine 中的程式碼訪問到,所以可以說,它是 P 級私有的。

以臨時對象池的Put方法為例,它一旦發現對應的本地池的private欄位已存有值,就會去訪問這個本地池的shared欄位。當然,由於shared欄位是共享的,所以此時必須受到互斥鎖的保護。

還記得本地池嵌入的那個sync.Mutex類型的欄位嗎?它就是這裡用到的互斥鎖,也就是說,本地池本身就擁有互斥鎖的功能。Put方法會在互斥鎖的保護下,把新的臨時對象追加到共享臨時對象列表的末尾。

相應的,臨時對象池的Get方法在發現對應本地池的private欄位未存有值時,也會去訪問後者的shared欄位。它會在互斥鎖的保護下,試圖把該共享臨時對象列表中的最後一個元素值取出並作為結果。

不過,這裡的共享臨時對象列表也可能是空的,這可能是由於這個本地池中的所有臨時對象都已經被取走了,也可能是當前的臨時對象池剛被清理過。

無論原因是什麼,Get方法都會去訪問當前的臨時對象池中的所有本地池,它會去逐個搜索它們的共享臨時對象列表。

只要發現某個共享臨時對象列表中包含元素值,它就會把該列表的最後一個元素值取出並作為結果返回。

image

從 sync.Pool 中獲取臨時對象的步驟

當然了,即使這樣也可能無法拿到一個可用的臨時對象,比如,在所有的臨時對象池都剛被大清洗的情況下就會是如此。

這時,Get方法就會使出最後的手段——調用可創建臨時對象的那個函數。還記得嗎?這個函數是由臨時對象池的New欄位代表的,並且需要我們在初始化臨時對象池的時候給定。如果這個欄位的值是nil,那麼Get方法此時也只能返回nil了。

以上,就是我對這個問題的較完整回答。

總結

今天,我們一起討論了另一個比較有用的同步工具——sync.Pool類型,它的值被我稱為臨時對象池。臨時對象池有一個New欄位,我們在初始化這個池的時候最好給定它。

臨時對象池還擁有兩個方法,即:Put和Get,它們分別被用於向池中存放臨時對象,和從池中獲取臨時對象。

臨時對象池中存儲的每一個值都應該是獨立的、平等的和可重用的。我們應該既不用關心從池中拿到的是哪一個值,也不用在意這個值是否已經被使用過。

要完全做到這兩點,可能會需要我們額外地寫一些程式碼。不過,這個程式碼量應該是微乎其微的,就像fmt包對臨時對象池的用法那樣。所以,在選用臨時對象池的時候,我們必須要把它將要存儲的值的特性考慮在內。

在臨時對象池的內部,有一個多層的數據結構支撐著對臨時對象的存儲。它的頂層是本地池列表,其中包含了與某個 P 對應的那些本地池,並且其長度與 P 的數量總是相同的。

在每個本地池中,都包含一個私有的臨時對象和一個共享的臨時對象列表。前者只能被其對應的 P 所關聯的那個 goroutine 中的程式碼訪問到,而後者卻沒有這個約束。從另一個角度講,前者用於臨時對象的快速存取,而後者則用於臨時對象的池內共享。

正因為有了這樣的數據結構,臨時對象池才能夠有效地分散存儲壓力和性能壓力。同時,又因為臨時對象池的Get方法對這個數據結構的妙用,才使得其中的臨時對象能夠被高效地利用。比如,該方法有時候會從其他的本地池的共享臨時對象列表中,「偷取」一個臨時對象。

這樣的內部結構和存取方式,讓臨時對象池成為了一個特點鮮明的同步工具。它存儲的臨時對象都應該是擁有較長生命周期的值,並且,這些值不應該被某個 goroutine 中的程式碼長期的持有和使用。

因此,臨時對象池非常適合用作針對某種數據的快取。從某種角度講,臨時對象池可以幫助程式實現可伸縮性,這也正是它的最大價值。

思考題

今天的思考題是:怎樣保證一個臨時對象池中總有比較充足的臨時對象?

請從臨時對象池的初始化和方法調用兩個方面作答。必要時可以參考fmt包以及 demo70.go 文件中使用臨時對象池的方式。

package main

import (
	"bytes"
	"fmt"
	"io"
	"sync"
)

// bufPool 代表存放數據塊緩衝區的臨時對象池。
var bufPool sync.Pool

// Buffer 代表了一個簡易的數據塊緩衝區的介面。
type Buffer interface {
	// Delimiter 用於獲取數據塊之間的定界符。
	Delimiter() byte
	// Write 用於寫一個數據塊。
	Write(contents string) (err error)
	// Read 用於讀一個數據塊。
	Read() (contents string, err error)
	// Free 用於釋放當前的緩衝區。
	Free()
}

// myBuffer 代表了數據塊緩衝區一種實現。
type myBuffer struct {
	buf       bytes.Buffer
	delimiter byte
}

func (b *myBuffer) Delimiter() byte {
	return b.delimiter
}

func (b *myBuffer) Write(contents string) (err error) {
	if _, err = b.buf.WriteString(contents); err != nil {
		return
	}
	return b.buf.WriteByte(b.delimiter)
}

func (b *myBuffer) Read() (contents string, err error) {
	return b.buf.ReadString(b.delimiter)
}

func (b *myBuffer) Free() {
	bufPool.Put(b)
}

// delimiter 代表預定義的定界符。
var delimiter = byte('\n')

func init() {
	bufPool = sync.Pool{
		New: func() interface{} {
			return &myBuffer{delimiter: delimiter}
		},
	}
}

// GetBuffer 用於獲取一個數據塊緩衝區。
func GetBuffer() Buffer {
	return bufPool.Get().(Buffer)
}

func main() {
	buf := GetBuffer()
	defer buf.Free()
	buf.Write("A Pool is a set of temporary objects that" +
		"may be individually saved and retrieved.")
	buf.Write("A Pool is safe for use by multiple goroutines simultaneously.")
	buf.Write("A Pool must not be copied after first use.")

	fmt.Println("The data blocks in buffer:")
	for {
		block, err := buf.Read()
		if err != nil {
			if err == io.EOF {
				break
			}
			panic(fmt.Errorf("unexpected error: %s", err))
		}
		fmt.Print(block)
	}
}

筆記源碼

//github.com/MingsonZheng/go-core-demo

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含鏈接: //www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發布。