go的GPM - 协程的本质

发布时间 2023-11-30 12:23:33作者: 杨阳的技术博客

协程与线程

线程在创建、切换、销毁时候,需要消耗CPU的资源。

协程就是将一段程序的运行状态打包, 可以在线程之间调度。减少CPU在操作线程的消耗

进程用分配内存空间
线程用来分配CPU时间
协程用来精细利用线程
协程的本质是一段包含了运行状态的程序  后面介绍后,会对这个概念更好理解

协程的本质

上面讲了 ,协程的本质就是 一段程序的运行状态的打包:

  func Do() {
  	for i := 1; i <= 1000; i++ {
  		fmt.Println(i)
  		time.Sleep(time.Second)
  	}
  }

  func main() {
  	go Do()
  	select {}
  }

例如上面这段代码,开了一个协程,然后一直循环打印。

假设程序都还有很多其他的协程也在工作,发现这个协程工作太久了,系统会进行切换别的协程,现在这个协程会放入协程队列中。

问题:要做到这点,协程需要怎么保存这个执行状态?

  1. 需要一个函数的调用栈,记录执行了那些函数,(例子中只有一个,正常情况下会是很多函数相互调用) 函数执行完后,还需要回到上层函数,所以要保存函数栈信息。
  1. 需要记录当前执行到了 那行代码,不能把多执行,也不能少执行那句代码,不然程序会不可控。
  1. 需要一个空间,存储整个协程的数据,例如变量的值等。

协程的底层定义

在runtime的runtim2.go中

  type g struct {
  // 只留了少量几个,里面有非常多的字段。
  	stack       stack  // 调用栈
  	m         *m        // 协程关联了一个m (GMP)
        sched     gobuf  // 协程的现场
  	goid         uint64   // 协程的编号
      atomicstatus atomic.Uint32 // 协程的状态

  }

type gobuf struct {
       sp   uintptr  // 当前调用的函数
	pc   uintptr  // 执行语句的指针
	g    guintptr
	ctxt unsafe.Pointer
	ret  uintptr
	lr   uintptr
	bp   uintptr // for framepointer-enabled architectures
}

// 栈的定义
  type stack struct {
  	lo uintptr  // 低地址
  	hi uintptr  // 高地址
  }

整体下:

假如有这么一段代码:

  func do3() {
  	fmt.Println("dododo")
  }

  func do2() {
  	do3()
  }

  func do1() {
  	do2()
  }

  func main() {
  	go do1()
  	time.Sleep(time.Hour)
  }

在do2断点:

能看到下方的调用栈中,会自动插入一个 goexit 在栈头。

小结下,整体的结构如下:

总结:

runtime 中,协程的本质是一个g 结构体
stack:堆栈地址
gobuf:目前程序运行现场
atomicstatus: 协程状态

线程的底层 m

操作系统的线程是由操作系统管理,这里的m只是记录线程的信息。

截取部分代码:
type m struct {
	g0      *g     // goroutine with scheduling stack
	id            int64 // id号
	morebuf gobuf  // gobuf arg to morestack	
	curg          *g       // 当前运行的g
	p             puintptr // attached p for executing go code (nil if not executing go code)
	mOS // 系统线程信息
}

go 是go程序启动创建的第一个协程,用来操控调度器的,第二个是主协程,可以看下 go启动那篇

小结:

runtime 中将操作系统线程抽象为 m结构体
g0:g0协程,操作调度器
curg:current g,目前线程运行的g
mOs:操作系统线程信息

如何工作

协程究竟是如何在 线程中工作的 ?

先讲总结,然后跟着总结往下看:

这是单个线程的循环,没有P的存在。

1. schedule() 是线程获取 协程的入口方法

线程通过执行 g0协程栈,获取 待执行的 协程

也就是意味着,每次线程执行 这个schedule方法,就意味着会切换一个 协程。
这个结论很重要,后面 协程调度时候,会大量看到调用这个方法。

在runtime的 proc.go下面能看到这个方法,这里只留了两行代码,
只和目前逻辑相关的,这个方法后面还要多次读
   func schedule() {
  	gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
  	execute(gp, inheritTime)
  }
