6.7 系统监控

Kesa...大约 8 分钟golang

系统监控是 Go 语言运行时的重要组成部分,它会每隔一段时间检查 Go 语言运行时,确保程序没有进入异常状态。

6.7.1 设计原理

在支持多任务的操作系统中,守护进程是在后台运行的计算机程序,不会由用户直接操作,一般会在操作系统启动时自动运行。

Go 语言的系统监控在内部启动了一个不会中止的循环,在循环的内部会轮询网络、抢占长期运行或者处于系统调用的 Goroutine 以及触发垃圾回收,通过这些行为,能够让系统的运行状态变得更健康。

golang-system-monitor
golang-system-monitor

6.7.2 监控循环

当 Go 语言程序启动时,运行时会在第一个 Goroutine 中调用 runtime.mainopen in new window 启动主程序,该函数会在系统栈中创建新的线程:

func main() {
	...
	if GOARCH != "wasm" {
		systemstack(func() {
			newm(sysmon, nil)
		})
	}
	...
}

runtime.newmopen in new window 会创建一个存储待执行函数和处理器的新结构体 runtime.mopen in new window。运行时执行系统监控不需要处理器,系统监控的 Goroutine 会直接在创建的线程上运行:

func newm(fn func(), _p_ *p) {
	mp := allocm(_p_, fn)
	mp.nextp.set(_p_)
	mp.sigmask = initSigmask
	...
	newm1(mp)
}

runtime.newm1open in new window 会调用特定平台的 runtime.newosprocopen in new window 通过系统调用 clone 创建一个新的线程并在新的线程中执行 runtime.mstartopen in new window

func newosproc(mp *m) {
	stk := unsafe.Pointer(mp.g0.stack.hi)
	var oset sigset
	sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
	ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(funcPC(mstart)))
	sigprocmask(_SIG_SETMASK, &oset, nil)
	...
}

在新创建的线程中,会执行存储在 runtime.mopen in new window 中的 runtime.sysmonopen in new window 启动系统监控:

func sysmon() {
	sched.nmsys++
	checkdead()

	lasttrace := int64(0)
	idle := 0
	delay := uint32(0)
	for {
		if idle == 0 {
			delay = 20
		} else if idle > 50 {
			delay *= 2
		}
		if delay > 10*1000 {
			delay = 10 * 1000
		}
		usleep(delay)
		...
	}
}

当调用上述函数时,会先通过 runtime.checkdeadopen in new window 检查是否存在死锁,然后进入核心的监控循环;系统监控在每次循环开始时都会通过 usleep 挂起当前线程,该函数的参数是微秒,运行时会遵循以下的规则决定休眠时间:

  • 初始的休眠时间是 20μs;
  • 最长的休眠时间是 10ms;
  • 当系统监控在 50 个循环中都没有唤醒 Goroutine 时,休眠时间在每个循环都会倍增;

当程序趋于稳定之后,系统监控的触发时间就会稳定在 10ms。它除了会检查死锁之外,还会在循环中完成以下的工作:

  • 运行计时器 — 获取下一个需要被触发的计时器;
  • 轮询网络 — 获取需要处理的到期文件描述符;
  • 抢占处理器 — 抢占运行时间较长的或者处于系统调用的 Goroutine;
  • 垃圾回收 — 在满足条件时触发垃圾收集回收内存;

检查死锁

系统监控通过 runtime.checkdeadopen in new window 检查运行时是否发生了死锁,可以将检查死锁的过程分成以下三个步骤:

  1. 检查是否存在正在运行的线程;
  2. 检查是否存在正在运行的 Goroutine;
  3. 检查处理器上是否存在计时器;

函数首先会检查 Go 语言运行时中正在运行的线程数量,通过调度器中的多个字段计算该值的结果:

func checkdead() {
	var run0 int32
	run := mcount() - sched.nmidle - sched.nmidlelocked - sched.nmsys
	if run > run0 {
		return
	}
	if run < 0 {
		print("runtime: checkdead: nmidle=", sched.nmidle, " nmidlelocked=", sched.nmidlelocked, " mcount=", mcount(), " nmsys=", sched.nmsys, "\n")
		throw("checkdead: inconsistent counts")
	}
	...
}
  1. runtime.mcountopen in new window 根据下一个待创建的线程 id 和释放的线程数得到系统中存在的线程数;
  2. nmidle 是处于空闲状态的线程数量;
  3. nmidlelocked 是处于锁定状态的线程数量;
  4. nmsys 是处于系统调用的线程数量;

对于正在运行的线程数:

  1. 如果线程数量大于 0,说明当前程序不存在死锁
  2. 如果线程数小于 0,说明当前程序的状态不一致
  3. 如果线程数等于 0,需要进一步检查程序的运行状态
func checkdead() {
	...
	grunning := 0
	for i := 0; i < len(allgs); i++ {
		gp := allgs[i]
		if isSystemGoroutine(gp, false) {
			continue
		}
		s := readgstatus(gp)
		switch s &^ _Gscan {
		case _Gwaiting, _Gpreempted:
			grunning++
		case _Grunnable, _Grunning, _Gsyscall:
			print("runtime: checkdead: find g ", gp.goid, " in status ", s, "\n")
			throw("checkdead: runnable g")
		}
	}
	unlock(&allglock)
	if grunning == 0 {
		throw("no goroutines (main called runtime.Goexit) - deadlock!")
	}
	...
}

当运行时存在等待的 Goroutine 并且不存在正在运行的 Goroutine 时,检查处理器中存在的计时器:

func checkdead() {
	...
	for _, _p_ := range allp {
		if len(_p_.timers) > 0 {
			return
		}
	}

	throw("all goroutines are asleep - deadlock!")
}

如果处理器中存在等待的计时器,那么所有的 Goroutine 陷入休眠状态是合理的,不过如果不存在等待的计时器,运行时会直接报错并退出程序。

运行计时器

在系统监控的循环中,通过 runtime.nanotimeopen in new windowruntime.timeSleepUntilopen in new window 获取当前时间和计时器下一次需要唤醒的时间;当前调度器需要执行垃圾回收或者所有处理器都处于闲置状态时,如果没有需要触发的计时器,那么系统监控可以暂时陷入休眠:

func sysmon() {
	...
	for {
		...
		now := nanotime()
		next, _ := timeSleepUntil()
		if debug.schedtrace <= 0 && (sched.gcwaiting != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs)) {
			lock(&sched.lock)
			if atomic.Load(&sched.gcwaiting) != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs) {
				if next > now {
					atomic.Store(&sched.sysmonwait, 1)
					unlock(&sched.lock)
					sleep := forcegcperiod / 2
					if next-now < sleep {
						sleep = next - now
					}
					...
					notetsleep(&sched.sysmonnote, sleep)
					...
					now = nanotime()
					next, _ = timeSleepUntil()
					lock(&sched.lock)
					atomic.Store(&sched.sysmonwait, 0)
					noteclear(&sched.sysmonnote)
				}
				idle = 0
				delay = 20
			}
			unlock(&sched.lock)
		}
		...
		if next < now {
			startm(nil, false)
		}
	}
}

休眠的时间会依据强制 GC 的周期 forcegcperiod 和计时器下次触发的时间确定,runtime.notesleepopen in new window 会使用信号量同步系统监控即将进入休眠的状态。

当系统监控被唤醒之后,会重新计算当前时间和下一个计时器需要触发的时间、调用 runtime.noteclearopen in new window 通知系统监控被唤醒并重置休眠的间隔。

如果在这之后发现下一个计时器需要触发的时间小于当前时间,说明所有的线程可能正在忙于运行 Goroutine,系统监控会启动新的线程来触发计时器,避免计时器的到期时间有较大的偏差。

轮询网络

