Skip to content

golang的协程调度是抢占式的吗?

操作系统的调度方式可以分为抢占式(Preemptive)和非抢占式(Non-preemptive,也称为协作式)两种。它们的区别主要体现在任务(进程或线程)被调度的时机以及调度的控制权如何分配。

抢占式调度

在抢占式调度中,操作系统具有较高的优先级,可以在任何时刻中断当前正在执行的任务,并强制将控制权交给另一个任务。也就是说一个正在执行的任务可能会被更高优先级的任务抢占,即使该任务尚未自愿释放控制权。这种方式可以确保高优先级任务及时响应,但也可能导致低优先级任务被频繁中断,影响系统的稳定性和可预测性。

非抢占式调度

在非抢占式调度中,任务只有在自愿释放控制权(例如等待I/O操作完成、进入休眠状态等)或者任务结束时,才会进行任务切换。操作系统无法强制中断当前正在执行的任务。这种方式可以确保低优先级任务不会被频繁中断,但也可能导致高优先级任务无法及时响应,特别是当某个任务陷入无限循环或阻塞状态时。

因此,抢占式和非抢占式就是看操作系统支不支持 优先级 这个概念,有优先级的话,高优先级的进程可以打断正在运行的低优先级进程,所以是抢占式的。没有优化级的话,进程只有自己让度CPU(如时间片到了,或者阻塞等),所以是非抢占式的。我们知道现在的操作系统都设置了 nice值,因此基本上都是抢占式的调度。

抢占式和协作式虽然说是操作系统的概念,但是golang的协程调度也是任务调度问题,那么它是抢占式的还是协作式的呢?

协程是用户态线程,我们可能会理所当然的认为go的协程调度就是非抢占式的,因为协程调度的GPM模型是由用户态主动让出执行线程的(让度条件就是自己的goroutine执行完了,或者pending), 协程没有优化级的概念。

但真是这样的吗?其实不然,从 go 1.14 开始,go 调度器是非合作抢占的。每个 goroutine 在一定的时间片后被抢占。在 go 1.19.1 中是 10ms 源码链接。就算该goroutine一直在占用CPU进行计算,只要10ms的时间到了,它依然会被调度出去重新丢到等待队列里面。

golang
// forcePreemptNS is the time slice given to a G before it is
// preempted.
const forcePreemptNS = 10 * 1000 * 1000 // 10ms

func retake(now int64) uint32 {
	n := 0
	// Prevent allp slice changes. This lock will be completely
	// uncontended unless we're already stopping the world.
	lock(&allpLock)
	// We can't use a range loop over allp because we may
	// temporarily drop the allpLock. Hence, we need to re-fetch
	// allp each time around the loop.
	for i := 0; i < len(allp); i++ {
		_p_ := allp[i]
		if _p_ == nil {
			// This can happen if procresize has grown
			// allp but not yet created new Ps.
			continue
		}
		pd := &_p_.sysmontick
		s := _p_.status
		sysretake := false
		if s == _Prunning || s == _Psyscall {
			// Preempt G if it's running for too long.
			t := int64(_p_.schedtick)
			if int64(pd.schedtick) != t {
				pd.schedtick = uint32(t)
				pd.schedwhen = now
			} else if pd.schedwhen+forcePreemptNS <= now {
				preemptone(_p_)
				// In case of syscall, preemptone() doesn't
				// work, because there is no M wired to P.
				sysretake = true
			}
		}
		if s == _Psyscall {
			// Retake P from syscall if it's there for more than 1 sysmon tick (at least 20us).
			t := int64(_p_.syscalltick)
			if !sysretake && int64(pd.syscalltick) != t {
				pd.syscalltick = uint32(t)
				pd.syscallwhen = now
				continue
			}
			// On the one hand we don't want to retake Ps if there is no other work to do,
			// but on the other hand we want to retake them eventually
			// because they can prevent the sysmon thread from deep sleep.
			if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
				continue
			}
			// Drop allpLock so we can take sched.lock.
			unlock(&allpLock)
			// Need to decrement number of idle locked M's
			// (pretending that one more is running) before the CAS.
			// Otherwise the M from which we retake can exit the syscall,
			// increment nmidle and report deadlock.
			incidlelocked(-1)
			if atomic.Cas(&_p_.status, s, _Pidle) {
				if trace.enabled {
					traceGoSysBlock(_p_)
					traceProcStop(_p_)
				}
				n++
				_p_.syscalltick++
				handoffp(_p_)
			}
			incidlelocked(1)
			lock(&allpLock)
		}
	}
	unlock(&allpLock)
	return uint32(n)
}

Last updated: