本文是《Go语言调度器源代码情景分析》系列的第15篇,也是第二章的第5小节。


上一节我们说过main goroutine退出时会直接执行exit系统调用退出整个进程,而非main goroutine退出时则会进入goexit函数完成最后的清理工作,本小节我们首先就来验证一下非main goroutine执行完成后是否真的会去执行goexit,然后再对非main goroutine的退出流程做个梳理。这一节我们需要重点理解以下内容:

  • 非main goroutine是如何返回到goexit函数的;

  • mcall函数如何从用户goroutine切换到g0继续执行;

  • 调度循环。

非main goroutine会返回到goexit吗

首先来看一段代码:

package main

import (
"fmt"
) func g2(n int, ch chan int) {
ch <- n*n
} func main() {
ch := make(chan int) go g2(100, ch) fmt.Println(<-ch)
}

这个程序比较简单,main goroutine启动后在main函数中创建了一个goroutine执行g2函数,我们称它为g2 goroutine,下面我们就用这个g2的退出来验证一下非main goroutine退出时是否真的会返回到goexit继续执行。

怎么验证呢?比较简单的办法就是用gdb来调试,在gdb中首先使用backtrace命令查看g2函数是被谁调用的,然后单步执行看它能否返回到goexit继续执行。下面是gdb调试过程:

(gdb) b main.g2       // 在main.g2函数入口处下断点
Breakpoint1at0x4869c0:file/home/bobo/study/go/goexit.go, line .
(gdb) r
Startingprogram:/home/bobo/study/go/goexit
Thread1"goexit"hit Breakpoint at /home/bobo/study/go/goexit.go:
(gdb) bt //查看函数调用链,看起来g2真的是被runtime.goexit调用的
# main.g2 (n=, ch=0xc000052060) at /home/bobo/study/go/goexit.go:
# 0x0000000000450ad1 in runtime.goexit () at /usr/local/go/src/runtime/asm_amd64.s:
(gdb) disass //反汇编找ret的地址,这是为了在ret处下断点
Dumpofassemblercodeforfunctionmain.g2:
=> 0x00000000004869c0 <+>:mov %fs:0xfffffffffffffff8,%rcx
0x00000000004869c9<+>:cmp 0x10(%rcx),%rsp
0x00000000004869cd<+>:jbe 0x486a0d <main.g2+>
0x00000000004869cf<+>:sub $0x20,%rsp
0x00000000004869d3<+>:mov %rbp,0x18(%rsp)
0x00000000004869d8<+>:lea 0x18(%rsp),%rbp
0x00000000004869dd<+>:mov 0x28(%rsp),%rax
0x00000000004869e2<+>:imul %rax,%rax
0x00000000004869e6<+>:mov %rax,0x10(%rsp)
0x00000000004869eb<+>:mov 0x30(%rsp),%rax
0x00000000004869f0<+>:mov %rax,(%rsp)
0x00000000004869f4<+>:lea 0x10(%rsp),%rax
0x00000000004869f9<+>:mov %rax,0x8(%rsp)
0x00000000004869fe<+>:callq 0x4046a0 <runtime.chansend1>
0x0000000000486a03<+>:mov 0x18(%rsp),%rbp
0x0000000000486a08<+>:add $0x20,%rsp
0x0000000000486a0c<+>:retq
0x0000000000486a0d<+>:callq 0x44ece0 <runtime.morestack_noctxt>
0x0000000000486a12<+>:jmp 0x4869c0 <main.g2>
Endofassemblerdump.
(gdb) b *0x0000000000486a0c //在retq指令位置下断点
Breakpoint2at0x486a0c:file/home/bobo/study/go/goexit.go, line .
(gdb) c
Continuing. Thread1"goexit"hit Breakpoint at /home/bobo/study/go/goexit.go:
(gdb) disass //程序停在了ret指令处
Dumpofassemblercodeforfunctionmain.g2:
0x00000000004869c0<+>:mov %fs:0xfffffffffffffff8,%rcx
0x00000000004869c9<+>:cmp 0x10(%rcx),%rsp
0x00000000004869cd<+>:jbe 0x486a0d <main.g2+>
0x00000000004869cf<+>:sub $0x20,%rsp
0x00000000004869d3<+>:mov %rbp,0x18(%rsp)
0x00000000004869d8<+>:lea 0x18(%rsp),%rbp
0x00000000004869dd<+>:mov 0x28(%rsp),%rax
0x00000000004869e2<+>:imul %rax,%rax
0x00000000004869e6<+>:mov %rax,0x10(%rsp)
0x00000000004869eb<+>:mov 0x30(%rsp),%rax
0x00000000004869f0<+>:mov %rax,(%rsp)
0x00000000004869f4<+>:lea 0x10(%rsp),%rax
0x00000000004869f9<+>:mov %rax,0x8(%rsp)
0x00000000004869fe<+>:callq 0x4046a0 <runtime.chansend1>
0x0000000000486a03<+>:mov 0x18(%rsp),%rbp
0x0000000000486a08<+>:add $0x20,%rsp
=> 0x0000000000486a0c <+>:retq
0x0000000000486a0d<+>:callq 0x44ece0 <runtime.morestack_noctxt>
0x0000000000486a12<+>:jmp 0x4869c0 <main.g2>
Endofassemblerdump.
(gdb) si //单步执行一条指令
runtime.goexit () at /usr/local/go/src/runtime/asm_amd64.s:
1338CALLruntime·goexit1(SB)// does not return
(gdb) disass //可以看出来g2已经返回到了goexit函数中
Dumpofassemblercodeforfunctionruntime.goexit:
0x0000000000450ad0<+>:nop
=> 0x0000000000450ad1 <+>:callq 0x42faf0 <runtime.goexit1>
0x0000000000450ad6<+>:nop

