GO调度模型-GMP(下)

循环调度

集齐各部分理论碎片之后,我们可以尝试对 GMP 的宏观调度流程进行整体串联,开始进入到调度函数runtime.schedule, 此时的执行权位于 g0 手中:

//go 1.20.3 path: /src/runtime/proc.go

func schedule() {
  // 获取当前 goroutine 的 M(操作系统线程)
	mp := getg().m
  
  // ... 省略锁以及cgo判断代码
  
top:
  // 获取当前 M 所属的 P(处理器)
	pp := mp.p.ptr()
  // 设置 P 的抢占标志为 false
	pp.preempt = false
	//如果当前 M 在自旋状态中,并且本地运行队列中有任务,则抛出异常
	if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
		throw("schedule: spinning with local work")
	}
  
	// 获取一个可运行的 G,可能会阻塞直到有可运行的任务
	gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available

  //如果M在自旋,重置自旋状态
	if mp.spinning {
		resetspinning()
	}

  //如果调度被禁用且 G 不是可运行的状态,则将 G 放入 sched.disable.runnable 中,并跳过调度
	if sched.disable.user && !schedEnabled(gp) {
		lock(&sched.lock)
		if schedEnabled(gp) {
			unlock(&sched.lock)
		} else {
			sched.disable.runnable.pushBack(gp)
			sched.disable.n++
			unlock(&sched.lock)
			goto top
		}
	}

  // 尝试唤醒其他的 P
	if tryWakeP {
		wakep()
	}
  // 如果 G 被锁住了,启动一个新的 M 来执行 G
	if gp.lockedm != 0 {
		startlockedm(gp)
		goto top
	}
  // 执行可运行的 G
	execute(gp, inheritTime)
}

分析代码,我们去掉一些非关键的逻辑和问题,总结起来该函数流程就两个:

  1. 通过调用 runtime.findRunnable函数查找一个可用的G
  2. 通过调用 runtime.execute函数执行这个可用的G

下面对这两个函数进行重点分析。

查找可用G — runtime.findRunnable

调度流程中,一个非常核心的步骤,就是为 M 寻找到下一个执行的 G,这部分内容位于函数 findRunnable 方法中,该函数非常冗长,为了寻找一个可用的G,真是用心良苦,我们看下源码分析:

//go 1.20.3 path: /src/runtime/proc.go

func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
    // 获取当前 M
    mp := getg().m

top:
    // 获取当前 P
    pp := mp.p.ptr()
    
    // 如果 GC 正在等待,停止当前 M 并重新尝试
    if sched.gcwaiting.Load() {
        gcstopm()
        goto top
    }

    // 检查 SafePoint 函数是否存在,如果存在则执行
    if pp.runSafePointFn != 0 {
        runSafePointFn()
    }

    // 检查定时器,获取当前时间和 pollUntil 时间
    now, pollUntil, _ := checkTimers(pp, 0)
    
    // 如果启用了 Trace 或正在关闭 Trace,则尝试从 Trace 中读取 G
    if trace.enabled || trace.shutdown {
        gp := traceReader()
        if gp != nil {
            // 将 G 的状态从等待状态改为可运行状态
            casgstatus(gp, _Gwaiting, _Grunnable)
            // 触发 Trace 的 GoUnpark 事件
            traceGoUnpark(gp, 0)
            return gp, false, true
        }
    }

    // 如果启用了 GC,并且找到可运行的 GC Worker,则返回该 Worker
    if gcBlackenEnabled != 0 {
        gp, tnow := gcController.findRunnableGCWorker(pp, now)
        if gp != nil {
            return gp, false, true
        }
        now = tnow
    }

    // 每隔 61 个调度时钟周期,尝试从全局运行队列中获取一个 G
    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
        }
    }

    // 如果有 Futex 唤醒,则尝试唤醒等待的 G
    if fingStatus.Load()&(fingWait|fingWake) == fingWait|fingWake {
        if gp := wakefing(); gp != nil {
            ready(gp, 0, true)
        }
    }

    // 如果存在 cgo_yield 函数,则执行
    if *cgo_yield != nil {
        asmcgocall(*cgo_yield, nil)
    }

    // 尝试从本地运行队列中获取一个可运行的 G
    if gp, inheritTime := runqget(pp); gp != nil {
        return gp, inheritTime, false
    }

    // 如果全局运行队列非空,则尝试从全局运行队列中获取一个 G
    if sched.runqsize != 0 {
        lock(&sched.lock)
        gp := globrunqget(pp, 0)
        unlock(&sched.lock)
        if gp != nil {
            return gp, false, false
        }
    }

    // 如果网络轮询已初始化,并且有等待的网络事件,并且上次轮询的时间不为零
    if netpollinited() && netpollWaiters.Load() > 0 && sched.lastpoll.Load() != 0 {
        // 尝试非阻塞获取网络事件列表
        if list := netpoll(0); !list.empty() {
            // 从列表中弹出一个 G,并将 G 插入 G 列表
            gp := list.pop()
            injectglist(&list)
            // 将 G 的状态从等待状态改为可运行状态
            casgstatus(gp, _Gwaiting, _Grunnable)
            // 如果启用了 Trace,则触发 Trace 的 GoUnpark 事件
            if trace.enabled {
                traceGoUnpark(gp, 0)
            }
            return gp, false, false
        }
    }

    // 如果 M 处于自旋状态,或者全局运行队列中的任务数量小于等于 M 的空闲数量的两倍
    if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
        // 如果 M 不在自旋状态,则将其切换为自旋状态
        if !mp.spinning {
            mp.becomeSpinning()
        }

        // 尝试从其他 P 中偷取任务
        gp, inheritTime, tnow, w, newWork := stealWork(now)
        if gp != nil {
            return gp, inheritTime, false
        }
        
        // 如果有新的任务,则重新尝试
        if newWork {
            goto top
        }

        now = tnow
        if w != 0 && (pollUntil == 0 || w < pollUntil) {
            pollUntil = w
        }
    }

    // 如果启用了 GC,并且 GC Mark 阶段有任务可用,并且可以添加空闲的 GC Mark Worker,则添加一个 Worker
    if gcBlackenEnabled != 0 && gcMarkWorkAvailable(pp) && gcController.addIdleMarkWorker() {
        // 从 GC Mark Worker 池中取出一个节点
        node := (*gcBgMarkWorkerNode)(gcBgMarkWorkerPool.pop())
        if node != nil {
            // 设置当前 P 的 GC Mark Worker 模式为空闲
            pp.gcMarkWorkerMode = gcMarkWorkerIdleMode
            // 从节点中获取 G,并将其状态从等待状态改为可运行状态
            gp := node.gp.ptr()
            casgstatus(gp, _Gwaiting, _Grunnable)
            // 如果启用了 Trace,则触发 Trace 的 GoUnpark 事件
            if trace.enabled {
                traceGoUnpark(gp, 0)
            }
            return gp, false, false
        }
        // 如果没有可用的节点,则移除空闲的 GC Mark Worker
        gcController.removeIdleMarkWorker()
    }

    // 在空闲时执行一些操作,如果获取到一个可运行的 G,则返回
    gp, otherReady := beforeIdle(now, pollUntil)
    if gp != nil {
        // 将 G 的状态从等待状态改为可运行状态
        casgstatus(gp, _Gwaiting, _Grunnable)
        // 如果启用了 Trace,则触发 Trace 的 GoUnpark 事件
        if trace.enabled {
            traceGoUnpark(gp, 0)
        }
        return gp, false, false
    }
    
    // 如果有其他的 G 准备好了,则重新尝试
    if otherReady {
        goto top
    }

    // 在没有找到可运行的 G 时,查看全局运行队列中是否有任务
    allpSnapshot := allp
    idlepMaskSnapshot := idlepMask
    timerpMaskSnapshot := timerpMask

    lock(&sched.lock)
    if sched.gcwaiting.Load() || pp.runSafePointFn != 0 {
        unlock(&sched.lock)
        goto top
    }
    if sched.runqsize != 0 {
        gp := globrunqget(pp, 0)
        unlock(&sched.lock)
        return gp, false, false
    }
    if !mp.spinning && sched.needspinning.Load() == 1 {
        // 如果 M 不在自旋状态,并且需要自旋,则切换为自旋状态
        mp.becomeSpinning()
        unlock(&sched.lock)
        goto top
    }
    if releasep() != pp {
        throw("findrunnable: wrong p")
    }
    now = pidleput(pp, now)
    unlock(&sched.lock)

    // 如果 M 处于自旋状态,则尝试从全局运行队列中获取一个 G
    wasSpinning := mp.spinning
    if mp.spinning {
        mp.spinning = false
        if sched.nmspinning.Add(-1) < 0 {
            throw("findrunnable: negative nmspinning")
        }

        pp := checkRunqsNoP(allpSnapshot, idlepMaskSnapshot)
        if pp != nil {
            acquirep(pp)
            mp.becomeSpinning()
            goto top
        }

        // 再次检查是否有空闲优先级的 GC 任务
        pp, gp := checkIdleGCNoP()
        if pp != nil {
            acquirep(pp)
            mp.becomeSpinning()

            // 运行空闲优先级的 GC 任务
            pp.gcMarkWorkerMode = gcMarkWorkerIdleMode
            casgstatus(gp, _Gwaiting, _Grunnable)
            if trace.enabled {
                traceGoUnpark(gp, 0)
            }
            return gp, false, false
        }

        // 检查定时器,获取当前时间
        pollUntil = checkTimersNoP(allpSnapshot, timerpMaskSnapshot, pollUntil)
    }

    // 如果网络轮询已初始化,并且有等待的网络事件,并且上次轮询的时间不为零
    if netpollinited() && (netpollWaiters.Load() > 0 || pollUntil != 0) && sched.lastpoll.Swap(0) != 0 {
        if list := netpoll(0); !list.empty() { // 非阻塞方式获取网络事件
            gp := list.pop()
            injectglist(&list)
            casgstatus(gp, _Gwaiting, _Grunnable)
            if trace.enabled {
                traceGoUnpark(gp, 0)
            }
            return gp, false, false
        }
    }

    // 如果网络轮询等待时间不为零,并且网络轮询已初始化,则触发网络轮询中断
    if pollUntil != 0 && netpollinited() {
        pollerPollUntil := sched.pollUntil.Load()
        if pollerPollUntil == 0 || pollerPollUntil > pollUntil {
            netpollBreak()
        }
    }

    // 停止当前 M,然后重新尝试
    stopm()
    goto top
}

综合代码,来总结下流程:

  1. 获取当前 M
  2. 获取当前 P,检查是否有等待的 GC(垃圾回收),如果有,则执行 gcstopm,并返回步骤1重新执行;
  3. 如果存在运行时注册的安全点函数 runSafePointFn,则执行它;
  4. 检查计时器,更新 now,并获取下一个计时器的触发时间 pollUntil;
  5. 如果启用了 Trace 或正在关闭 Trace,则尝试从 Trace 中读取 G;
  6. 如果启用了 GC,并且找到可运行的 GC Worker,则返回该 Worker;
  7. 每隔 61 个调度时钟周期(pp.schedtick%61 == 0)且全局运行队列不为空(sched.runqsize > 0),尝试调用 globrunqget 函数 从全局运行队列中获取一个 G,如果获取到则返回;
  8. 如果有 Futex 唤醒,则尝试唤醒等待的 G, 如果存在 cgo_yield 函数,则执行;
  9. 尝试调用函数runqget从本地运行队列中获取一个可运行的 G,如果获取到则返回;
  10. 如果全局运行队列(sched.runq)非空,则再次尝试从全局运行队列中获取一个 G(不死心啊),如果获取到则返回;
  11. 如果网络轮询已初始化,并且有等待的网络事件,并且上次轮询的时间不为零,尝试从网络轮询中获取可运行的 goroutine,如果获取到则返回;
  12. 如果 M 处于自旋状态,或者全局运行队列中的任务数量小于等于 M 的空闲数量的两倍,则调用stealWork函数尝试从其他 P 中偷取任务,如果获取到则返回;
  13. 如果启用了 GC,并且 GC Mark 阶段有任务可用,并且可以添加空闲的 GC Mark Worker,则添加一个 Worker并尝试从GC Mark Worker 池中取出一个节点,如果获取成功则返回;
  14. 在空闲时执行一些操作,如果获取到一个可运行的 G,则返回;
  15. 如果没有找到可运行的 G,查看全局运行队列中是否有任务,然后根据各种情况,瞅准机会不断地去全局运行队列里面看看能否获取到可运行的G,最后如果实在取不到G,则调用stopm(),然后返回第2步继续重试;

总结起来就是各种办法找G,到最后还不死心,在能找的地方再找一次。

其流程图如下:

image-20231117155654917

下面来根据 runtime.findRunnable函数,来看看调度函数的调度策略。

调度策略

1. 从全局运行队列中获取G — runtime.globrunqget

runtime.findRunnable函数中,有多处是调用runtime.globrunqget函数从全局运动队列中获取G的地方:

//go 1.20.3 path: /src/runtime/proc.go

func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
  	// ...... 
  
		// 每隔 61 个调度时钟周期,尝试从全局运行队列中获取一个 G
    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
        }
    }
		// ......

    // 如果全局运行队列非空,则尝试从全局运行队列中获取一个 G
    if sched.runqsize != 0 {
        lock(&sched.lock)
        gp := globrunqget(pp, 0)
        unlock(&sched.lock)
        if gp != nil {
            return gp, false, false
        }
    }
		// ......
	
    if sched.runqsize != 0 {
        gp := globrunqget(pp, 0)
        unlock(&sched.lock)
        return gp, false, false
    }
  	// ......
}

我们来看看runtime.globrunqget函数到底做了什么,它是什么处理逻辑:

//go 1.20.3 path: /src/runtime/proc.go

func globrunqget(pp *p, max int32) *g {
	// 确保调度器锁被持有
	assertLockHeld(&sched.lock)
	
	// 如果全局运行队列为空,则返回 nil
	if sched.runqsize == 0 {
		return nil
	}

	// 计算从全局运行队列中获取的 g 的数量
  // 为了保证公平性,每个p都有机会从全局队列中拿到一部分g,数量为 n := sched.runqsize/gomaxprocs + 1
	n := sched.runqsize/gomaxprocs + 1
	if n > sched.runqsize {
		n = sched.runqsize
	}

	// 如果有指定的最大数量,并且 n 超过了最大数量,则将 n 限制为最大数量
	if max > 0 && n > max {
		n = max
	}

	// 如果 n 超过了 pp 的本地运行队列的一半,则将 n 限制为本地运行队列的一半
	if n > int32(len(pp.runq))/2 {
		n = int32(len(pp.runq)) / 2
	}

	// 更新全局运行队列的大小
	sched.runqsize -= n

	// 从全局运行队列中取出一个 g
	gp := sched.runq.pop()
	n--

	// 从全局运行队列中取出剩余的 g,并放入 pp 的本地运行队列
	for ; n > 0; n-- {
		gp1 := sched.runq.pop()
		runqput(pp, gp1, false)
	}

	// 返回取出的 g
	return gp
}

该函数的主要流程逻辑为:

  1. 确保调度器锁 (sched.lock) 被持有,检查全局运行队列 (sched.runq) 是否为空,如果为空,直接返回 nil
  2. 计算从全局运行队列中获取的 goroutineg)的数量 nn 的计算方式是将全局运行队列的大小 (sched.runqsize) 除以最大处理器数量 (gomaxprocs) 加 1,确保至少获取一个 g
  3. 如果指定了最大数量 (max > 0),则将 n 限制为最大数量;如果 n 超过了全局运行队列的大小,则将 n 限制为全局运行队列的大小;
  4. 从全局运行队列中取出一个 g (gp),并将全局运行队列大小减去 n,使用循环从全局运行队列中取出剩余的 g,并将它们放入调用者的本地运行队列 (pp.runq);
  5. 返回取出的第一个 g (gp),供调用者使用。

其中将全局运行队列中的G存放进调用者本地运行队列中,使用了函数runtime.runqput,该函数在 GO调度模型-GMP(上)已经说明过,此处不再重复。

image-20231121120331875

2. 从P的本地队列中获取G — runtime.runqget

runtime.findRunnable函数中,调用runtime.runqget函数尝试从 P 本地队列中获取一个可执行的 goroutine,其出处:

//go 1.20.3 path: /src/runtime/proc.go

func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
	// ...... 
	if gp, inheritTime := runqget(pp); gp != nil {
		return gp, inheritTime, false
	}
  // ...... 
}

runtime.runqget函数核心逻辑:

//go 1.20.3 path: /src/runtime/proc.go

// 从本地运行队列获取一个 goroutine
func runqget(pp *p) (gp *g, inheritTime bool) {
	// 尝试从本地运行队列的 runnext 字段获取一个 goroutine
	next := pp.runnext
	if next != 0 && pp.runnext.cas(next, 0) {
		return next.ptr(), true
	}

	// 如果 runnext 为空,或者 cas 失败,尝试从 runq 数组中获取一个 goroutine
	for {
		// 获取本地运行队列头和尾
		h := atomic.LoadAcq(&pp.runqhead)
		t := pp.runqtail

		// 如果队列为空,返回 nil 和 false
		if t == h {
			return nil, false
		}

		// 从 runq 数组中获取一个 goroutine
		gp := pp.runq[h%uint32(len(pp.runq))].ptr()

		// 尝试更新本地运行队列头,如果成功则返回 goroutine 和 false
		if atomic.CasRel(&pp.runqhead, h, h+1) {
			return gp, false
		}
	}
}

来分析下流程:

  1. 尝试从本地运行队列的 pp.runnext 字段获取一个 goroutine,如果 pp.runnext 不为空且 cas 操作成功,表示有下一个可运行的 goroutine,直接返回;
  2. 如果 pp.runnext 为空或 cas 失败,尝试从 pp.runq 数组中获取一个 goroutine, 通过取余操作计算索引,从数组中获取 goroutine,获取成功后尝试更新本地运行队列头。

该函数主要用于从本地运行队列获取可运行的 goroutine,尽量保持先进先出的顺序。如果本地运行队列为空,则返回 nilfalse。函数的返回值中,inheritTime 表示是否继承时间片。

3. 从其他P的本地队列中偷取G — runtime.stealWork

当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。这种机制称为work stealing 机制。

如果 M 处于自旋状态,或者全局运行队列中的任务数量小于等于 M 的空闲数量的两倍, 这时候就会触发work stealing 机制,采取steal模式:

//go 1.20.3 path: /src/runtime/proc.go
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
  	// ...... 
	if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
        // 如果 M 不在自旋状态,则将其切换为自旋状态
        if !mp.spinning {
            mp.becomeSpinning()
        }
        // 尝试从其他 P 中偷取任务
        gp, inheritTime, tnow, w, newWork := stealWork(now)
        if gp != nil {
            return gp, inheritTime, false
        }
    		// ......
    }
		
		// ......
}

首先了解什么是自旋?为什么会自旋?

Go 的运行时系统中,M(内核线程)在一些情况下会采用自旋(spinning)的方式等待可运行的 Ggoroutine)。

自旋是指在等待某个条件满足的时候,持续地执行一些快速的空循环,而不是让线程进入休眠状态。自旋的目的是避免线程在等待的过程中进入内核态,减少上下文切换的开销。

Go 的运行时系统中,M 可能会自旋的情况包括:

  1. 自旋等待运行队列中有可运行的 G: 当一个 M 在运行队列中没有可运行的 G 时,它可能会自旋一段时间,避免直接进入休眠状态。这是为了避免频繁地在用户态和内核态之间切换的开销。如果一段时间内仍然没有可运行的 GM 就会放弃自旋,进入休眠状态。
  2. 自旋等待全局运行队列中有可运行的 G: 如果一个 M 在本地运行队列和全局运行队列都没有可运行的 G,它可能会在全局运行队列上自旋一段时间。这是为了避免进入休眠状态后,又需要被唤醒执行的开销。
  3. 自旋等待 GC 完成: 在进行垃圾回收(GC)的过程中,M 有时会自旋等待 GC 完成,以减少 GC 的停顿时间。

了解完这些,我们就大致明白为什么自旋下会去去其他PG了。

再来看看 runtime.stealWork 源码:

//go 1.20.3 path: /src/runtime/proc.go

func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
	pp := getg().m.p.ptr()

	ranTimer := false

	const stealTries = 4
	for i := 0; i < stealTries; i++ {
		stealTimersOrRunNextG := i == stealTries-1

		// 遍历 P 的顺序是随机的,通过 fastrand()函数获取一个伪随机数种子
		for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
			if sched.gcwaiting.Load() {
				return nil, false, now, pollUntil, true
			}
			p2 := allp[enum.position()]
			if pp == p2 {
				continue
			}
      
			// 如果是最后一次迭代,或者当前 P 所在的位置上有定时器,就检查定时器
			if stealTimersOrRunNextG && timerpMask.read(enum.position()) {
				tnow, w, ran := checkTimers(p2, now)
				now = tnow
				if w != 0 && (pollUntil == 0 || w < pollUntil) {
					pollUntil = w
				}
				if ran {
					// 尝试从当前 P 的运行队列中获取可运行的 G
					if gp, inheritTime := runqget(pp); gp != nil {
						return gp, inheritTime, now, pollUntil, ranTimer
					}
					ranTimer = true
				}
			}

			// 如果当前enum.position位置上没有空闲 P,尝试从其运行队列偷取 G
			if !idlepMask.read(enum.position()) {
				if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
					return gp, false, now, pollUntil, ranTimer
				}
			}
		}
	}

	// 没有找到可运行的 G,返回 nil
	return nil, false, now, pollUntil, ranTimer
}

