Go 并發(fā)編程 — sync.Pool 源碼級(jí)原理剖析 [2] 終結(jié)篇

大綱

前情提要
原理剖析
數(shù)據(jù)結(jié)構(gòu)
Get
Put
runtime
思考問題
1. 如果不是 Pool.Get 申請(qǐng)的對(duì)象,調(diào)用了 Put ,會(huì)怎么樣?
2. `Pool.Get` 出來的對(duì)象,為什么要 `Pool.Put` 放回 Pool 池,是為了不變成自己討厭的垃圾嗎?
3. Pool 本身允許復(fù)制之后使用嗎?
總結(jié)

前情提要

上次我們從使用層面做了梳理分析,詳情見: Go 并發(fā)編程—深入淺出sync.Pool [1] 使用姿勢(shì)篇,得到以下幾點(diǎn)小知識(shí):
sync.Pool 本質(zhì)用途是增加臨時(shí)對(duì)象的重用率,減少 GC 負(fù)擔(dān); 不能對(duì) Pool.Get 出來的對(duì)象做預(yù)判,有可能是新的(新分配的),有可能是舊的(之前人用過,然后 Put 進(jìn)去的); 不能對(duì) Pool 池里的元素個(gè)數(shù)做假定,你不能夠; sync.Pool 本身的 Get, Put 調(diào)用是并發(fā)安全的, sync.New指向的初始化函數(shù)會(huì)并發(fā)調(diào)用,里面安不安全只有自己知道;當(dāng)用完一個(gè)從 Pool 取出的實(shí)例時(shí)候,一定要記得調(diào)用 Put,否則 Pool 無法復(fù)用這個(gè)實(shí)例,通常這個(gè)用 defer 完成;
官方開頭聲明:
A Pool is a set of temporary objects that may be individually saved and retrieved.
并且還制作了一個(gè)演示動(dòng)畫視頻來幫助理解,詳情見: Go 并發(fā)編程 — 有趣的sync.Pool原理動(dòng)畫。
本篇是 sync.Pool 源碼級(jí)別的分析,屬于 sync.Pool 分析完結(jié)篇,三次分享梳理循序漸進(jìn),配合一起學(xué)習(xí)效果更好哦。

原理剖析

下面我們從數(shù)據(jù)結(jié)構(gòu)和實(shí)現(xiàn)邏輯來深入剖析下 sync.Pool 的原理。
數(shù)據(jù)結(jié)構(gòu)
Pool 結(jié)構(gòu)
sturct Pool 結(jié)構(gòu)是給到用戶的使用的結(jié)構(gòu),定義:
type Pool struct {
// 用于檢測(cè) Pool 池是否被 copy,因?yàn)?Pool 不希望被 copy;
// 有了這個(gè)字段之后,可用用 go vet 工具檢測(cè),在編譯期間就發(fā)現(xiàn)問題;
noCopy noCopy
// 數(shù)組結(jié)構(gòu),對(duì)應(yīng)每個(gè) P,數(shù)量和 P 的數(shù)量一致;
local unsafe.Pointer
localSize uintptr
// GC 到時(shí),victim 和 victimSize 會(huì)分別接管 local 和 localSize;
// victim 的目的是為了減少 GC 后冷啟動(dòng)導(dǎo)致的性能抖動(dòng),讓分配對(duì)象更平滑;
victim unsafe.Pointer
victimSize uintptr
// 對(duì)象初始化構(gòu)造方法,使用方定義
New func() interface{}
}
有幾個(gè)注意點(diǎn):
noCopy 為了防止 copy 加的打樁代碼,但這個(gè)阻止不了編譯,只能通過 go vet檢查出來;local和localSize這兩個(gè)字段實(shí)現(xiàn)了一個(gè)數(shù)組,數(shù)組元素為poolLocal結(jié)構(gòu),用來管理臨時(shí)對(duì)象;victim和victimSize這個(gè)是在poolCleanup流程里賦值了,賦值的內(nèi)容就是local和localSize。victim 機(jī)制是把 Pool 池的清理由一輪 GC 改成 兩輪 GC,進(jìn)而提高對(duì)象的復(fù)用率,減少抖動(dòng);使用方只能賦值 New 字段,定義對(duì)象初始化構(gòu)造行為;
poolLocal 結(jié)構(gòu)
該結(jié)構(gòu)是管理 Pool 池里 cache 元素的關(guān)鍵結(jié)構(gòu),Pool.local 指向的就是這么一個(gè)類型的數(shù)組,這個(gè)結(jié)構(gòu)值得注意的一點(diǎn)是使用了內(nèi)存填充,對(duì)齊 cache line,防止 false sharing 性能問題的技巧。
Pool 里面該結(jié)構(gòu)數(shù)組是按照 P 的個(gè)數(shù)分配的,每個(gè) P 都對(duì)應(yīng)一個(gè)這個(gè)結(jié)構(gòu)。
// Pool.local 指向的數(shù)組元素類型
type poolLocal struct {
poolLocalInternal
// 把 poolLocal 填充至 128 字節(jié)對(duì)齊,避免 false sharing 引起的性能問題
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}
// 管理 cache 的內(nèi)部結(jié)構(gòu),跟每個(gè) P 對(duì)應(yīng),操作無需加鎖
type poolLocalInternal struct {
// 每個(gè) P 的私有,使用時(shí)無需加鎖
private interface{}
// 雙鏈表結(jié)構(gòu),用于掛接 cache 元素
shared poolChain
}
poolChain
我們可以稍微看下 poolChain 結(jié)構(gòu),這個(gè)純粹是一個(gè)連接件,本身空間也就兩指針,占用內(nèi)存 16 Byte。
type poolChain struct {
head *poolChainElt
tail *poolChainElt
}
所以關(guān)鍵還是鏈表的元素,鏈表元素的結(jié)構(gòu)是 poolChainElt,這個(gè)結(jié)構(gòu)體長(zhǎng)這樣:
type poolChainElt struct {
// 本質(zhì)是個(gè)數(shù)組內(nèi)存空間,管理成 ringbuffer 的模式;
poolDequeue
// 鏈表指針
next, prev *poolChainElt
}
type poolDequeue struct {
headTail uint64
// vals is a ring buffer of interface{} values stored in this
// dequeue. The size of this must be a power of 2.
vals []eface
}
poolChainElt 是雙鏈表的元素點(diǎn),里面其實(shí)是一段數(shù)組空間,類似于 ringbuffer,Pool 管理的 cache 對(duì)象就都存儲(chǔ)在 poolDequeue 的 vals[] 數(shù)組里。