这里的gp就是 待执行的g

 可以和上面的图对上,这里去  `Runnable` 找一个协程。然后,调用 `execute` 方法。
 至于怎么去找的,知道GMP的肯定都知道,这个后面聊。

也只有部分代码,和这里业务相关的
 func execute(gp *g, inheritTime bool) {
  	mp := getg().m  //获取m,线程的抽象
  	mp.curg = gp   // 还记得 m的定义 里面有个 当前的 g 在这里赋值了
  	gp.m = mp      // g的定义也有个 m,这里也赋值了
  	gogo(&gp.sched)
  }

到gogo
func gogo(buf *gobuf) // 只有定义,说明是汇编实现的,而且是平台相关的
     
// func gogo(buf *gobuf)  
// 这里把 g的gobuf传过去了,gobuf 存着 sp 和 pc ,当前的执行函数,和执行语句
//  到这里就基本对应上了
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $0-8
	MOVQ	buf+0(FP), BX		// gobuf
	MOVQ	gobuf_g(BX), DX
	MOVQ	0(DX), CX		// make sure g != nil
	JMP	gogo<>(SB)

//  插入了 goexit的栈针 然后开始运行业务 
TEXT gogo<>(SB), NOSPLIT, $0
	get_tls(CX)
	MOVQ	DX, g(CX)
	MOVQ	DX, R14		// set the g register
	MOVQ	gobuf_sp(BX), SP	// restore SP 插入了 goexit的栈针
	MOVQ	gobuf_ret(BX), AX
	MOVQ	gobuf_ctxt(BX), DX
	MOVQ	gobuf_bp(BX), BP
	MOVQ	$0, gobuf_sp(BX)	// clear to help garbage collector
	MOVQ	$0, gobuf_ret(BX)
	MOVQ	$0, gobuf_ctxt(BX)
	MOVQ	$0, gobuf_bp(BX)
	MOVQ	gobuf_pc(BX), BX
	JMP	BX

在运行业务之前 jmp bx,都还在 g0的协程栈上。

目前,已经把开始执行,到执行都整理了一遍,但是,没有讲 goexit 插入 到底有什么作用?

经验丰富的伙伴大致能猜到, 当执行完了协程的任务后,需要回到 schedule方法中, 线程重新去执行别的协程,这就是 goexit的作用

goexit

汇编实现
TEXT runtime·goexit(SB),NOSPLIT|TOPFRAME,$0-0
BYTE	$0x90	// NOP
CALL	runtime·goexit1(SB)	//  去调用 goexit1 这个方法

// Finishes execution of the current goroutine.
func goexit1() {
	mcall(goexit0) // 通过mcall 调用goexit0  
}

 // mcall switches from the g to the g0 stack and invokes fn(g),
 // 切换到 g0 栈
 func mcall(fn func(*g))
 就是只,上面的都是在 业务协程中,运行的,到这里,开始使用 g0栈去运行,goexit0

// goexit continuation on g0. 
func goexit0(gp *g) {
	mp := getg().m
	pp := mp.p.ptr()

	casgstatus(gp, _Grunning, _Gdead)
	gcController.addScannableStack(pp, -int64(gp.stack.hi-gp.stack.lo))
	if isSystemGoroutine(gp, false) {
		sched.ngsys.Add(-1)
	}
	gp.m = nil
	locked := gp.lockedm != 0
	gp.lockedm = 0
	mp.lockedg = 0
	gp.preemptStop = false
	gp.paniconfault = false
	gp._defer = nil // should be true already but just in case.
	gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data.
	gp.writebuf = nil
	gp.waitreason = waitReasonZero
	gp.param = nil
	gp.labels = nil
	gp.timer = nil
	schedule()
}
// 对结束的g进行了一些置0的工作,然后调用了 schedule()

schedule() 意味着 为现在的线程,切换协程。

到此,和上面的图都对应上了。但是目前还是单线程,多线程时候,是如何工作了,下篇再聊。