從原始碼分析 GMP 排程原理

daemon365發表於2024-12-08

本身涉及到的 go 程式碼 都是基於 go 1.23.0 版本

傳統 OS 執行緒

執行緒是 CPU 的最小排程單位,CPU 透過不斷切換執行緒來實現多工的併發。這會引發一些問題(對於使用者角度):

  1. 執行緒的建立和銷燬等是昂貴的,因為要不斷在使用者空間和核心空間切換。
  2. 執行緒的排程是由作業系統負責的,使用者無法控制。而作業系統又可能不知道執行緒已經 IO 阻塞,導致執行緒被排程,浪費 CPU 資源。
  3. 執行緒的棧是很大的,最新版 linux 預設是 8M,會引起記憶體浪費。
  4. ......

所以,最簡單的辦法就是複用執行緒,go 中使用的是 M:N 模型,即 M 個 OS 執行緒對應 N 個 任務。

GMP 模型

  1. G

goroutine, 一個 goroutine 代表一個任務。它有自己的棧空間,預設是 2K,棧空間可以動態增長。方式就是把舊的棧空間複製到新的棧空間,然後釋放舊的棧空間。它的棧是在 heap (對於 OS) 上分配的。

  1. M

machine, 一個 M 代表一個 OS 執行緒。

  1. P

processor, 一個 P 代表一個邏輯處理器,它維護了一個 goroutine 佇列。P 會把 goroutine 分配給 M,M 會執行 goroutine。預設的大小為 CPU 核心數。

資料結構

G

結構體在 src/runtime/runtime2.go 中定義,主要介紹一些重要的欄位:

type g struct {
  // goroutine 的棧 兩個地址,分別是棧的起始地址和結束地址
  stack       stack
  // 繫結的m
  m         *m 
  // goroutine 被排程走儲存的中間狀態
  sched     gobuf
  // goroutine 的狀態
  atomicstatus atomic.Uint32
}

type gobuf struct {
	sp   uintptr // stack pointer 棧指標
	pc   uintptr // program counter 程式要從哪裡開始執行
	g    guintptr // goroutine 的 指標
	ctxt unsafe.Pointer // 儲存的上下文
	ret  uintptr // 返回地址
	lr   uintptr // link register
	bp   uintptr // base pointer 棧的基地址
}

goroutine 狀態

// defined constants
const (
	// 未初始化
	_Gidle = iota // 0

	// 準備好了 可以被 P 排程
	_Grunnable // 1

	// 正在執行中
	_Grunning // 2

	// 正在執行系統呼叫
	_Gsyscall // 3

	// 正在等待 例如 channel network 等
	_Gwaiting // 4

	// 沒有被使用 為了相容性
	_Gmoribund_unused // 5

	// 未使用的 goroutine 
  // 1. 可能初始化了但是沒有被使用 
  // 2. 因為會複用未擴棧的 goroutine 所以也可能上次使用完了 還沒繼續使用
	_Gdead // 6

	// 沒有被使用 為了相容性
	_Genqueue_unused // 7

	// 棧擴容中
	_Gcopystack // 8

	// 被搶佔了 等待到 _Gwaiting 
	_Gpreempted // 9

	// 用於 GC 掃描
	_Gscan          = 0x1000
	_Gscanrunnable  = _Gscan + _Grunnable  // 0x1001
	_Gscanrunning   = _Gscan + _Grunning   // 0x1002
	_Gscansyscall   = _Gscan + _Gsyscall   // 0x1003
	_Gscanwaiting   = _Gscan + _Gwaiting   // 0x1004
	_Gscanpreempted = _Gscan + _Gpreempted // 0x1009
)
func malg(stacksize int32) *g {
	newg := new(g)
	// 分配 runtime 棧
	if stacksize >= 0 {
		stacksize = round2(stackSystem + stacksize)
		systemstack(func() {
			newg.stack = stackalloc(uint32(stacksize))
		})
		newg.stackguard0 = newg.stack.lo + stackGuard
		newg.stackguard1 = ^uintptr(0)
		*(*uintptr)(unsafe.Pointer(newg.stack.lo)) = 0
	}
	return newg
}