Get
func (p *Pool) Get() interface{} {
// 把 G 鎖住在當(dāng)前 M(聲明當(dāng)前 M 不能被搶占),返回 M 綁定的 P 的 ID
// 在當(dāng)前的場(chǎng)景,也可以認(rèn)為是 G 綁定到 P,因?yàn)檫@種場(chǎng)景 P 不可能被搶占,只有系統(tǒng)調(diào)用的時(shí)候才有 P 被搶占的場(chǎng)景;
l, pid := p.pin()
// 如果能從 private 取出緩存的元素,那么將是最快的路徑;
x := l.private
l.private = nil
if x == nil {
// 從 shared 隊(duì)列里獲取,shared 隊(duì)列在 Get 獲取,在 Put 投遞;
x, _ = l.shared.popHead()
if x == nil {
// 嘗試從獲取其他 P 的隊(duì)列里取元素,或者嘗試從 victim cache 里取元素
x = p.getSlow(pid)
}
}
// G-M 鎖定解除
runtime_procUnpin()
// 最慢的路徑:現(xiàn)場(chǎng)初始化,這種場(chǎng)景是 Pool 池里一個(gè)對(duì)象都沒有,只能現(xiàn)場(chǎng)創(chuàng)建;
if x == nil && p.New != nil {
x = p.New()
}
// 返回對(duì)象
return x
}
// 把 G 鎖住在當(dāng)前 M(聲明當(dāng)前 M 不能被搶占),返回 M 綁定的 P 的 ID
// 在當(dāng)前的場(chǎng)景,也可以認(rèn)為是 G 綁定到 P,因?yàn)檫@種場(chǎng)景 P 不可能被搶占,只有系統(tǒng)調(diào)用的時(shí)候才有 P 被搶占的場(chǎng)景;
l, pid := p.pin()
// 如果能從 private 取出緩存的元素,那么將是最快的路徑;
x := l.private
l.private = nil
if x == nil {
// 從 shared 隊(duì)列里獲取,shared 隊(duì)列在 Get 獲取,在 Put 投遞;
x, _ = l.shared.popHead()
if x == nil {
// 嘗試從獲取其他 P 的隊(duì)列里取元素,或者嘗試從 victim cache 里取元素
x = p.getSlow(pid)
}
}
// G-M 鎖定解除
runtime_procUnpin()
// 最慢的路徑:現(xiàn)場(chǎng)初始化,這種場(chǎng)景是 Pool 池里一個(gè)對(duì)象都沒有,只能現(xiàn)場(chǎng)創(chuàng)建;
if x == nil && p.New != nil {
x = p.New()
}
// 返回對(duì)象
return x
}
Get 的語義就是從 Pool 池里取一個(gè)元素出來,這里的重點(diǎn)是:元素是層層 cache 的,由最快到最慢一層層嘗試。最快的是本 P 對(duì)應(yīng)的列表里通過 private 字段直接取出,最慢的就是調(diào)用 New 函數(shù)現(xiàn)場(chǎng)構(gòu)造。
嘗試路徑:
當(dāng)前 P 對(duì)應(yīng)的 local.private字段;當(dāng)前 P 對(duì)應(yīng)的 local的雙向鏈表;其他 P 對(duì)應(yīng)的 local列表;victim cache 里的元素; New現(xiàn)場(chǎng)構(gòu)造;
runtime_procPin
runtime_procPin 是 procPin 的一層封裝,procPin 實(shí)現(xiàn)如下:
func procPin() int {
_g_ := getg()
mp := _g_.m
mp.locks++
return int(mp.p.ptr().id)
}
procPin 函數(shù)的目的是為了當(dāng)前 G 被搶占了執(zhí)行權(quán)限(也就是說,當(dāng)前 G 就在當(dāng)前 M 上不走了),這里的核心實(shí)現(xiàn)是對(duì) mp.locks++ 操作,在 newstack 里會(huì)對(duì)此條件做判斷,如果
if preempt {
// 已經(jīng)打了搶占標(biāo)識(shí)了,但是還需要判斷條件滿足才能讓出執(zhí)行權(quán);
if thisg.m.locks != 0 || thisg.m.mallocing != 0 || thisg.m.preemptoff != "" || thisg.m.p.ptr().status != _Prunning {
gp.stackguard0 = gp.stack.lo + _StackGuard
gogo(&gp.sched) // never return
}
}
Pool.pinSlow
這個(gè)函數(shù)必須提一下,這個(gè)函數(shù)做了非常重要的事情,一般是 Pool 第一次調(diào)用 Get 的時(shí)候才會(huì)走進(jìn)來(注意,是每個(gè) P 的第一次 Get 調(diào)用,但是只有一個(gè) P 上的 G 才能干成事,因?yàn)橛?allPoolsMu 鎖互斥)。
func (p *Pool) pinSlow() (*poolLocal, int) {
// G-M 先解鎖
runtime_procUnpin()
// 以下邏輯在全局鎖 allPoolsMu 內(nèi)
allPoolsMu.Lock()
defer allPoolsMu.Unlock()
// 獲取當(dāng)前 G-M-P ,P 的 id
pid := runtime_procPin()
s := p.localSize
l := p.local
if uintptr(pid) < s {
return indexLocal(l, pid), pid
}
if p.local == nil {
// 首次,Pool 需要把自己注冊(cè)進(jìn) allPools 數(shù)組
allPools = append(allPools, p)
}
// P 的個(gè)數(shù)
size := runtime.GOMAXPROCS(0)
// local 數(shù)組的大小就等于 runtime.GOMAXPROCS(0)
local := make([]poolLocal, size)
atomic.StorePointer(&p.local, unsafe.Pointer(&local[0])) // store-release
atomic.StoreUintptr(&p.localSize, uintptr(size)) // store-release
return &local[pid], pid
}
pinSlow 主要做以下幾個(gè)事情:
首次 Pool需要把自己注冊(cè)進(jìn)allPools數(shù)組;Pool.local數(shù)組按照runtime.GOMAXPROCS(0)的大小進(jìn)行分配,如果是默認(rèn)的,那么這個(gè)就是 P 的個(gè)數(shù),也就是 CPU 的個(gè)數(shù);
runtime_procUnpin
這個(gè)是對(duì)應(yīng) runtime_procPin 配套的函數(shù),聲明該 M 可以被搶占,字段 m.locks-- 。
func procUnpin() {
_g_ := getg()
_g_.m.locks--
}Put
Put 方法非常簡(jiǎn)單,因?yàn)槭呛笾锰幚?,該做的都在前面做好了,而清理?dòng)作又是在 runtime 的后臺(tái)流程·,所以這里只是把元素放置到隊(duì)列里就完成了。
// Put 一個(gè)元素進(jìn)池子;
func (p *Pool) Put(x interface{}) {
if x == nil {
return
}
// G-M 鎖定
l, _ := p.pin()
if l.private == nil {
// 嘗試放到最快的位置,這個(gè)位置也跟 Get 請(qǐng)求的順序是一一對(duì)應(yīng)的;
l.private = x
x = nil
}
if x != nil {
// 放到雙向鏈表中
l.shared.pushHead(x)
}
// G-M 鎖定解除
runtime_procUnpin()
}
但是也要注意一個(gè)小點(diǎn),就是 Put 也會(huì)調(diào)用 p.pin() ,所以 Pool.local 也可能會(huì)在這里創(chuàng)建。
runtime
全局變量
每一個(gè) Pool 結(jié)構(gòu)都加到了全局隊(duì)列里,在 src/sync/pool.go 文件里,定義了幾個(gè)全局變量:
var (
// 互斥用
allPoolsMu Mutex
// 全局的 Pool 數(shù)組,所有的 Pool 都在這里有注冊(cè)地址;
allPools []*Pool
// 配合 victim 機(jī)制用的;
oldPools []*Pool
)
后臺(tái)流程
init
初始化的時(shí)候注冊(cè)清理函數(shù)。
func init() {
runtime_registerPoolCleanup(poolCleanup)
}
在 Golang GC 開始的時(shí)候 gcStart 調(diào)用 clearpools() 函數(shù)就會(huì)調(diào)用到 poolCleanup 函數(shù)。也就是說,每一輪 GC 都是對(duì)所有的 Pool 做一次清理。
poolCleanup
這個(gè)是定期執(zhí)行的,在 sync package init 的時(shí)候注冊(cè),由 runtime 后臺(tái)執(zhí)行,內(nèi)容就是批量清理 allPools 里的元素。
func poolCleanup() {
// 清理 oldPools 上的 victim 的元素
for _, p := range oldPools {
p.victim = nil
p.victimSize = 0
}
// 把 local cache 遷移到 victim 上;
// 這樣就不致于讓 GC 把所有的 Pool 都清空了,有 victim 再兜底以下,這樣可以防止抖動(dòng);
for _, p := range allPools {
p.victim = p.local
p.victimSize = p.localSize
p.local = nil
p.localSize = 0
}
// 清理一波所有的 allPools
oldPools, allPools = allPools, nil
}
victim 把回收動(dòng)作由一次變?yōu)榱藘纱?,這樣更抗造一點(diǎn)。每次清理都是只有上次 cache 的對(duì)象才會(huì)被真正清理掉,當(dāng)前的 cache 對(duì)象只是移到回收站(victim)。
知識(shí)小結(jié):
每輪 GC 開始都會(huì)調(diào)用 poolCleanup函數(shù);使用兩輪清理過程來抵抗波動(dòng),也就是 local cache 和 victim cache 配合;

