一、基础知识

1.1 Linux 虚拟地址空间布局

我们知道CPU有实模式和保护模式,系统刚刚启动的时候是运行在实模式下,然后经过一系列初始化工作以后,Linux会把CPU的实模式改为保护模式(具体就是修改CPUCR0寄存器相关标记位),在保护模式下,CPU访问的地址都是虚拟地址(逻辑地址)。Linux 为了每个进程维护了一个单独的虚拟地址空间,虚拟地址空间又分为“用户空间”和“内核空间”。 虚拟地址空间更多相关可以看Linux内核虚拟地址空间这篇文章。

Linux-Memory-X86-64.jpg


1.2 Golang 栈和虚拟地址空间栈的区别

Golang 的内存管理是用的 TCMallocThread-Caching Malloc)算法, 简单点说就是 Golang 是使用 mmap 函数去操作系统申请一大块内存,然后把内存按照 8Byte~32KB 67size 类型的 mspan,每个 mspan按照它自身的属性 Size Class 的大小分割成若干个 object(每个span默认是8K),因为分需要 gcmspan 和不需要 gcmspanGolangstack),所以一共有134种类型。

上面说了 Golang 内存申请是用的 mmap,mmap申请的内存都在虚拟地址空间的“Memory Mapping Segment”,所有Golang 所有的内存使用(包括栈)都是在“Memory Mapping Segment”,并不是在传统应用地址空间划的栈区。

Demo个代码验证一下

func main() {
    a := 8
    println("a address : ", &a)
    println("pid : ", os.Getpid())
    time.Sleep(time.Hour)
}

// 这里加“-m”来check没有内存逃逸
[root@n227-005-021 stack ]$ go build -gcflags "-N -l -m"  demo.go
[root@n227-005-021 stack ]$ ./demo
a address :  0xc00006ef60
pid :  1710242

pmap看进程的内存区域,可以看到stack的起始地址是7ffe46cfa000a地址是0xc00006ef60很显然不在虚地址空间的栈区。000000c000000000 属于[ anon ],这个一般代码mmap映射区。

[root@n227-005-021 stack ]$ pmap  1710242
1710242:   ./demo
0000000000400000    416K r-x-- demo
0000000000468000    472K r---- demo
00000000004de000     20K rw--- demo
00000000004e3000    200K rw---   [ anon ]
000000c000000000  65536K rw---   [ anon ]
00007f10068a1000  36548K rw---   [ anon ]
00007f1008c52000 263680K -----   [ anon ]
00007f1018dd2000      4K rw---   [ anon ]
00007f1018dd3000 293564K -----   [ anon ]
00007f102ac82000      4K rw---   [ anon ]
00007f102ac83000  36692K -----   [ anon ]
00007f102d058000      4K rw---   [ anon ]
00007f102d059000   4580K -----   [ anon ]
00007f102d4d2000      4K rw---   [ anon ]
00007f102d4d3000    508K -----   [ anon ]
00007f102d552000    384K rw---   [ anon ]
00007ffe46cfa000    132K rw---   [ stack ]
00007ffe46dbf000     12K r----   [ anon ]
00007ffe46dc2000      8K r-x--   [ anon ]

allocate_stack

1.3 常用寄存器

寄存器 64位名称 32位名称 16位名称 用途
AX rax eax ax 函数返回值一般都存这个里面
BX rbx ebx bx 基址(Base)寄存器,常做内存数据的指针, 或者说常以它为基址来访问内存.
CX rcx ecx cx 计数器(Counter)寄存器,常做字符串和循环操作中的计数器, 或者用于保存函数调用第4个参数
DX rdx edx dx 数据(Data)寄存器,常用于乘、除法和 I/O 指针,或者用于保存函数调用第3个参数
SI rsi esi si 来源索引(Source Index)寄存器,常做内存数据指针和源字符串指针,或者用于保存函数调用第2个参数
DI rdi edi di 目的索引(Destination Index)寄存器,或者用于保存函数调用第1个参数 ,常做内存数据指针和目的字符串指针
SP rsp esp sp 堆栈指针(Stack Point)寄存器,只做堆栈的栈顶指针; 不能用于算术运算与数据传送
BP rbp ebp bp 基址指针(Base Point)寄存器,只做堆栈指针, 可以访问堆栈内任意地址, 经常用于中转 ESP 中的数据, 也常以它为基址来访问堆栈; 不能用于算术运算与数据传送
IP rip eip ip 指令指针(Instruction Pointer)寄存器,总是指向下一条指令的地址; 所有已执行的指令都被它指向过.

还有 R8 ~ R15 8个寄存器这里就不详细列出来了, R8用于保存函数调用5个参数,R9用于保存函数调用6个参数

虽然在x86-64架构下,增加了很多通用寄存器,使得调用惯例(calling convention)变为函数传参可以部分(最多6个)使用寄存器直接传递,但是在Golang中,编译器强制规定函数的传参全部都用栈传递,不使用寄存器传参。go 1.17以后好像已经开始可以用寄存器传参。

Golang不使用寄存器传参,应该还是为了使生成的伪汇编方便跨平台。这里扩展一个优化点,为了减少函数调用的开销小,可以尽量让函数内联。

内联的条件:1. 函数语法解析完,token数不超过 80. 2. Interface 的方法调用,不能内联。

1.4 调用规约

栈帧,也就是stack frame,其本质就是一种栈,只是这种栈专门用于保存函数调用过程中的各种信息(参数,返回地址,本地变量等)。栈帧有栈顶和栈底之分,其中栈顶的地址最低,栈底的地址最高用,BP(栈指针)指向栈底,SP(栈指针)指向栈顶的。

具体实现,我们看一个Demo

#include <stdio.h>
#include <stdlib.h>
int add(int x, int y)
{
    return x + y;
}
int main()
{
    int p = add(2, 6);
    printf("add:%d\n", p);
    return 0;
}

clang add.c -o add.o // 编译,Linux可以用 gcc add.c -o add.o
otool -tvV add.o //导出汇编代码 Linux可以用 objdump -d -M at -S add.o