使用gdb调试时,首先我们在g2函数入口处下了一个断点,程序暂停后通过查看函数调用栈发现g2函数确实是被goexit调用的,然后再一次使用断点让程序暂停在g2返回之前的最后一条指令retq处,最后单步执行这条指令,可以看到程序从g2函数返回到了goexit函数的第二条指令的位置,这个位置正是当初在创建goroutine时设置好的返回地址。可以看到,虽然g2函数并不是被goexit函数直接调用的,但它执行完成之后却返回到了goexit函数中!

至此,我们已经证实非main goroutine退出时确实会返回到goexit函数继续执行,下面我们就沿着这条线继续分析非main goroutine的退出流程。

非main goroutine的退出流程

首先来看goexit函数

runtime/asm_amd64.s : 1334

// The top-most function running on a goroutine
// returns to goexit+PCQuantum.
TEXT runtime·goexit(SB),NOSPLIT,$-
BYTE $0x90 // NOP
CALL runtime·goexit1(SB) // does not return
// traceback from goexit1 must hit code range of goexit
BYTE $0x90 // NOP

从前面的分析我们已经看到,非main goroutine返回时直接返回到了goexit的第二条指令:CALL runtime·goexit1(SB),该指令继续调用goexit1函数。

runtime/proc.go : 2652

// Finishes execution of the current goroutine.
func goexit1() {
if raceenabled { //与竞态检查有关,不关注
racegoend()
}
if trace.enabled { //与backtrace有关,不关注
traceGoEnd()
}
mcall(goexit0)
}

goexit1函数通过调用mcall从当前运行的g2 goroutine切换到g0,然后在g0栈上调用和执行goexit0这个函数。

runtime/asm_amd64.s : 270