来总结下流程:

  1. 获取当前 G 所在的 P,设置ranTimerfalse,表示当前是否已经运行过定时器;
  2. 指定了尝试偷取的次数stealTries4,每次循环尝试从其他 P 中偷取 G,并设置stealTimersOrRunNextG用来标志着是否是最后一次迭代,循环主要步骤如下:
    • 遍历其他 P,通过fastrand随机数来随机获取P,如果当前状态处于 GC 等待状态,直接返回,如果当前P等于随机抽取到的P,则跳过本次循环;
    • 检查是否是最后一次迭代且当前 P 位置上有定时,如果符合这些条件则调用 checkTimers 函数检查其他 P 的定时器,并获取当前时间 tnow、最早的定时器时间 w 以及是否已经运行定时器 ran,然后做如下操作:
      • 更新当前时间为 tnow,如果最早的定时器时间 w 不为零(表示有定时器)并且(pollUntil 为零或者 w 小于 pollUntil),则更新 pollUntilw,这是为了确保 pollUntil 记录的是最早的定时器时间;
      • 如果定时器已经运行过(if ran),说明在 checkTimers 中发现了到期的定时器,这种情况下,尝试从当前 P 的运行队列中获取可运行的 G,如果成功从运行队列中获取到 G则直接返回;
      • 执行ranTimer = true ,标记定时器已经运行。
    • 如果 所在的位置enum.position上没有空闲 P,尝试调用runtime.runqsteal从其运行队列偷取 G
  3. 如果找到可运行的 G,返回 GinheritTime、当前时间 nowpollUntil 时间(用于下次轮询)和是否运行过定时器 ranTimer;如果没找到可运行的 G,返回 nil

image-20231121154619277

stealWork 函数中调用 checkTimers 检查定时器的目的是在尝试从其他 P 中偷取可运行的 G 时,顺便检查是否有到期的定时器。这是因为在某些情况下,如果有到期的定时器,可能会有可运行的 G 与之关联,从而提供额外的工作。总体来说,目的是充分利用系统资源,通过检查定时器,可能获取到额外的可运行 G,从而提高系统的并发性。

再来看看从其他P中偷取G的主逻辑函数runtime.runqsteal

//go 1.20.3 path: /src/runtime/proc.go

// runqsteal 尝试从另一个 P 的运行队列中偷取 G。
func runqsteal(pp, p2 *p, stealRunNextG bool) *g {
  // 获取本地运行队列pp的尾部
	t := pp.runqtail
  // 从 p2 偷取 G,runqgrab 返回偷取的 G 的数量
	n := runqgrab(p2, &pp.runq, t, stealRunNextG)
  // 如果没有偷取到 G,返回 nil
	if n == 0 {
		return nil
	}
  //更新 pp 的运行队列尾部
	n--
	gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr()
  // 如果只偷取到一个 G,则直接返回
	if n == 0 {
		return gp
	}
  // 如果偷取到多个 G,则更新运行队列尾部
	h := atomic.LoadAcq(&pp.runqhead)
  // 检查是否会导致运行队列溢出
	if t-h+n >= uint32(len(pp.runq)) {
		throw("runqsteal: runq overflow")
	}
  // 更新运行队列尾部
	atomic.StoreRel(&pp.runqtail, t+n) 
  // 返回偷取到的 G
	return gp
}

这个函数用于从另一个 P 的运行队列中偷取 G。关键步骤包括:

  1. 获取 pp 的运行队列尾部,即 t;
  2. 调用 runtime.runqgrab 函数,从 p2 的运行队列中偷取 G,并返回偷取的数量 n;
  3. 如果没有偷取到 G,直接返回 nil;如果只偷取到一个 G,直接返回该 G;
  4. 如果偷取到多个 G,更新 pp 的运行队列尾部,然后检查是否会导致运行队列溢出,如果是,抛出异常;
  5. 更新运行队列尾部,并返回偷取到的 G

其中 runtime.runqgrab 函数是关键steal逻辑,代码如下:

//go 1.20.3 path: /src/runtime/proc.go

// runqgrab 尝试从 P 的运行队列中批量偷取 G。
func runqgrab(pp *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
    for {
        // 获取运行队列头部和尾部
        h := atomic.LoadAcq(&pp.runqhead)
        t := atomic.LoadAcq(&pp.runqtail)
        // 计算可偷取的数量,取一半
        n := t - h
        n = n - n/2
        // 如果可偷取数量为 0
        if n == 0 {
            // 如果是偷取下一个 G,并且下一个 G 不为空
            if stealRunNextG {
                if next := pp.runnext; next != 0 {
                    // 如果 P 的状态是 _Prunning
                    if pp.status == _Prunning {
                        // 根据不同的操作系统进行不同的暂停操作
                        if GOOS != "windows" && GOOS != "openbsd" && GOOS != "netbsd" {
                            usleep(3)
                        } else {
                            osyield()
                        }
                    }
                    // 尝试将下一个 G 从运行队列头部移除
                    if !pp.runnext.cas(next, 0) {
                        continue
                    }
                    // 将偷取到的下一个 G 放入批量数组中
                    batch[batchHead%uint32(len(batch))] = next
                    // 返回偷取数量为 1
                    return 1
                }
            }
            // 如果没有偷取到 G,则返回 0
            return 0
        }
        // 如果可偷取数量大于运行队列长度的一半,则继续尝试
        if n > uint32(len(pp.runq)/2) {
            continue
        }
        // 将运行队列中的 G 复制到批量数组中
        for i := uint32(0); i < n; i++ {
            g := pp.runq[(h+i)%uint32(len(pp.runq))]
            batch[(batchHead+i)%uint32(len(batch))] = g
        }
        // 尝试更新运行队列头部
        if atomic.CasRel(&pp.runqhead, h, h+n) {
            // 返回偷取数量
            return n
        }
    }
}

这个函数用于从 P 的运行队列中批量偷取 G。关键步骤包括:

  1. 获取运行队列头部和尾部, 计算可偷取的数量,取一半;
  2. 如果可偷取数量为 0,则进一步判断stealRunNextG是否为true(即本次偷取是否为循环最后一次),如果是,则偷取pp.runnextG,将其放入批量数组batch中并返回1,否则直接返回0
  3. 如果可偷取数量n大于运行队列长度的一半(len(pp.runq)/2),则跳过本次执行,回到for循环继续尝试;
  4. 走到这一步,则将运行队列中的需要偷取数量nG 复制到批量数组batch中后,更新运行队列头部(pp.runqhead),返回偷取数量。

除了上述三种调度策略,还有从GC Work中获取G,从NetPoller中获取G等,再次不再详细说明,后续章节相关内容会提及。

执行可用G — runtime.execute

分析完了怎么选取运行g的问题,现在在回到schedule调度函数,分析它是如何执行要运行g的,也就是对应到schedule中的execute处理逻辑。

//go 1.20.3 path: /src/runtime/proc.go

func execute(gp *g, inheritTime bool) {
  // 获取当前G的M
  mp := getg().m

  // 检查是否启用了 Goroutine Profile。
  if goroutineProfile.active {
      // 尝试记录 Goroutine 的 Profile。
      tryRecordGoroutineProfile(gp, osyield)
  }

  // 将当前 G 设置为 M 的当前 G。
  mp.curg = gp
  // 将当前 M 设置为 G 的 M。
  gp.m = mp
  // 将 G 的状态从 Runnable 切换到 Running。
  casgstatus(gp, _Grunnable, _Grunning)
  // 将 G 的 waitsince字段置零,表示该 G 不再处于等待状态。
  gp.waitsince = 0
  // 将 G 的 preempt 字段置为 false,表示不允许抢占。
  gp.preempt = false
  // 设置 G 的栈警戒区域的起始地址。
  gp.stackguard0 = gp.stack.lo + _StackGuard

  // 如果不继承时间片(不是被抢占的状态),则增加所属 P 的 schedtick 字段,表示该 P 的调度轮次增加。
  if !inheritTime {
      mp.p.ptr().schedtick++
  }

  // 从调度器中获取 Profile 的频率。
  hz := sched.profilehz
  // 如果当前 M 的 Profile 频率不等于全局频率,则设置线程的 CPU Profiler。
  if mp.profilehz != hz {
      setThreadCPUProfiler(hz)
  }

  // 如果 Trace 已经启用,则执行相应的 Trace 操作。
  if trace.enabled {
      // 如果 G 处于系统调用中且被跟踪,记录系统调用退出事件。
      if gp.syscallsp != 0 && gp.sysblocktraced {
          traceGoSysExit(gp.sysexitticks)
      }
      // 调用 traceGoStart() 记录 Goroutine 的开始事件。
      traceGoStart()
  }

  // 调用 gogo 函数,启动 G 的执行。
  gogo(&gp.sched)

}

runtime.execute切换到选择的要运行的g的栈执行,设置g为运行状态和是否被抢占等信息之后,调用gogo函数从当前g0栈切换到要运行g的栈,真正开始执行用户程序。

gogo函数是用汇编语言实现的,具体源码解析如下,切换原理就是将要运行g的调度信息g.sched从内存中恢复到CPU寄存器,设置SPIP等寄存器的值,跳转到要运行的位置开始执行指令。

在完成gp运行前的准备工作后,excute函数调用gogo函数完成从g0gp的转换:

  1. 让出CPU的执行权;
  2. 栈的切换;

gogo函数汇编代码如下:

TEXT runtime·gogo(SB), NOSPLIT, $16-8
	MOVQ	buf+0(FP), BX		// 0(FP)表示第一个参数,即buf=&gp.sched , BX = buf
	MOVQ	gobuf_g(BX), DX     // DX=buf.g=&gp.sched.g
	MOVQ	0(DX), CX			// make sure g != nil
	get_tls(CX)
	MOVQ	DX, g(CX)          	// 把g放入到tls[0],即把要运行的g的指针放进线程本地存储,后面的代码可以通过本地线程存储,获取到当前正在执行的goroutine的地址,在这之前,本地线程存储中存放的是g0的地址
	MOVQ	gobuf_sp(BX), SP	//SP=buf.sp=&gp.sched.sp,即把CPU的栈顶寄存器SP设置为gp.sched.sp,成功完成从g0栈切换到gp栈
 	// 恢复调度上下文到CPU对应的寄存器
 	// 将系统调用的返回值放入到AX寄存器中
	MOVQ	gobuf_ret(BX), AX
	MOVQ	gobuf_ctxt(BX), DX
	MOVQ	gobuf_bp(BX), BP
	// 前面已经将调度相关的值都放入到CPU的寄存器中了,将gp.sched中的值清空,这样可以减轻gc的工作量
	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)
	// 把gp.sched.pc的值放入到BX寄存器,如果是对于main goroutine,sched.pc中存储的是runtime包中main()函数的地址
	MOVQ	gobuf_pc(BX), BX
	// JMP把BX寄存器中的地址值放入到CPU的IP寄存器中,然后CPU跳转到该地址的位置开始执行指令
	JMP	BX

gogo函数主要做了两件事情:

  1. gp.sched的成员恢复到CPU的寄存器完成状态以及栈的切换;
  2. 跳转到gp.sched.pc所指的指令地址(初始化情况下,该地址为函数runtime.main地址入口)处执行。

其实gogo函数调用的G分两种情况:

  • 一种是初始化的时候,我们创建了第一个G,即main goroutine,我们完成了G的创建,在程序初始化的情况下,我们第一个调度完成的G便是它;
  • 另外就是普通的G,即我们自己写程序创建的G

在此,我们也顺便了解下main goroutine,看看这个G干了些什么。

runtime.main

直接看源码:

//go 1.20.3 path: /src/runtime/proc.go

func main() {
    // 获取当前 G 的 M(线程本地存储的运行时线程)。
	mp := getg().m

    // 将 G0 的 race 上下文置零。
	mp.g0.racectx = 0

    // 设置最大栈大小。
	if goarch.PtrSize == 8 {
		maxstacksize = 1000000000
	} else {
		maxstacksize = 250000000
	}

    // 设置栈大小的上限。
	maxstackceiling = 2 * maxstacksize
    // 标记主函数已经启动。
	mainStarted = true

    // 如果 GOARCH 不是 wasm,则创建系统监控线程。
	if GOARCH != "wasm" {
    // 使用系统栈运行 `sysmon` 函数创建一个新的 M。
		systemstack(func() {
			newm(sysmon, nil, -1)
		})
	}

    // 锁定当前 OS 线程。
	lockOSThread()

    // 检查当前 M 是否为 M0。
	if mp != &m0 {
		throw("runtime.main not on m0")
	}

    // 获取当前时间作为运行时初始化的时间。
	runtimeInitTime = nanotime()
    // 检查获取时间是否为零。
	if runtimeInitTime == 0 {
		throw("nanotime returning zero")
	}

    // 如果开启了 inittrace,则初始化 inittrace。
	if debug.inittrace != 0 {
		inittrace.id = getg().goid
		inittrace.active = true
	}

    // 执行 runtime 初始化任务。
	doInit(&runtime_inittask)

    // 设置需要解锁当前线程。
	needUnlock := true
	defer func() {
        // 在函数退出时解锁当前线程。
		if needUnlock {
			unlockOSThread()
		}
	}()

    // 启用垃圾回收。
	gcenable()

    // 创建 main 初始化完成的通道。
	main_init_done = make(chan bool)
    // 如果是 Cgo,启动 Cgo 线程并通知运行时初始化完成。
	if iscgo {
        // 检查必要的 Cgo 函数是否存在。
		if _cgo_thread_start == nil {
			throw("_cgo_thread_start missing")
		}
		if GOOS != "windows" {
			if _cgo_setenv == nil {
				throw("_cgo_setenv missing")
			}
			if _cgo_unsetenv == nil {
				throw("_cgo_unsetenv missing")
			}
		}
		if _cgo_notify_runtime_init_done == nil {
			throw("_cgo_notify_runtime_init_done missing")
		}

        // 启动模板线程。
		startTemplateThread()
        // 调用 `_cgo_notify_runtime_init_done` 通知运行时初始化完成。
		cgocall(_cgo_notify_runtime_init_done, nil)
	}

    // 执行 main 初始化任务。
	doInit(&main_inittask)

    // 关闭 init 完成的通道。
	inittrace.active = false
	close(main_init_done)

    // 不再需要解锁当前线程。
	needUnlock = false
	unlockOSThread()

    // 如果是归档文件或库,则直接返回。
	if isarchive || islibrary {
		return
	}

    // 获取 main 函数并执行。
	fn := main_main
	fn()

    // 如果启用了 race,则执行退出钩子。
	if raceenabled {
		runExitHooks(0)
		racefini()
	}

    // 检查是否有正在运行的 panic defers。
	if runningPanicDefers.Load() != 0 {
		for c := 0; c < 1000; c++ {
			if runningPanicDefers.Load() == 0 {
				break
			}
			Gosched()
		}
	}

    // 如果有 panic,则将 G 挂起,等待处理。
	if panicking.Load() != 0 {
		gopark(nil, nil, waitReasonPanicWait, traceEvGoStop, 1)
	}

    // 执行退出钩子。
	runExitHooks(0)

    // 退出程序。
	exit(0)

    // 无限循环,触发空指针异常。
	for {
		var x *int32
		*x = 0
	}
}

runtime.main函数的主要工作是:

  • 启动一个sysmon系统监控线程,该线程负责程序的gc、抢占调度等;
  • 执行runtime包和所有包的初始化;
  • 执行main.main函数;
  • 最后调用exit系统调用退出进程,之前提到的注入goexit程序对main goroutine不起作用,是为了其他线程的回收而做的。

runtime.goexit

而对于非main goroutine来说,执行完用户逻辑之后会回到goexit函数,因为在创建非main goroutine的时候伪造成G是被goexit函数调用的,下面开始分析回到goexit函数做了哪些处理:

// 在 Goroutine 返回 goexit + PCQuantum 时运行的最顶层函数。
TEXT runtime·goexit(SB),NOSPLIT,$0-0
	BYTE	$0x90	// NOP
	CALL	runtime·goexit1(SB)	// 不会返回
	// traceback from goexit1 must hit code range of goexit
	BYTE	$0x90	// NOP

看上去是被goexit调用,后续,其会继续调用goexit1goexit0函数:

func goexit1() {
	if raceenabled {
		racegoend()
	}
	if trace.enabled {
		traceGoEnd()
	}
	mcall(goexit0)
}

runtime.goexit1函数处理一些data race等检查逻辑之后,调用了mcall函数,mcall是汇编实现的,mcall函数的功能是从当前用户程序g切换到g0上运行,然后在g0栈上执行goexit0函数。

TEXT runtime·mcall(SB), NOSPLIT, $0-8
	// 取出参数的值也就是goexit0的地址,放入到DI寄存器中
	MOVQ	fn+0(FP), DI

	// 获取当前的g,这里的g还是用户程序的g
	get_tls(CX)
	//设置AX寄存器的值为g
	MOVQ	g(CX), AX	// save state in g->sched
	//mcall返回地址放入到BX寄存器中
	MOVQ	0(SP), BX	// caller's PC

	// 将当前的调度信息也就是CPU寄存器的值保存到g的sched字段中,因为接下来将发送goroutine的切换,从用户程序g切换到g0

	// 保存PC
	MOVQ	BX, (g_sched+gobuf_pc)(AX)
	LEAQ	fn+0(FP), BX	// caller's SP
	// 保存SP
	MOVQ	BX, (g_sched+gobuf_sp)(AX)
	// 保存g
	MOVQ	AX, (g_sched+gobuf_g)(AX)
	// 保存BP
	MOVQ	BP, (g_sched+gobuf_bp)(AX)

	// 将g保存到BX寄存器中
	MOVQ	g(CX), BX
	// 将g.m保存到BX寄存器中
	MOVQ	g_m(BX), BX
	 // 将g.m.g0保存到寄存器SI中
	MOVQ	m_g0(BX), SI
	// 经过上面3步操作,成功拿到了g0的地址,非常完美
 	// 比较g0是否与g相等,理论上不应该相等,如果出现相等,一定是有问题
	CMPQ	SI, AX	// if g == m->g0 call badmcall
	JNE	3(PC)
	MOVQ	$runtime·badmcall(SB), AX
	JMP	AX
    // SI中保存的是g0的地址,经过这步操作将g0的地址放到了线程本地存储中
	MOVQ	SI, g(CX)	// g = m->g0
	// 恢复g0的栈顶指针到CPU的SP寄存器中,成功将g0栈放入到CPU上,完成了从用户程序g栈到g0的栈切换
	MOVQ	(g_sched+gobuf_sp)(SI), SP	// sp = m->g0->sched.sp
	// 将goexit0的参数g入栈
	PUSHQ	AX
    // 将funcval对象的指针保存到DX
	MOVQ	DI, DX
    // 获取funcval对象的第一个成员也就是goexit0的地址
	MOVQ	0(DI), DI
	//调用goexit0(g)函数
	CALL	DI
	POPQ	AX
	MOVQ	$runtime·badmcall2(SB), AX
	JMP	AX
	RET

概括起来,mcall完成两个主要逻辑:

  1. 保存当前的g的调度信息到内存中,通过当前的g,找到与它绑定的m,在通过m找到m中保存的g0,然后将g0保存到tls中,修改CPU寄存器的值为g0栈的内容;
  2. 切换到g0栈,执行goexit0函数。

可以看到mcall完成的功能与前面介绍的gogo函数功能完全相反。

gogo函数实现从g0栈到用户程序g的切换,而这里的mcall恰好实现从用户程序gg0的切换,所以通过gogomcall函数,我们可以在runtime代码和用户程序代码之间来回切换。

再来看看 goexit0函数,goexit0函数把gp状态从_Grunning修改为_Gdead,然后清理gp对象中保存内容,其次通过函数dropg解除gpm之间的绑定关系,然后将gp放入到Pfreeg队列中缓存起来,以便后续复用,最后调用schedule,进行新一轮调度。

goexit0函数源码如下:

//go 1.20.3 path: /src/runtime/proc.go

func goexit0(gp *g) {
	mp := getg().m
	pp := mp.p.ptr()

	// 将 G 的状态从 _Grunning 设置为 _Gdead
	casgstatus(gp, _Grunning, _Gdead)

	// 将 G 的栈标记为可扫描,并更新 gcController 相关的统计信息
	gcController.addScannableStack(pp, -int64(gp.stack.hi-gp.stack.lo))

	// 如果是系统 goroutine,则减少系统 goroutine 计数
	if isSystemGoroutine(gp, false) {
		sched.ngsys.Add(-1)
	}

	// 将 G 的 M 字段置为空,表示不再关联 M
	gp.m = nil
	locked := gp.lockedm != 0
	gp.lockedm = 0
	mp.lockedg = 0
	gp.preemptStop = false
	gp.paniconfault = false
	gp._defer = nil
	gp._panic = nil
	gp.writebuf = nil
	gp.waitreason = waitReasonZero
	gp.param = nil
	gp.labels = nil
	gp.timer = nil

	// 如果启用了 GC 并且 G 协助的字节数大于 0
	if gcBlackenEnabled != 0 && gp.gcAssistBytes > 0 {
		// 计算协助的工作单元,并添加到后台 GC 扫描的工作量中
		assistWorkPerByte := gcController.assistWorkPerByte.Load()
		scanCredit := int64(assistWorkPerByte * float64(gp.gcAssistBytes))
		gcController.bgScanCredit.Add(scanCredit)
		gp.gcAssistBytes = 0
	}

	// 调用 dropg 函数,将 G 从运行队列中移除
	dropg()

	// 如果 GOARCH 是 "wasm"
	if GOARCH == "wasm" {
		// 将 G 放回 P 的全局队列,并调用 schedule 函数
		gfput(pp, gp)
		schedule()
	}

	// 如果 M 处于锁定状态
	if mp.lockedInt != 0 {
		print("invalid m->lockedInt = ", mp.lockedInt, "\n")
		throw("internal lockOSThread error")
	}

	// 将 G 放回 P 的全局队列
	gfput(pp, gp)

	// 如果处于锁定状态
	if locked {
		// 根据不同的操作系统进行相应的处理
		if GOOS != "plan9" {
			// 在 Plan 9 操作系统下,将 M 的 lockedExt 置零
			gogo(&mp.g0.sched)
		} else {
			mp.lockedExt = 0
		}
	}

	// 调用 schedule 函数
	schedule()
}

该函数总起来有以下流程:

  • G的状态由运行中(_Grunning)改为已中止(_Gdead);
  • 清空G的成员;
  • 调用dropg函数解除MG之间的关联;
  • 调用gfput函数把G放到P的自由列表中, 下次创建G时可以复用;
  • 调用 schedule 函数继续调度。

到这里,一个简单的 Goroutine 的生命周期就结束了,但是 M 物理线程还不会结束,它会再次执行 schedule 进入下一个调度循环。

调度流程

任何goroutine被调度起来都是通过schedule()->execute()->gogo()g0调度到用户goroutine,然后当协程结束后(非main goroutine),又会通过goexit()->goexit1()->mcall()->goexit0()->schedule()又回到了schedule()函数,基本上就是如下的调度循环:

image-20231127135716666

如上图,main Goroutine与普通Goroutine在调度上还是存在一定区别的,main Goroutine执行完后就直接调用exit退出了循环,而普通的Goroutine则继续进行着循环调度。

还有一个问题,以上循环中从mcall调用goexit0函数开始,到gogo函数结束,都是在g0栈上执行的,如此循环,不管系统栈有多大,终究会耗尽g0的系统栈啊,那Go中是如何避免这件事的呢?

秘密在于每次通过mcall函数切换到g0栈的时候都是切换到g0.sched.sp所在的固定位置,而这之所以行得通也得益于从schedule开始的一些列函数都不会返回,所以重用这些函数上一轮调度时使用的栈内存是没有问题的。

总结一下每一个工作线程的执行流程和调度循环都如下图所示:

image-20231127140705318

调度时机

调度策略让我们知道了协程是如何调度的,下面继续说明什么时候会发生协程调度。根据调度方式的不同,调度时机分为3种,分别是 主动调度、被动调度 和 抢占调度。

主动调度

主动调度的逻辑还是很好理解的,就是当前的g放弃CPU执行权,将其放入到全局运行队列中,因为它还是可以继续运行的,只是我们主动放弃了,等待下次被调度程序执行。在用户代码中通过调用runtime.Gosched()函数发生主动调度。

主动调用的触发路径为: runtime.Gosched-> runtime.gosched_m -> runtime.goschedImpl

相关源码如下:

//go 1.20.3 path: /src/runtime/proc.go
func Gosched() {
	checkTimeouts()
	mcall(gosched_m)
}

//前面有讲过此函数为汇编代码,此处忽略
func mcall(fn func(*g))

func gosched_m(gp *g) {
	......
	// gp为准备挂起的用户协程g,即调用runtime.Gosched()函数所在的协程g
	goschedImpl(gp)
}

func goschedImpl(gp *g) {
	// 获取准备挂起g(gp)的状态,它的状态为目前处于_Grunning,因为它正在运行
	status := readgstatus(gp)
	// 检查gp的状态是否合法,如果不为_Grunning状态说明状态出现了异常
	if status&^_Gscan != _Grunning {
		dumpgstatus(gp)
		throw("bad g status")
	}
	// 将gp的状态从_Grunning修改为_Grunnable
	casgstatus(gp, _Grunning, _Grunnable)
	// 解除m和gp的互相绑定,分别将m.curg和gp.m设置为nil
	dropg()
	lock(&sched.lock)
	// 把gp放入全局的运行队列中
	globrunqput(gp)
	unlock(&sched.lock)
	// 进行新一轮调度
	schedule()
}

goschedImpl函数的主要流程是:

  • 主动调度会从当前协程g切换到g0并更新协程状态由运行中_Grunning变为可运行_Grunnable
  • 通过dropg()取消gm的绑定关系;
  • 通过globrunqput()将g放入到全局运行队列中;
  • 最后调用schedule()函数开启新一轮的调度循环。

被动调度

协程挂起

当协程休眠、通道堵塞、网络堵塞、垃圾回收导致暂停时,协程会被动让渡出执行的权利给其他可运行的协程继续执行,这个叫协程挂起。在协程挂起的流程中,调度器通过gopark()函数执行被动调度逻辑。gopark()函数最终调用park_m()函数来完成调度逻辑。

被动调用的触发路径为: runtime.gopark-> runtime.park_m

来看看 gopark以及相关源码:

//go 1.20.3 path: /src/runtime/proc.go

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    // 检查是否有超时的情况
    if reason != waitReasonSleep {
        checkTimeouts()
    }
    
    // 获取当前 M 和 G
    mp := acquirem()
    gp := mp.curg
    
    // 检查 G 的状态,必须是 _Grunning 或 _Gscanrunning
    status := readgstatus(gp)
    if status != _Grunning && status != _Gscanrunning {
        throw("gopark: bad g status")
    }
    
    // 设置等待相关的参数
    mp.waitlock = lock
    mp.waitunlockf = unlockf
    gp.waitreason = reason
    mp.waittraceev = traceEv
    mp.waittraceskip = traceskip
    
    // 释放当前 M
    releasem(mp)
    
    // 切换到内核态执行 park_m
    mcall(park_m)
}

gopark函数则使用mcall函数(前面分析过,主要作用是保存当前goroutine现场,然后切换到g0栈去调用作为参数传入的函数)取执行park_m函数:

//go 1.20.3 path: /src/runtime/proc.go
func park_m(gp *g) {
    // 获取当前 M
    mp := getg().m
    
    // 如果开启了 trace,记录 GoPark 事件
    if trace.enabled {
        traceGoPark(mp.waittraceev, mp.waittraceskip)
    }
    
    // 将 G 的状态设置为 _Gwaiting
    casgstatus(gp, _Grunning, _Gwaiting)
    // 释放 G
    dropg()

    // 获取等待解锁的函数和锁
    if fn := mp.waitunlockf; fn != nil {
        // 调用等待解锁的函数
        ok := fn(gp, mp.waitlock)
        // 清空等待解锁的函数和锁
        mp.waitunlockf = nil
        mp.waitlock = nil
        // 如果等待解锁成功,将 G 的状态设置为 _Grunnable,执行 G
        if !ok {
            if trace.enabled {
                traceGoUnpark(gp, 2)
            }
            casgstatus(gp, _Gwaiting, _Grunnable)
            execute(gp, true)
        }
    }
    
    // 调度新的 G
    schedule()
}

park_m函数主要做了下面几件事:

  • 首先会从当前协程g切换到g0并更新协程状态由运行中_Grunning变为等待中_Gwaiting
  • 然后通过dropg()取消gm的绑定关系;
  • 接着执行waitunlockf函数,如果该函数返回false, 则协程g立即恢复执行,否则等待唤醒;
  • 最后调用schedule()函数开启新一轮的调度循环。
协程唤醒

与主动调度不同的是,被动调度的协程g不会放入到全局队列中进行调度。而是一直处于等待中_Gwaiting状态等待被唤醒。当等待中的协程被唤醒时,协程的状态由_Gwaiting变为可运行_Grunnable状态,然后被添加到当前p的局部运行队列中。唤醒逻辑通过函数goready()调用ready()实现:

//go 1.20.3 path: /src/runtime/proc.go
func goready(gp *g, traceskip int) {
	systemstack(func() {
		ready(gp, traceskip, true)
	})
}

func ready(gp *g, traceskip int, next bool) {
    // 如果开启了 trace,记录 GoUnpark 事件
    if trace.enabled {
        traceGoUnpark(gp, traceskip)
    }
   
    // 读取 G 的状态
    status := readgstatus(gp)
    
    // 获取当前 M
    mp := acquirem()
    
    // 检查 G 的状态是否为等待状态
    if status&^_Gscan != _Gwaiting {
        // 打印 G 的状态信息
        dumpgstatus(gp)
        throw("bad g->status in ready")
    }
    
    // 将 G 的状态设置为可运行状态
    casgstatus(gp, _Gwaiting, _Grunnable)
    
    // 将 G 放入本地 P 的运行队列
    runqput(mp.p.ptr(), gp, next)
    
    // 唤醒本地 P
    wakep()
    
    // 释放当前 M
    releasem(mp)
}

函数用于将 G 设置为可运行状态(_Grunnable),然后将其放入本地 P 的运行队列。最后,唤醒本地 P 进行调度。如果开启了 trace,还会记录 GoUnpark 事件。

抢占调度

现代操作系统的调度器多为抢占式调度,其实现方式通过硬件中断来支持线程的切换, 进而能安全的保存运行上下文。在 Go 运行时实现抢占式调度同样也可以使用类似的方式,通过 向线程发送系统信号的方式来中断 M 的执行,进而达到抢占的目的。 但与操作系统的不同之处在于,由于运行时诸多机制的存在(例如垃圾回收器),还必须能够在 Goroutine 被停止时,保存充足的上下文信息。 这就给中断信号带来了麻烦,如果中断信号恰好发生在一些关键阶段(例如写屏障期间), 则无法保证程序的正确性。这也就要求我们需要严格考虑触发异步抢占的时机。

异步抢占式调度的一种方式就与运行时系统监控有关,监控循环会将发生阻塞的 Goroutine 抢占, 因此为了确保每个g都有机会被调度执行,保证调度的公平性,在初始化的时候会启动一个特殊的线程来执行监控任务(sysmon函数)。

特殊的线程来执行系统监控任务,系统监控运行在一个独立的工作线程M上,该线程不用绑定逻辑处理器P。系统监控每隔10ms会检测是否有准备就绪的网络协程,并放置到全局队列中。

抢占调用的触发路径为: runtime.sysmon -> runtime.retake -> runtime.preemptone

runtime.sysmon

先来看下 runtime.sysmon 源码:

//go 1.20.3 path: /src/runtime/proc.go

