本文是《Go语言调度器源代码情景分析》系列的第17篇,也是第三章《Goroutine调度策略》的第2小节。


上一小节我们分析了从全局运行队列与工作线程的本地运行队列获取goroutine的过程,这一小节我们继续分析因无法从上述两个队列中拿到需要运行的goroutine而导致的从其它工作线程的本地运行队列中盗取goroutine的过程。

findrunnable() 函数负责处理与盗取相关的逻辑,该函数代码很繁杂,因为它还做了与gc和netpoll等相关的事情,为了不影响我们的分析思路,这里我们仍然把不相关的代码删掉了,不过代码还是比较多,但总结起来就一句话:尽力去各个运行队列中寻找goroutine,如果实在找不到则进入睡眠状态。下面是代码细节:

runtime/proc.go : 2176

// Finds a runnable goroutine to execute.
// Tries to steal from other P's, get g from global queue, poll network.
func findrunnable() (gp *g, inheritTime bool) {
_g_ := getg() // The conditions here and in handoffp must agree: if
// findrunnable would return a G to run, handoffp must start
// an M. top:
_p_ := _g_.m.p.ptr() ...... // local runq
//再次看一下本地运行队列是否有需要运行的goroutine
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime
} // global runq
//再看看全局运行队列是否有需要运行的goroutine
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(_p_, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false
}
} ...... // Steal work from other P's.
//如果除了当前工作线程还在运行外,其它工作线程已经处于休眠中,那么也就不用去偷了,肯定没有
procs := uint32(gomaxprocs)
if atomic.Load(&sched.npidle) == procs-1 {
// Either GOMAXPROCS=1 or everybody, except for us, is idle already.
// New work can appear from returning syscall/cgocall, network or timers.
// Neither of that submits to local run queues, so no point in stealing.
goto stop
}
// If number of spinning M's >= number of busy P's, block.
// This is necessary to prevent excessive CPU consumption
// when GOMAXPROCS>>1 but the program parallelism is low.
// 这个判断主要是为了防止因为寻找可运行的goroutine而消耗太多的CPU。
// 因为已经有足够多的工作线程正在寻找可运行的goroutine,让他们去找就好了,自己偷个懒去睡觉
if !_g_.m.spinning && 2*atomic.Load(&sched.nmspinning) >= procs-atomic.Load(&sched.npidle) {
goto stop
}
if !_g_.m.spinning {
//设置m的状态为spinning
_g_.m.spinning = true
//处于spinning状态的m数量加一
atomic.Xadd(&sched.nmspinning, 1)
} //从其它p的本地运行队列盗取goroutine
for i := 0; i < 4; i++ {
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
if sched.gcwaiting != 0 {
goto top
}
stealRunNextG := i > 2 // first look for ready queues with more than 1 g
if gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {
return gp, false
}
}
} stop: ...... // Before we drop our P, make a snapshot of the allp slice,
// which can change underfoot once we no longer block
// safe-points. We don't need to snapshot the contents because
// everything up to cap(allp) is immutable.
allpSnapshot := allp // return P and block
lock(&sched.lock) ...... if sched.runqsize != 0 {
gp := globrunqget(_p_, 0)
unlock(&sched.lock)
return gp, false
} // 当前工作线程解除与p之间的绑定,准备去休眠
if releasep() != _p_ {
throw("findrunnable: wrong p")
}
//把p放入空闲队列
pidleput(_p_)
unlock(&sched.lock) // Delicate dance: thread transitions from spinning to non-spinning state,
// potentially concurrently with submission of new goroutines. We must
// drop nmspinning first and then check all per-P queues again (with
// #StoreLoad memory barrier in between). If we do it the other way around,
// another thread can submit a goroutine after we've checked all run queues
// but before we drop nmspinning; as the result nobody will unpark a thread
// to run the goroutine.
// If we discover new work below, we need to restore m.spinning as a signal
// for resetspinning to unpark a new worker thread (because there can be more
// than one starving goroutine). However, if after discovering new work
// we also observe no idle Ps, it is OK to just park the current thread:
// the system is fully loaded so no spinning threads are required.
// Also see "Worker thread parking/unparking" comment at the top of the file.
wasSpinning := _g_.m.spinning
if _g_.m.spinning {
//m即将睡眠,状态不再是spinning
_g_.m.spinning = false
if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {
throw("findrunnable: negative nmspinning")
}
} // check all runqueues once again
// 休眠之前再看一下是否有工作要做
for _, _p_ := range allpSnapshot {
if !runqempty(_p_) {
lock(&sched.lock)
_p_ = pidleget()
unlock(&sched.lock)
if _p_ != nil {
acquirep(_p_)
if wasSpinning {
_g_.m.spinning = true
atomic.Xadd(&sched.nmspinning, 1)
}
goto top
}
break
}
} ......
//休眠
stopm()
goto top
}