# func mcall(fn func(*g))
# Switch to m->g0's stack, call fn(g).
# Fn must never return. It should gogo(&g->sched)
# to keep running g.
# mcall的参数是一个指向funcval对象的指针
TEXT runtime·mcall(SB), NOSPLIT, $0-8
#取出参数的值放入DI寄存器,它是funcval对象的指针,此场景中fn.fn是goexit0的地址
MOVQ fn+0(FP), DI get_tls(CX)
MOVQ g(CX), AX# AX = g,本场景g 是 g2 #mcall返回地址放入BX
MOVQ 0(SP), BX# caller's PC #保存g2的调度信息,因为我们要从当前正在运行的g2切换到g0
MOVQ BX, (g_sched+gobuf_pc)(AX) #g.sched.pc = BX,保存g2的rip
LEAQ fn+(FP), BX# caller's SP
MOVQ BX, (g_sched+gobuf_sp)(AX) #g.sched.sp = BX,保存g2的rsp
MOVQ AX, (g_sched+gobuf_g)(AX) #g.sched.g = g
MOVQ BP, (g_sched+gobuf_bp)(AX) #g.sched.bp = BP,保存g2的rbp # switch to m->g0 & its stack, call fn
#下面三条指令主要目的是找到g0的指针
MOVQ g(CX), BX #BX = g
MOVQ g_m(BX), BX #BX = g.m
MOVQ m_g0(BX), SI #SI = g.m.g0 #此刻,SI = g0, AX = g,所以这里在判断g 是否是 g0,如果g == g0则一定是哪里代码写错了
CMPQ SI, AX# if g == m->g0 call badmcall
JNE 3(PC)
MOVQ $runtime·badmcall(SB), AX
JMP AX #把g0的地址设置到线程本地存储之中
MOVQ SI, g(CX) #恢复g0的栈顶指针到CPU的rsp积存,这一条指令完成了栈的切换,从g的栈切换到了g0的栈
MOVQ (g_sched+gobuf_sp)(SI), SP# rsp = g0->sched.sp #AX = g
PUSHQ AX #fn的参数g入栈
MOVQ DI, DX #DI是结构体funcval实例对象的指针,它的第一个成员才是goexit0的地址
MOVQ 0(DI), DI #读取第一个成员到DI寄存器
CALL DI #调用goexit0(g)
POPQ AX
MOVQ $runtime·badmcall2(SB), AX
JMP AX
RET

mcall的参数是一个函数,在Go语言的实现中,函数变量并不是一个直接指向函数代码的指针,而是一个指向funcval结构体对象的指针,funcval结构体对象的第一个成员fn才是真正指向函数代码的指针。

type funcval struct {
fn uintptr
// variable-size, fn-specific data here
}

也就是说,在我们这个场景中mcall函数的fn参数的fn成员中存放的才是goexit0函数的第一条指令的地址。

mcall函数主要有两个功能:

  1. 首先从当前运行的g(我们这个场景是g2)切换到g0,这一步包括保存当前g的调度信息,把g0设置到tls中,修改CPU的rsp寄存器使其指向g0的栈;

  2. 以当前运行的g(我们这个场景是g2)为参数调用fn函数(此处为goexit0)。

从mcall的功能我们可以看出,mcall做的事情跟gogo函数完全相反,gogo函数实现了从g0切换到某个goroutine去运行,而mcall实现了从某个goroutine切换到g0来运行,因此,mcall和gogo的代码非常相似,然而mcall和gogo在做切换时有个重要的区别:gogo函数在从g0切换到其它goroutine时首先切换了栈,然后通过跳转指令从runtime代码切换到了用户goroutine的代码,而mcall函数在从其它goroutine切换回g0时只切换了栈,并未使用跳转指令跳转到runtime代码去执行。为什么会有这个差别呢?原因在于在从g0切换到其它goroutine之前执行的是runtime的代码而且使用的是g0栈,所以切换时需要首先切换栈然后再从runtime代码跳转某个goroutine的代码去执行(切换栈和跳转指令不能颠倒,因为跳转之后执行的就是用户的goroutine代码了,没有机会切换栈了),然而从某个goroutine切换回g0时,goroutine使用的是call指令来调用mcall函数,mcall函数本身就是runtime的代码,所以call指令其实已经完成了从goroutine代码到runtime代码的跳转,因此mcall函数自身的代码就不需要再跳转了,只需要把栈切换到g0栈即可。

因为mcall跟gogo非常相似,前面我们对gogo的每一条指令已经做过详细的分析,所以这里就不再详细解释mcall的每一条指令了,但笔者在上面所展示的mcall代码中做了一些注释(注释中的g表示当前正在运行的goroutine,我们这个场景g就是g2),这里大家可以结合gogo的代码以及mcall的代码和注释来加深对g0与其它goroutine之间的切换的理解。