思考問題

原理上面已經(jīng)剖析的非常清晰了,現(xiàn)在我們思考一些與眾不同的問題:
1. 如果不是 Pool.Get 申請(qǐng)的對(duì)象,調(diào)用了 Put ,會(huì)怎么樣?
不會(huì)有任何異常(是不是驚呆了),Pool 池里能接納任意來源,任意類型的對(duì)象。就算不是 Pool.Get 出來的對(duì)象,也能正常調(diào)用 Pool.Put,而一旦你做了這個(gè)事情之后,Pool 池里的就不是單一的對(duì)象元素了,而是一個(gè)雜貨鋪了。
原因解析:
首先, Put(x interface{})接口沒有對(duì) x 類型做判斷和斷言;其次, Pool.Put內(nèi)部也沒有對(duì)類型做斷言和判斷,無法追究元素是否是來自于 Get 的接口;
所以,在上一篇剖析 Pool 使用姿勢(shì)文章的中,在調(diào)用 Pool.Get 出來元素之后,我有一行類型斷言就是這個(gè)意思:
buffer := bufferPool.Get()
_ = buffer.(*[]byte)
注意這個(gè)很重要,因?yàn)?sync.Pool 框架支持存放任何類型,本質(zhì)上可以是一個(gè)雜貨鋪,所以 Get 出來和 Put 進(jìn)去的對(duì)象類型要業(yè)務(wù)自己把控。
2. Pool.Get 出來的對(duì)象,為什么要 Pool.Put 放回 Pool 池,是為了不變成自己討厭的垃圾嗎?
首先,從使用姿勢(shì)來說,Pool.Get 和 Pool.Put 一定要配套使用,通常使用 defer Pool.Put 這種形式保證釋放元素進(jìn)池子。
你想過建議 Get,Put 配套使用的原因嗎?如果不配套是會(huì)變成不可回收的垃圾嗎?
首先,這個(gè)說法是錯(cuò)誤的,雖然 Pool.Get,Pool.Put 通常是配套使用的,但是也絕對(duì)不是硬性要求,Get.Put 出來的元素使用完之后,就算不調(diào)用 Pool.Put 放進(jìn)池子也不會(huì)成為垃圾,而是自然再?zèng)]有人用到這個(gè)對(duì)象的時(shí)候,GC 會(huì)釋放他。
舉個(gè)極限的例子,如果我使用 Pool 的姿勢(shì)上做下改動(dòng),每次都 Pool.Get ,一次都不調(diào)用 Pool.Put ,那么會(huì)有什么情況發(fā)生?
答案是:沒啥情況發(fā)生,程序照常運(yùn)行。只不過 Pool 每次 Get 的時(shí)候,都要執(zhí)行 New 函數(shù)來構(gòu)造對(duì)象而已,Pool 也失去了最本質(zhì)的功能而已:復(fù)用臨時(shí)對(duì)象。調(diào)用 Pool.Put 調(diào)用的本質(zhì)目的就是為了對(duì)象復(fù)用。
3. Pool 本身允許復(fù)制之后使用嗎?
不允許,但是你可以做的到。什么意思?
如果你在代碼里 copy 了一個(gè) Pool 池,你的代碼 go build 是可以編譯通過的,但是可能會(huì)導(dǎo)致內(nèi)泄露的問題。在結(jié)構(gòu)體 struct Pool 的實(shí)現(xiàn)中中已經(jīng)明確說了,不允許 copy 。以下為官方原話:
// A Pool must not be copied after first use.
在 struct Pool 有一個(gè)字段 Pool.noCopy 明確限制你不要 copy,但是這個(gè)只有運(yùn)行 go vet 才能檢查出來(所以大家的代碼編譯之前一定要 go vet 做一次靜態(tài)檢查,可以避免非常多的問題)。
$:~/pool$ go vet test_pool.go
# command-line-arguments
./test_pool.go:26:20: assignment copies lock value to bufferPool2: sync.Pool contains sync.noCopy
思考下,為什么要 Pool 禁止 copy ?
因?yàn)?Copy 之后,對(duì)于同一個(gè) Pool 里面 cache 的對(duì)象,我們有了兩個(gè)指向來源,原 Pool 清空之后,copy 的 Pool 沒有清理掉,那么里面的對(duì)象就全都泄露了。并且 Pool 里面的無鎖設(shè)計(jì)的基礎(chǔ)是多個(gè) Goroutine 不會(huì)操作到同一個(gè)數(shù)據(jù)結(jié)構(gòu),Pool 拷貝之后則不能保證這點(diǎn)。類似 sync.WaitGroup, sync.Cond 首字段都用了 noCopy 結(jié)構(gòu),所以這兩個(gè)結(jié)構(gòu)體也是不能 copy 使用的。
所以,Pool 千萬不要 copy 使用,編譯之前一定要 go vet 檢查代碼。