从上面的代码可以看到,工作线程在放弃寻找可运行的goroutine而进入睡眠之前,会反复尝试从各个运行队列寻找需要运行的goroutine,可谓是尽心尽力了。这个函数需要重点注意以下两点:

第一点,工作线程M的自旋状态(spinning)工作线程在从其它工作线程的本地运行队列中盗取goroutine时的状态称为自旋状态。从上面代码可以看到,当前M在去其它p的运行队列盗取goroutine之前把spinning标志设置成了true,同时增加处于自旋状态的M的数量,而盗取结束之后则把spinning标志还原为false,同时减少处于自旋状态的M的数量,从后面的分析我们可以看到,当有空闲P又有goroutine需要运行的时候,这个处于自旋状态的M的数量决定了是否需要唤醒或者创建新的工作线程。

第二点,盗取算法。盗取过程用了两个嵌套for循环。内层循环实现了盗取逻辑,从代码可以看出盗取的实质就是遍历allp中的所有p,查看其运行队列是否有goroutine,如果有,则取其一半到当前工作线程的运行队列,然后从findrunnable返回,如果没有则继续遍历下一个p。但这里为了保证公平性,遍历allp时并不是固定的从allp[0]即第一个p开始,而是从随机位置上的p开始,而且遍历的顺序也随机化了,并不是现在访问了第i个p下一次就访问第i+1个p,而是使用了一种伪随机的方式遍历allp中的每个p,防止每次遍历时使用同样的顺序访问allp中的元素。下面是这个算法的伪代码:

offset := uint32(random()) % nprocs
coprime := 随机选取一个小于nprocs且与nprocs互质的数
for i := 0; i < nprocs; i++ {
p := allp[offset]
从p的运行队列偷取goroutine
if 偷取成功 {
break
}
offset += coprime
offset = offset % nprocs
}

下面举例说明一下上述算法过程,现假设nprocs为8,也就是一共有8个p。

如果第一次随机选择的offset = 6,coprime = 3(3与8互质,满足算法要求)的话,则从allp切片中偷取的下标顺序为6, 1, 4, 7, 2, 5, 0, 3,计算过程:

,(+)%=,(+)%=, (+)%=, (+)%=, (+)%=, (+)%=, (+)%=

如果第二次随机选择的offset = 4,coprime = 5的话,则从allp切片中偷取的下标顺序为1, 6, 3, 0, 5, 2, 7, 4,计算过程:

,(+)%=,(+)%=, (+)%=, (+)%=, (+)%=, (+)%=, (+)%=

可以看到只要随机数不一样,偷取p的顺序也不一样,但可以保证经过8次循环,每个p都会被访问到。可以用数论知识证明,不管nprocs是多少,这个算法都可以保证经过nprocs次循环,每个p都可以得到访问。

挑选出盗取的对象p之后,则调用runqsteal盗取p的运行队列中的goroutine,runqsteal函数再调用runqgrap从p的队列中批量拿出多个goroutine,这两个函数本身比较简单,但runqgrab有一个小细节需要注意一下,见下面代码:

runtime/proc.go : 4854

// Grabs a batch of goroutines from _p_'s runnable queue into batch.
// Batch is a ring buffer starting at batchHead.
// Returns number of grabbed goroutines.
// Can be executed by any P.
func runqgrab(_p_ *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
for {
h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with other consumers
t := atomic.LoadAcq(&_p_.runqtail) // load-acquire, synchronize with the producer
n := t - h //计算队列中有多少个goroutine
n = n - n/2 //取队列中goroutine个数的一半
if n == 0 {
......
return ......
}
//小细节:按理说队列中的goroutine个数最多就是len(_p_.runq),
//所以n的最大值也就是len(_p_.runq)/2,那为什么需要这个判断呢?
if n > uint32(len(_p_.runq)/2) { // read inconsistent h and t
continue
} ......
}
}