从g2栈切换到g0栈之后,下面开始在g0栈执行goexit0函数,该函数完成最后的清理工作:

  1. 把g的状态从_Grunning变更为_Gdead;

  2. 然后把g的一些字段清空成0值;

  3. 调用dropg函数解除g和m之间的关系,其实就是设置g->m = nil, m->currg = nil;

  4. 把g放入p的freeg队列缓存起来供下次创建g时快速获取而不用从内存分配。freeg就是g的一个对象池;

  5. 调用schedule函数再次进行调度;

runtime/proc.go : 2662

// goexit continuation on g0.
func goexit0(gp*g) {
_g_ := getg() //g0 casgstatus(gp, _Grunning, _Gdead) //g马上退出,所以设置其状态为_Gdead
if isSystemGoroutine(gp, false) {
atomic.Xadd(&sched.ngsys, -1)
} //清空g保存的一些信息
gp.m=nil
locked:=gp.lockedm!=0
gp.lockedm=0
_g_.m.lockedg=0
gp.paniconfault=false
gp._defer=nil// should be true already but just in case.
gp._panic=nil// non-nil for Goexit during panic. points at stack-allocated data.
gp.writebuf=nil
gp.waitreason=0
gp.param=nil
gp.labels=nil
gp.timer=nil ...... // Note that gp's stack scan is now "valid" because it has no
// stack.
gp.gcscanvalid=true //g->m = nil, m->currg = nil 解绑g和m之关系
dropg() ...... gfput(_g_.m.p.ptr(), gp) //g放入p的freeg队列,方便下次重用,免得再去申请内存,提高效率 ...... //下面再次调用schedule
schedule()
}

到此为止g2的生命周期就结束了,工作线程再次调用了schedule函数进入新一轮的调度循环。

调度循环

我们说过,任何goroutine被调度起来运行都是通过schedule()->execute()->gogo()这个函数调用链完成的,而且这个调用链中的函数一直没有返回。以我们刚刚讨论过的g2 goroutine为例,从g2开始被调度起来运行到退出是沿着下面这条路径进行的

schedule()->execute()->gogo()->g2()->goexit()->goexit1()->mcall()->goexit0()->schedule()

可以看出,一轮调度是从调用schedule函数开始的,然后经过一系列代码的执行到最后又再次通过调用schedule函数来进行新一轮的调度,从一轮调度到新一轮调度的这一过程我们称之为一个调度循环,这里说的调度循环是指某一个工作线程的调度循环,而同一个Go程序中可能存在多个工作线程,每个工作线程都有自己的调度循环,也就是说每个工作线程都在进行着自己的调度循环。

从前面的代码分析可以得知,上面调度循环中的每一个函数调用都没有返回,虽然g2()->goexit()->goexit1()->mcall()这几个函数是在g2的栈空间执行的,但剩下的函数都是在g0的栈空间执行的,那么问题就来了,在一个复杂的程序中,调度可能会进行无数次循环,也就是说会进行无数次没有返回的函数调用,大家都知道,每调用一次函数都会消耗一定的栈空间,而如果一直这样无返回的调用下去无论g0有多少栈空间终究是会耗尽的,那么这里是不是有问题?其实没有问题,关键点就在于,每次执行mcall切换到g0栈时都是切换到g0.sched.sp所指的固定位置,这之所以行得通,正是因为从schedule函数开始之后的一系列函数永远都不会返回,所以重用这些函数上一轮调度时所使用过的栈内存是没有问题的。

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

总结

我们用上图来总结一下工作线程的执行流程:

  1. 初始化,调用mstart函数;

  2. 调用mstart1函数,在该函数中调用save函数设置g0.sched.sp和g0.sched.pc等调度信息,其中g0.sched.sp指向mstart函数栈帧的栈顶;

  3. 依次调用schedule->execute->gogo函数执行调度;

  4. 运行用户的goroutine代码;

  5. 用户goroutine代码执行过程中调用runtime中的某些函数,然后这些函数调用mcall切换到g0.sched.sp所指的栈并最终再次调用schedule函数进入新一轮调度,之后工作线程一直循环执行着3~5这一调度循环直到进程退出为止。

非main goroutine的退出及调度循环(15)的更多相关文章

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

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

  2. Golang源码学习:调度逻辑(三)工作线程的执行流程与调度循环

    本文内容主要分为三部分: main goroutine 的调度运行 非 main goroutine 的退出流程 工作线程的执行流程与调度循环. main goroutine 的调度运行 runtim ...

  3. Golang源码学习:调度逻辑(二)main goroutine的创建

    接上一篇继续分析一下runtime.newproc方法. 函数签名 newproc函数的签名为 newproc(siz int32, fn *funcval) siz是传入的参数大小(不是个数):fn ...

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

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

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

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

  6. C++ 退出双层for循环,解决 break、return、continue无法实现问题

    遇到一个情景,采用双层for循环 遍历图像的像素,当找到某一个像素点满足条件时,退出双层for 循环 . 首先了解一下 continue.break.return 各自功能用法: 1.continue ...

  7. twisted 模拟scrapy调度循环

    """模拟scrapy调度循环 """from ori_test import pr_typeimport loggingimport ti ...

  8. Qt窗口退出与事件循环退出的问题

    我在Qt主程序中开启一个线程,线程中使用信号-槽来产生QMainWindow(GUI),main函数代码如下:int main(int argc, char *argv[]){ QApplicatio ...

  9. 优雅的退出asyncio事件循环

    import asyncio import functools import os import signal """ 信号值 符号 行为 2 SIGINT 进程终端,C ...

随机推荐

  1. 浅谈this指向问题

    链接地址:https://www.jianshu.com/p/34572435b5d0

  2. day66_10_10,vue项目环境搭建

    一.下载. 首先去官网查看网址. 下载vue环境之前需要先下载node,使用应用商城npm下载,可以将其下载源改成cnpm: """ node ~~ python:nod ...

  3. Jenkins根据svn版本号进行构建

    在svn版本url后面加上“@svn版本号”,如@2105 原文:https://blog.csdn.net/jlminghui/article/details/40426849

  4. CF1225B1 TV Subscriptions (Easy Version)

    CF1225B1 TV Subscriptions (Easy Version) 洛谷评测传送门 题目描述 The only difference between easy and hard vers ...

  5. mysql group by 的用法解析

    1. group by的常规用法 group by的常规用法是配合聚合函数,利用分组信息进行统计,常见的是配合max等聚合函数筛选数据后分析,以及配合having进行筛选后过滤. 聚合函数max se ...

  6. 树莓派包含python2.7系统路径

  7. <LinkedList> 369 (高)143 (第二遍)142 148

    369. Plus One Linked List 1.第1次while: 从前往后找到第一个不是9的位,记录. 2.第2次while: 此位+1,后面的所有值设为0(因为后面的位都是9). 返回时注 ...

  8. 【LOJ2838】「JOISC 2018 Day 3」比太郎的聚会(设阈值预处理/分块)

    点此看题面 大致题意: 给你一张\(DAG\),多组询问,每次问你在起点不为某些点的前提下,到达给定终点的最大距离是多少. 设阈值 由于限制点数总和与\(n\)同阶,因此容易想到去设阈值. 对于限制点 ...

  9. C++ 基于rapidjson对json字符串的进行序列化与反序列化

    json字符串的解析以封装在我们开发过程中经常见到, 尤其在socket通信上面, 在一次项目中碰到json字符串的进行解析, 而公司有没有封装好的库, 于是就自己基于开源的库进行了一次封装, 接下是 ...

  10. QSS QPushButton:hover :pressed ...为状态下变更字体颜色(color)无效,变成字体粗细(font-weight)有效???

    //字体颜色变更无效 QPushButton:hover{ font-weight:bold; color:rgba(, , , ); } //字体颜色变更有效 QPushButton#pushBut ...