狀態流轉:

  1. 如果 groutine 還未初始化,那麼狀態是 _Gidle
  2. 初始化完畢是 _Gdead
  3. 當被呼叫 go func() 時,狀態變為 _Grunnable
  4. 當被排程到 M 上執行時,狀態變為 _Grunning
  5. 執行完畢後,狀態變為 _Gdead
  6. 如果 goroutine 阻塞,狀態變為 _Gwaiting 等待阻塞完畢 狀態再變為 _Grunnable 等待排程
  7. 如果 goroutine 被搶佔 (gc 要 STW 時),狀態變為 _Gpreempted 等待變成 _Gwaiting
  8. 如果發生系統呼叫,狀態變為 _Gsyscall 如果很快完成(10ms) 狀態會變為 _Grunning 繼續執行 否則會變為 _Grunnable 等待排程
  9. 如果發生棧擴容,狀態變為 _Gcopystack 等待棧擴容完畢 狀態變為 _Grunnable 等待排程

M

結構體在 src/runtime/runtime2.go 中定義,主要介紹一些重要的欄位:

type m struct {
  g0      *g  
  // 暫存器上下文
  morebuf gobuf
  // tls 是執行緒本地儲存 用於儲存 M 相關的執行緒本地資料 包括當前 G 的引用等重要資訊
  tls           [tlsSlots]uintptr
  // 現在正在執行的 goroutine
  curg          *g 

  // 1. 正常執行: p 有效
  // 2. 系統呼叫前: p -> oldp
  // 3. 系統呼叫中: p == nil
  // 4. 系統呼叫返回: 嘗試重新獲取 oldp
	p             puintptr 
	nextp         puintptr
	oldp          puintptr
}

M 的建立

func newm(fn func(), pp *p, id int64) {
	// 禁止被搶佔
	acquirem()
	// 分配 M 結構體 並新增列表中
	mp := allocm(pp, fn, id)
	// 設定 nextP m 會盡量與之繫結
	mp.nextp.set(pp)
	mp.sigmask = initSigmask
	// ...

	// 建立 M
	newm1(mp)
	// 釋放 m 的鎖定狀態
	releasem(getg().m)
}

func allocm(pp *p, fn func(), id int64) *m {	
	// ... 加鎖解鎖
	// 如果當前 M 沒有繫結 P,臨時借用傳入的 P
	if gp.m.p == 0 {
		acquirep(pp) // temporarily borrow p for mallocs in this function
	}

	// 處理空閒的 M 
	if sched.freem != nil {
		
	}

	// 建立 M
	mp := new(m)
	mp.mstartfn = fn
	mcommoninit(mp, id)

	// 對於 cgo 或者特定的作業系統 使用系統分配的棧 否則使用 go runtime 的棧
	if iscgo || mStackIsSystemAllocated() {
		mp.g0 = malg(-1)
	} else {
		mp.g0 = malg(16384 * sys.StackGuardMultiplier)
	}
	mp.g0.m = mp
	// 清理臨時借用的 P
	if pp == gp.m.p.ptr() {
		releasep()
	}
	return mp
}

// newm1 -> newosproc 
func newosproc(mp *m) {
	// 棧頂指標
	stk := unsafe.Pointer(mp.g0.stack.hi)

	// 訊號遮蔽
	var oset sigset
	sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
	// 重試的系統呼叫
	ret := retryOnEAGAIN(func() int32 {
		// 建立新執行緒 是彙編程式碼 可以找去看看
		r := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(abi.FuncPCABI0(mstart)))
		if r >= 0 {
			return 0
		}
		return -r
	})
	// 恢復訊號
	sigprocmask(_SIG_SETMASK, &oset, nil)
	// ...
}
// mstart 啟動 M 是一個彙編程式碼
TEXT runtime·mstart(SB),NOSPLIT|TOPFRAME|NOFRAME,$0
	CALL	runtime·mstart0(SB) // 呼叫 mstart0
	RET // not reached

// mstart0 -> mstart1
func mstart1() {
	gp := getg()

	if gp != gp.m.g0 {
		throw("bad runtime·mstart")
	}

	// 儲存排程資訊
	gp.sched.g = guintptr(unsafe.Pointer(gp))
	gp.sched.pc = getcallerpc()
	gp.sched.sp = getcallersp()

	// 初始化
	asminit()
	minit()
	// 主執行緒初始一些東西
	if gp.m == &m0 {
		mstartm0()
	}


	// 排程
	schedule()
}


// 初始化訊號 之後搶佔那塊會介紹
// 只有主執行緒需要初始化的原因是 其他執行緒是 clone 而來 而且包括了 _CLONE_SIGHAND 會繼承這些
func mstartm0() {
	// ...
	initsig(false)
}

g0: 一個特殊的 g 用於執行排程任務 它未使用 go runtime 的 stack 而是使用 os stack
流程大概為使用者態的 g -> g0 排程 -> 使用者的其他 g

P

結構體在 src/runtime/runtime2.go 中定義,主要介紹一些重要的欄位:

type p struct {
	// p 的狀態
	status      uint32 
  // 分配記憶體使用 每個p 都有的目的是少加鎖
  mcache      *mcache
  // 定長的 queue 用於儲存 goroutine
  runqhead uint32
	runqtail uint32
	runq     [256]guintptr
  //  下個執行的 goroutine 主要用來快速排程 比如從 chan 讀取資料,把 g 放到 runnext 中 當完成讀取時 直接從 runnext 中取出來執行
  runnext guintptr

}

狀態:

const (
	// 空閒
	_Pidle = iota

	// 正在執行中
	_Prunning

	// 正在執行系統呼叫
	_Psyscall

	// GC 停止
	_Pgcstop

	// 死亡狀態
	_Pdead
)

P的建立

// 程式啟動
TEXT main(SB),NOSPLIT,$-8
	JMP	runtime·rt0_go(SB)

TEXT runtime·rt0_go(SB),NOSPLIT|NOFRAME|TOPFRAME,$0
// ...
CALL	runtime·schedinit(SB)

func schedinit() {
	// ...
	if procresize(procs) != nil {
		throw("unknown runnable goroutine during bootstrap")
	}
}

// nprocs 是 process 數 預設是 cpu 個數
func procresize(nprocs int32) *p {
	// ...

	// 擴容 allp 加入未初始化的 P
	if nprocs > int32(len(allp)) {
		// 。。。
	}

	// 初始化所有新建的 P
	for i := old; i < nprocs; i++ {
		pp := allp[i]
		if pp == nil {
			pp = new(p)
		}
		pp.init(i)
		atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
	}

	// 處理 p 的狀態
	gp := getg()
	if gp.m.p != 0 && gp.m.p.ptr().id < nprocs {
		// continue to use the current P
		gp.m.p.ptr().status = _Prunning
		gp.m.p.ptr().mcache.prepareForSweep()
	} else {
		// ...
	}

	// g.m.p is now set, so we no longer need mcache0 for bootstrapping.
	mcache0 = nil

	// 清理多餘的 P
	for i := nprocs; i < old; i++ {
		pp := allp[i]
		pp.destroy()
		// can't free P itself because it can be referenced by an M in syscall
	}

	// 裁剪 allp 切片
	if int32(len(allp)) != nprocs {
		lock(&allpLock)
		allp = allp[:nprocs]
		idlepMask = idlepMask[:maskWords]
		timerpMask = timerpMask[:maskWords]
		unlock(&allpLock)
	}

	// 重新分配 P
	var runnablePs *p
	for i := nprocs - 1; i >= 0; i-- {
		pp := allp[i]
		if gp.m.p.ptr() == pp {
			continue
		}
		pp.status = _Pidle
		if runqempty(pp) {
			pidleput(pp, now)
		} else {
			pp.m.set(mget())
			pp.link.set(runnablePs)
			runnablePs = pp
		}
	}
	stealOrder.reset(uint32(nprocs))
	var int32p *int32 = &gomaxprocs // make compiler check that gomaxprocs is an int32
	atomic.Store((*uint32)(unsafe.Pointer(int32p)), uint32(nprocs))
	if old != nprocs {
		// Notify the limiter that the amount of procs has changed.
		gcCPULimiter.resetCapacity(now, nprocs)
	}
	return runnablePs
}

func (pp *p) init(id int32) {
	// ...
	// 分配 cache
	if pp.mcache == nil {
		if id == 0 {
			if mcache0 == nil {
				throw("missing mcache?")
			}
			// Use the bootstrap mcache0. Only one P will get
			// mcache0: the one with ID 0.
			pp.mcache = mcache0
		} else {
			pp.mcache = allocmcache()
		}
	}
	// ...
}

func (pp *p) destroy() {
	// 枷鎖 確保 stw
	assertLockHeld(&sched.lock)
	assertWorldStopped()

	// 將本地佇列中的 goroutine 移到全域性佇列
	for pp.runqhead != pp.runqtail {
		pp.runqtail--
		gp := pp.runq[pp.runqtail%uint32(len(pp.runq))].ptr()
		globrunqputhead(gp)
	}
	if pp.runnext != 0 {
		globrunqputhead(pp.runnext.ptr())
		pp.runnext = 0
	}

	// ...

	// 清理 span
	systemstack(func() {
		for i := 0; i < pp.mspancache.len; i++ {
			// Safe to call since the world is stopped.
			mheap_.spanalloc.free(unsafe.Pointer(pp.mspancache.buf[i]))
		}
		pp.mspancache.len = 0
		lock(&mheap_.lock)
		pp.pcache.flush(&mheap_.pages)
		unlock(&mheap_.lock)
	})

	// 釋放 mcache
	freemcache(pp.mcache)
	pp.mcache = nil
	
	// ...
}

