goroutine 的状态 切换

goroutine在Go中有多种状态,如运行中、可运行及等待中。这些状态受runtime控制并影响任务调度。

 

goroutine 的状态

在 golang 中,我们使用go 创建一个新的gorotine,我们都知道操作系统线程有自己的状态, 比如 在 The time in computers: how long will it take to switch the context? 中,我们总结了线程的状态以及线程调度耗时。
process status change,Uploaded by the author
goroutine也是一样的,有自己的状态,并且它的状态由 runtime 控制。

rimetime2.go 中定义了goroutine 的数据结构。g.atomicstatus 表示 goroutine 的状态。它的取值范围在源码中也有定义。
除了几个已经不被使用的以及与 GC 相关的状态之外,Goroutine 可能处于以下 9 种状态:

状态 描述
_Gidle 刚刚被分配并且还没有被初始化
_Grunnable 没有执行代码,没有栈的所有权,存储在运行队列中
_Grunning 可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P
_Gsyscall 正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
_Gwaiting 由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
_Gdead 没有被使用,没有执行代码,可能有分配的栈
_Gcopystack 栈正在被拷贝,没有执行代码,不在运行队列上
_Gpreempted 由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
_Gscan GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在
上述状态中比较常见是 _Grunnable_Grunning_Gsyscall_Gwaiting 和 _Gpreempted 五个状态,这里会重点介绍这几个状态。Goroutine 的状态迁移是个复杂的过程,触发 Goroutine 状态迁移的方法也很多,在这里我们也没有办法介绍全部的迁移路线,只会从中选择一些介绍。

虽然 Goroutine 在运行时中定义的状态非常多而且复杂,但是我们可以将这些不同的状态聚合成三种:等待中、可运行、运行中,运行期间会在这三种状态来回切换:

  • 等待中:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括 _Gwaiting_Gsyscall 和 _Gpreempted 几个状态;
  • 可运行:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即 _Grunnable;这个时候Goroutine 在 P的local queue 或者 全局队列中。
  • 运行中:Goroutine 正在某个线程上运行,即 _Grunning
    Pasted image 20240410114951

Grunnable

goroutine在下列几种情况会设置为Grunnable状态:

创建goroutine

在go中,包括用户入口main.mian在内的所有goroutine都是通过runtime.newproc->runtime.newproc1创建的,前者是对后者的一层封装。go关键字最终会被编译器映射为对runtime.newproc的调用。当runtime.newproc1完整资源分配及初始化后,新任务的状态会被置为Grunnable,然后被添加到当前P的本地任务队列中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) {
  // --snip--
  // 获取当前g所在的p,从p中创建一个新g(newg)
  _p_ := _g_.m.p.ptr()
  newg := gfget(_p_)
  // --snip--
  // 设置Goroutine状态为Grunnable
  casgstatus(newg, _Gdead, _Grunnable)
  // --snip--
  // // 新创建的g添加到run队列中
  runqput(_p_, newg, true)
  // --snip--
}

阻塞任务唤醒

当某个阻塞任务(Gwaiting)的等待条件满足而被唤醒时。(如g1项channel写入数据将唤醒等待接收的),g1通过调用runtime.ready将g2状态重新置为Grunnable并添加到任务队列中。关于groutine阻塞,还有更详细的介绍。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func ready(gp *g, traceskip int, next bool) {
  // --snip--
  // 获取current g
  _g_ := getg()
  // 将状态从Gwaiting转换至Grunnable
  casgstatus(gp, _Gwaiting, _Grunnable)
  // 添加到运行队列中
  runqput(_g_.m.p.ptr(), gp, next)
  // --snip--
}

其他

另外的路径是从Grunning和Gsyscall状态转换到Grunnable,后面再介绍。总之处于Grunnable的任务一定是在某个任务队列中,随时等待被调度执行。

Grunning

所有状态为Grunnable的任务都可能通过findrunnable函数被调度器(P&M)获取,进而通过execute将其状态切换到Grunning,最后调用runtime.gogo加载context并执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
  // --snip--
  // 挑一个可运行的g,并执行
  if gp == nil {
    gp, inheritTime = findrunnable() // blocks until work is available
  }
  // --snip--
  execute(gp, inheritTime)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Schedules gp to run on the current M.
func execute(gp *g, inheritTime bool) {
  // 将当前g的M切换到新的g上
  _g_ := getg()
  _g_.m.curg = gp
  gp.m = _g_.m
  // 将Grunnable状态变更为Grunning
  casgstatus(gp, _Grunnable, _Grunning)
  // --snip--
  // 真正执行goroutine
  gogo(&gp.sched)
}

go采取的是一种协作式调度方案,一个正在运行的任务,需要通过yield的方式显式的让出处理器。

在Go1.2之后,runtime也支持一定程度的任务抢占–当系统线程sysmon发现某个任务执行时间过长或者runtime判断需要进行垃圾收集时,会将任务置为“可被抢占”的,当该任务下一次函数调用时,就会让出处理器并重新切换到Grunnable状态。

Gsyscall

Go运行时为了保证高的并发性能,当会在任务执行OS系统调用前,先调用runtime.entersyscall函数将自己的状态置为Gsyscall(如果系统调用是阻塞式的或者执行过久,则将当前M与P分离),当系统调用返回后,执行线程调用runtime.exitsyscall尝试重新获取P,如果成功且当前任务没有被抢占,则将状态切换回Grunning并继续执行;否则将状态置为Grunnable,等待再次被调度执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func reentersyscall(pc, sp uintptr) {
	_g_ := getg()
	// --snip--
	casgstatus(_g_, _Grunning, _Gsyscall)
	// --snip--
  // 将m和p分离
	pp := _g_.m.p.ptr()
	pp.m = 0
	_g_.m.oldp.set(pp)
	_g_.m.p = 0
	// --snip--
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func exitsyscall() {
  _g_ := getg()
  // --snip--
  // 如果P还存在则重新获取P
  if exitsyscallfast(oldp) {
    // --snip--
    casgstatus(_g_, _Gsyscall, _Grunning)
    // --snip--
    return
  }
  // --snip--
  // 如果P不存在则Gsyscall->Grunnable
  mcall(exitsyscall0)
  // --snip--
}

Gwaiting

当一个任务需要的资源或运行条件不能被满足时,需要调用runtime.park函数进入该状态,之后除非等待条件满足,否则任务将一直处于等待状态不能执行。除了之前举过的channel的例子外,Go的定时器,网络io操作,原子,信号量都可能引起任务的阻塞。

1
2
3
4
5
6
// park continuation on g0.
func park_m(gp *g) {
  // --snip--
  casgstatus(gp, _Grunning, _Gwaiting)
	// --snip--
}
1
2
3
4
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
	// --snip--
  mcall(park_m)
}

runtime.park中lock是goroutine阻塞时需要释放的锁,(比如channel),reason是阻塞的原因,方便gdb调试。当所有任务处于Gwaiting状态时,也就表示当前程序进入了死锁状态,那么runtime回检测到这种情况,并输出所有Gwaiting任务的backtrace信息。

Gdead

当一个任务执行结束后,会调用runtime.goexit结束。将状态置为Gdead,并进入当前P的gFree列表。

总结

goroutine的状态切换跟线程状态切换其实差不多,不过,因为gc的原因导致看上去复杂一点。但是如果去掉gc部分,其实goroutine 状态的切换跟 线程状态切换差不多。
下一篇文章,我将会总结分析一下 P的状态切换。

Licensed under CC BY-NC-SA 4.0
最后更新于 Jan 06, 2025 10:35 CST
使用 Hugo 构建
主题 StackJimmy 设计