不知道大家在实际工作中有没有遇到过老版本 Go 调度器的坑:死循环导致程序“死机”。我去年就遇到过,并且搞出了一起 P0 事故,还写了篇弱智的找 bug 文章。

识别事故的本质,并且用一个非常简单的示例展示出来,是功力的一种体现。那次事故的原因可以简化成如下的 demo:

demo-1

我来简单解释一下上面这个程序。在主 goroutine 里,先用 GoMAXPROCS 函数拿到 CPU 的逻辑核心数 threads。这意味着 Go 进程会创建 threads 个数的 P。接着,启动了 threads 个数的 goroutine,每个 goroutine 都在执行一个无限循环,并且这个无限循环只是简单地执行 x++

接着,主 goroutine sleep 了 1 秒钟;最后,打印 x 的值。

你可以自己思考一下,输出会是什么?

如果你想出了答案,接着再看下面这个 demo:

demo-2

我也来解释一下,在主 goroutine 里,只启动了一个 goroutine(虽然程序里用了一个 for 循环,但其实只循环了一次,完全是为了和前面的 demo 看起来更协调一些),同样执行了一个 x++ 的无限 for 循环。

和前一个 demo 的不同点在于,在主 goroutine 里,我们手动执行了一次 GC;最后,打印 x 的值。

如果你能答对第一题,大概率也能答对第二题。

下面我就来揭晓答案。

其实我留了一个坑,我没说用哪个版本的 Go 来运行代码。所以,正确的答案是:

Go 版本 demo-1 demo-2
1.13 卡死 卡死
1.14 0 0

这个其实就是 Go 调度器的坑了。

假设在 demo-1 中,共有 4 个 P,于是创建了 4 个 goroutine。当主 goroutine 执行 sleep 的时候,刚刚创建的 4 个 goroutine 马上就把 4 个 P 霸占了,执行死循环,而且竟然没有进行函数调用,就只有一个简单的赋值语句。Go 1.13 对这种情况是无能为力的,没有任何办法让这些 goroutine 停下来,进程对外表现出“死机”。

demo-1 示意图

由于 Go 1.14 实现了基于信号的抢占式调度,这些执行无限循环的 goroutine 会被调度器“拿下”,P 就会空出来。所以当主 goroutine sleep 时间到了之后,马上就能获得 P,并得以打印出 x 的值。至于 x 为什么输出的是 0,不太好解释,因为这是一种未定义(有数据竞争,正常情况下要加锁)的行为,可能的一个原因是 CPU 的 cache 没有来得及更新,不过不太好验证。

理解了这个 demo,第二个 demo 其实是类似的道理:

demo-2 示意图

当主 goroutine 主动触发 GC 时,需要把所有当前正在运行的 goroutine 停止下来,即 stw(stop the world),但是 goroutine 正在执行无限循环,没法让它停下来。当然,Go 1.14 还是可以抢占掉这个 goroutine,从而打印出 x 的值,也是 0。

Go 1.14 之前的版本,能否抢占一个正在执行死循环的 goroutine 其实是有讲究的:

能否被抢占,不是看有没有调用函数,而是看函数的序言部分有没有插入扩栈检测指令。

如果没有调用函数,肯定不会被抢占。

有些虽然也调用了函数,但其实不会插入检测指令,这个时候也不会被抢占。

像前面的两个 demo,不可能有机会在函数扩栈检测期间主动放弃 CPU 使用权,从而完成抢占,因为没有函数调用。具体的过程后面有机会再写一篇文章详细讲,本文主要看基于信号的抢占式调度如何实现。

preemptone

一方面,Go 进程在启动的时候,会开启一个后台线程 sysmon,监控执行时间过长的 goroutine,进而发出抢占。另一方面,GC 执行 stw 时,会让所有的 goroutine 都停止,其实就是抢占。这两者都会调用 preemptone() 函数。

preemptone() 函数会沿着下面这条路径:

preemptone->preemptM->signalM->tgkill

向正在运行的 goroutine 所绑定的的那个 M(也可以说是线程)发出 SIGURG 信号。

注册 sighandler

每个 M 在初始化的时候都会设置信号处理函数:

initsig->setsig->sighandler

信号执行过程

我们从“宏观”层面看一下信号的执行过程:

信号执行过程

主程序(线程)正在“勤勤恳恳”地执行指令:它已经执行完了指令 m,接着就要执行指令 m+1 了……不幸在这个时候发生了,线程收到了一个信号,对应图中的

接着,内核会接管执行流,转而去执行预先设置好的信号处理器程序,对应到 Go 里,就是执行 sighandler,对应图中的

最后,执行流又交到线程手上,继续执行指令 m+1,对应图中的

这里其实涉及到了一些现场的保护和恢复,内核都帮我们搞定了,我们不用操心。

dosigPreempt

当线程收到 SIGURG 信号的时候,就会去执行 sighandler 函数,核心是 doSigPreempt 函数。

func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {...if sig == sigPreempt && debug.asyncpreemptoff == 0 {doSigPreempt(gp, c)}...
}

doSigPreempt 这个函数其实很短,一会儿就执行完了。

func doSigPreempt(gp *g, ctxt *sigctxt) {...if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {// Adjust the PC and inject a call to asyncPreempt.ctxt.pushCall(funcPC(asyncPreempt), newpc)}...
}

isAsyncSafePoint 函数会返回当前 goroutine 能否被抢占,以及从哪条指令开始抢占,返回的 newpc 表示安全的抢占地址。

接着,pushCall 调整了一下 SP,设置了几个寄存器的值就返回了。按理说,返回之后,就会接着执行指令 m+1 了,但那还怎么实现抢占呢?其实魔法都在 pushCall 这个函数里。

pushCall

在分析这个函数之前,我们需要先复习一下 Go 函数的调用规约,重点回顾一下 CALL 和 RET 指令就行了。

call 和 ret 指令

call 指令可以简单地理解为 push ip + JMP。这个 ip 其实就是返回地址,也就是调用完子函数接下来该执行啥指令的地址。所以 push ip 就是在 call 一个子函数之前,将返回地址压入栈中,然后 JMP 到子函数的地址执行。

ret 指令和 call 指令刚好相反,它将返回地址从栈上 pop 到 IP 寄存器,使得 CPU 从这个地址继续执行。

理解了 callret,我们再来分析 pushCall 函数:

func (c *sigctxt) pushCall(targetPC, resumePC uintptr) {// Make it look like we called target at resumePC.sp := uintptr(c.rsp())sp -= sys.PtrSize*(*uintptr)(unsafe.Pointer(sp)) = resumePCc.set_rsp(uint64(sp))c.set_rip(uint64(targetPC))
}

注意看这行注释:

// Make it look like we called target at resumePC.

它清晰地说明了这个函数的作用:让 CPU 误以为是 resumePC 调用了 targetPC。而这个 resumePC 就是上一步调用 isAsyncSafePoint 函数返回的 newpc,它代表我们抢占 goroutine 的指令地址。

前两行代码将 SP 下移了 8 个字节,并且把 resumePC 入栈(注意,它其实是一个返回地址),接着把 targetPC 设置到 ip 寄存器,sp 设置到 SP 寄存器。这使得从内核返回到用户态执行时,不是从指令 m+1,而是直接从 targetPC 开始执行,等到 targetPC 执行完,才会返回到 resumePC 继续执行。整个过程就像是 resumePC 调用了 targetPC 一样。而 targetPC 其实就是 funcPC(asyncPreempt),也就是抢占函数。

于是我们可以看到,信号处理器程序 sighandler 只是将一个异步抢占函数给“安插”进来了,而真正的抢占过程则是在 asyncPreempt 函数中完成。

异步抢占

当执行完 sighandler,执行流再次回到线程。由于 sighandler 插入了一个 asyncPreempt 的函数调用,所以 goroutine 原本的任务就得不到推进,转而执行 asyncPreempt 去了:

asyncPreempt 调用链路

mcall(fn) 的作用是切到 g0 栈去执行函数 fn, fn 永不返回。在 mcall(gopreempt_m) 这里,fn 就是 gopreempt_m。

gopreempt_m 直接调用 goschedImpl

goschedImpl

dropg

最精彩的部分就在 goschedImpl 函数。它首先将 goroutine 的状态从 running 改成 runnable;接着调 dropg 将 g 和 m 解绑;然后调用 globrunqput 将 goroutine 丢到全局可运行队列,由于是全局可运行队列,所以需要加锁。最后,调用 schedule() 函数进入调度循环。关于调度循环,可以看这篇文章。

运行 schedule 函数用的是 g0 栈,它会去寻找其他可运行的 goroutine,包括从当前 P 本地可运行队列获取、从全局可运行队列获取、从其他 P 偷等方式找到下一个可运行的 goroutine 并执行。

至此,这个线程就转而去执行其他的 goroutine,当前的 goroutine 也就被抢占了。

那被抢占的这个 goroutine 什么时候会再次得到执行呢?

因为它已经被丢到全局可运行队列了,所以它的优先级就会降低,得到调度的机会也就降低,但总还是有机会再次执行的,并且它会从调用 mcall 的下一条指令接着执行。

还记得 mcall 函数的作用吗?它会切到 g0 栈执行 gopreempt_m,自然它也会保存 goroutine 的执行进度,其实就是 SP、BP、PC 寄存器的值,当 goroutine 再次被调度执行时,就会从原来的执行流断点处继续执行下去。

总结

本文讲述了 Go 语言基于信号的异步抢占的全过程,一起来回顾下:

  1. M 注册一个 SIGURG 信号的处理函数:sighandler。

  2. sysmon 线程检测到执行时间过长的 goroutine、GC stw 时,会向相应的 M(或者说线程,每个线程对应一个 M)发送 SIGURG 信号。

  3. 收到信号后,内核执行 sighandler 函数,通过 pushCall 插入 asyncPreempt 函数调用。

  4. 回到当前 goroutine 执行 asyncPreempt 函数,通过 mcall 切到 g0 栈执行 gopreempt_m。

  5. 将当前 goroutine 插入到全局可运行队列,M 则继续寻找其他 goroutine 来运行。

  6. 被抢占的 goroutine 再次调度过来执行时,会继续原来的执行流。


期待你的关注~

深度解密Go语言之基于信号的抢占式调度相关推荐

  1. 深度解密 Go 语言之基于信号的抢占式调度

    作者 | qcrao       责编 | 欧阳姝黎 不知道大家在实际工作中有没有遇到过老版本 Go 调度器的坑:死循环导致程序"死机".我去年就遇到过,并且搞出了一起 P0 事故 ...

  2. go1.14基于信号的抢占式调度实现原理

    转载地址:http://xiaorui.cc/archives/6535 前言: 疫情期间里老老实实在家蹲着,这期间主要研究下go 1.14新增的部分.go 1.14中比较大的更新有信号的抢占调度.d ...

  3. 深度解密Go语言之channel

    大家好啊!"深度解密 Go 语言"系列好久未见,我们今天讲 channel,预祝阅读愉快!在开始正文之前,我们先说些题外话. 上一篇关于 Go 语言的文章讲 Go 程序的整个编码. ...

  4. 深度解密Go语言之scheduler

    好久不见,你还好吗?距离上一篇文章已经过去了一个多月了,迟迟未更新文章,我也很着急啊,哈哈. 跟大家汇报一下,这段时间我在看 proc.go 的源码,其实就是调度器的源码.代码有几千行之多,不像以往的 ...

  5. 深度解密Go语言之 scheduler

    目录 前置知识 os scheduler 线程切换 函数调用过程分析 goroutine 是怎么工作的 什么是 goroutine goroutine 和 thread 的区别 M:N 模型 什么是 ...

  6. 深度解密Go语言之反射

    反射和 Interface 息息相关,而 Interface 是我们上一篇文章的内容.在开始正文前,和大家说点题外话. 上一篇关于 Interface 的文章发出后,获得了很多的关注和阅读.比如,登上 ...

  7. 深度解密Go语言之Slice

    Go 语言的 slice 很好用,不过也有一些坑.slice 是 Go 语言一个很重要的数据结构.网上已经有很多文章写过了,似乎没必要再写.但是每个人看问题的视角不同,写出来的东西自然也不一样.我这篇 ...

  8. 深度解密Go语言之context

    文章目录 什么是 context 为什么有 context context 底层实现原理 整体概览 接口 Context canceler 结构体 emptyCtx cancelCtx timerCt ...

  9. 源码分析 | 深度解密Go语言之context

    之前写的文章 Context是怎么在Go语言中发挥关键作用的 以图解的方式给大家讲解了 Context的实现原理以及它为什么能便捷地对多层并发任务进行控制,写作期间阅读了不少作者的源码解析文章,桃花源 ...

最新文章

  1. 这本1900页的机器学习数学全书火了!完整版开放下载
  2. BWA SAM文件格式
  3. mysql修改字符集utf8为utf8mb4
  4. LA3266田忌赛马
  5. 关于arm-linux-gcc的安装与配置
  6. 离散正(余)弦信号的时域与FFT变换后所得频域之间的关系(幅值和相角)
  7. pycharm工具下代码下面显示波浪线的去处方法
  8. 机器学习原理与算法(六) 支持向量机
  9. 开博1个月不到,照样可以申请到text-link-ads
  10. sql 纵向求和_sql 行列转换 求和平均值等
  11. 3.2-点云配准原理概述
  12. 计算机注册dll,电脑中注册dll文件和ocx文件的方法与操作步骤
  13. [Luogu P4630] [BZOJ 5463] [APIO2018] Duathlon 铁人两项
  14. 2019PR怎么设置导出比特率?为什么我的PR导出的时候不能控制文件大小?为什么我的PR导出的文件特别大?
  15. MATLAB从入门到精通:MATLAB调色板,作图更酷炫
  16. 《东周列国志》第三十七回 介子推守志焚绵上 太叔带怙宠入宫中
  17. 朱晔的互联网架构实践心得S1E8:三十种架构设计模式(下)
  18. Java程序员如何通过阿里、百度的招聘面试
  19. docker wordpress 提示:Error establishing a database connection
  20. python中apply函数的使用

热门文章

  1. c语言如何获取按键,c语言获得键盘的按键
  2. 瑞士桁架机器人_机器人库晚报:人工智能可以在实验室中预测人的血糖水平
  3. 简易 IM 双向通信电脑端 GUI 应用——基于 Netty、WebSocket、JavaFX 、多线程技术等
  4. 201110阶段二qt事件
  5. 【图文详解】如何彻底删除JDK(以win10、jdk1.8为例)
  6. Linux--结构体的详细学习
  7. macOS NSTableView
  8. 人工智能化发展已经到了哪一步?
  9. (PCB)进程控制块
  10. HTML5本地存储之Web Storage篇