golang服務監控

以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就能得到結果

image.png

通常我們會配合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調用的系統內部方法(這個不在本文討論中)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章