add.o:
(__TEXT,__text) section
_add:
0000000100003f20    pushq    %rbp
0000000100003f21    movq    %rsp, %rbp
0000000100003f24    movl    %edi, -0x4(%rbp)
0000000100003f27    movl    %esi, -0x8(%rbp)
0000000100003f2a    movl    -0x4(%rbp), %eax
0000000100003f2d    addl    -0x8(%rbp), %eax
0000000100003f30    popq    %rbp
0000000100003f31    retq
0000000100003f32    nopw    %cs:(%rax,%rax)
0000000100003f3c    nopl    (%rax)
_main:
0000000100003f40    pushq    %rbp
0000000100003f41    movq    %rsp, %rbp
0000000100003f44    subq    $0x10, %rsp
0000000100003f48    movl    $0x0, -0x4(%rbp)
0000000100003f4f    movl    $0x2, %edi
0000000100003f54    movl    $0x6, %esi
0000000100003f59    callq    _add
0000000100003f5e    movl    %eax, -0x8(%rbp)
0000000100003f61    movl    -0x8(%rbp), %esi
0000000100003f64    leaq    0x37(%rip), %rdi                ##literal pool for: "add:%d\n"
0000000100003f6b    movb    $0x0, %al
0000000100003f6d    callq    0x100003f80                     ##symbol stub for: _printf
0000000100003f72    xorl    %ecx, %ecx
0000000100003f74    movl    %eax, -0xc(%rbp)
0000000100003f77    movl    %ecx, %eax
0000000100003f79    addq    $0x10, %rsp
0000000100003f7d    popq    %rbp
0000000100003f7e    retq

我们接着lldb add.o Debug一下。我们在Add函数最开始的地方0000000100003f20打上断点,看下调用add过程栈的变化。我们执行x/8xg $rbp先打印下rbp,由下面可以知rbp的地址是0x7ffeefbff580 rbp地址指向的内容是0x00007ffeefbff590

(lldb) s
Process 43672 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
    frame #0: 0x0000000100003f20 add.o`add
add.o`add:
->  0x100003f20 <+0>: pushq  %rbp // 把rbp地址压栈
    0x100003f21 <+1>: movq   %rsp, %rbp // rbp = rsp
    0x100003f24 <+4>: movl   %edi, -0x4(%rbp) // $(rbp-4) = 2
    0x100003f27 <+7>: movl   %esi, -0x8(%rbp) // $(rbp-8) = 6
Target 0: (add.o) stopped.
(lldb) x/8xg $rbp
0x7ffeefbff580: 0x00007ffeefbff590 0x00007fff203fbf3d
0x7ffeefbff590: 0x0000000000000000 0x0000000000000001

执行完movl %esi, -0x8(%rbp) 我们再x/8xg $rbp 看下rbp地址,发现rbp地址变成了0x7ffeefbff560 rbp指向了0x00007ffeefbff580

(lldb) s
Process 43672 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
    frame #0: 0x0000000100003f2a add.o`add + 10