全域性佇列

type schedt struct {
	// 鎖
	lock mutex

	// m 相關配置
	midle        muintptr
	// ...

	// p 相關配置 
	pidle        puintptr // idle p's
	// ...

	// g 佇列
	runq     gQueue
	runqsize int32

	// ...

	// G 物件池
	gFree struct {
		lock    mutex
		stack   gList // Gs with stacks
		noStack gList // Gs without stacks
		n       int32
	}

	// ...
}

P 的空閒列表: M 獲取 P 的時候拿到
M 的空閒列表: 執行緒的建立於銷燬代價是很大的 為了複用性

排程

go 有三種進行到排程的方式:

  1. 使用者 goroutine 主動執行 runtime.Gosched() 會把當前 goroutine 放到佇列中等待排程
  2. 使用者 goroutine 阻塞,例如 channel 讀寫,網路 IO 等 會主動呼叫修改自己狀態並切換到 g0 執行排程任務
  3. go runtime 中有個 OS 執行緒 (名稱是 sysmon) 檢測到 goroutine 超時(上次執行到現在超過 10ms)那就會給執行緒發訊號 使其切換到 g0 執行排程任務

為什麼 sysmon 使用物理執行緒而不是 goroutine 呢?

因為所有 p 上正在執行的 g 都阻塞住了 比如 for {} 那麼其他的 g 永遠無法執行了包括負責檢測的 sysmon

主動排程

func Gosched() {
	checkTimeouts()
	mcall(gosched_m)
}

阻塞排程

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) {
	if reason != waitReasonSleep {
		checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
	}
	mp := acquirem()
	gp := mp.curg
	status := readgstatus(gp)
	if status != _Grunning && status != _Gscanrunning {
		throw("gopark: bad g status")
	}
	mp.waitlock = lock
	mp.waitunlockf = unlockf
	gp.waitreason = reason
	mp.waitTraceBlockReason = traceReason
	mp.waitTraceSkip = traceskip
	releasem(mp)
	// can't do anything that might move the G between Ms here.
	mcall(park_m)
}

搶佔排程

// m 在 start 的時候會註冊一些訊號處理函式
func initsig(preinit bool) {
	for i := uint32(0); i < _NSIG; i++ {
		// ...
		setsig(i, abi.FuncPCABIInternal(sighandler))
	}
}

// sighandler -> doSigPreempt -> asyncPreempt (去彙編程式碼裡找) -> asyncPreempt2 
func asyncPreempt2() {
	gp := getg()
	gp.asyncSafePoint = true
	if gp.preemptStop {
		mcall(preemptPark)
	} else {
		mcall(gopreempt_m)
	}
	gp.asyncSafePoint = false
}


// sysmon 發訊號 
// sysmon -> retake -> preemptone -> preemptM
func preemptM(mp *m) {
	if mp.signalPending.CompareAndSwap(0, 1) {
		// ...
		signalM(mp, sigPreempt)
	}
}

func signalM(mp *m, sig int) {
	tgkill(getpid(), int(mp.procid), sig)
}

// 程式碼在在彙編裡 就是對執行緒傳送訊號 系統呼叫
func tgkill(tgid, tid, sig int)

排程程式碼

可以看到排程程式碼都是透過 mcall 呼叫的,mcall 會切換到 g0 執行排程任務 如果引數的函式不太一樣 但是都是處理一些狀態資訊等,最好都會執行到 schedule 函式。

func schedule() {
	// 核心程式碼就是選一個 g 去執行
	gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available

	execute(gp, inheritTime)
}

findRunnable:

func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
	
	// Try to schedule a GC worker.
	if gcBlackenEnabled != 0 {
		gp, tnow := gcController.findRunnableGCWorker(pp, now)
		if gp != nil {
			return gp, false, true
		}
		now = tnow
	}

	if pp.schedtick%61 == 0 && sched.runqsize > 0 {
		lock(&sched.lock)
		gp := globrunqget(pp, 1)
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}

	// local runq
	if gp, inheritTime := runqget(pp); gp != nil {
		return gp, inheritTime, false
	}

	// global runq
	if sched.runqsize != 0 {
		lock(&sched.lock)
		gp := globrunqget(pp, 0)
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}

	if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
		if list, delta := netpoll(0); !list.empty() { // non-blocking
			gp := list.pop()
			injectglist(&list)
			netpollAdjustWaiters(delta)
			trace := traceAcquire()
			casgstatus(gp, _Gwaiting, _Grunnable)
			if trace.ok() {
				trace.GoUnpark(gp, 0)
				traceRelease(trace)
			}
			return gp, false, false
		}
	}

	// Spinning Ms: steal work from other Ps.
	//
	// Limit the number of spinning Ms to half the number of busy Ps.
	// This is necessary to prevent excessive CPU consumption when
	// GOMAXPROCS>>1 but the program parallelism is low.
	if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
		if !mp.spinning {
			mp.becomeSpinning()
		}

		gp, inheritTime, tnow, w, newWork := stealWork(now)
		if gp != nil {
			// Successfully stole.
			return gp, inheritTime, false
		}
		if newWork {
			// There may be new timer or GC work; restart to
			// discover.
			goto top
		}

		now = tnow
		if w != 0 && (pollUntil == 0 || w < pollUntil) {
			// Earlier timer to wait for.
			pollUntil = w
		}
	}

}

簡化了一下程式碼還是很多 價紹一些這個功能吧

  1. 優先執行 GC worker
  2. 每 61 次 從全域性佇列中獲取一個 g 去執行 作用是 防止所有 p 的本地佇列誰都非常多 導致全域性佇列的 g 餓死
  3. 從本地佇列中獲取一個 g 去執行 有限使用 runnext
  4. 從全域性佇列中獲取一個 g 去執行 並 load 一些到本地佇列
  5. 如果有網路 IO 準備好了 就從網路 IO 中獲取一個 g 去執行 (go 中網路 epoll_wait 正常情況下使用的阻塞模式)
  6. 從其他的 p 中偷取 g 去執行 (cas 保證資料安全)

execute:

func execute(gp *g, inheritTime bool) {
	// 修改狀態
	casgstatus(gp, _Grunnable, _Grunning)
  // 執行
	gogo(&gp.sched)
}

我的 arch 是 amd64 所以程式碼在 src/runtime/asm_amd64.s

TEXT runtime·gogo(SB), NOSPLIT, $0-8
	MOVQ	buf+0(FP), BX	  // 將 gobuf 指標載入到 BX 暫存器
	MOVQ	gobuf_g(BX), DX  // 將 gobuf 中儲存的 g 指標載入到 DX
	MOVQ	0(DX), CX	  // 檢查 g 不為 nil
	JMP	gogo<>(SB)

TEXT gogo<>(SB), NOSPLIT, $0
	get_tls(CX)
	MOVQ	DX, g(CX)
	MOVQ	DX, R14		// set the g register
  // 恢復暫存器狀態 (sp ret bp ctxt) 執行 
	MOVQ	gobuf_sp(BX), SP	// restore SP
	MOVQ	gobuf_ret(BX), AX
	MOVQ	gobuf_ctxt(BX), DX
	MOVQ	gobuf_bp(BX), BP
  // 載入之後 清空 go 的 gobuf 結構體 為了給 gc 節省壓力
	MOVQ	$0, gobuf_sp(BX)	
	MOVQ	$0, gobuf_ret(BX)
	MOVQ	$0, gobuf_ctxt(BX)
	MOVQ	$0, gobuf_bp(BX)
  // 跳轉到儲存的 PC (程式執行到哪了) 去執行
	MOVQ	gobuf_pc(BX), BX
	JMP	BX

syscall

我的 arch 是 and64 作業系統是 linux 所以程式碼在 src/runtime/asm_linux_amd64.s

TEXT ·SyscallNoError(SB),NOSPLIT,$0-48
	CALL	runtime·entersyscall(SB)
	MOVQ	a1+8(FP), DI
	MOVQ	a2+16(FP), SI
	MOVQ	a3+24(FP), DX
	MOVQ	$0, R10
	MOVQ	$0, R8
	MOVQ	$0, R9
	MOVQ	trap+0(FP), AX	// syscall entry
	SYSCALL
	MOVQ	AX, r1+32(FP)
	MOVQ	DX, r2+40(FP)
	CALL	runtime·exitsyscall(SB)
	RET

系統呼叫前執行這個函式:

func entersyscall() {
	fp := getcallerfp()
	reentersyscall(getcallerpc(), getcallersp(), fp)
}

func reentersyscall(pc, sp, bp uintptr) {
	// 儲存暫存器資訊
	save(pc, sp, bp)
	gp.syscallsp = sp
	gp.syscallpc = pc
	gp.syscallbp = bp
  // 修改 g 狀態
	casgstatus(gp, _Grunning, _Gsyscall)
	

	if sched.sysmonwait.Load() {
		systemstack(entersyscall_sysmon)
		save(pc, sp, bp)
	}

	if gp.m.p.ptr().runSafePointFn != 0 {
		// runSafePointFn may stack split if run on this stack
		systemstack(runSafePointFn)
		save(pc, sp, bp)
	}

	gp.m.syscalltick = gp.m.p.ptr().syscalltick
	pp := gp.m.p.ptr()
  // 解綁 P 和 M 並設定 oldP 為當前 P 等待系統呼叫之後重新繫結
	pp.m = 0
	gp.m.oldp.set(pp)
	gp.m.p = 0
  // 修改 P 的狀態為 syscall
	atomic.Store(&pp.status, _Psyscall)
	if sched.gcwaiting.Load() {
		systemstack(entersyscall_gcwait)
		save(pc, sp, bp)
	}

	gp.m.locks--
}

系統呼叫後執行這個函式:

func exitsyscall() {
	// 如果之前儲存的oldp不為空 那麼重新繫結
	if exitsyscallfast(oldp) {
		// 設定狀態為 runnable 並重新執行
		casgstatus(gp, _Gsyscall, _Grunning)
		if sched.disable.user && !schedEnabled(gp) {
			// Scheduling of this goroutine is disabled.
			Gosched()
		}

		return
	}
  // 切換到 g0 執行 exitsyscall0
	mcall(exitsyscall0)
}

func exitsyscall0(gp *g) {
	// 修改 g 狀態到 _Grunnable 讓重新可排程
	casgstatus(gp, _Gsyscall, _Grunnable)
	
	// 刪除 gm 的繫結
	dropg()
	lock(&sched.lock)
	// 找個空閒的 p (狀態為 _Gidle) 與 M 繫結
  var pp *p
	if schedEnabled(gp) {
		pp, _ = pidleget(0)
	}
	var locked bool
	if pp == nil {
    // 如果繫結失敗了 直接把 g 放到全域性佇列中
		globrunqput(gp)
		locked = gp.lockedm != 0
	} else if sched.sysmonwait.Load() {
    // 如果 sysmon 在等待 那麼喚醒它
		sched.sysmonwait.Store(false)
		notewakeup(&sched.sysmonnote)
	}
	unlock(&sched.lock)
  // 如果找到 p 了 那麼就去執行
	if pp != nil {
		acquirep(pp)
		execute(gp, false) // Never returns.
	}
	if locked {
		// Wait until another thread schedules gp and so m again.
		//
		// N.B. lockedm must be this M, as this g was running on this M
		// before entersyscall.
		stoplockedm()
		execute(gp, false) // Never returns.
	}
  // 如果沒有 P 給我這個 M 繫結的話 那麼把 M 休眠並加入到 schedlink 佇列中  做複用
	stopm()
  // 直到有新的 g 被排程到這個 M 上
	schedule() // Never returns.
}

goroutine 切換通用暫存器問題

我們知道 goroutine 中的 gobuf 中只儲存了 sp pc bp 等暫存器資訊,但是 goroutine 切換的時候還有其他通用暫存器,如果中間丟失會引起結果不一致。那麼 go 中是怎麼儲存的呢?

goroutine 切換大體有兩種情況

  1. 在編譯階段知道 goroutine 可能交出控制權 比如 讀寫 channel 等待網路 系統呼叫等
  2. goroutine 被搶佔了 GC 超時等

對於第一種方式,在編譯階段知道後續會使用哪個暫存器和知道在哪裡可能會交出控制權,就會在後續儲存這些暫存器。

func test(a chan int, b, c int) int {
	d := <-a
	return d + b + c
}

