非main goroutine的退出及调度循环(15)

本文是《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 7.
(gdb) r
Startingprogram:/home/bobo/study/go/goexit
Thread1"goexit"hit Breakpoint 1 at /home/bobo/study/go/goexit.go:7
(gdb) bt       //查看函数调用链,看起来g2真的是被runtime.goexit调用的
#0 main.g2 (n=100, ch=0xc000052060) at /home/bobo/study/go/goexit.go:7
#1 0x0000000000450ad1 in runtime.goexit () at /usr/local/go/src/runtime/asm_amd64.s:1337
(gdb) disass     //反汇编找ret的地址,这是为了在ret处下断点
Dumpofassemblercodeforfunctionmain.g2:
=> 0x00000000004869c0 <+0>:mov   %fs:0xfffffffffffffff8,%rcx
  0x00000000004869c9<+9>:cmp   0x10(%rcx),%rsp
  0x00000000004869cd<+13>:jbe   0x486a0d <main.g2+77>
  0x00000000004869cf<+15>:sub   $0x20,%rsp
  0x00000000004869d3<+19>:mov   %rbp,0x18(%rsp)
  0x00000000004869d8<+24>:lea   0x18(%rsp),%rbp
  0x00000000004869dd<+29>:mov   0x28(%rsp),%rax
  0x00000000004869e2<+34>:imul   %rax,%rax
  0x00000000004869e6<+38>:mov   %rax,0x10(%rsp)
  0x00000000004869eb<+43>:mov   0x30(%rsp),%rax
  0x00000000004869f0<+48>:mov   %rax,(%rsp)
  0x00000000004869f4<+52>:lea   0x10(%rsp),%rax
  0x00000000004869f9<+57>:mov   %rax,0x8(%rsp)
  0x00000000004869fe<+62>:callq 0x4046a0 <runtime.chansend1>
  0x0000000000486a03<+67>:mov   0x18(%rsp),%rbp
  0x0000000000486a08<+72>:add   $0x20,%rsp
  0x0000000000486a0c<+76>:retq
  0x0000000000486a0d<+77>:callq 0x44ece0 <runtime.morestack_noctxt>
  0x0000000000486a12<+82>:jmp   0x4869c0 <main.g2>
Endofassemblerdump.
(gdb) b *0x0000000000486a0c             //在retq指令位置下断点
Breakpoint2at0x486a0c:file/home/bobo/study/go/goexit.go, line 9.
(gdb) c
Continuing.

Thread1"goexit"hit Breakpoint 2 at /home/bobo/study/go/goexit.go:9
(gdb) disass             //程序停在了ret指令处
Dumpofassemblercodeforfunctionmain.g2:
  0x00000000004869c0<+0>:mov   %fs:0xfffffffffffffff8,%rcx
  0x00000000004869c9<+9>:cmp   0x10(%rcx),%rsp
  0x00000000004869cd<+13>:jbe   0x486a0d <main.g2+77>
  0x00000000004869cf<+15>:sub   $0x20,%rsp
  0x00000000004869d3<+19>:mov   %rbp,0x18(%rsp)
  0x00000000004869d8<+24>:lea   0x18(%rsp),%rbp
  0x00000000004869dd<+29>:mov   0x28(%rsp),%rax
  0x00000000004869e2<+34>:imul   %rax,%rax
  0x00000000004869e6<+38>:mov   %rax,0x10(%rsp)
  0x00000000004869eb<+43>:mov   0x30(%rsp),%rax
  0x00000000004869f0<+48>:mov   %rax,(%rsp)
  0x00000000004869f4<+52>:lea   0x10(%rsp),%rax
  0x00000000004869f9<+57>:mov   %rax,0x8(%rsp)
  0x00000000004869fe<+62>:callq 0x4046a0 <runtime.chansend1>
  0x0000000000486a03<+67>:mov   0x18(%rsp),%rbp
  0x0000000000486a08<+72>:add   $0x20,%rsp
=> 0x0000000000486a0c <+76>:retq
  0x0000000000486a0d<+77>:callq 0x44ece0 <runtime.morestack_noctxt>
  0x0000000000486a12<+82>:jmp   0x4869c0 <main.g2>
Endofassemblerdump.
(gdb) si        //单步执行一条指令
runtime.goexit () at /usr/local/go/src/runtime/asm_amd64.s:1338
1338CALLruntime·goexit1(SB)// does not return
(gdb) disass           //可以看出来g2已经返回到了goexit函数中
Dumpofassemblercodeforfunctionruntime.goexit:
  0x0000000000450ad0<+0>:nop