add.o`add:
->  0x100003f2a <+10>: movl   -0x4(%rbp), %eax
    0x100003f2d <+13>: addl   -0x8(%rbp), %eax
    0x100003f30 <+16>: popq   %rbp
    0x100003f31 <+17>: retq
Target 0: (add.o) stopped.
(lldb)  x/8xg $rbp
0x7ffeefbff560: 0x00007ffeefbff580 0x0000000100003f5e
0x7ffeefbff570: 0x00007ffeefbff590 0x0000000000011025

我们在看下 $rbp-8,发现 $rbp-8的位置数据存的是6 $rbp-4位置存的数据是2$rbp数据存的是main函数的rbp地址,$rbp+8的地址是 add 函数返回以后需要执行的代码地址0x00003f5e

(lldb)  x/8xw $rbp-8
0x7ffeefbff558: 0x00000006 0x00000002 0xefbff580 0x00007ffe
0x7ffeefbff568: 0x00003f5e 0x00000001 0xefbff590 0x00007ffe

栈的图大致如下

image.png

这个栈帧的图里面我们主要关注几点。

  1. 栈是由高地址像低地址扩张,比如main函数里面执行subq $0x10, %rsp 就是申请16个字节的栈空间,执行完add函数再调用addq $0x10, %rsp 表示释放之前申请的16个字节栈空间。
  2. 每次函数调用都会有一定的栈空间的开销,(老的rbp压栈、函数的返回地址压栈)
  3. 函数执行完以后。会继续执行return address指向的代码。

这里要理解栈帧是向下扩展的很重要。下面golang的stack扩容缩容判断的时候会用到

1.5 什么是内存逃逸?

逃逸分析是一种确定指针动态范围的方法。简单来说就是分析在程序的哪些地方可以访问到该指针。编译器会根据变量是否被外部引用来决定是否逃逸:

  1. 如果函数外部没有引用,则优先放到栈中;
  2. 如果函数外部存在引用,则必定放到堆中;

注意:go 在编译阶段确立逃逸,并不是在运行时。

1.5.1 逃逸场景(什么情况才分配到堆中)

  1. 指针逃逸,Go可以返回局部变量指针,这其实是一个典型的变量逃逸案例,示例代码如下:

     func StudentRegister(name string, age int) *Student {
         s := new(Student) //局部变量s逃逸到堆
         s.Name = name
         s.Age = age
         return s
     }
     
    
  2. 栈空间不足逃逸(空间开辟过大),下面代码Slice()函数中分配了一个1000个长度的切片,是否逃逸取决于栈空间是否足够大。如下:

     func Slice() { 
         s := make([]int, 1000, 1000) // s 会分配在堆上
     
         for index, _ := range s {
             s[index] = index
         }
     }
    
  3. 动态类型逃逸(不确定长度大小)。很多函数参数为interface类型,比如fmt.Println(a …interface{}),编译期间很难确定其参数的具体类型,也能产生逃逸。

     func main() {
         s := "Escape"
         fmt.Println(s)
     }
    
  4. 闭包引用对象逃逸

     func Fibonacci() func() int {
         a, b := 0, 1
         return func() int {
             a, b = b, a+b
             return a
         }
     }
    

可以使用go build -gcflags=-m查看逃逸分析日志

二、Golang 栈

2.1 Golang 栈大小变更历史

Go 语言使用用户态线程 Goroutine 作为执行上下文,它的额外开销和默认栈大小都比线程小很多,然而 Goroutine 的栈内存空间和栈结构也在经过很多次变化:

第一版提交sys·newproc 栈默认是4Kmalg commit (早期Golangruntime代码是用c写的)

第二次修改 4K -> 8K,主要是提高部分encodedecode的性能

第三次修改 8K -> 4K runtime: grow stack by copying

第四次修改 4K -> 8K runtime: switch default stack size back to 8kB

最后、将最小栈内存从8K降低到了2KB change minimum stack size to 2K,主要是为了节省内存空间使用。

v1.0 ~ v1.1 — 最小栈内存空间为 4KB;
v1.2 — 将最小栈内存提升到了 8KB;
v1.3 — 使用连续栈替换之前版本的分段栈;
v1.4 — 将最小栈内存降低到了 2KB;

2.2 分段栈

分段栈是 Go 语言在 v1.3 版本之前的实现,所有 Goroutine 在栈扩容的时候都会调用runtime·newstack:go1.2 分配的内存为StackMin + StackSystem 表示,在 v1.2 版本中为StackMin 8KB

// Called from runtime·newstackcall or from runtime·morestack when a new
// stack segment is needed.  Allocate a new stack big enough for
// m->moreframesize bytes, copy m->moreargsize bytes to the new frame,
// and then act as though runtime·lessstack called the function at
// m->morepc.
void
runtime·newstack(void)
{
   ...........
    // gp->status is usually Grunning, but it could be Gsyscall if a stack split
    // happens during a function call inside entersyscall.
    gp = m->curg;
    oldstatus = gp->status;

    framesize = m->moreframesize;
    argsize = m->moreargsize;
    gp->status = Gwaiting;
    gp->waitreason = "stack split";
    newstackcall = framesize==1;
    if(newstackcall)
        framesize = 0;
   
   
    if(newstackcall && m->morebuf.sp - sizeof(Stktop) - argsize - 32 > gp->stackguard) {
       .........
    } else {
        // 这里计算栈的空间大小
        // allocate new segment.
        framesize += argsize;
        framesize += StackExtra;    // room for more functions, Stktop.
        if(framesize < StackMin)
            framesize = StackMin; // 栈最小8K
        framesize += StackSystem; // StackSystem  Window-64 是4K,plan9 是512,其他平台是0
        gp->stacksize += framesize;
        if(gp->stacksize > runtime·maxstacksize) { // maxstacksize x64是 1G,x32是250m
            runtime·printf("runtime: goroutine stack exceeds %D-byte limit\n", (uint64)runtime·maxstacksize);
            runtime·throw("stack overflow");
        }
        stk = runtime·stackalloc(framesize);
        top = (Stktop*)(stk+framesize-sizeof(*top));
        free = framesize;
    }
}    

..............


void*
runtime·stackalloc(uint32 n)
{
    uint32 pos;
    void *v;

    if(g != m->g0)
        runtime·throw("stackalloc not on scheduler stack");

    if(n == FixedStack || m->mallocing || m->gcing) {
        if(n != FixedStack) {
            runtime·printf("stackalloc: in malloc, size=%d want %d\n", FixedStack, n);
            runtime·throw("stackalloc");
        }
        if(m->stackcachecnt == 0)
            stackcacherefill();
        pos = m->stackcachepos;
        pos = (pos - 1) % StackCacheSize;
        v = m->stackcache[pos];
        m->stackcachepos = pos;
        m->stackcachecnt--;
        m->stackinuse++;
        return v;
    }
    
    // https://github.com/golang/go/blob/go1.2/src/pkg/runtime/malloc.goc#L34
    // 这里调用 runtime·mallocgc 去申请内存,指定内存不需要GC
    return runtime·mallocgc(n, 0, FlagNoProfiling|FlagNoGC|FlagNoZero|FlagNoInvokeGC);
}

如果通过该方法申请的内存大小为固定的 8KB 或者满足其他的条件,运行时会在全局的栈缓存链表中找到空闲的内存块并作为新 Goroutine 的栈空间返回;在其余情况下,栈内存空间会从堆上申请一块合适的内存。

Goroutine 调用的函数层级或者局部变量需要的越来越多时,运行时会调用 runtime.morestack:go1.2runtime.newstack:go1.2 创建一个新的栈空间,这些栈空间虽然不连续,但是当前 Goroutine 的多个栈空间会以链表的形式串联起来,运行时会通过指针找到连续的栈片段:

一旦 Goroutine 申请的栈空间不在被需要,运行时会调用 runtime.lessstack:go1.2runtime.oldstack:go1.2 释放不再使用的内存空间。

分段栈机制虽然能够按需为当前 Goroutine 分配内存并且及时减少内存的占用,但是它也存在两个比较大的问题:

  1. 如果当前 Goroutine 的栈几乎充满,那么任意的函数调用都会触发栈扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为热分裂问题(Hot split)
  2. 一旦 Goroutine 使用的内存越过了分段栈的扩缩容阈值,运行时会触发栈的扩容和缩容,带来额外的工作量;

2.3 连续栈

2.3.1 什么是连续栈

连续栈可以解决分段栈中存在的两个问题,其核心原理是每当程序的栈空间不足时,初始化一片更大的栈空间并将原栈中的所有值都迁移到新栈中,新的局部变量或者函数调用就有充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:

  1. 在内存空间中分配更大的栈内存空间;
  2. 将旧栈中的所有内容复制到新栈中;
  3. 将指向旧栈对应变量的指针重新指向新栈;
  4. 销毁并回收旧栈的内存空间;

在扩容的过程中,最重要的是调整指针的第三步,这一步能够保证指向栈的指针的正确性,因为栈中的所有变量内存都会发生变化,所以原本指向栈中变量的指针也需要调整。我们在前面提到过经过逃逸分析的 Go 语言程序的遵循以下不变性 —— 指向栈对象的指针不能存在于堆中,所以指向栈中变量的指针只能在栈上,我们只需要调整栈中的所有变量就可以保证内存的安全了。

2.3.2 NOSPLIT的自动检测

我们在编写函数时,编译出汇编代码会发现,在一些函数的执行代码中,编译器很智能的加上了NOSPLIT标记。这个标记可以禁用栈溢出检测prolog,即该函数运行不会导致栈分裂,由于不需要再照常执行栈溢出检测,所以会提升一些函数性能。这是如何做到的

// cmd/internal/obj/s390x/objz.go

if p.Mark&LEAF != 0 && autosize < objabi.StackSmall {
    // A leaf function with a small stack can be marked
    // NOSPLIT, avoiding a stack check.
    p.From.Sym.Set(obj.AttrNoSplit, true)
}

当函数处于调用链的叶子节点,且栈帧小于StackSmall字节时,则自动标记为NOSPLITx86架构处理与之类似

自动标记为NOSPLIT的函数,链接器就会知道该函数最多还会使用StackLimit字节空间,不需要栈分裂。

备注:用户也可以使用//go:nosplit强制指定NOSPLIT属性,但如果函数实际真的溢出了,则会在编译期就报错nosplit stack overflow

$ GOOS=linux GOARCH=amd64 go build -gcflags="-N -l" main.go
#command-line-arguments
main.add: nosplit stack overflow
    744    assumed on entry to main.add (nosplit)
    -79264    after main.add (nosplit) uses 80008

2.3.3 Goroutine的创建

程序中执行 go func(){} 创建Goroutine的时候,runtime会执行newproc1,这里会先去_p_.gFree列表拿,如果没有空闲的g就会调用malg 去new一个。

// https://github.com/golang/go/blob/go1.16.6/src/runtime/proc.go#L4065

func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
   ..............
   
    newg := gfget(_p_) // 先去P的free list拿 没有的话就调用malg new一个g
    if newg == nil {
        newg = malg(_StackMin)
        casgstatus(newg, _Gidle, _Gdead)
        allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
    }
}

new 一个新的Goroutine的时候,会调用stackalloc来申请栈空间。

//https://github.com/golang/go/blob/go1.16.6/src/runtime/proc.go#L3987
// Allocate a new g, with a stack big enough for stacksize bytes.
func malg(stacksize int32) *g {
    newg := new(g)
    if stacksize >= 0 {
        // round2 是求2的指数,比如传 6 返回 8
        // _StackSystem linux是0、plan9 是512 、 Windows-x64 是4k
        stacksize = round2(_StackSystem + stacksize)
        systemstack(func() {//切换到 G0 为 newg 初始化栈内存
            newg.stack = stackalloc(uint32(stacksize))
        })
        // 设置 stackguard0 ,用来判断是否要进行栈扩容
        newg.stackguard0 = newg.stack.lo + _StackGuard
        newg.stackguard1 = ^uintptr(0)
        // Clear the bottom word of the stack. We record g
        // there on gsignal stack during VDSO on ARM and ARM64.
        *(*uintptr)(unsafe.Pointer(newg.stack.lo)) = 0
    }
    return newg
}

每一个Goroutineg->stackguard0都被设置为指向stack.lo + StackGuard的位置。所以每一个函数在真正执行前都会将SPstackguard0进行比较。


2.3.4 栈的初始化

// Number of orders that get caching. Order 0 is FixedStack
// and each successive order is twice as large.
// We want to cache 2KB, 4KB, 8KB, and 16KB stacks. Larger stacks
// will be allocated directly.
// Since FixedStack is different on different systems, we
// must vary NumStackOrders to keep the same maximum cached size.
//   OS               | FixedStack | NumStackOrders
//   -----------------+------------+---------------
//   linux/darwin/bsd | 2KB        | 4
//   windows/32       | 4KB        | 3
//   windows/64       | 8KB        | 2
//   plan9            | 4KB        | 3
_NumStackOrders = 4 - sys.PtrSize/4*sys.GoosWindows - 1*sys.GoosPlan9


// 全局的栈缓存,分配 32KB以下内存
var stackpool [_NumStackOrders]struct {
    item stackpoolItem
    _    [cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize]byte // CacheLine对齐,防止Flase Sharding的问题
}

//go:notinheap
type stackpoolItem struct {
    mu   mutex
    span mSpanList 
}

// 全局的栈缓存,分配 32KB 以上内存 
// heapAddrBits 48 , pageShift 13
var stackLarge struct {
    lock mutex
    free [heapAddrBits - pageShift]mSpanList // free lists by log_2(s.npages)
}

func stackinit() {
    if _StackCacheSize&_PageMask != 0 {
        throw("cache size must be a multiple of page size")
    }
    for i := range stackpool {
        stackpool[i].item.span.init()
        lockInit(&stackpool[i].item.mu, lockRankStackpool)
    }
    for i := range stackLarge.free {
        stackLarge.free[i].init()
        lockInit(&stackLarge.lock, lockRankStackLarge)
    }
}

在执行栈初始化的时候会初始化两个全局变量 stackpoolstackLargestackpool 可以分配小于 32KB 的内存,stackLarge 用来分配大于 32KB 的栈空间。


2.3.5 栈内存分配

// https://github.com/golang/go/blob/go1.16.6/src/runtime/stack.go#L327

// Per-P, per order stack segment cache size.
_StackCacheSize = 32 * 1024
// stackalloc allocates an n byte stack.
//
// stackalloc must run on the system stack because it uses per-P
// resources and must not split the stack.
//
//go:systemstack
func stackalloc(n uint32) stack {
    // Stackalloc must be called on scheduler stack, so that we
    // never try to grow the stack during the code that stackalloc runs.
    // Doing so would cause a deadlock (issue 1547).
    thisg := getg() // 必须是g0
    if thisg != thisg.m.g0 {
        throw("stackalloc not on scheduler stack")
    }
    if n&(n-1) != 0 {
        throw("stack size not a power of 2")
    }
    if stackDebug >= 1 {
        print("stackalloc ", n, "\n")
    }

    if debug.efence != 0 || stackFromSystem != 0 {
        n = uint32(alignUp(uintptr(n), physPageSize))
        v := sysAlloc(uintptr(n), &memstats.stacks_sys)
        if v == nil {
            throw("out of memory (stackalloc)")
        }
        return stack{uintptr(v), uintptr(v) + uintptr(n)}
    }

    // Small stacks are allocated with a fixed-size free-list allocator.
    // If we need a stack of a bigger size, we fall back on allocating
    // a dedicated span.
    var v unsafe.Pointer
    // _FixedStack: 2K、_NumStackOrders:4 、_StackCacheSize = 32 * 1024
    if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize {// 小于32K
        order := uint8(0)
        n2 := n
        // 大于 2048 ,那么 for 循环 将 n2 除 2,直到 n 小于等于 2048
        for n2 > _FixedStack {
            order++
            n2 >>= 1
        }
        var x gclinkptr
        //preemptoff != "", 在 GC 的时候会进行设置,表示如果在 GC 那么从 stackpool 分配
        // thisg.m.p = 0 会在系统调用和 改变 P 的个数的时候调用,如果发生,那么也从 stackpool 分配
        if stackNoCache != 0 || thisg.m.p == 0 || thisg.m.preemptoff != "" {
            // thisg.m.p == 0 can happen in the guts of exitsyscall
            // or procresize. Just get a stack from the global pool.
            // Also don't touch stackcache during gc
            // as it's flushed concurrently.
            lock(&stackpool[order].item.mu)
            x = stackpoolalloc(order)// 从 stackpool 分配

            unlock(&stackpool[order].item.mu)
        } else {
        
            // 从 P 的 mcache 分配内存
            c := thisg.m.p.ptr().mcache
            x = c.stackcache[order].list
            if x.ptr() == nil {
                // 从堆上申请一片内存空间填充到stackcache中
                stackcacherefill(c, order)
                x = c.stackcache[order].list
            }
            c.stackcache[order].list = x.ptr().next // 移除链表的头节点
            c.stackcache[order].size -= uintptr(n)
        }
        v = unsafe.Pointer(x) // 获取到分配的span内存块
    } else {
        // 申请的内存空间过大,从 runtime.stackLarge 中检查是否有剩余的空间
        var s *mspan
        // 计算需要分配多少个 span 页, 8KB 为一页
        npage := uintptr(n) >> _PageShift
        log2npage := stacklog2(npage)

        // Try to get a stack from the large stack cache.
        lock(&stackLarge.lock)
        // 如果 stackLarge 对应的链表不为空
        if !stackLarge.free[log2npage].isEmpty() {
            //获取链表的头节点,并将其从链表中移除
            s = stackLarge.free[log2npage].first
            stackLarge.free[log2npage].remove(s)
        }
        unlock(&stackLarge.lock)

        lockWithRankMayAcquire(&mheap_.lock, lockRankMheap)
        //这里是stackLarge为空的情况
        if s == nil {
            // 从堆上申请新的内存 span
            // Allocate a new stack from the heap.
            s = mheap_.allocManual(npage, spanAllocStack)
            if s == nil {
                throw("out of memory")
            }
            // OpenBSD 6.4+ 系统需要做额外处理
            osStackAlloc(s)
            s.elemsize = uintptr(n)
        }
        v = unsafe.Pointer(s.base())
    }

    ..........
    
    return stack{uintptr(v), uintptr(v) + uintptr(n)}
}

小于32KB的栈内存分配

小栈指大小为 2K/4K/8K/16K 的栈,在分配的时候,会根据大小计算不同的 order 值,如果栈大小是 2K,那么 order 就是 0,4K 对应 order 就是 1,以此类推。这样一方面可以减少不同 Goroutine 获取不同栈大小的锁冲突,另一方面可以预先缓存对应大小的 span ,以便快速获取。

thisg.m.p == 0可能发生在系统调用 exitsyscall 或改变 P 的个数 procresize 时,thisg.m.preemptoff != ""会发生在 GC 的时候。也就是说在发生在系统调用 exitsyscall 或改变 P 的个数在变动,亦或是在 GC 的时候,会从 stackpool 分配栈空间,否则从 mcache 中获取。

stackpoolalloc 函数中会去找 stackpool 对应 order 下标的 span 链表的头节点,如果不为空,那么直接将头节点的属性 manualFreeList 指向的节点从链表中移除,并返回;

如果 list.first为空,那么调用 mheap_allocManual 函数从堆中分配 mspan

allocManual 函数会分配 32KB 大小的内存块,分配好新的 span 之后会根据 elemsize 大小将 32KB 内存进行切割,然后通过单向链表串起来并将最后一块内存地址赋值给 manualFreeList

func stackpoolalloc(order uint8) gclinkptr {
    list := &stackpool[order].item.span
    s := list.first
    lockWithRankMayAcquire(&mheap_.lock, lockRankMheap)
    if s == nil {
        // no free stacks. Allocate another span worth.
        // 从堆上分配 mspan
        // _StackCacheSize = 32 * 1024
        s = mheap_.allocManual(_StackCacheSize>>_PageShift, &memstats.stacks_inuse)
        if s == nil {
            throw("out of memory")
        }
        // 刚分配的 span 里面分配对象个数肯定为 0
        if s.allocCount != 0 {
            throw("bad allocCount")
        }
        if s.manualFreeList.ptr() != nil {
            throw("bad manualFreeList")
        }
        //OpenBSD 6.4+ 系统需要做额外处理
        osStackAlloc(s)
        // Linux 中 _FixedStack = 2048
        s.elemsize = _FixedStack << order
        //_StackCacheSize =  32 * 1024
        // 这里是将 32KB 大小的内存块分成了elemsize大小块,用单向链表进行连接
        // 最后 s.manualFreeList 指向的是这块内存的尾部
        for i := uintptr(0); i < _StackCacheSize; i += s.elemsize {
            x := gclinkptr(s.base() + i)
            x.ptr().next = s.manualFreeList
            s.manualFreeList = x
        }
        // 插入到 list 链表头部
        list.insert(s)
    }
    x := s.manualFreeList
    // 代表被分配完毕
    if x.ptr() == nil {
        throw("span has no free stacks")
    }
    // 将 manualFreeList 往后移动一个单位
    s.manualFreeList = x.ptr().next
    // 统计被分配的内存块
    s.allocCount++
    // 因为分配的时候第一个内存块是 nil
    // 所以当指针为nil 的时候代表被分配完毕
    // 那么需要将该对象从 list 的头节点移除
    if s.manualFreeList.ptr() == nil {
        // all stacks in s are allocated.
        list.remove(s)
    }
    return x
}

如果 mcache 对应的 stackcache 获取不到,那么调用 stackcacherefill 从堆上申请一片内存空间填充到stackcache中。

stackcacherefill 函数会调用 stackpoolallocstackpool 中获取一半的空间组装成 list 链表,然后放入到 stackcache 数组中。

func stackcacherefill(c *mcache, order uint8) { 
    var list gclinkptr
    var size uintptr
    lock(&stackpool[order].item.mu)
    //_StackCacheSize = 32 * 1024
    // 将 stackpool 分配的内存组成一个单向链表 list
    for size < _StackCacheSize/2 {
        x := stackpoolalloc(order)
        x.ptr().next = list
        list = x
        // _FixedStack = 2048
        size += _FixedStack << order
    }
    unlock(&stackpool[order].item.mu)
    c.stackcache[order].list = list
    c.stackcache[order].size = size
}

大于等于32KB的栈内存分配

对于大栈内存分配,运行时会查看 stackLarge 中是否有剩余的空间,如果不存在剩余空间,它也会调用 mheap_.allocManual 从堆上申请新的内存。


2.3.6 栈的扩容

Go 语言中的执行栈由 runtime.stack 表示,该结构体中只包含两个字段,分别表示栈的顶部和栈的底部,每个栈结构体都表示范围为 [lo, hi) 的内存空间:

type stack struct {
    lo uintptr
    hi uintptr
}

栈的结构虽然非常简单,但是想要理解 Goroutine 栈的实现原理,还是需要我们从编译期间和运行时两个阶段入手:

  1. 编译器会在编译阶段会通过 cmd/internal/obj/x86.stacksplit 在调用函数前插入 runtime.morestack 或者 runtime.morestack_noctxt 函数;
  2. 运行时在创建新的 Goroutine 时会在 runtime.malg 中调用 runtime.stackalloc 申请新的栈内存,并在编译器插入的 runtime.morestack 中检查栈空间是否充足;

需要注意的是,Go 语言的编译器不会为所有的函数插入 runtime.morestack,它只会在必要时插入指令以减少运行时的额外开销,编译指令 nosplit 可以跳过栈溢出的检查,虽然这能降低一些开销,不过固定大小的栈也存在溢出的风险。本节将分别分析栈的初始化、创建 Goroutine 时栈的分配、编译器和运行时协作完成的栈扩容以及当栈空间利用率不足时的缩容过程。

Goroutine 中会通过 stackguard0 来判断是否要进行栈增长:

  • stackguard0stack.lo + StackGuard, 用于stack overlow的检测;
  • StackGuard:保护区大小,常量Linux上为 928 字节
  • StackSmall:常量大小为 128 字节,用于小函数调用的优化;
  • StackBig:常量大小为 4096 字节;

image.png

image.png

需要注意的是,由于栈是由高地址向低地址增长的,所以对比的时候,都是小于才执行扩容。

当执行栈扩容时,会在内存空间中分配更大的栈内存空间,然后将旧栈中的所有内容复制到新栈中,并修改指向旧栈对应变量的指针重新指向新栈,最后销毁并回收旧栈的内存空间,从而实现栈的动态扩容。

具体代码实现

runtime.morestack_noctxt 是用汇编实现的,它会调用到 runtime·morestack,下面我们看看它的实现:

TEXT runtime·morestack(SB),NOSPLIT,$0-0
    // Cannot grow scheduler stack (m->g0).
    // 无法增长调度器的栈(m->g0)
    get_tls(CX)
    MOVQ    g(CX), BX
    MOVQ    g_m(BX), BX
    MOVQ    m_g0(BX), SI
    CMPQ    g(CX), SI
    JNE    3(PC)
    CALL    runtime·badmorestackg0(SB)
    CALL    runtime·abort(SB)
    // 省略signal stack、morebuf和sched的处理
    ...
    // Call newstack on m->g0's stack.
    // 在 m->g0 栈上调用 newstack.
    MOVQ    m_g0(BX), BX
    MOVQ    BX, g(CX)
    MOVQ    (g_sched+gobuf_sp)(BX), SP
    CALL    runtime·newstack(SB)
    CALL    runtime·abort(SB)    // 如果 newstack 返回则崩溃 crash if newstack returns
    RET

runtime·morestack 做完校验和赋值操作后会切换到 G0 调用 runtime·newstack来完成扩容的操作。

func newstack() {
    thisg := getg() 

    gp := thisg.m.curg
     
    // 初始化寄存器相关变量
    morebuf := thisg.m.morebuf
    thisg.m.morebuf.pc = 0
    thisg.m.morebuf.lr = 0
    thisg.m.morebuf.sp = 0
    thisg.m.morebuf.g = 0
    ...
    // 校验是否被抢占
    preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt
 
    // 如果被抢占
    if preempt {
        // 校验是否可以安全的被抢占
        // 如果 M 上有锁
        // 如果正在进行内存分配
        // 如果明确禁止抢占
        // 如果 P 的状态不是 running
        // 那么就不执行抢占了
        if !canPreemptM(thisg.m) {
            // 到这里表示不能被抢占?
            // Let the goroutine keep running for now.
            // gp->preempt is set, so it will be preempted next time.
            gp.stackguard0 = gp.stack.lo + _StackGuard
            // 触发调度器的调度
            gogo(&gp.sched) // never return
        }
    }

    if gp.stack.lo == 0 {
        throw("missing stack in newstack")
    }
    // 寄存器 sp
    sp := gp.sched.sp
    if sys.ArchFamily == sys.AMD64 || sys.ArchFamily == sys.I386 || sys.ArchFamily == sys.WASM {
        // The call to morestack cost a word.
        sp -= sys.PtrSize
    } 
    ...
    if preempt {
        //需要收缩栈
        if gp.preemptShrink { 
            gp.preemptShrink = false
            shrinkstack(gp)
        }
        // 被 runtime.suspendG 函数挂起
        if gp.preemptStop {
            // 被动让出当前处理器的控制权
            preemptPark(gp) // never returns
        }
 
        //主动让出当前处理器的控制权
        gopreempt_m(gp) // never return
    }
 
    // 计算新的栈空间是原来的两倍
    oldsize := gp.stack.hi - gp.stack.lo
    newsize := oldsize * 2 
    ... 
    //将 Goroutine 切换至 _Gcopystack 状态
    casgstatus(gp, _Grunning, _Gcopystack)
 
    //开始栈拷贝
    copystack(gp, newsize) 
    casgstatus(gp, _Gcopystack, _Grunning)
    gogo(&gp.sched)
}

2.3.7 栈拷贝

在开始执行栈拷贝之前会先计算新栈的大小是原来的两倍,然后将 Goroutine 状态切换至 _Gcopystack 状态。

func copystack(gp *g, newsize uintptr) { 
    old := gp.stack 
    // 当前已使用的栈空间大小
    used := old.hi - gp.sched.sp
 
    //分配新的栈空间
    new := stackalloc(uint32(newsize))
    ...
 
    // 计算调整的幅度
    var adjinfo adjustinfo
    adjinfo.old = old
    // 新栈和旧栈的幅度来控制指针的移动
    adjinfo.delta = new.hi - old.hi
 
    // 调整 sudogs, 必要时与 channel 操作同步
    ncopy := used
    if !gp.activeStackChans {
        ...
        adjustsudogs(gp, &adjinfo)
    } else {
        // 到这里代表有被阻塞的 G 在当前 G 的channel 中,所以要防止并发操作,需要获取 channel 的锁
         
        // 在所有 sudog 中找到地址最大的指针
        adjinfo.sghi = findsghi(gp, old) 
        // 对所有 sudog 关联的 channel 上锁,然后调整指针,并且复制 sudog 指向的部分旧栈的数据到新的栈上
        ncopy -= syncadjustsudogs(gp, used, &adjinfo)
    } 
    // 将源栈中的整片内存拷贝到新的栈中
    memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)
    // 继续调整栈中 txt、defer、panic 位置的指针
    adjustctxt(gp, &adjinfo)
    adjustdefers(gp, &adjinfo)
    adjustpanics(gp, &adjinfo)
    if adjinfo.sghi != 0 {
        adjinfo.sghi += adjinfo.delta
    } 
    // 将 G 上的栈引用切换成新栈
    gp.stack = new
    gp.stackguard0 = new.lo + _StackGuard // NOTE: might clobber a preempt request
    gp.sched.sp = new.hi - used
    gp.stktopsp += adjinfo.delta
 
    // 在新栈重调整指针
    gentraceback(^uintptr(0), ^uintptr(0), 0, gp, 0, nil, 0x7fffffff, adjustframe, noescape(unsafe.Pointer(&adjinfo)), 0)
 
    if stackPoisonCopy != 0 {
        fillstack(old, 0xfc)
    }
    //释放原始栈的内存空间
    stackfree(old)
}
  1. copystack 首先会计算一下使用栈空间大小,那么在进行栈复制的时候只需要复制已使用的空间就好了;
  2. 然后调用 stackalloc 函数从堆上分配一片内存块;
  3. 然后对比新旧栈的 hi 的值计算出两块内存之间的差值 delta,这个 delta 会在调用 adjustsudogsadjustctxt 等函数的时候判断旧栈的内存指针位置,然后加上 delta 然后就获取到了新栈的指针位置,这样就可以将指针也调整到新栈了;
  4. 调用 memmove 将源栈中的整片内存拷贝到新的栈中;
  5. 然后继续调用调整指针的函数继续调整栈中 txtdeferpanic 位置的指针;
  6. 接下来将 G 上的栈引用切换成新栈;
  7. 最后调用 stackfree 释放原始栈的内存空间;

image.png


2.3.8 栈的收缩

栈的收缩发生在 GC 时对栈进行扫描的阶段:

func scanstack(gp *g, gcw *gcWork) {
    ... 
    // 进行栈收缩
    shrinkstack(gp)
    ...
}

func shrinkstack(gp *g) {
    ...
    oldsize := gp.stack.hi - gp.stack.lo
    newsize := oldsize / 2 
    // 当收缩后的大小小于最小的栈的大小时,不再进行收缩
    if newsize < _FixedStack {
        return
    }
    avail := gp.stack.hi - gp.stack.lo
    // 计算当前正在使用的栈数量,如果 gp 使用的当前栈少于四分之一,则对栈进行收缩
    // 当前使用的栈包括到 SP 的所有内容以及栈保护空间,以确保有 nosplit 功能的空间
    if used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= avail/4 {
        return
    }
    // 将旧栈拷贝到新收缩后的栈上
    copystack(gp, newsize)
}

新栈的大小会缩小至原来的一半,如果小于 _FixedStack2KB)那么不再进行收缩。除此之外还会计算一下当前栈的使用情况是否不足 1/4 ,如果使用超过 1/4 那么也不会进行收缩。

最后判断确定要进行收缩则调用 copystack 函数进行栈拷贝的逻辑。


2.4 关于Golang栈几个实验


Demo0:Golang栈会自动扩容,是不是永远不会栈溢出?

func f(i int) int {
    if i == 0 || i == 1 {
        return i
    }
    return f(i - 1)
}
func main() {
    println(f(100000000))
}

执行这个函数,程序会报“stack overflow”的excepttion,具体错误日志如下:

➜  GoTest git:(master) ✗ go run stack.go
runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0xc0200e0390 stack=[0xc0200e0000, 0xc0400e0000]
fatal error: stack overflow

runtime stack:
runtime.throw(0x1074923, 0xe)
    /Users/fanlv/.g/go/src/runtime/panic.go:1117 +0x72
runtime.newstack()
    /Users/fanlv/.g/go/src/runtime/stack.go:1069 +0x7ed
runtime.morestack()
    /Users/fanlv/.g/go/src/runtime/asm_amd64.s:458 +0x8f

这个错误是在newstack函数中抛出来的

if newsize > maxstacksize || newsize > maxstackceiling {
    if maxstacksize < maxstackceiling {
        print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")
    } else {
        print("runtime: goroutine stack exceeds ", maxstackceiling, "-byte limit\n")
    }
    print("runtime: sp=", hex(sp), " stack=[", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")
    throw("stack overflow")
}

有上面可知,当新的栈大小超过了maxstacksize就会抛出”stack overflow“的异常。maxstacksize是在runtime.main中设置的。64位 系统下栈的最大值1GB32位系统是250MB

// Max stack size is 1 GB on 64-bit, 250 MB on 32-bit.
// Using decimal instead of binary GB and MB because
// they look nicer in the stack overflow failure message.
if sys.PtrSize == 8 {
    maxstacksize = 1000000000
} else {
    maxstacksize = 250000000
}

Demo1:验证栈扩容以后地址变了,对我们是不是写代码的时候会有影响?

func f(i int) int {
    if i == 0 || i == 1 {
        return i
    }
    return f(i - 1)
}

func main() {    
      demo1()
}

func demo1() {
    var xDemo1 uint64
    xAddr := uintptr(unsafe.Pointer(&xDemo1))
    println("demo1 before stack copy xDemo1 : ", xDemo1, " xDemo1 pointer: ", &xDemo1)

    f(10000000)

    xPointer := (*uint64)(unsafe.Pointer(xAddr))
    atomic.AddUint64(xPointer, 1)
    println("demo1 after stack copy xDemo1 : ", xDemo1, " xDemo1 pointer:", &xDemo1)
}

编译执行上面的代码,输入结果如下,由下面内容可以知道栈扩容过程中,栈上变量的地址的确会发生改变。

➜  GoTest git:(master) ✗ go build -gcflags "-N -l -m"  stack.go
➜  GoTest git:(master) ✗ ./stack
demo1 before stack copy xDemo1 :  0  xDemo1 pointer:  0xc000044740
demo1 after stack copy xDemo1 :  0  xDemo1 pointer: 0xc04015ff40

Demo2:变量逃逸到堆上的情况

上面是变量没有逃逸的情况,我们构造个变量逃逸到堆上的case(其实用fmt.Println打印x变量就导致x逃逸,笔者最开始使用fmt.Println打印x变量发现栈扩容的情况下,x地址也一直不会变),代码如下:

// f 和 main 函数代码如demo1
func demo2() {
    var xDemo2 uint64
    println("demo2 before stack copy xDemo2 : ", xDemo2, " xDemo2 pointer: ", &xDemo2)
    f(10000000)
    atomic.AddUint64(&xDemo2, 1)
    println("demo2 after stack copy xDemo2 : ", xDemo2, " xDemo2 pointer:", &xDemo2)
}

demo2中,我们直接用取地址的方式去调用atomic.AddUint64(&xDemo2, 1),看编译日志可以知道xDemo2这个变量逃逸到堆上了。逃逸 到堆上的变量地址是不会变的。这样也符合预期。

➜  GoTest git:(master) ✗ go build -gcflags "-N -l -m"  stack.go
#command-line-arguments
./stack.go:36:6: moved to heap: xDemo2
➜  GoTest git:(master) ✗ ./stack
demo2 before stack copy xDemo2 :  0  xDemo2 pointer:  0xc0000180b8
demo2 after stack copy xDemo2 :  1  xDemo2 pointer: 0xc0000180b8

Demo3:go run方式去运行代码

demo3 中,我们直接返回变量的地址,这个时候我们知道,变量肯定已经逃逸到堆上。我们用go run的方式去跑下面代码,地址会变吗?

func demo3() *uint64 {
    var xDemo3 uint64 = 8
    println("demo3 before stack copy xDemo3 : ", xDemo3, " xDemo3 pointer: ", &xDemo3)
    f(1000)
    println("demo3 after stack copy xDemo3 : ", xDemo3, " xDemo3 pointer:", &xDemo3)
    return &xDemo3
}

输入日志如下,我们发现逃逸到堆上的变量地址也变了,这个是为什么?

➜  GoTest git:(master) ✗ go run stack.go
demo3 before stack copy xDemo3 :  0  xDemo3 pointer:  0xc000044770
demo3 after stack copy xDemo3 :  0  xDemo3 pointer: 0xc000117f70

其实执行go run的时候,它就是想编译在运行,执行go build的时候没有“禁止内联”,所以demo3函数就发生了内联,所以变量也不会逃逸到堆上了。我们可以go build -gcflags "-m" stack.go看下,输出日志如下。can inline demo3 ,这个时候demo3已经内联了。

➜  GoTest git:(master) ✗ go build -gcflags "-m"  stack.go
#command-line-arguments
./stack.go:37:6: can inline demo3
./stack.go:14:7: inlining call to demo3
./stack.go:38:6: moved to heap: xDemo3

虽然逃逸分析日志还有moved to heap: xDemo3 ,但是其实这个时候已经没有逃逸了,具体我们可以导出汇编代码 otool -tvV stack >> ~/Desktop/stack.s 看下。可以看到main里面已经没有demo3的调用了。xDemo3这边变量还是在栈上$0x8, 0x10(%rsp)

_main.main:
000000000105c920    movq    %gs:0x30, %rcx
000000000105c929    cmpq    0x10(%rcx), %rsp
000000000105c92d    jbe    0x105ca21
000000000105c933    subq    $0x20, %rsp
000000000105c937    movq    %rbp, 0x18(%rsp)
000000000105c93c    leaq    0x18(%rsp), %rbp
000000000105c941    movq    $0x8, 0x10(%rsp)
000000000105c94a    callq    _runtime.printlock
000000000105c94f    leaq    0x18e96(%rip), %rax
000000000105c956    movq    %rax, (%rsp)
000000000105c95a    movq    $0x22, 0x8(%rsp)
000000000105c963    callq    _runtime.printstring
000000000105c968    movq    0x10(%rsp), %rax
000000000105c96d    movq    %rax, (%rsp)
000000000105c971    callq    _runtime.printuint
000000000105c976    leaq    0x16ccd(%rip), %rax
000000000105c97d    movq    %rax, (%rsp)
000000000105c981    movq    $0x13, 0x8(%rsp)
000000000105c98a    callq    _runtime.printstring
000000000105c98f    leaq    0x10(%rsp), %rax
000000000105c994    movq    %rax, (%rsp)
000000000105c998    callq    _runtime.printpointer
000000000105c99d    nopl    (%rax)
000000000105c9a0    callq    _runtime.printnl
000000000105c9a5    callq    _runtime.printunlock
000000000105c9aa    movq    $0x3e8, (%rsp)  
000000000105c9b2    callq    _main.f
000000000105c9b7    callq    _runtime.printlock
000000000105c9bc    leaq    0x18bf7(%rip), %rax
000000000105c9c3    movq    %rax, (%rsp)
000000000105c9c7    movq    $0x21, 0x8(%rsp)
000000000105c9d0    callq    _runtime.printstring
000000000105c9d5    movq    0x10(%rsp), %rax
000000000105c9da    movq    %rax, (%rsp)
000000000105c9de    nop
000000000105c9e0    callq    _runtime.printuint
000000000105c9e5    leaq    0x16b62(%rip), %rax
000000000105c9ec    movq    %rax, (%rsp)
000000000105c9f0    movq    $0x12, 0x8(%rsp)
000000000105c9f9    callq    _runtime.printstring
000000000105c9fe    leaq    0x10(%rsp), %rax
000000000105ca03    movq    %rax, (%rsp)
000000000105ca07    callq    _runtime.printpointer
000000000105ca0c    callq    _runtime.printnl
000000000105ca11    callq    _runtime.printunlock
000000000105ca16    movq    0x18(%rsp), %rbp
000000000105ca1b    addq    $0x20, %rsp
000000000105ca1f    nop
000000000105ca20    retq
000000000105ca21    callq    _runtime.morestack_noctxt
000000000105ca26    jmp    _main.main

三、参考资料

https://www.cnblogs.com/luozhiyun/p/14619585.html

http://www.huamo.online/2019/06/25/%E6%B7%B1%E5%85%A5%E7%A0%94%E7%A9%B6goroutine%E6%A0%88/

https://www.cnblogs.com/shijingxiang/articles/12200355.html

https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-stack-management/