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调用的系统内部方法(这个不在本文讨论中)。

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