golang pprof 監控系列(3) —— memory,block,mutex 統計原理

藍胖子的程式設計夢發表於2023-04-05

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資訊讀取出來。

相關文章