代码中n的计算很简单,从计算过程来看n应该是runq队列中goroutine数量的一半,它的最大值不会超过队列容量的一半,但为什么这里的代码却偏偏要去判断n是否大于队列容量的一半呢?这里关键点在于读取runqhead和runqtail是两个操作而非一个原子操作,当我们读取runqhead之后但还未读取runqtail之前,如果有其它线程快速的在增加(这是完全有可能的,其它偷取者从队列中偷取goroutine会增加runqhead,而队列的所有者往队列中添加goroutine会增加runqtail)这两个值,则会导致我们读取出来的runqtail已经远远大于我们之前读取出来放在局部变量h里面的runqhead了,也就是代码注释中所说的h和t已经不一致了,所以这里需要这个if判断来检测异常情况。

工作线程进入睡眠

分析完盗取过程,我们继续回到findrunnable函数。

如果工作线程经过多次努力一直找不到需要运行的goroutine则调用stopm进入睡眠状态,等待被其它工作线程唤醒。

runtime/proc.go : 1918

// Stops execution of the current m until new work is available.
// Returns with acquired P.
func stopm() {
_g_ := getg() if _g_.m.locks != 0 {
throw("stopm holding locks")
}
if _g_.m.p != 0 {
throw("stopm holding p")
}
if _g_.m.spinning {
throw("stopm spinning")
} lock(&sched.lock)
mput(_g_.m) //把m结构体对象放入sched.midle空闲队列
unlock(&sched.lock)
notesleep(&_g_.m.park) //进入睡眠状态 //被其它工作线程唤醒
noteclear(&_g_.m.park)
acquirep(_g_.m.nextp.ptr())
_g_.m.nextp = 0
}

stopm的核心是调用mput把m结构体对象放入sched的midle空闲队列,然后通过notesleep(&m.park)函数让自己进入睡眠状态

note是go runtime实现的一次性睡眠和唤醒机制,一个线程可以通过调用notesleep(*note)进入睡眠状态,而另外一个线程则可以通过notewakeup(*note)把其唤醒。note的底层实现机制跟操作系统相关,不同系统使用不同的机制,比如linux下使用的futex系统调用,而mac下则是使用的pthread_cond_t条件变量,note对这些底层机制做了一个抽象和封装,这种封装给扩展性带来了很大的好处,比如当睡眠和唤醒功能需要支持新平台时,只需要在note层增加对特定平台的支持即可,不需要修改上层的任何代码。

回到stopm,当从notesleep函数返回后,需要再次绑定一个p,然后返回到findrunnable函数继续重新寻找可运行的goroutine,一旦找到可运行的goroutine就会返回到schedule函数,并把找到的goroutine调度起来运行,如何把goroutine调度起来运行的代码我们已经分析过了。现在继续看notesleep函数。

runtime/lock_futex.go : 139

func notesleep(n *note) {
gp := getg()
if gp != gp.m.g0 {
throw("notesleep not on g0")
}
ns := int64(-1) //超时时间设置为-1,表示无限期等待
if *cgo_yield != nil {
// Sleep for an arbitrary-but-moderate interval to poll libc interceptors.
ns = 10e6
} //使用循环,保证不是意外被唤醒
for atomic.Load(key32(&n.key)) == 0 {
gp.m.blocked = true
futexsleep(key32(&n.key), 0, ns)
if *cgo_yield != nil {
asmcgocall(*cgo_yield, nil)
}
gp.m.blocked = false
}
}

notesleep函数调用futexsleep进入睡眠,这里之所以需要用一个循环,是因为futexsleep有可能意外从睡眠中返回,所以从futexsleep函数返回后还需要检查note.key是否还是0,如果是0则表示并不是其它工作线程唤醒了我们,只是futexsleep意外返回了,需要再次调用futexsleep进入睡眠。

futexsleep调用futex函数进入睡眠。

runtime/os_linux.go : 32

