以linux服務器爲例子,正常情況下,我們要獲取服務的內存信息一般都是通過相關的命令,例如top、ps等命令。然而這些監控內存使用情況的方法,一般需要編寫腳本,執行腳本後將執行結果發送給對應的監控服務,從而達到監控的效果。
本文代碼基於1.14閱讀。至於原因,肯定是因爲某種需求。
問題
常規的監控方式會有什麼問題呢?
內存飆升,但是我們根本不知道爲什麼,只能通過經驗去分析我們的代碼,瓶頸在哪,哪裏有溢出,怎麼優化之類的。
pprof
golang在runtime包底下提供pprof的工具以方便我們的使用。
安裝
go get -u github.com/google/pprof
分析工具(命令行)
go tool pprof 是命令行指令,用於分析 Profiling 數據,源數據可以是 http 地址,也可以是已經 dump 下當 profile 文件;查看模式可以命令行交互模式,也可以是瀏覽器模式(-http 參數)。
兩種應用:
-
web服務型應用 _ "net/http/pprof" 包,專用於採集 web 服務 運行數據的分析。即在運行的服務中通過 API 調用取數據。
服務型應用場景中因爲應用要一直提供服務。所以 pprof 是通過 API 訪問來獲取,pprof 使用了默認的 http.DefaultServeMux 掛在這些 API 接口。開發者也可以手動註冊路由規則掛載到指定的路由API。
-
工具包型應用 "runtime/pprof" 包,專用於採集應用程序運行數據的分析。通過代碼手動添加收集命令。
工具型應用是一個提供特定api go package,需要開發者進行編碼寫入文件。也可以將信息封裝到接口方便調用,比如:
要進行 CPU Profiling,則調用 pprof.StartCPUProfile(w io.Writer) 寫入到 w 中,停止時調用 StopCPUProfile(); 要獲取內存數據,直接使用 pprof.WriteHeapProfile(w io.Writer) 函數則可。
支持的分析內容
CPU 分析(profile): 你可以在 url 上用 seconds 參數指定抽樣持續時間(默認 30s),你獲取到概覽文件後可以用 go tool pprof 命令調查這個概覽
內存分配(allocs): 所有內存分配的抽樣
阻塞(block): 堆棧跟蹤導致阻塞的同步原語
命令行調用(cmdline): 命令行調用的程序
goroutine: 當前 goroutine 的堆棧信息
堆(heap): 當前活動對象內存分配的抽樣,完全也可以指定 gc 參數在對堆取樣前執行 GC
互斥鎖(mutex): 堆棧跟蹤競爭狀態互斥鎖的持有者
系統線程的創建(threadcreate): 堆棧跟蹤系統新線程的創建
trace: 追蹤當前程序的執行狀況. 可以用 seconds 參數指定抽樣持續時間,獲取到 trace 概覽後可以用 go tool pprof 命令調查這個 trace
使用
http
要使用http服務,直接使用官方示例即可
// To use pprof, link this package into your program:
// import _ "net/http/pprof"
//
// If your application is not already running an http server, you
// need to start one. Add "net/http" and "log" to your imports and
// the following code to your main function:
//
// go func() {
// log.Println(http.ListenAndServe("localhost:6060", nil))
// }()
pprof默認註冊瞭如下路由,其實我們也可以修改path
http.HandleFunc("/debug/pprof/", Index)
http.HandleFunc("/debug/pprof/cmdline", Cmdline)
http.HandleFunc("/debug/pprof/profile", Profile)
http.HandleFunc("/debug/pprof/symbol", Symbol)
http.HandleFunc("/debug/pprof/trace", Trace)
直接訪問相關path就能得到結果
通常我們會配合Go tool pprof進行數據採集再去進行分析。
手動編碼
舉個列子,獲取goroutine數量。這是官方的示例。
func TestGoroutineCounts(t *testing.T) {
// Setting GOMAXPROCS to 1 ensures we can force all goroutines to the
// desired blocking point.
defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1))
c := make(chan int)
for i := 0; i < 100; i++ {
switch {
case i%10 == 0:
go func1(c)
case i%2 == 0:
go func2(c)
default:
go func3(c)
}
// Let goroutines block on channel
for j := 0; j < 5; j++ {
runtime.Gosched()
}
}
var w bytes.Buffer
goroutineProf := Lookup("goroutine")
// Check debug profile
goroutineProf.WriteTo(&w, 1)
prof := w.String()
if !containsInOrder(prof, "\n50 @ ", "\n40 @", "\n10 @", "\n1 @") {
t.Errorf("expected sorted goroutine counts:\n%s", prof)
}
// Check proto profile
w.Reset()
goroutineProf.WriteTo(&w, 0)
p, err := profile.Parse(&w)
if err != nil {
t.Errorf("error parsing protobuf profile: %v", err)
}
if err := p.CheckValid(); err != nil {
t.Errorf("protobuf profile is invalid: %v", err)
}
if !containsCounts(p, []int64{50, 40, 10, 1}) {
t.Errorf("expected count profile to contain goroutines with counts %v, got %v",
[]int64{50, 40, 10, 1}, p)
}
close(c)
time.Sleep(10 * time.Millisecond) // let goroutines exit
}
可以看出,作爲開發者的我們,只需要簡單的引入一個接口類型io.Writer的實現對象,將其寫入部門修改即可(當然我們可以做很多擴展,比如落地、報警,結合數據限流、熔斷之類的)。
支持的分析內容
CPU 分析(profile): 你可以在 url 上用 seconds 參數指定抽樣持續時間(默認 30s),你獲取到概覽文件後可以用 go tool pprof 命令調查這個概覽
內存分配(allocs): 所有內存分配的抽樣
阻塞(block): 堆棧跟蹤導致阻塞的同步原語
命令行調用(cmdline): 命令行調用的程序
goroutine: 當前 goroutine 的堆棧信息
堆(heap): 當前活動對象內存分配的抽樣,完全也可以指定 gc 參數在對堆取樣前執行 GC
互斥鎖(mutex): 堆棧跟蹤競爭狀態互斥鎖的持有者
系統線程的創建(threadcreate): 堆棧跟蹤系統新線程的創建
trace: 追蹤當前程序的執行狀況. 可以用 seconds 參數指定抽樣持續時間,獲取到 trace 概覽後可以用 go tool pprof 命令調查這個 trace
實現原理
pprof這麼強大,那麼pprof是怎麼實現的呢?帶着這個問題去閱讀pprof源碼
// profiles records all registered profiles.
var profiles struct {
mu sync.Mutex
m map[string]*Profile
}
var goroutineProfile = &Profile{
name: "goroutine",
count: countGoroutine,
write: writeGoroutine,
}
var threadcreateProfile = &Profile{
name: "threadcreate",
count: countThreadCreate,
write: writeThreadCreate,
}
var heapProfile = &Profile{
name: "heap",
count: countHeap,
write: writeHeap,
}
var allocsProfile = &Profile{
name: "allocs",
count: countHeap, // identical to heap profile
write: writeAlloc,
}
var blockProfile = &Profile{
name: "block",
count: countBlock,
write: writeBlock,
}
var mutexProfile = &Profile{
name: "mutex",
count: countMutex,
write: writeMutex,
}
func lockProfiles() {
profiles.mu.Lock()
if profiles.m == nil {
// Initial built-in profiles.
//所以,這些定義不是和上面的支持的命令一樣嗎?
profiles.m = map[string]*Profile{
"goroutine": goroutineProfile,
"threadcreate": threadcreateProfile,
"heap": heapProfile,
"allocs": allocsProfile,
"block": blockProfile,
"mutex": mutexProfile,
}
}
}
goroutine
協程的狀態是遍歷整個Stack獲取的,至於協程數量就比較好獲取,gcount()直接可以得到
// 如果不是取全部,則只取當前g的棧信息
func Stack(buf []byte, all bool) int {
if all {
stopTheWorld("stack trace") //如果遍歷所有,是需要stw的,慎用
}
n := 0
if len(buf) > 0 {
gp := getg()
sp := getcallersp()
pc := getcallerpc()
systemstack(func() { //systemstack是go內部一個及其重要的方法,可以理解成你當前切換到了GMP模型的M上,我們的業務邏輯通常只會運行在G上
g0 := getg()
g0.m.traceback = 1
g0.writebuf = buf[0:0:len(buf)]
goroutineheader(gp)
traceback(pc, sp, 0, gp)
if all {
tracebackothers(gp)
}
g0.m.traceback = 0
n = len(g0.writebuf)
g0.writebuf = nil
})
}
if all {
startTheWorld()
}
return n
}
threadcreate
線程這個比較乾脆,直接遍歷鏈表獲取,數量也就是鏈表長度
func ThreadCreateProfile(p []StackRecord) (n int, ok bool) {
first := (*m)(atomic.Loadp(unsafe.Pointer(&allm))) //allm存放一個全局單向鏈表指針
for mp := first; mp != nil; mp = mp.alllink {
n++
}
if n <= len(p) {
ok = true
i := 0
for mp := first; mp != nil; mp = mp.alllink {
p[i].Stack0 = mp.createstack
i++
}
}
return
}
堆heap,以及內存使用情況allocs
在內存方向,golang都使用了同一個結構體進行存儲(runtime.MemStats):
runtime.MemStats這個結構體包含的字段比較多,但是大多都很有用:
Alloc uint64 //golang語言框架堆空間分配的字節數
TotalAlloc uint64 //從服務開始運行至今分配器爲分配的堆空間總 和,只有增加,釋放的時候不減少
Sys uint64 //服務現在系統使用的內存
Lookups uint64 //被runtime監視的指針數
Mallocs uint64 //服務malloc heap objects的次數
Frees uint64 //服務回收的heap objects的次數
HeapAlloc uint64 //服務分配的堆內存字節數
HeapSys uint64 //系統分配的作爲運行棧的內存
HeapIdle uint64 //申請但是未分配的堆內存或者回收了的堆內存(空閒)字節數
HeapInuse uint64 //正在使用的堆內存字節數
HeapReleased uint64 //返回給OS的堆內存,類似C/C++中的free。
HeapObjects uint64 //堆內存塊申請的量
StackInuse uint64 //正在使用的棧字節數
StackSys uint64 //系統分配的作爲運行棧的內存
MSpanInuse uint64 //用於測試用的結構體使用的字節數
MSpanSys uint64 //系統爲測試用的結構體分配的字節數
MCacheInuse uint64 //mcache結構體申請的字節數(不會被視爲垃圾回收)
MCacheSys uint64 //操作系統申請的堆空間用於mcache的字節數
BuckHashSys uint64 //用於剖析桶散列表的堆空間
GCSys uint64 //垃圾回收標記元信息使用的內存
OtherSys uint64 //golang系統架構佔用的額外空間
NextGC uint64 //垃圾回收器檢視的內存大小
LastGC uint64 // 垃圾回收器最後一次執行時間。
PauseTotalNs uint64 // 垃圾回收或者其他信息收集導致服務暫停的次數。
PauseNs [256]uint64 //一個循環隊列,記錄最近垃圾回收系統中斷的時間
PauseEnd [256]uint64 //一個循環隊列,記錄最近垃圾回收系統中斷的時間開始點。
NumForcedGC uint32 //服務調用runtime.GC()強制使用垃圾回收的次數。
GCCPUFraction float64 //垃圾回收佔用服務CPU工作的時間總和。如果有100個goroutine,垃圾回收的時間爲1S,那麼就佔用了100S。
BySize //內存分配器使用情況
在pprof中是調用的writeHeapInternal,進行讀寫渲染
func writeHeapInternal(w io.Writer, debug int, defaultSampleType string) error {
var s *runtime.MemStats
// do any more
fmt.Fprintf(w, "\n# runtime.MemStats\n")
// print any more
fmt.Fprintf(w, "# DebugGC = %v\n", s.DebugGC)
}
runtime.ReadMemStats方法是需要stw的,所以儘量不要在線上調用
func ReadMemStats(m *MemStats) {
stopTheWorld("read mem stats")
systemstack(func() {
readmemstats_m(m)
})
startTheWorld()
}
阻塞代碼塊(block)
block代碼塊信息一樣,也是通過了一個全局的bbuckets指針存放信息。
func BlockProfile(p []BlockProfileRecord) (n int, ok bool) {
lock(&proflock)
for b := bbuckets; b != nil; b = b.allnext {
n++
}
if n <= len(p) {
ok = true
for b := bbuckets; b != nil; b = b.allnext {
bp := b.bp()
r := &p[0]
r.Count = bp.count
r.Cycles = bp.cycles
if raceenabled {
racewriterangepc(unsafe.Pointer(&r.Stack0[0]), unsafe.Sizeof(r.Stack0), getcallerpc(), funcPC(BlockProfile))
}
if msanenabled {
msanwrite(unsafe.Pointer(&r.Stack0[0]), unsafe.Sizeof(r.Stack0))
}
i := copy(r.Stack0[:], b.stk())
for ; i < len(r.Stack0); i++ {
r.Stack0[i] = 0
}
p = p[1:]
}
}
unlock(&proflock)
return
}
肯定很好奇block代碼塊是怎麼產生的。其實和我們的CURD一樣,就是CREATE和UPDATE階段生成的(stkbucket方法內追加到鏈表尾部)。
//保存上下文 update
func saveblockevent(cycles int64, skip int, which bucketType) {
gp := getg()
//...
b := stkbucket(which, 0, stk[:nstk], true)
//...
}
//創建新的 create
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
//...
mp := acquirem()
profilealloc(mp, x, size) // => mProf_Malloc => stkbucket
releasem(mp)
//
}
同步代碼塊(mutex)
同步代碼塊的記錄方式和block類似,都是通過一個鏈表進行存儲,寫入方法也一樣(stkbucket方法)。
func MutexProfile(p []BlockProfileRecord) (n int, ok bool) {
lock(&proflock)
for b := xbuckets; b != nil; b = b.allnext {
n++
}
if n <= len(p) {
ok = true
for b := xbuckets; b != nil; b = b.allnext {
bp := b.bp()
r := &p[0]
r.Count = int64(bp.count)
r.Cycles = bp.cycles
i := copy(r.Stack0[:], b.stk())
for ; i < len(r.Stack0); i++ {
r.Stack0[i] = 0
}
p = p[1:]
}
}
unlock(&proflock)
return
}
stkbucket看來是一個比較重要的方法
type bucket struct {
next *bucket
allnext *bucket
typ bucketType // memBucket or blockBucket (includes mutexProfile)
hash uintptr
size uintptr
nstk uintptr
}
func stkbucket(typ bucketType, size uintptr, stk []uintptr, alloc bool) *bucket {
if buckhash == nil {
buckhash = (*[buckHashSize]*bucket)(sysAlloc(unsafe.Sizeof(*buckhash), &memstats.buckhash_sys))
if buckhash == nil {
throw("runtime: cannot allocate memory")
}
}
// Hash stack.
var h uintptr
// hash 計算...
i := int(h % buckHashSize)
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
}
}
if !alloc {
return nil
}
// Create new bucket.
b := newBucket(typ, len(stk))
copy(b.stk(), stk) //這裏其實我略帶疑惑,全局copy一個,會不會造成內存過多?
b.hash = h
b.size = size
b.next = buckhash[i]
buckhash[i] = b
//重新賦值相應的頭指針
if typ == memProfile {
b.allnext = mbuckets
mbuckets = b
} else if typ == mutexProfile {
b.allnext = xbuckets
xbuckets = b
} else {
b.allnext = bbuckets
bbuckets = b
}
return b
}
彙總
通過閱讀源碼,我們可以發現,pprof包的具體實現都在runtime包之上做了封裝。儘管 Go 編譯器產生的是本地可執行代碼,但是大部分信息仍舊運行在 Go 的 runtime,並且可以尋蹤匿跡,有完整的trace。
要注意的是:
很多地方都涉及到stw,線上需要慎重調用。
對於現在GO最主流的監控開源組件prometheus,其內部也是大量的使用runtime包底下的方法進行監控,還有一部分是調用的syscall調用的系統內部方法(這個不在本文討論中)。