golang pprof 監控系列(3) —— memory,block,mutex 統計原理
大家好,我是藍胖子。
在上一篇文章 golang pprof監控系列(2) —— memory,block,mutex 使用裡我講解了這3種效能指標如何在程式中暴露以及各自監控的範圍。也有提到memory,block,mutex 把這3類資料放在一起講,是因為他們統計的原理是很類似的。今天來看看它們究竟是如何統計的。
先說下結論,這3種型別在runtime內部都是透過一個叫做bucket的結構體做的統計,bucket結構體內部有指標指向下一個bucket 這樣構成了bucket的連結串列,每次分配記憶體,或者每次阻塞產生時,會判斷是否會建立一個新的bucket來記錄此次分配資訊。
先來看下bucket裡面有哪些資訊。
bucket結構體介紹
// src/runtime/mprof.go:48
type bucket struct {
next *bucket
allnext *bucket
typ bucketType // memBucket or blockBucket (includes mutexProfile)
hash uintptr
size uintptr
nstk uintptr
}
挨個詳細解釋下這個bucket結構體:
首先是兩個指標,一個next 指標,一個allnext指標,allnext指標的作用就是形成一個連結串列結構,剛才提到的每次記錄分配資訊時,如果新增了bucket,那麼這個bucket的allnext指標將會指向 bucket的連結串列頭部。
bucket的連結串列頭部資訊是由一個全域性變數儲存起來的,程式碼如下:
// src/runtime/mprof.go:140
var (
mbuckets *bucket // memory profile buckets
bbuckets *bucket // blocking profile buckets
xbuckets *bucket // mutex profile buckets
buckhash *[179999]*bucket
不同的指標型別擁有不同的連結串列頭部變數,mbuckets 是記憶體指標的連結串列頭,bbuckets 是block指標的連結串列頭,xbuckets 是mutex指標的連結串列頭。
這裡還有個buckethash結構,無論那種指標型別,只要有bucket結構被建立,那麼都將會在buckethash裡存上一份,而buckethash用於解決hash衝突的方式則是將衝突的bucket透過指標形成連結串列聯絡起來,這個指標就是剛剛提到的next指標了。
至此,解釋完了bucket的next指標,和allnext指標,我們再來看看bucket的其他屬性。
// src/runtime/mprof.go:48
type bucket struct {
next *bucket
allnext *bucket
typ bucketType // memBucket or blockBucket (includes mutexProfile)
hash uintptr
size uintptr
nstk uintptr
}
type 屬性含義很明顯了,代表了bucket屬於那種指標型別。
hash 則是儲存在buckethash結構內的hash值,也是在buckethash 陣列中的索引值。
size 記錄此次分配的大小,對於記憶體指標而言有這個值,其餘指標型別這個值為0。
nstk 則是記錄此次分配時,堆疊資訊陣列的大小。還記得在上一講golang pprof監控系列(2) —— memory,block,mutex 使用裡從網頁看到的堆疊資訊嗎。
heap profile: 7: 5536 [110: 2178080] @ heap/1048576
2: 2304 [2: 2304] @ 0x100d7e0ec 0x100d7ea78 0x100d7f260 0x100d7f78c 0x100d811cc 0x100d817d4 0x100d7d6dc 0x100d7d5e4 0x100daba20
# 0x100d7e0eb runtime.allocm+0x8b /Users/lanpangzi/goproject/src/go/src/runtime/proc.go:1881
# 0x100d7ea77 runtime.newm+0x37 /Users/lanpangzi/goproject/src/go/src/runtime/proc.go:2207
# 0x100d7f25f runtime.startm+0x11f /Users/lanpangzi/goproject/src/go/src/runtime/proc.go:2491
# 0x100d7f78b runtime.wakep+0xab /Users/lanpangzi/goproject/src/go/src/runtime/proc.go:2590
# 0x100d811cb runtime.resetspinning+0x7b /Users/lanpangzi/goproject/src/go/src/runtime/proc.go:3222
# 0x100d817d3 runtime.schedule+0x2d3 /Users/lanpangzi/goproject/src/go/src/runtime/proc.go:3383
# 0x100d7d6db runtime.mstart1+0xcb /Users/lanpangzi/goproject/src/go/src/runtime/proc.go:1419
# 0x100d7d5e3 runtime.mstart0+0x73 /Users/lanpangzi/goproject/src/go/src/runtime/proc.go:1367
# 0x100daba1f runtime.mstart+0xf /Users/lanpangzi/goproject/src/go/src/runtime/asm_arm64.s:117
nstk 就是記錄的堆疊資訊陣列的大小,看到這裡,你可能會疑惑,這裡僅僅是記錄了堆疊大小,堆疊的內容呢?關於分配資訊的記錄呢?
要回答這個問題,得搞清楚建立bucket結構體的時候,記憶體是如何分配的。
首先要明白結構體在進行記憶體分配的時候是一塊連續的記憶體,例如剛才介紹bucket結構體的時候講到的幾個屬性都是在一塊連續的記憶體上,當然,指標指向的地址可以不和結構體記憶體連續,但是指標本身是儲存在這一塊連續記憶體上的。
接著,我們來看看runtime是如何建立一個bucket的。
// src/runtime/mprof.go:162
func newBucket(typ bucketType, nstk int) *bucket {
size := unsafe.Sizeof(bucket{}) + uintptr(nstk)*unsafe.Sizeof(uintptr(0))
switch typ {
default:
throw("invalid profile bucket type")
case memProfile:
size += unsafe.Sizeof(memRecord{})
case blockProfile, mutexProfile:
size += unsafe.Sizeof(blockRecord{})
}
b := (*bucket)(persistentalloc(size, 0, &memstats.buckhash_sys))
bucketmem += size
b.typ = typ
b.nstk = uintptr(nstk)
return b
}
上述程式碼是建立一個bucket時原始碼, 其中persistentalloc 是runtime內部一個用於分配記憶體的方法,底層還是用的mmap,這裡就不展開了,只需要知道該方法可以分配一段記憶體,size 則是需要分配的記憶體大小。
persistentalloc返回後的unsafe.Pointer可以強轉為bucket型別的指標,unsafe.Pointer是go編譯器允許的 代表指向任意型別的指標 型別。所以關鍵是看 分配一個bucket結構體的時候,這個size的記憶體空間是如何計算出來的。
首先unsafe.Sizeof 得到分配一個bucket程式碼結構 本身所需要的記憶體長度,然後加上了nstk 個uintptr 型別的記憶體長度 ,uintptr代表了一個指標型別,還記得剛剛提到nstk的作用嗎?nstk表明了堆疊資訊陣列的大小,而陣列中每個元素就是一個uintptr型別,指向了具體的堆疊位置。
接著判斷 需要建立的bucket的型別,如果是memProfile 記憶體型別 則又用unsafe.Sizeof 得到一個memRecord的結構體所佔用的空間大小,如果是blockProfile,或者是mutexProfile 則是在size上加上一個blockRecord結構體佔用的空間大小。memRecord和blockRecord 裡承載了此次記憶體分配或者此次阻塞行為的詳細資訊。
// src/runtime/mprof.go:59
type memRecord struct {
active memRecordCycle
future [3]memRecordCycle
}
// src/runtime/mprof.go:120
type memRecordCycle struct {
allocs, frees uintptr
alloc_bytes, free_bytes uintptr
}
關於記憶體分配的詳細資訊最後是有memRecordCycle 承載的,裡面有此次記憶體分配的記憶體大小和分配的物件個數。那memRecord 裡的active 和future又有什麼含義呢,為啥不乾脆用memRecordCycle結構體來表示此次記憶體分配的詳細資訊? 這裡我先預留一個坑,放在下面在解釋,現在你只需要知道,在分配一個記憶體bucket結構體的時候,也分配了一段記憶體空間用於記錄關於記憶體分配的詳細資訊。
然後再看看blockRecord。
// src/runtime/mprof.go:135
type blockRecord struct {
count float64
cycles int64
}
blockRecord 就比較言簡意賅,count代表了阻塞的次數,cycles則代表此次阻塞的週期時長,關於週期的解釋可以看看我前面一篇文章golang pprof監控系列(2) —— memory,block,mutex 使用 ,簡而言之,週期時長是cpu記錄時長的一種方式。你可以把它理解成就是一段時間,不過時間單位不在是秒了,而是一個週期。
可以看到,在計算一個bucket佔用的空間的時候,除了bucket結構體本身佔用的空間,還預留了堆疊空間以及memRecord或者blockRecord 結構體佔用的記憶體空間大小。
你可能會疑惑,這樣子分配一個bucket結構體,那麼如何取出bucket中的memRecord 或者blockRecord結構體呢? 答案是 透過計算memRecord在bucket 中的位置,然後強轉unsafe.Pointer指標。
拿memRecord舉例,
//src/runtime/mprof.go:187
func (b *bucket) mp() *memRecord {
if b.typ != memProfile {
throw("bad use of bucket.mp")
}
data := add(unsafe.Pointer(b), unsafe.Sizeof(*b)+b.nstk*unsafe.Sizeof(uintptr(0)))
return (*memRecord)(data)
}
上面的地址可以翻譯成如下公式:
memRecord開始的地址 = bucket指標的地址 + bucket結構體的記憶體佔用長度 + 棧陣列佔用長度
這一公式成立的前提便是 分配結構體的時候,是連續的分配了一塊記憶體,所以我們當然能透過bucket首部地址以及中間的空間長度計算出memRecord開始的地址。
至此,bucket的結構體描述算是介紹完了,但是還沒有深入到記錄指標資訊的細節,下面我們深入研究下記錄細節,正戲開始。
記錄指標細節介紹
由於記憶體分配的取樣還是和block阻塞資訊的取樣有點點不同,所以我還是決定分兩部分來介紹下,先來看看記憶體分配時,是如何記錄此次記憶體分配資訊的。
memory
首先在上篇文章golang pprof監控系列(2) —— memory,block,mutex 使用 我介紹過 MemProfileRate ,MemProfileRate 用於控制記憶體分配的取樣頻率,代表平均每分配MemProfileRate位元組便會記錄一次記憶體分配記錄。
當觸發記錄條件時,runtime便會呼叫 mProf_Malloc 對此次記憶體分配進行記錄,
// src/runtime/mprof.go:340
func mProf_Malloc(p unsafe.Pointer, size uintptr) {
var stk [maxStack]uintptr
nstk := callers(4, stk[:])
lock(&proflock)
b := stkbucket(memProfile, size, stk[:nstk], true)
c := mProf.cycle
mp := b.mp()
mpc := &mp.future[(c+2)%uint32(len(mp.future))]
mpc.allocs++
mpc.alloc_bytes += size
unlock(&proflock)
systemstack(func() {
setprofilebucket(p, b)
})
}
實際記錄之前還會先獲取堆疊資訊,上述程式碼中stk 則是記錄堆疊的陣列,然後透過 stkbucket 去獲取此次分配的bucket,stkbucket 裡會判斷是否先前存在一個相同bucket,如果存在則直接返回。而判斷是否存在相同bucket則是看存量的bucket的分配的記憶體大小和堆疊位置是否和當前一致。
// src/runtime/mprof.go:229
for b := buckhash[i]; b != nil; b = b.next {
if b.typ == typ && b.hash == h && b.size == size && eqslice(b.stk(), stk) {
return b
}
}
透過剛剛介紹bucket結構體,可以知道 buckhash 裡容納了程式中所有的bucket,透過一段邏輯算出在bucket的索引值,也就是i的值,然後取出buckhash對應索引的連結串列,迴圈查詢是否有相同bucket。相同則直接返回,不再建立新bucket。
讓我們再回到記錄記憶體分配的主邏輯,stkbucket 方法建立或者獲取 一個bucket之後,會透過mp()方法獲取到其內部的memRecord結構,然後將此次的記憶體分配的位元組累加到memRecord結構中。
不過這裡並不是直接由memRecord 承載累加任務,而是memRecord的memRecordCycle 結構體。
c := mProf.cycle
mp := b.mp()
mpc := &mp.future[(c+2)%uint32(len(mp.future))]
mpc.allocs++
mpc.alloc_bytes += size
這裡先是從memRecord 結構體的future結構中取出一個memRecordCycle,然後在memRecordCycle上進行累加位元組數,累加分配次數。
這裡有必要介紹下mProf.cycle 和memRecord中的active和future的作用了。
我們知道記憶體分配是一個持續性的過程,記憶體的回收是由gc定時執行的,golang設計者認為,如果每次產生記憶體分配的行為就記錄一次記憶體分配資訊,那麼很有可能這次分配的記憶體雖然程式已經沒有在引用了,但是由於還沒有垃圾回收,所以會造成記憶體分配的曲線就會出現嚴重的傾斜(因為記憶體只有垃圾回收以後才會被記錄為釋放,也就是memRecordCycle中的free_bytes 才會增加,所以記憶體分配曲線會在gc前不斷增大,gc後出現陡降)。
所以,在記錄記憶體分配資訊的時候,是將當前的記憶體分配資訊經過一輪gc後才記錄下來,mProf.cycle 則是當前gc的週期數,每次gc時會加1,在記錄記憶體分配時,將當前週期數加2與future取模後的索引值記錄到future ,而在釋放記憶體時,則將 當前週期數加1與future取模後的索引值記錄到future,想想這裡為啥要加1才能取到 對應的memRecordCycle呢? 因為當前的週期數比起記憶體分配的週期數已經加1了,所以釋放時只加1就好。
// src/runtime/mprof.go:362
func mProf_Free(b *bucket, size uintptr) {
lock(&proflock)
c := mProf.cycle
mp := b.mp()
mpc := &mp.future[(c+1)%uint32(len(mp.future))]
mpc.frees++
mpc.free_bytes += size
unlock(&proflock)
}
在記錄記憶體分配時,只會往future陣列裡記錄,那讀取記憶體分配資訊的 資料時,怎麼讀取呢?
還記得memRecord 裡有一個型別為memRecordCycle 的active屬性嗎,在讀取的時候,runtime會呼叫
mProf_FlushLocked()方法,將當前週期的future資料讀取到active裡。
// src/runtime/mprof.go:59
type memRecord struct {
active memRecordCycle
future [3]memRecordCycle
}
// src/runtime/mprof.go:120
type memRecordCycle struct {
allocs, frees uintptr
alloc_bytes, free_bytes uintptr
}
// src/runtime/mprof.go:305
func mProf_FlushLocked() {
c := mProf.cycle
for b := mbuckets; b != nil; b = b.allnext {
mp := b.mp()
// Flush cycle C into the published profile and clear
// it for reuse.
mpc := &mp.future[c%uint32(len(mp.future))]
mp.active.add(mpc)
*mpc = memRecordCycle{}
}
}
程式碼比較容易理解,mProf.cycle獲取到了當前gc週期,然後用當前週期從future裡取出 當前gc週期的記憶體分配資訊 賦值給acitve ,對每個記憶體bucket都進行這樣的賦值。
賦值完後,後續讀取當前記憶體分配資訊時就只讀active裡的資料了,至此,算是講完了runtime是如何對記憶體指標進行統計的。
接著,我們來看看如何對block和mutex指標進行統計的。
block mutex
block和mutex的統計是由同一個方法,saveblockevent 進行記錄的,不過方法內部針對這兩種型別還是做了一點點不同的處理。
有必要注意再提一下,mutex是在解鎖unlock時才會記錄一次阻塞行為,而block在記錄mutex鎖阻塞資訊時,是在開始執行lock呼叫的時候記錄的 ,除此以外,block在select 阻塞,channel通道阻塞,wait group 產生阻塞時也會記錄一次阻塞行為。
// src/runtime/mprof.go:417
func saveblockevent(cycles, rate int64, skip int, which bucketType) {
gp := getg()
var nstk int
var stk [maxStack]uintptr
if gp.m.curg == nil || gp.m.curg == gp {
nstk = callers(skip, stk[:])
} else {
nstk = gcallers(gp.m.curg, skip, stk[:])
}
lock(&proflock)
b := stkbucket(which, 0, stk[:nstk], true)
if which == blockProfile && cycles < rate {
// Remove sampling bias, see discussion on http://golang.org/cl/299991.
b.bp().count += float64(rate) / float64(cycles)
b.bp().cycles += rate
} else {
b.bp().count++
b.bp().cycles += cycles
}
unlock(&proflock)
}
首先還是獲取堆疊資訊,然後stkbucket() 方法獲取到 一個bucket結構體,然後bp()方法獲取了bucket裡的blockRecord 結構,並對其count次數和cycles阻塞週期時長進行累加。
// src/runtime/mprof.go:135
type blockRecord struct {
count float64
cycles int64
}
注意針對blockProfile 型別的次數累加 還進行了特別的處理,還記得上一篇golang pprof監控系列(2) —— memory,block,mutex 使用提到的BlockProfileRate引數嗎,它是用來設定block取樣的納秒取樣率的,如果阻塞週期時長cycles小於BlockProfileRate的話,則需要fastrand函式乘以設定的納秒時間BlockProfileRate 來決定是否取樣了,所以如果是小於BlockProfileRate 並且saveblockevent進行了記錄阻塞資訊的話,說明我們只是取樣了部分這樣情況的阻塞,所以次數用BlockProfileRate 除以 此次阻塞週期時長數,得到一個估算的總的 這類阻塞的次數。
讀取阻塞資訊就很簡單了,直接讀取阻塞bucket的count和週期數即可。
總結
至此,算是介紹完了這3種指標型別的統計原理,簡而言之,就是透過一個攜帶有堆疊資訊的bucket對每次記憶體分配或者阻塞行為進行取樣記錄,讀取記憶體分配資訊 或者阻塞指標資訊的 時候便是所有的bucket資訊讀取出來。