不会飞的章鱼

熟能生巧,勤能补拙;念念不忘,必有回响。

Go语言精进——了解goroutine的调度原理

什么是goroutine

goroutine是由Go运行时管理的用户层轻量级线程。

相比较于操作系统线程,goroutine的资源占用和使用代价都要小得多,可以创建几十个、几百个甚至成千上万个goroutine也不会造成系统资源的枯竭,Go的运行时负责对goroutine进行管理。而所谓的管理 == 调度。

goroutine调度器

一个Go程序中可以创建成千上万个并发的goroutine,而将这些goroutine按照一定算法放到CPU上执行的程序就称为goroutine调度器

一个Go程序对于操作系统来说只是一个用户层程序,操作系统眼中只有线程,goroutine的调度全要靠Go自己完成。

goroutine调度器原理

G、P、M

  • G:代表goroutine,存储了goroutine的执行栈信息,goroutine状态以及goroutine的任务函数等。另外G对象是可以重用的。
  • P:代表逻辑processor,P的数量决定了系统内最大可并行的G的数量。P中最有用的是其拥有的各种G对象队列、链表、一些缓存和状态。
  • M:代表真正的执行计算资源。在绑定有效的P后,进入一个调度循环;而调度循环的机制大致是从各种队列、P的本地运行队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M。如此返回。M不保留G的状态,这是G可以跨M调度的基础。

G被抢占调度

如果某个G没有进行系统调用、没有进行I/O操作、没有阻塞在一个channel的操作上,那么M是如何让G停下来并调度下一个可运行的G的呢?——G是被抢占调度的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// forcePreemptNS is the time slice given to a G before it is
// preempted.
const forcePreemptNS = 10 * 1000 * 1000 // 10ms

func retake(now int64) uint32 {
...
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
}
}
}

如果一个G任务运行超过10ms,sysmon就会认为其运行时间太久而发出抢占式调度的请求。一旦G的抢占标志被设为true,那么在这个G下一次调用函数或方法时,运行时便可以将G抢占并移出运行状态,放入P的本地运行队列中,等待下一次被调度。

channel阻塞或网络I/O情况下的调度

如果G被阻塞在某个channel操作或网络I/O操作上,那么G会被放置到某个等待队列中,而M会尝试运行P的下一个可运行的G。如果此时P没有可运行的G供M运行,那么M将解绑P,并进入挂起状态。当I/O操作完成或channel操作完成,在等待队列中的G会被唤醒,标记为runnable-可运行,并被放入某个P的队列中,绑定一个M后继续执行。

系统调用阻塞情况下的调度

如果G被阻塞在某个系统调用上,那么不仅G会阻塞,执行该G的M也会解绑P,与G一起进入阻塞状态。
如果此时有空闲的M,则P会与其绑定并继续执行其他G;如果没有空闲的M,但仍然有其他G要执行,那么就会创建一个新M(线程)。
当系统调用返回后,阻塞在该系统调用上的G会尝试获取一个可用的P,如果有可用P,之前运行该G的M将绑定P继续运行G;如果没有可用的P,那么G与M之间的关联将解除,同时G会被标记为runnable,放入全局的运行队列中,等待调度器的再次调度。

------ 本文结束------
如果本篇文章对你有帮助,可以给作者加个鸡腿~(*^__^*),感谢鼓励与支持!