func sysmon() {
	// 加锁,增加系统监控计数
	lock(&sched.lock)
	sched.nmsys++
	checkdead()
	unlock(&sched.lock)

	lasttrace := int64(0)
	idle := 0
	delay := uint32(0)

	for {
		// 根据 idle 情况设置 delay
		if idle == 0 {
			delay = 20
		} else if idle > 50 {
			delay *= 2
		}
		if delay > 10*1000 {
			delay = 10 * 1000
		}
		// 休眠一段时间
		usleep(delay)
    
		now := nanotime()
		// 如果处于 GC 等待状态或没有空闲 P,则尝试唤醒系统监控
		if debug.schedtrace <= 0 && (sched.gcwaiting.Load() || sched.npidle.Load() == gomaxprocs) {
			lock(&sched.lock)
			if sched.gcwaiting.Load() || sched.npidle.Load() == gomaxprocs {
				syscallWake := false
				next := timeSleepUntil()
				if next > now {
					sched.sysmonwait.Store(true)
					unlock(&sched.lock)
					sleep := forcegcperiod / 2
					if next-now < sleep {
						sleep = next - now
					}
					shouldRelax := sleep >= osRelaxMinNS
					if shouldRelax {
						osRelax(true)
					}
					syscallWake = notetsleep(&sched.sysmonnote, sleep)
					if shouldRelax {
						osRelax(false)
					}
					lock(&sched.lock)
					sched.sysmonwait.Store(false)
					noteclear(&sched.sysmonnote)
				}
				if syscallWake {
					idle = 0
					delay = 20
				}
			}
			unlock(&sched.lock)
		}

		// 获取 sysmonlock,并进行系统监控的一系列操作
		lock(&sched.sysmonlock)
		now = nanotime()

		// 如果存在 cgo_yield 函数,则调用它
		if *cgo_yield != nil {
			asmcgocall(*cgo_yield, nil)
		}

		// 检查网络轮询是否超时,进行网络轮询
		lastpoll := sched.lastpoll.Load()
		if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
			sched.lastpoll.CompareAndSwap(lastpoll, now)
			list := netpoll(0)
			if !list.empty() {
				incidlelocked(-1)
				injectglist(&list)
				incidlelocked(1)
			}
		}

		// 处理 NetBSD 的 sysmon 问题
		if GOOS == "netbsd" && needSysmonWorkaround {
			if next := timeSleepUntil(); next < now {
				startm(nil, false)
			}
		}

		// 唤醒 scavenge 垃圾回收器
		if scavenger.sysmonWake.Load() != 0 {
			scavenger.wake()
		}

		// 尝试重新获取 P
		if retake(now) != 0 {
			idle = 0
		} else {
			idle++
		}

		// 如果满足触发 GC 的条件,并且强制 GC 为 idle 状态,则唤醒强制 GC
		if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && forcegc.idle.Load() {
			lock(&forcegc.lock)
			forcegc.idle.Store(false)
			var list gList
			list.push(forcegc.g)
			injectglist(&list)
			unlock(&forcegc.lock)
		}

		// 如果开启了调度追踪,并且距离上次追踪已经超过指定时间,则进行调度追踪
		if debug.schedtrace > 0 && lasttrace+int64(debug.schedtrace)*1000000 <= now {
			lasttrace = now
			schedtrace(debug.scheddetail > 0)
		}

		// 释放 sysmonlock
		unlock(&sched.sysmonlock)
	}
}

该函数主要是用于监视和调度系统级任务,流程如下:

  1. 增加系统监控计数(sched.nmsys++),检查是否有死锁;

  2. 根据空闲状态设置延迟时间 delay,在第一轮循环的时候休眠20微妙,之后每轮循环中休眠时间加倍,直到最大休眠时间达到10毫秒;

  3. 休眠一段时间,之后进行一系列的监控和调度任务:

    • 如果处于 GC 等待状态或没有空闲 P,尝试唤醒系统监控;

    • 获取 sysmonlock 并进行一系列系统监控任务:

      • 调用 cgo_yield 函数;
      • 检查网络轮询是否超时,进行网络轮询;
      • 处理 NetBSDsysmon 问题;
      • 如果存在需要唤醒的垃圾回收器任务,进行scavenge 垃圾回收器唤醒;
      • 调用retake函数尝试重新获取 P
      • 如果满足触发 GC 的条件,并且强制 GC 处于 idle 状态,则唤醒强制 GC
      • 如果开启了调度追踪,并且距离上次追踪已经超过指定时间,则进行调度追踪。
    • 释放 sysmonlock

runtime.sysmon函数是一个死循环函数,为了保证每个协程都有执行的机会,系统监控服务会对执行时间过长(大于10ms)的协程、或者处于系统调用(大于20微秒)的协程进行抢占。在循环中也会检查是否有准备就绪的网络,并将其放入到全局队列中,也会进行抢占处理,按时间强制执行gc等操作。

runtime.retake

接下来关注下 runtime.retake这个函数,runtime.retake是一个大循环,检查所有的P,所有的P保存在全局变量allp中。对于P只对它的两种情况进行处理,分别是_Prunning_Psyscall,因为只有这两种状态的P与之关联的G正在执行,需要判断是否进行抢占。

runtime.retake函数源码如下:

//go 1.20.3 path: /src/runtime/proc.go

func retake(now int64) uint32 {
	n := 0
  // 获取全局 P 列表锁,确保对 P 列表的操作是互斥的
  lock(&allpLock)

  // 遍历所有 P
  for i := 0; i < len(allp); i++ {
    // 获取当前索引的 P
    pp := allp[i]

    // 如果 P 为空,跳过
    if pp == nil {
        continue
    }

    // 获取 P 的系统监控信息
    pd := &pp.sysmontick

    // 获取 P 的状态
    s := pp.status

    // 标志变量,表示是否需要进行系统抢占
    sysretake := false

    // 如果 P 处于运行或系统调用状态
    if s == _Prunning || s == _Psyscall {
        // 获取 P 的调度时钟计数
        t := int64(pp.schedtick)

        // 如果系统监控信息中的调度时钟与当前 P 的不一致,则更新系统监控信息
        if int64(pd.schedtick) != t {
            pd.schedtick = uint32(t)
            pd.schedwhen = now
        } else if pd.schedwhen+forcePreemptNS <= now {
            // 如果距离上次抢占的时间已经超过一定阈值,则触发一次抢占
            preemptone(pp)
            sysretake = true
        }
    }

    // 如果 P 处于系统调用状态
    if s == _Psyscall {
        // 获取 P 的系统调用时钟计数
        t := int64(pp.syscalltick)

        // 如果未进行系统抢占并且系统监控信息中的系统调用时钟与当前 P 的不一致,则更新系统监控信息
        if !sysretake && int64(pd.syscalltick) != t {
            pd.syscalltick = uint32(t)
            pd.syscallwhen = now
            continue
        }

        // 如果运行队列为空且有空闲 P 且距离上次系统调用的时间小于一定阈值,则继续等待
        if runqempty(pp) && sched.nmspinning.Load()+sched.npidle.Load() > 0 && pd.syscallwhen+10*1000*1000 > now {
            continue
        }

        // 释放全局 P 列表锁
        unlock(&allpLock)

        // 减少全局空闲 P 数量
        incidlelocked(-1)

        // 尝试将 P 状态从运行中改为空闲
        if atomic.Cas(&pp.status, s, _Pidle) {
            // 如果启用了 trace,则记录相关事件
            if trace.enabled {
                traceGoSysBlock(pp)
                traceProcStop(pp)
            }

            // 增加系统监控中的空闲 P 数量
            n++

            // 增加 P 的系统调用时钟计数
            pp.syscalltick++

            // 切换当前 P,并继续执行
            handoffp(pp)
        }

        // 增加全局空闲 P 数量
        incidlelocked(1)

        // 重新获取全局 P 列表锁
        lock(&allpLock)
    }
  }

  // 释放全局 P 列表锁
  unlock(&allpLock)

  // 返回触发抢占的 P 数量
  return uint32(n)
}

该函数的主要流程逻辑如下:

  1. 获取全局 P 列表锁,确保对 P 列表的操作是互斥的;
  2. 遍历所有 P,基本操作如下:
    • 获取当前索引的 P;如果 P 为空,跳过;获取 P 的系统监控信息;获取 P 的状态;
    • 如果 P 处于运行或系统调用状态(s == _Prunning || s == _Psyscall):
      • 获取 P 的系统调用时钟计数,如果未进行系统抢占并且系统监控信息中的系统调用时钟与当前 P 的不一致,则更新系统监控信息;
      • 如果距离上次抢占的时间已经超过一定阈值(pd.schedwhen+forcePreemptNS <= now),则触发一次preemptone抢占;
    • 如果 P 处于系统调用状态(s == _Psyscall):
      • 获取 P 的系统调用时钟计数,如果未进行系统抢占并且系统监控信息中的系统调用时钟与当前 P 的不一致,则更新系统监控信息;
      • 如果运行队列为空且有空闲 P 且距离上次系统调用的时间小于一定阈值,则继续等待, 结束本次循环;
      • 释放全局 P 列表锁,减少全局空闲 P 数量;
      • 尝试将 P 状态从_Psyscall改为_Pidle,如果成功,则增加系统监控中的空闲 P 数量,增加 P 的系统调用时钟计数,调用handoffp函数切换当前 P,并继续执行;
      • 增加全局空闲 P 数量,重新获取全局 P 列表锁;
  3. 释放全局 P 列表锁;
  4. 返回触发抢占的 P 数量。

从函数的逻辑来看, runtime.retake函数会对两类情况进行P抢占:

抢占运行时间过长的P

首先什么情况才是运行时间过长呢?主要条件是:

  1. 首先 P的状态为 _Prunning,即说明与P关联的M上的G正在被运行;

  2. 当前G运行时间超过了10毫秒,即运行时间超过了forcePreemptNS值;

这个G的运行时间怎么去计算呢?答案是根据P的上次调度时间,首先P结构有一个schedtick字段,每进行一次调度,schedtick的值会+1,也就是每切换一个G运行,schedtick的值加1, 而 P中还有一个sysmontick字段,该字段是一个复合结构,它有四个成员:

//go 1.20.3 path: /src/runtime/runtime2.go
type p struct {
   ......
    schedtick    uint32
    syscalltick  uint32
    sysmontick   sysmontick
   ......
}

//go 1.20.3 path: /src/runtime/proc.go
type sysmontick struct {
    // 调度的计数器
    schedtick   uint32
    // 保存调度的时间
    schedwhen   int64
    // 进行系统调用的计数器
    syscalltick uint32
    // 进入系统调用时的时间
    syscallwhen int64
}

sysmontick.schedticksysmontick.schedwhen 分别保存上一次P调度的时候计数器值和调度的时间。

有趣的是P结构体中也有个 p.schedtick字段,而这也是设计的巧妙之处:

  • p.schedtick == p.sysmontick.schedtick 时,说明这段时间没有进行G的切换, 同一个 G的情况下,就可以去判断 该 G的运行的时间了,就比较当前的时间(now)与sysmontickschedwhen时间差是否大于10毫秒,如果大于10毫秒,需要进行preemptone函数抢占;
  • p.schedtick != p.sysmontick.schedtick 时,说明这段时间进行了G的切换,直接更新sysmontick计数器的值为当前Pschedtick值,更新sysmontick调度时间为当前时间即可。

下面看进行真正抢占的处理逻辑函数 runtime.preemptone:

//go 1.20.3 path: /src/runtime/proc.go