// Atomically,
// if(*addr == val) sleep
// Might be woken up spuriously; that's allowed.
// Don't sleep longer than ns; ns < 0 means forever.
//go:nosplit
func futexsleep(addr *uint32, val uint32, ns int64) {
var ts timespec // Some Linux kernels have a bug where futex of
// FUTEX_WAIT returns an internal error code
// as an errno. Libpthread ignores the return value
// here, and so can we: as it says a few lines up,
// spurious wakeups are allowed.
if ns < 0 {
//调用futex进入睡眠
futex(unsafe.Pointer(addr), _FUTEX_WAIT_PRIVATE, val, nil, nil, 0)
return
} // It's difficult to live within the no-split stack limits here.
// On ARM and 386, a 64-bit divide invokes a general software routine
// that needs more stack than we can afford. So we use timediv instead.
// But on real 64-bit systems, where words are larger but the stack limit
// is not, even timediv is too heavy, and we really need to use just an
// ordinary machine instruction.
if sys.PtrSize == 8 {
ts.set_sec(ns / 1000000000)
ts.set_nsec(int32(ns % 1000000000))
} else {
ts.tv_nsec = 0
ts.set_sec(int64(timediv(ns, 1000000000, (*int32)(unsafe.Pointer(&ts.tv_nsec)))))
}
futex(unsafe.Pointer(addr), _FUTEX_WAIT_PRIVATE, val, unsafe.Pointer(&ts), nil, 0)
}

futex是go汇编实现的函数,主要功能就是执行futex系统调用进入操作系统内核进行睡眠。

runtime/sys_linux_amd64.s : 525

// int64 futex(int32 *uaddr, int32 op, int32 val,
// struct timespec *timeout, int32 *uaddr2, int32 val2);
TEXT runtime·futex(SB),NOSPLIT,$
#下面的6条指令在为futex系统调用准备参数
MOVQ addr+(FP), DI
MOVL op+(FP), SI
MOVL val+(FP), DX
MOVQ ts+(FP), R10
MOVQ addr2+(FP), R8
MOVL val3+(FP), R9 MOVL $SYS_futex, AX #系统调用编号放入AX寄存器
SYSCALL #执行futex系统调用进入睡眠,从睡眠中被唤醒后接着执行下一条MOVL指令
MOVL AX, ret+(FP) #保存系统调用的返回值
RET

futex系统的参数比较多,其函数原型为

int64 futex(int32*uaddr, int32op, int32val, structtimespec*timeout, int32*uaddr2, int32val2);

这里,futex系统调用为我们提供的功能为如果 *uaddr == val 则进入睡眠,否则直接返回。顺便说一下,为什么futex系统调用需要第三个参数val,需要在内核判断*uaddr与val是否相等,而不能在用户态先判断它们是否相等,如果相等才进入内核睡眠岂不是更高效?原因在于判断*uaddr与val是否相等和进入睡眠这两个操作必须是一个原子操作,否则会存在一个竞态条件:如果不是原子操作,则当前线程在第一步判断完*uaddr与val相等之后进入睡眠之前的这一小段时间内,有另外一个线程通过唤醒操作把*uaddr的值修改了,这就会导致当前工作线程永远处于睡眠状态而无人唤醒它。而在用户态无法实现判断与进入睡眠这两步为一个原子操作,所以需要内核来为其实现原子操作。

我们知道线程一旦进入睡眠状态就停止了运行,那么如果后来又有可运行的goroutine需要工作线程去运行,正在睡眠的线程怎么知道有工作可做了呢?

从前面的代码我们已经看到,stopm调用notesleep时给它传递的参数是m结构体的park成员,而m又早已通过mput放入了全局的milde空闲队列,这样其它运行着的线程一旦发现有更多的goroutine需要运行时就可以通过全局的m空闲队列找到处于睡眠状态的m,然后调用notewakeup(&m.park)将其唤醒,至于怎么唤醒,我们在其它章节继续讨论。

到此,我们已经完整分析了调度器的调度策略,从下一章起我们将开始讨论有关调度的另外一个话题:调度时机,即什么时候会发生调度。

Go语言调度器之盗取goroutine(17)的更多相关文章

  1. Go语言调度器之调度main goroutine(14)

    本文是<Go语言调度器源代码情景分析>系列的第14篇,也是第二章的第4小节. 上一节我们通过分析main goroutine的创建详细讨论了goroutine的创建及初始化流程,这一节我们 ...

  2. Go语言调度器之创建main goroutine(13)

    本文是<Go语言调度器源代码情景分析>系列的第13篇,也是第二章的第3小节. 上一节我们分析了调度器的初始化,这一节我们来看程序中的第一个goroutine是如何创建的. 创建main g ...

  3. Go语言调度器之主动调度(20)

    本文是<Go语言调度器源代码情景分析>系列的第20篇,也是第五章<主动调度>的第1小节. Goroutine的主动调度是指当前正在运行的goroutine通过直接调用runti ...

  4. 详解Go语言调度循环源码实现

    转载请声明出处哦~,本篇文章发布于luozhiyun的博客: https://www.luozhiyun.com/archives/448 本文使用的go的源码15.7 概述 提到"调度&q ...

  5. go语言调度器源代码情景分析之一:开篇语

    专题简介 本专题以精心设计的情景为线索,结合go语言最新1.12版源代码深入细致的分析了goroutine调度器实现原理. 适宜读者 go语言开发人员 对线程调度器工作原理感兴趣的工程师 对计算机底层 ...

  6. [翻译]Go语言调度器

    Go语言调度器 译序 本文翻译 Daniel Morsing 的博文 The Go scheduler.个人认为这篇文章把Go Routine和调度器的知识讲的浅显易懂.作为一篇介绍性的文章.非常不错 ...

  7. 深入Golang调度器之GMP模型

    前言 随着服务器硬件迭代升级,配置也越来越高.为充分利用服务器资源,并发编程也变的越来越重要.在开始之前,需要了解一下并发(concurrency)和并行(parallesim)的区别. 并发:  逻 ...

  8. [翻译] 深入浅出Go语言调度器:第一部分 - 系统调度器

    目录 译者序 序 介绍 系统调度器 执行指令 Figure 1 Listing 1 Listing 2 Listing 3 线程状态 任务侧重 上下文切换 少即是多 寻找平衡 缓存行 Figure 2 ...

  9. go语言调度器源代码情景分析之五:汇编指令

    本文是<go调度器源代码情景分析>系列 第一章 预备知识的第4小节. 汇编语言是每位后端程序员都应该掌握的一门语言,因为学会了汇编语言,不管是对我们调试程序还是研究与理解计算机底层的一些运 ...

随机推荐

  1. Python爬虫--初识爬虫

    Python爬虫 一.爬虫的本质是什么? 模拟浏览器打开网页,获取网页中我们想要的那部分数据 浏览器打开网页的过程:当你在浏览器中输入地址后,经过DNS服务器找到服务器主机,向服务器发送一个请求,服务 ...

  2. 2017-2018-1 20179209《Linux内核原理与分析》第二周作业

    本周课业主要通过分析汇编代码执行情况掌握栈的变化.本人本科时期学过intel 80X86汇编语言,所以有一定基础:在Linux中32位AT&T风格的汇编稍微熟悉就可以明白.所以我学习的重点放在 ...

  3. 题解 P1095 【守望者的逃离】

    贪心.数组都不用开那种. 考虑跑步距离的构成.发现跑步只有三种情况构成 休息 传送 朴素地跑 显然,如果可以传送,我们就不要朴素地跑步.因为\(17\le 60 \div 2 =30\). 假如我们知 ...

  4. oracle-jforum论坛链接Oracle

    问题描述 jforum论坛链接Oracle jforum论坛链接Oracle数据库 论坛主题页面不显示 是权限引起的吗 解决方案 页面不显示,你需要看一下错误,估计是配置不对引起的 参考一下这个 jf ...

  5. 编译debian内核

    玩腻了开发板,在pc上编译linux内核. debian 官方的内核文档见http://kernel-handbook.alioth.debian.org 我选择编译与当前内核版本对应的linux内核 ...

  6. 版本名称SNAPSHOT、alpha、beta、release、GA含义

    Alpha:是内部测试版,一般不向外部发布,会有很多Bug.一般只有测试人员使用.Beta:也是测试版,这个阶段的版本会一直加入新的功能.在Alpha版之后推出.RC:(Release Candida ...

  7. Linux学习之路(二)文件处理命令之上

    文件处理命令 1.目录处理命令 2.文件处理命令 3.链接命令 1.目录处理命令 建立目录:mkdirmkdir -p [目录名]-p 递归创建命令英文原意 : make directories 切换 ...

  8. BZOJ 1232 [Usaco2008Nov]安慰奶牛cheer:最小生成树【树上dfs性质】

    题目链接:http://www.lydsy.com/JudgeOnline/problem.php?id=1232 题意: 给你一个无向图,n个点,m条边. 每条边有边权len[i][j],每个点有点 ...

  9. L83

    Kids Gulp 7 Trillion Calories Per Year Kids from the ages of 2 to 19, consume about seven trillion c ...

  10. TF-IFD算法及python实现关键字提取

    TF-IDF算法: TF:词频(Term Frequency),即在分词后,某一个词在文档中出现的频率. IDF:逆文档频率(Inverse Document Frequency).在词频的基础上给每 ...