如果上一次轮询网络已经过去了 10ms,那么系统监控还会在循环中轮询网络,检查是否有待执行的文件描述符:

func sysmon() {
	...
	for {
		...
		lastpoll := int64(atomic.Load64(&sched.lastpoll))
		if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
			atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
			list := netpoll(0)
			if !list.empty() {
				incidlelocked(-1)
				injectglist(&list)
				incidlelocked(1)
			}
		}
		...
	}
}

函数会非阻塞地调用 runtime.netpollopen in new window 检查待执行的文件描述符并通过 runtime.injectglistopen in new window 将所有处于就绪状态的 Goroutine 加入全局运行队列中:

func injectglist(glist *gList) {
	if glist.empty() {
		return
	}
	lock(&sched.lock)
	var n int
	for n = 0; !glist.empty(); n++ {
		gp := glist.pop()
		casgstatus(gp, _Gwaiting, _Grunnable)
		globrunqput(gp)
	}
	unlock(&sched.lock)
	for ; n != 0 && sched.npidle != 0; n-- {
		startm(nil, false)
	}
	*glist = gList{}
}

该函数会将所有 Goroutine 的状态从 _Gwaiting 切换至 _Grunnable 并加入全局运行队列等待运行,如果当前程序中存在空闲的处理器,会通过 runtime.startmopen in new window 启动线程来执行这些任务。

抢占处理器

系统监控会在循环中调用 runtime.retakeopen in new window 抢占处于运行或者系统调用中的处理器,该函数会遍历运行时的全局处理器,每个处理器都存储了一个 runtime.sysmontickopen in new window

type sysmontick struct {
	schedtick   uint32
	schedwhen   int64
	syscalltick uint32
	syscallwhen int64
}

该结构体中的四个字段分别存储了处理器的调度次数、处理器上次调度时间、系统调用的次数以及系统调用的时间。runtime.retakeopen in new window 的循环包含了两种不同的抢占逻辑:

func retake(now int64) uint32 {
	n := 0
	for i := 0; i < len(allp); i++ {
		_p_ := allp[i]
		pd := &_p_.sysmontick
		s := _p_.status
		if s == _Prunning || s == _Psyscall {
			t := int64(_p_.schedtick)
			if pd.schedwhen+forcePreemptNS <= now {
				preemptone(_p_)
			}
		}

		if s == _Psyscall {
			if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
				continue
			}
			if atomic.Cas(&_p_.status, s, _Pidle) {
				n++
				_p_.syscalltick++
				handoffp(_p_)
			}
		}
	}
	return uint32(n)
}
  1. 当处理器处于 _Prunning 或者 _Psyscall 状态时,如果上一次触发调度的时间已经过去了 10ms,会通过 runtime.preemptoneopen in new window 抢占当前处理器;
  2. 当处理器处于 _Psyscall 状态时,在满足以下两种情况下会调用 runtime.handoffpopen in new window 让出处理器的使用权:
    1. 当处理器的运行队列不为空或者不存在空闲处理器时
    2. 当系统调用时间超过了 10ms 时

系统监控通过在循环中抢占处理器来避免同一个 Goroutine 占用线程太长时间造成饥饿问题。

垃圾回收

系统监控还会决定是否需要触发强制垃圾回收,runtime.sysmonopen in new window 会构建 runtime.gcTriggeropen in new window 并调用 runtime.gcTrigger.testopen in new window 方法判断是否需要触发垃圾回收:

func sysmon() {
	...
	for {
		...
		if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
			lock(&forcegc.lock)
			forcegc.idle = 0
			var list gList
			list.push(forcegc.g)
			injectglist(&list)
			unlock(&forcegc.lock)
		}
		...
	}
}

如果需要触发垃圾回收,会将用于垃圾回收的 Goroutine 加入全局队列,让调度器选择合适的处理器去执行。

Reference

  1. https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sysmon/open in new window
上次编辑于:
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.2