func preemptone(pp *p) bool {
    // 获取当前 P 所属的 M
    mp := pp.m.ptr()
    // 如果 M 为空,或者 M 就是当前 goroutine 所属的 M,直接返回 false
    if mp == nil || mp == getg().m {
        return false
    }
    // 获取当前 M 的当前执行的 goroutine
    gp := mp.curg
    // 如果当前 goroutine 为空,或者当前 goroutine 是 M 的初始 goroutine,直接返回 false
    if gp == nil || gp == mp.g0 {
        return false
    }

    // 设置当前 goroutine 为可抢占状态
    gp.preempt = true
    // 设置当前 goroutine 的栈警戒地址为 stackPreempt
    gp.stackguard0 = stackPreempt

    // 如果支持异步抢占并且没有禁用异步抢占
    if preemptMSupported && debug.asyncpreemptoff == 0 {
        // 设置当前 P 为可抢占状态
        pp.preempt = true
        // 调用预抢占函数 preemptM
        preemptM(mp)
    }

    // 返回 true 表示执行了抢占操作
    return true
}

该函数的主要作用是将指定 P 中的当前 goroutine 设置为可抢占状态,并根据条件调用异步抢占函数 preemptM。如果发生抢占,返回 true,否则返回 false

在这边将当前的goroutine 设置为可抢占状态的同时,也将stackguard0字段设置为stackPreempt(0xfffffade),并没有看到将当前G暂停执行的逻辑, 这是一个异步处理。

在栈章节中,我们知道函数调用序言部分会检查 SP 寄存器与 stackguard0 之间的大小,如果 SP 小于 stackguard0 则会 触发 morestack_noctxt,触发栈分段操作,换言之,如果抢占标记将 stackgard0 设为比所有可能的 SP 都要大(即 stackPreempt), 则会触发 morestack,进而调用 newstack

//go 1.20.3 path: /src/runtime/stack.go
func newstack() {
	...

	// 如果是发起的抢占请求而非真正的栈分段
	preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt

	// 保守的对用户态代码进行抢占,而非抢占运行时代码
	// 如果正持有锁、分配内存或抢占被禁用,则不发生抢占
	if preempt {
		if !canPreemptM(thisg.m) {
			// 不发生抢占,继续调度
			gp.stackguard0 = gp.stack.lo + _StackGuard
			gogo(&gp.sched) // 重新进入调度循环
		}
	}
	...
	if preempt {
		...
		// 表现得像是调用了 runtime.Gosched,主动让权
		gopreempt_m(gp) // 重新进入调度循环
	}
	...
}

// 与 gosched_m 一致
func gopreempt_m(gp *g) {
	...
	goschedImpl(gp)
}

newstack代码可以看出,newstack函数主要完成两项工作:

  1. 检查是否响应sysmon发起的抢占请求;
  2. 检查栈是否需要进行扩容;

newstack检查被抢占G的状态,如果处于抢占状态,调用gopreempt_m将被抢占的gp切换出去放入到全局g运行队列。gopreempt_mgoschedImpl的简单包装,真正处理逻辑在goschedImpl函数, 该函数是否很熟悉?对,它又回到了主动调度的调度逻辑中去了。

抢占系统调用中堵塞的P

该类型P的抢占需要满足 P_Psyscall状态并符合下列条件的任何一个或者多个:

  • P的运行队列pp.runq中还有等待运行的Grunqempty(pp)false),需要及时的让P的本地队列中的G得到调度,而当前的G又处在系统调用中,无法执行其他的G,因此将当前的P抢占过来运行其他的G
  • P对应的M处于系统调用中到现在已经超过了10毫秒(pd.syscallwhen+10*1000*1000 > now);
  • 当前没有空闲的P(sched.npidle0)也没有自旋的M(sched.nmspinning0)(sched.nmspinning.Load()+sched.npidle.Load() > 0),说明大家都在忙,这时抢占当前正处于系统调用中而实际上用不上的P,分配给其他线程用于调度执行其他的G

当上述三个条件有任何一个不满足时,会将P的状态从_Psyscall修改为_Pidle,然后调用handoffp函数,将与进入系统调用的M绑定的P分离。这种机制我们称为hand off 机制

handoffp源码如下:

//go 1.20.3 path: /src/runtime/proc.go

func handoffp(pp *p) {
    // 如果 P 中的本地队列不为空,或者全局运行队列不为空,则直接启动 M 执行任务
    if !runqempty(pp) || sched.runqsize != 0 {
        startm(pp, false)
        return
    }

    // 如果处于追踪状态或者追踪正在关闭,并且追踪读取器可用,则直接启动 M 执行任务
    if (trace.enabled || trace.shutdown) && traceReaderAvailable() != nil {
        startm(pp, false)
        return
    }

    // 如果启用了 GC,并且存在需要标记的工作,则直接启动 M 执行任务
    if gcBlackenEnabled != 0 && gcMarkWorkAvailable(pp) {
        startm(pp, false)
        return
    }

    // 如果没有其他 M 处于自旋状态且没有空闲的 M,则将当前 P 标记为自旋中状态,并启动 M 执行任务
    if sched.nmspinning.Load()+sched.npidle.Load() == 0 && sched.nmspinning.CompareAndSwap(0, 1) {
        sched.needspinning.Store(0)
        startm(pp, true)
        return
    }

    // 获取全局调度锁
    lock(&sched.lock)
    // 如果 GC 正在等待,则将 P 状态设置为 _Pgcstop,并减少等待计数
    if sched.gcwaiting.Load() {
        pp.status = _Pgcstop
        sched.stopwait--
        // 如果所有 M 都停止等待,则唤醒停止等待的通知
        if sched.stopwait == 0 {
            notewakeup(&sched.stopnote)
        }
        unlock(&sched.lock)
        return
    }

    // 如果 P 上存在需要运行的 SafePoint 函数,则执行该函数,并减少 SafePoint 等待计数
    if pp.runSafePointFn != 0 && atomic.Cas(&pp.runSafePointFn, 1, 0) {
        sched.safePointFn(pp)
        sched.safePointWait--
        // 如果所有 M 的 SafePoint 函数都执行完毕,则唤醒 SafePoint 等待的通知
        if sched.safePointWait == 0 {
            notewakeup(&sched.safePointNote)
        }
    }

    // 如果全局运行队列不为空,则释放调度锁并启动 M 执行任务
    if sched.runqsize != 0 {
        unlock(&sched.lock)
        startm(pp, false)
        return
    }

    // 如果当前空闲的 P 数量为 gomaxprocs-1,并且上次轮询的时间不为零,则释放调度锁并启动 M 执行任务
    if sched.npidle.Load() == gomaxprocs-1 && sched.lastpoll.Load() != 0 {
        unlock(&sched.lock)
        startm(pp, false)
        return
    }

    // 计算无障碍唤醒时间
    when := nobarrierWakeTime(pp)
    // 将 P 放入空闲队列
    pidleput(pp, 0)
    unlock(&sched.lock)

    // 如果有合适的唤醒时间,则唤醒网络轮询器
    if when != 0 {
        wakeNetPoller(when)
    }
}

handoff会对当前的条件进行检查,如果满足下面的条件则会调用startm函数启动新的工作线程来与当前的P进行关联,执行可运行的G

handoffp的代码可以看出,在如下几种情况下则需要调用我们已经分析过的startm函数启动新的工作线程出来接管_p_

  • _p_的本地运行队列或全局运行队列里面有待运行的goroutine
  • 需要帮助gc完成标记工作;
  • 系统比较忙,所有其它_p_都在运行goroutine,需要帮忙;
  • 所有其它P都已经处于空闲状态,如果需要监控网络连接读写事件,则需要启动新的mpoll网络连接。

系统调度

系统调用为用户态进程提供了硬件的抽象接口。并且是用户空间访问内核的唯一手段,除异常和陷入外,它们是内核唯一的合法入口。保证系统的安全和稳定。

Linux中,每个系统调用被赋予一个独一无二的系统调用号。当用户空间的进程执行一个系统调用时,会使用调用号指明系统调用。

因为用户代码特权级较低,无权访问需要最高特权级才能访问的内核地址空间的代码和数据。所以需要特殊指令,在golang中支持系统调用是Syscall函数。

Go通过以下函数提供了对Syscall的直接调用支持:

//go 1.20.3 path: /src/syscall/syscall_unix.go

func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)
func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)

这些函数的实现都是汇编,并且和具体的硬件架构及OS相关,比如LinuxARM架构的相应实现,在 src/pkg/syscall/asm_linux_arm.s中。

按照 linuxsyscall 调用规范,我们只要在汇编中把参数依次传入寄存器,并调用 SYSCALL 指令即可进入内核处理逻辑,系统调用执行完毕之后,返回值放在 RAX 中:

RDIRSIRDXR10R8R9RAX
参数一参数二参数三参数四参数五参数六系统调用编号/返回值

Syscall 与 Syscall6 的区别:只是参数个数的不同,其他都相同。

Syscall 与 RawSyscall 的区别Syscall 开始会调用 runtime·entersyscall ,结束时会调用 runtime·exitsyscall;而 RawSyscall 没有。这意味着 Syscall 是受调度器控制的,RawSyscall不受。因此 RawSyscall 可能会造成阻塞。

下面来看一下源代码:

TEXT ·Syscall(SB),NOSPLIT,$0-56
	CALL	runtime·entersyscall(SB)	// 进入系统调用
	// 准备参数,执行系统调用
	MOVQ	a1+8(FP), DI
	MOVQ	a2+16(FP), SI
	MOVQ	a3+24(FP), DX
	MOVQ	trap+0(FP), AX	// syscall entry
	SYSCALL
	CMPQ	AX, $0xfffffffffffff001		// 对比返回结果
	JLS	ok
	MOVQ	$-1, r1+32(FP)
	MOVQ	$0, r2+40(FP)
	NEGQ	AX
	MOVQ	AX, err+48(FP)
	CALL	runtime·exitsyscall(SB)		// 退出系统调用
	RET
ok:
	MOVQ	AX, r1+32(FP)
	MOVQ	DX, r2+40(FP)
	MOVQ	$0, err+48(FP)
	CALL	runtime·exitsyscall(SB)		// 退出系统调用
	RET

在通过汇编指令 INVOKE_SYSCALL 执行系统调用前后,上述函数会调用运行时的 runtime.entersyscallruntime.exitsyscall,正是这一层包装能够让我们在陷入系统调用前触发运行时的准备和清理工作。系统调用函数示意图:

image-20221027145704693

不过出于性能的考虑,如果这次系统调用不需要运行时参与,就会使用 syscall.RawSyscall简化这一过程,不再调用运行时函数。

系统调用类型
SYS_TIMERawSyscall
SYS_GETTIMEOFDAYRawSyscall
SYS_SETRLIMITRawSyscall
SYS_GETRLIMITRawSyscall
SYS_EPOLL_WAITSyscall

由于直接进行系统调用会阻塞当前的线程,所以只有可以立刻返回的系统调用才可能会被设置成 RawSyscall 类型,例如:SYS_EPOLL_CREATESYS_EPOLL_WAIT(超时时间为 0)、SYS_TIME 等。