=> 0x0000000000450ad1 <+1>:callq 0x42faf0 <runtime.goexit1>
  0x0000000000450ad6<+6>: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,$0-0
    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+0(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这一调度循环直到进程退出为止。

原文地址:https://www.cnblogs.com/abozhang/p/10856858.html

时间: 2024-10-18 13:38:51

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

循环-15. 统计素数并求和

循环-15. 统计素数并求和(20) 时间限制 400 ms 内存限制 65536 kB 代码长度限制 8000 B 判题程序 Standard 作者 张彤彧(浙江大学) 本题要求统计给定整数M和N区间内素数的个数并对它们求和. 输入格式: 输入在一行中给出2个正整数M和N(1<=M<=N<=500). 输出格式: 在一行中顺序输出M和N区间内素数的个数以及它们的和,数字间以空格分隔. 输入样例: 10 31 输出样例: 7 143 1 #include<stdio.h> 2

循环-15. 统计素数并求和(20)

1 #include<iostream> 2 #include<cmath> 3 using namespace std; 4 bool isPrime(int n){ 5 int i; 6 for(i=2;i<=sqrt(n);++i) 7 if(n%i==0) 8 break; 9 if(n>=2&&i>sqrt(n)) 10 return true; 11 else 12 return false; 13 } 14 int main(){ 1

优雅的退出asyncio事件循环

import asyncio import functools import os import signal """ 信号值 符号 行为 2 SIGINT 进程终端,CTRL+C 9 SIGKILL 强制终端 15 SIGTEM 请求中断 20 SIGTOP 停止(挂起)进程 CRTL+D """ def exit(sign_name): print(f"获取信号{sign_name}: exit") loop.stop()

【非原创】 珠心算测验(循环?)

#include<stdio.h>//第一次,20分main(){ int n,a[101],b,k,ans=0; scanf("%d",&n); for(int x=0;x<n;x++) { scanf("%d",&a[x]); } for(int x=0;x<n-1;x++) { k=x; for(int i=1;i<n-2;i++) { b=a[k]+a[i]; for(int j=0;j<n;j++)//w

湘潭大学oj循环1-5

#include <stdio.h>#include <stdlib.h> int main(){   int b,s,n;    int a[101]; A:scanf("%d",&n);    s=0;    if(n!=0)    {        for(b=1;b<=n;b++)    {   scanf("%d",&a[b]); if(a[b]%2==0)       s=s+a[b]; }       pr

第三章 Goroutine调度策略(16)

本文是<Go语言调度器源代码情景分析>系列的第16篇,也是第三章<Goroutine调度策略>的第1小节. 在调度器概述一节我们提到过,所谓的goroutine调度,是指程序代码按照一定的算法在适当的时候挑选出合适的goroutine并放到CPU上去运行的过程.这句话揭示了调度系统需要解决的三大核心问题: 调度时机:什么时候会发生调度? 调度策略:使用什么策略来挑选下一个进入运行的goroutine? 切换机制:如何把挑选出来的goroutine放到CPU上运行? 对这三大问题的解

Golang Channel用法简编

转自:http://tonybai.com/2014/09/29/a-channel-compendium-for-golang/ 在进入正式内容前,我这里先顺便转发一则消息,那就是Golang 1.3.2已经正式发布了.国内的golangtc已经镜像了golang.org的安装包下载页面,国内go程序员与爱好者们可以到"Golang中 国",即golangtc.com去下载go 1.3.2版本. Go这门语言也许你还不甚了解,甚至是完全不知道,这也有情可原,毕竟Go在TIOBE编程语

golang channel 用法

一.Golang并发基础理论 Golang在并发设计方面参考了C.A.R Hoare的CSP,即Communicating Sequential Processes并发模型理论.但就像John Graham-Cumming所说的那样,多数Golang程序员或爱好者仅仅停留在“知道”这一层次,理解CSP理论的并不多,毕竟多数程序员是搞工程 的.不过要想系统学习CSP的人可以从这里下载到CSP论文的最新版本. 维基百科中概要罗列了CSP模型与另外一种并发模型Actor模型的区别: Actor模型广义

go语言之行--golang核武器goroutine调度原理、channel详解

一.goroutine简介 goroutine是go语言中最为NB的设计,也是其魅力所在,goroutine的本质是协程,是实现并行计算的核心.goroutine使用方式非常的简单,只需使用go关键字即可启动一个协程,并且它是处于异步方式运行,你不需要等它运行完成以后在执行以后的代码. go func()//通过go关键字启动一个协程来运行函数 二.goroutine内部原理 概念介绍 在进行实现原理之前,了解下一些关键性术语的概念. 并发 一个cpu上能同时执行多项任务,在很短时间内,cpu来