// go tool compile -S main.go 的彙編程式碼
main.test STEXT size=105 args=0x18 locals=0x20 funcid=0x0 align=0x0
	0x0000 00000 (./main.go:3)	TEXT	main.test(SB), ABIInternal, $32-24
	// 棧檢查
	0x0000 00000 (./main.go:3)	CMPQ	SP, 16(R14)
	0x0004 00004 (./main.go:3)	PCDATA	$0, $-2
	0x0004 00004 (./main.go:3)	JLS	68
	0x0006 00006 (./main.go:3)	PCDATA	$0, $-1
	0x0006 00006 (./main.go:3)	PUSHQ	BP
	0x0007 00007 (./main.go:3)	MOVQ	SP, BP
	0x000a 00010 (./main.go:3)	SUBQ	$24, SP
	// 除錯使用的
	0x000e 00014 (./main.go:3)	FUNCDATA	$0, gclocals·wgcWObbY2HYnK2SU/U22lA==(SB)
	0x000e 00014 (./main.go:3)	FUNCDATA	$1, gclocals·J5F+7Qw7O7ve2QcWC7DpeQ==(SB)
	0x000e 00014 (./main.go:3)	FUNCDATA	$5, main.test.arginfo1(SB)
	0x000e 00014 (./main.go:3)	FUNCDATA	$6, main.test.argliveinfo(SB)
	0x000e 00014 (./main.go:3)	PCDATA	$3, $1
	// 把 BX CX 儲存到棧中
	0x000e 00014 (./main.go:5)	MOVQ	BX, main.b+48(SP)
	0x0013 00019 (./main.go:5)	MOVQ	CX, main.c+56(SP)
	0x0018 00024 (./main.go:5)	PCDATA	$3, $2
	// 初始化臨時變數 並 chanrecv1 接受 chan 資料
	0x0018 00024 (./main.go:4)	MOVQ	$0, main..autotmp_5+16(SP)
	0x0021 00033 (./main.go:4)	LEAQ	main..autotmp_5+16(SP), BX
	0x0026 00038 (./main.go:4)	PCDATA	$1, $1
	0x0026 00038 (./main.go:4)	CALL	runtime.chanrecv1(SB)
	// 恢復接受 chan 之前入棧的暫存器 
	0x002b 00043 (./main.go:5)	MOVQ	main.b+48(SP), CX
	0x0030 00048 (./main.go:5)	ADDQ	main..autotmp_5+16(SP), CX
	0x0035 00053 (./main.go:5)	MOVQ	main.c+56(SP), DX
	0x003a 00058 (./main.go:5)	LEAQ	(DX)(CX*1), AX
	0x003e 00062 (./main.go:5)	ADDQ	$24, SP
	0x0042 00066 (./main.go:5)	POPQ	BP
	0x0043 00067 (./main.go:5)	RET
	0x0044 00068 (./main.go:5)	NOP
	// 處理擴容棧相關的程式碼
	0x0044 00068 (./main.go:3)	PCDATA	$1, $-1
	0x0044 00068 (./main.go:3)	PCDATA	$0, $-2
	0x0044 00068 (./main.go:3)	MOVQ	AX, 8(SP)
	0x0049 00073 (./main.go:3)	MOVQ	BX, 16(SP)
	0x004e 00078 (./main.go:3)	MOVQ	CX, 24(SP)
	0x0053 00083 (./main.go:3)	CALL	runtime.morestack_noctxt(SB)
	0x0058 00088 (./main.go:3)	PCDATA	$0, $-1
	0x0058 00088 (./main.go:3)	MOVQ	8(SP), AX
	0x005d 00093 (./main.go:3)	MOVQ	16(SP), BX
	0x0062 00098 (./main.go:3)	MOVQ	24(SP), CX
	0x0067 00103 (./main.go:3)	JMP	0


func test2(a int, b, c int) int {
	return a + b + c
}

main.test2 STEXT nosplit size=9 args=0x18 locals=0x0 funcid=0x0 align=0x0
	0x0000 00000 (/home/zhy/code/test1/main.go:8)	TEXT	main.test2(SB), NOSPLIT|NOFRAME|ABIInternal, $0-24
	0x0000 00000 (/home/zhy/code/test1/main.go:8)	FUNCDATA	$0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
	0x0000 00000 (/home/zhy/code/test1/main.go:8)	FUNCDATA	$1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
	0x0000 00000 (/home/zhy/code/test1/main.go:8)	FUNCDATA	$5, main.test2.arginfo1(SB)
	0x0000 00000 (/home/zhy/code/test1/main.go:8)	FUNCDATA	$6, main.test2.argliveinfo(SB)
	0x0000 00000 (/home/zhy/code/test1/main.go:8)	PCDATA	$3, $1
	0x0000 00000 (/home/zhy/code/test1/main.go:9)	LEAQ	(BX)(AX*1), DX
	0x0004 00004 (/home/zhy/code/test1/main.go:9)	LEAQ	(CX)(DX*1), AX
	0x0008 00008 (/home/zhy/code/test1/main.go:9)	RET