正常的系统调用过程相对比较复杂,下面将分别介绍进入系统调用前的准备工作和系统调用结束后的收尾工作。

系统调用前函数

在执行系统调用前调用 runtime.entersyscall -> runtime.reentersyscall,,reentersyscall它会完成 Goroutine 进入系统调用前的准备工作:

//go 1.20.3 path: /src/runtime/proc.go

func entersyscall() {
	reentersyscall(getcallerpc(), getcallersp())
}

func reentersyscall(pc, sp uintptr) {
    // 获取当前 G
    gp := getg()
    // 增加当前 G 的锁计数
    gp.m.locks++
    // 设置栈警戒值为 stackPreempt,表示在 syscall 期间可以发生抢占
    gp.stackguard0 = stackPreempt
    // 设置 throwsplit 为 true,表示当前 G 正在进行 split stack 操作

    // 保存 pc 和 sp 到当前 G 的栈中
    save(pc, sp)
    // 设置当前 G 的 syscallsp 和 syscallpc
    gp.syscallsp = sp
    gp.syscallpc = pc
    // 将当前 G 的状态切换为 _Gsyscall
    casgstatus(gp, _Grunning, _Gsyscall)
    // 检查 syscallsp 是否在当前 G 的栈范围内,如果不在则抛出异常
    if gp.syscallsp < gp.stack.lo || gp.stack.hi < gp.syscallsp {
        systemstack(func() {
            print("entersyscall inconsistent ", hex(gp.syscallsp), " [", hex(gp.stack.lo), ",", hex(gp.stack.hi), "]\n")
            throw("entersyscall")
        })
    }

    // 如果开启了追踪,执行 traceGoSysCall,并再次保存 pc 和 sp
    if trace.enabled {
        systemstack(traceGoSysCall)
        save(pc, sp)
    }

    // 如果处于 sysmon 等待状态,执行 entersyscall_sysmon,并再次保存 pc 和 sp
    if sched.sysmonwait.Load() {
        systemstack(entersyscall_sysmon)
        save(pc, sp)
    }

    // 如果 P 上存在需要运行的 SafePoint 函数,执行 runSafePointFn,并再次保存 pc 和 sp
    if gp.m.p.ptr().runSafePointFn != 0 {
        systemstack(runSafePointFn)
        save(pc, sp)
    }

    // 获取当前 G 所在的 P
    pp := gp.m.p.ptr()
    // 解除当前 G 和 P 之间的关联
    pp.m = 0
    gp.m.oldp.set(pp)
    gp.m.p = 0
    // 将当前 P 的状态设置为 _Psyscall
    atomic.Store(&pp.status, _Psyscall)
    // 如果全局 GC 正在等待,执行 entersyscall_gcwait,并再次保存 pc 和 sp
    if sched.gcwaiting.Load() {
        systemstack(entersyscall_gcwait)
        save(pc, sp)
    }
    // 减少当前 G 的锁计数
    gp.m.locks--
}

runtime.reentersyscall的主要做了下列事情:

  1. 设置当前G进入系统调用状态: _Grunning–>_Gsyscall

  2. 如果sysmon处于等待状态,唤醒sysmon线程;

  3. 将当前的PM解绑。但M会记住该PM会保留Pm.oldp中, 同时设置P的状态为处于系统调用_Psyscall;

  4. 如果GC处于等待运行状态,且当前P是最后一个转换为_Pgcstop的,则唤醒GC

需要注意的是 runtime.reentersyscall 会使PM的分离,当前线程会陷入系统调用等待返回,在锁被释放后,会有其他 Goroutine 抢占处理器资源。

系统调用后函数

系统调用结束后,会调用退出系统调用的函数 runtime.exitsyscall 为当前 Goroutine 重新分配资源,该函数有两个不同的执行路径:

  1. 调用 runtime.exitsyscallfast
  2. 切换至调度器的 Goroutine 并调用 runtime.exitsyscall0

runtime.exitsyscall 源码如下:

//go 1.20.3 path: /src/runtime/proc.go

func exitsyscall() {
    // 获取当前 G
    gp := getg()

    // 增加当前 G 的锁计数
    gp.m.locks++
    // 检查 syscall frame 是否有效,如果不再有效,抛出异常
    if getcallersp() > gp.syscallsp {
        throw("exitsyscall: syscall frame is no longer valid")
    }

    // 重置 waitsince,用于标记当前 G 不再等待
    gp.waitsince = 0
    // 获取旧的 P,并清空 G 中保存的旧 P 的指针
    oldp := gp.m.oldp.ptr()
    gp.m.oldp = 0

    // 调用 exitsyscallfast 进行快速退出 syscall 的处理
    if exitsyscallfast(oldp) {
        // 如果开启了 goroutineProfile,尝试记录 goroutine 的 profile
        if goroutineProfile.active {
            systemstack(func() {
                tryRecordGoroutineProfileWB(gp)
            })
        }
        // 如果开启了 trace,执行 traceGoStart
        if trace.enabled {
            if oldp != gp.m.p.ptr() || gp.m.syscalltick != gp.m.p.ptr().syscalltick {
                systemstack(traceGoStart)
            }
        }

        // 增加 P 的 syscalltick,表示 syscall 完成
        gp.m.p.ptr().syscalltick++
        // 将 G 的状态切换为 _Grunning
        casgstatus(gp, _Gsyscall, _Grunning)

        // 重置 G 的 syscallsp 为 0,重置 G 的锁计数
        gp.syscallsp = 0
        gp.m.locks--
        // 根据是否需要抢占,设置 G 的 stackguard0
        if gp.preempt {
            gp.stackguard0 = stackPreempt
        } else {
            gp.stackguard0 = gp.stack.lo + _StackGuard
        }
        // 重置 G 的 throwsplit 为 false

        // 如果调度被禁用且 G 不处于可调度状态,执行 Gosched
        if sched.disable.user && !schedEnabled(gp) {
            Gosched()
        }
        return
    }

    // 重置 G 的 sysexitticks 为 0
    gp.sysexitticks = 0
    // 如果开启了 trace,等待 oldp 的 syscalltick 不等于 G 的 syscalltick
    if trace.enabled {
        for oldp != nil && oldp.syscalltick == gp.m.syscalltick {
            osyield()
        }
        // 设置 G 的 sysexitticks 为当前 CPU 时间
        gp.sysexitticks = cputicks()
    }

    // 减少 G 的锁计数
    gp.m.locks--

    // 调用 exitsyscall0 处理 syscall 的退出过程
    mcall(exitsyscall0)

    // 重置 G 的 syscallsp 为 0,增加 P 的 syscalltick,重置 G 的 throwsplit 为 false
    gp.syscallsp = 0
    gp.m.p.ptr().syscalltick++
    gp.throwsplit = false
}

函数用于处理 Gsyscall 中退出的过程,其中涉及到对状态的切换和处理。根据不同条件,可能执行快速退出 syscall 的处理或者调用 exitsyscall0 处理 syscall 的退出过程。

这两种不同的路径会分别通过不同的方法查找一个用于执行当前 Goroutine 处理器 P,快速路径 runtime.exitsyscallfast

//go 1.20.3 path: /src/runtime/proc.go

func exitsyscallfast(oldp *p) bool {
    // 获取当前 G
    gp := getg()

    // 如果调度处于 freezeStopWait 状态,不执行 fast exit syscall
    if sched.stopwait == freezeStopWait {
        return false
    }

    // 如果存在旧的 P 且旧 P 的状态为 _Psyscall,将其状态切换为 _Pidle
    if oldp != nil && oldp.status == _Psyscall && atomic.Cas(&oldp.status, _Psyscall, _Pidle) {
        // 将 G 切换到新的 P 上
        wirep(oldp)
        // 执行 exitsyscallfast_reacquired 处理
        exitsyscallfast_reacquired()
        return true
    }

    // 如果存在空闲的 P,执行 exitsyscallfast_pidle 处理
    if sched.pidle != 0 {
        var ok bool
        // 在系统栈上执行 exitsyscallfast_pidle,并检查是否成功
        systemstack(func() {
            ok = exitsyscallfast_pidle()
            // 如果成功且开启了 trace,等待 oldp 的 syscalltick 不等于 G 的 syscalltick
            if ok && trace.enabled {
                if oldp != nil {
                    for oldp.syscalltick == gp.m.syscalltick {
                        osyield()
                    }
                }
                // 记录 G 的退出 syscall 的事件
                traceGoSysExit(0)
            }
        })
        if ok {
            return true
        }
    }
    return false
}

快速路径 runtime.exitsyscallfast流程如下:

  1. 如果 Goroutine 的原来的P,即oldp处于 _Psyscall 状态,会直接调用 wirepGoroutine 与处理器进行关联;
  2. 如果调度器中存在闲置的处理器(即sched.pidle>0),会调用 runtime.acquirep 使用闲置P处理当前 Goroutine

另一个相对较慢的路径 runtime.exitsyscall0 会将当前 Goroutine 切换至 _Grunnable 状态,并移除线程 M 和当前 Goroutine 的关联:

//go 1.20.3 path: /src/runtime/proc.go

func exitsyscall0(gp *g) {
    // 将 G 的状态从 _Gsyscall 切换为 _Grunnable
    casgstatus(gp, _Gsyscall, _Grunnable)
    // 释放当前 G
    dropg()
    // 锁定调度器
    lock(&sched.lock)
    var pp *p
    // 如果调度器启用,尝试从空闲 P 队列中获取 P
    if schedEnabled(gp) {
        pp, _ = pidleget(0)
    }
    var locked bool
    if pp == nil {
        // 如果未获取到 P,将 G 放入全局运行队列
        globrunqput(gp)
        // 记录是否 G 被锁定
        locked = gp.lockedm != 0
    } else if sched.sysmonwait.Load() {
        // 如果获取到 P 且 sysmon 在等待中,唤醒 sysmon
        sched.sysmonwait.Store(false)
        notewakeup(&sched.sysmonnote)
    }
    // 解锁调度器
    unlock(&sched.lock)
    // 如果获取到 P,将 G 放入 P 的本地队列,并执行 G
    if pp != nil {
        acquirep(pp)
        execute(gp, false)
    }
    // 如果 G 被锁定,停止锁定的 M 并执行 G
    if locked {
        stoplockedm()
        execute(gp, false)
    }
    // 停止当前 M 并调度
    stopm()
    schedule()
}

runtime.exitsyscall0流程如下:

  1. 更新G 的状态是_Grunnable
  2. 调用dropg():解除当前GM的绑定关系;
  3. 尝试获取P,获取到则acquirep绑定PM, execute进入调度循环;未获取到则globrunqputgp放入sched.runq
  4. 调用stopm()释放M,将M加入全局的idel M列表,这个调用会阻塞,知道获取到可用的P
  5. M获取到了可用的P,阻塞结束, 会调用schedule()函数,执行一次新的调度。

无论哪种情况,我们在这个函数中都会调用 runtime.schedule触发调度器的调度。

  • 5
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值