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

MingsonZheng發表於2021-11-23

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)
	}
}

筆記原始碼

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

知識共享許可協議

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

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

相關文章