從上方可以看到 test2 函式沒有儲存 BX CX 暫存器,因為編譯器知道這個函式不會交出控制權,所以不需要儲存這些暫存器。如果呼叫函式不做引數入棧的話,只用暫存器的話效能會更好。

那如果是搶佔呢,編譯階段肯定是不知道會在哪被搶佔的,是怎麼恢復要使用的暫存器呢?

處理訊號的邏輯:

func doSigPreempt(gp *g, ctxt *sigctxt) {
	// Check if this G wants to be preempted and is safe to
	// preempt.
	if wantAsyncPreempt(gp) {
		if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
			// Adjust the PC and inject a call to asyncPreempt.
			ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
		}
	}

}

程式碼在 src/runtime/preempt_amd64.go

TEXT ·asyncPreempt(SB),NOSPLIT|NOFRAME,$0-0
	PUSHQ BP
	MOVQ SP, BP
	// Save flags before clobbering them
	PUSHFQ
	// obj doesn't understand ADD/SUB on SP, but does understand ADJSP
	ADJSP $368
	// But vet doesn't know ADJSP, so suppress vet stack checking
	NOP SP
	MOVQ AX, 0(SP)
	MOVQ CX, 8(SP)
	MOVQ DX, 16(SP)
	MOVQ BX, 24(SP)
	MOVQ SI, 32(SP)
	MOVQ DI, 40(SP)
	MOVQ R8, 48(SP)
	MOVQ R9, 56(SP)
	MOVQ R10, 64(SP)
	MOVQ R11, 72(SP)
	MOVQ R12, 80(SP)
	MOVQ R13, 88(SP)
	MOVQ R14, 96(SP)
	MOVQ R15, 104(SP)
	#ifdef GOOS_darwin
	#ifndef hasAVX
	CMPB internal∕cpu·X86+const_offsetX86HasAVX(SB), $0
	JE 2(PC)
	#endif
	VZEROUPPER
	#endif
	MOVUPS X0, 112(SP)
	MOVUPS X1, 128(SP)
	MOVUPS X2, 144(SP)
	MOVUPS X3, 160(SP)
	MOVUPS X4, 176(SP)
	MOVUPS X5, 192(SP)
	MOVUPS X6, 208(SP)
	MOVUPS X7, 224(SP)
	MOVUPS X8, 240(SP)
	MOVUPS X9, 256(SP)
	MOVUPS X10, 272(SP)
	MOVUPS X11, 288(SP)
	MOVUPS X12, 304(SP)
	MOVUPS X13, 320(SP)
	MOVUPS X14, 336(SP)
	MOVUPS X15, 352(SP)
	CALL ·asyncPreempt2(SB)
	MOVUPS 352(SP), X15
	MOVUPS 336(SP), X14
	MOVUPS 320(SP), X13
	MOVUPS 304(SP), X12
	MOVUPS 288(SP), X11
	MOVUPS 272(SP), X10
	MOVUPS 256(SP), X9
	MOVUPS 240(SP), X8
	MOVUPS 224(SP), X7
	MOVUPS 208(SP), X6
	MOVUPS 192(SP), X5
	MOVUPS 176(SP), X4
	MOVUPS 160(SP), X3
	MOVUPS 144(SP), X2
	MOVUPS 128(SP), X1
	MOVUPS 112(SP), X0
	MOVQ 104(SP), R15
	MOVQ 96(SP), R14
	MOVQ 88(SP), R13
	MOVQ 80(SP), R12
	MOVQ 72(SP), R11
	MOVQ 64(SP), R10
	MOVQ 56(SP), R9
	MOVQ 48(SP), R8
	MOVQ 40(SP), DI
	MOVQ 32(SP), SI
	MOVQ 24(SP), BX
	MOVQ 16(SP), DX
	MOVQ 8(SP), CX
	MOVQ 0(SP), AX
	ADJSP $-368
	POPFQ
	POPQ BP
	RET

這段彙編程式碼很簡單,把各種暫存器儲存到棧中,然後呼叫 asyncPreempt2 函式,這個函式會恢復這些寋存器。

func asyncPreempt2() {
	gp := getg()
	gp.asyncSafePoint = true
	if gp.preemptStop {
		mcall(preemptPark)
	} else {
		mcall(gopreempt_m)
	}
	gp.asyncSafePoint = false
}

這個程式碼就是開始交給 g0 去執行排程任務,當 goroutine 回來可以繼續執行的時候,會執行恢復暫存器的程式碼。

相關文章