總結(jié)

以上知識(shí)點(diǎn)做個(gè)總結(jié):
Pool 本質(zhì)是為了提高臨時(shí)對(duì)象的復(fù)用率; Pool 使用兩層回收策略(local + victim)避免性能波動(dòng); Pool 本質(zhì)是一個(gè)雜貨鋪屬性,啥都可以放。把什么東西放進(jìn)去,預(yù)期從里面拿出什么類型的東西都需要業(yè)務(wù)使用方把控,Pool 池本身不做限制; Pool 池里面 cache 對(duì)象也是分層的,一層層的 cache,取用方式從最熱的數(shù)據(jù)到最冷的數(shù)據(jù)遞進(jìn); Pool 是并發(fā)安全的,但是內(nèi)部是無鎖結(jié)構(gòu),原理是對(duì)每個(gè) P 都分配 cache 數(shù)組( poolLocalInternal數(shù)組),這樣 cache 結(jié)構(gòu)就不會(huì)導(dǎo)致并發(fā);永遠(yuǎn)不要 copy 一個(gè) Pool,明確禁止,不然會(huì)導(dǎo)致內(nèi)存泄露和程序并發(fā)邏輯錯(cuò)誤; 代碼編譯之前用 go vet做靜態(tài)檢查,能減少非常多的問題;每輪 GC 開始都會(huì)清理一把 Pool 里面 cache 的對(duì)象,注意流程是分兩步,當(dāng)前 Pool 池 local 數(shù)組里的元素交給 victim 數(shù)組句柄,victim 里面 cache 的元素全部清理。換句話說,引入 victim 機(jī)制之后,對(duì)象的緩存時(shí)間變成兩個(gè) GC 周期; 不要對(duì) Pool 里面的對(duì)象做任何假定,有兩種方案:要么就歸還的時(shí)候 memset 對(duì)象之后,再調(diào)用 Pool.Put,要么就Pool.Get取出來的時(shí)候 memset 之后再使用;本篇文章配合動(dòng)畫演示一起學(xué)習(xí)效果更佳哦;
推薦閱讀

