深入理解 Golang Stack

一、基礎知識

(一)Linux 進程地址空間佈局

我們知道 CPU 有實模式和保護模式,系統剛剛啓動的時候是運行在實模式下,然後經過一系列初始化工作以後,Linux 會把 CPU 的實模式改爲保護模式(具體就是修改 CPU 的 CR0 寄存器相關標記位),在保護模式下,CPU 訪問的地址都是虛擬地址 (邏輯地址)。Linux 爲了每個進程維護了一個單獨的虛擬地址空間,虛擬地址空間又分爲“用戶空間” 和“內核空間”。虛擬地址空間更多相關可以看 Linux 內核虛擬地址空間這篇文章。

(二)Golang 棧內存在虛擬地址空間哪個區域

Golang 的內存管理是用的 TCMalloc(Thread-Caching Malloc)算法, 簡單點說就是 Golang 是使用 mmap 函數去操作系統申請一大塊內存,然後把內存按照 8Byte~32KB 67 個 size 類型的 mspan,每個 mspan 按照它自身的屬性 Size Class 的大小分割成若干個 object(每個 span 默認是 8K),因爲分需要 gc 的 mspan 和不需要 gc 的 mspan(golang 的 stack),所以一共有 134 種類型。

上面說了 golang 內存申請是用的 mmap,mmap 申請的內存都在虛擬地址空間的 “Memory Mapping Segment”,所有 golang 所有的內存使用(包括棧)都是在 “Memory Mapping Segment”,並不是在傳統應用地址空間劃的棧區。

寫 Demo 個代碼驗證一下

func main() {
    a := 8
    println("a address :  ", &a)
    time.Sleep(time.Hour)
}
// 這裏加“-m”來check沒有內存逃逸
[root@n227-005-021 GoTest ]$ go build -gcflags "-N -l -m"  stack3.go 
[root@n227-005-021 GoTest ]$ ./stack3
a address :   0xc000070f68

我們可以看到變量 a 的地址是 0xc000070f68,是一個很小的地址,而虛擬地址空間的堆區,都是在最上面,地址一般都很大(比如 00007ffd8fe4d000 這種),所以我們可以變量 a 肯定不是在堆區。我們可以進一步用 pmap 看進程的內存區域驗證一下。

[root@n227-005-021 fanlv ]$ pmap  162277
162277:   ./stack3
0000000000400000    400K r-x-- stack3
0000000000464000    448K r---- stack3
00000000004d4000     20K rw--- stack3
00000000004d9000    200K rw---   [ anon ]
000000c000000000  65536K rw---   [ anon ]
00007f13d92f3000  36292K rw---   [ anon ]
00007f13db664000 263680K -----   [ anon ]
00007f13eb7e4000      4K rw---   [ anon ]
00007f13eb7e5000 293564K -----   [ anon ]
00007f13fd694000      4K rw---   [ anon ]
00007f13fd695000  36692K -----   [ anon ]
00007f13ffa6a000      4K rw---   [ anon ]
00007f13ffa6b000   4580K -----   [ anon ]
00007f13ffee4000      4K rw---   [ anon ]
00007f13ffee5000    508K -----   [ anon ]
00007f13fff64000    384K rw---   [ anon ]
00007ffd8fe4d000    132K rw---   [ stack ]
00007ffd8ff82000     12K r----   [ anon ]
00007ffd8ff85000      8K r-x--   [ anon ]
 total           702472K

pmap 我們看到 000000c000000000 屬於 mmap 映射的一個匿名的 page([anon]),符合預期。

(三)常用寄存器

還有 R8 ~ R15 8 個寄存器這裏就不詳細列出來了, R8 用於保存函數調用 5 個參數,R9 用於保存函數調用 6 個參數

雖然在 x86-64 架構下,增加了很多通用寄存器,使得調用慣例 (calling convention) 變爲函數傳參可以部分(最多 6 個)使用寄存器直接傳遞,但是在 golang 中,編譯器強制規定函數的傳參全部都用棧傳遞,不使用寄存器傳參。go 1.17 以後好像已經開始可以用寄存器傳參。

Golang 不使用寄存器傳參,應該還是爲了使生成的僞彙編方便跨平臺。這裏擴展一個優化點,爲了減少函數調用的開銷小,可以儘量讓函數內聯。

內聯的條件:1. 函數語法解析完,token 數不超過 80. 2. Interface 的方法調用,不能內聯。

(四)什麼是棧幀

棧幀,也就是 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 0x0000000000000

執行完 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

棧的圖大致如下

這個棧幀的圖裏面我們主要關注幾點。

棧是由高地址像低地址擴張,比如 main 函數里面執行 subq $0x10, %rsp 就是申請 16 個字節的棧空間,執行完 main 函數再調用 addq $0x10, %rsp 表示釋放之前申請的 16 個字節棧空間。

每次函數調用都會有一定的棧空間的開銷,(老的 rbp 壓棧、函數的返回地址壓棧)

函數執行完以後。會繼續執行 return address 指向的代碼。

這裏要理解棧幀是向下擴展的很重要。下面 golang 的 stack 擴容縮容判斷的時候會用到

(五)什麼是內存逃逸

逃逸分析是一種確定指針動態範圍的方法。簡單來說就是分析在程序的哪些地方可以訪問到該指針。編譯器會根據變量是否被外部引用來決定是否逃逸:

  1. 如果函數外部沒有引用,則優先放到棧中;

  2. 如果函數外部存在引用,則必定放到堆中;

注意:go 在編譯階段確立逃逸,並不是在運行時。

(六)逃逸場景分析(什麼情況才分配到堆中)

 func StudentRegister(name string, age int) *Student {
     s := new(Student) //局部變量s逃逸到堆
     s.Name = name
     s.Age = age
     return s
 }
 func Slice() { 
     s := make([]int, 1000, 1000) // s 會分配在堆上
     for index, _ := range s {
         s[index] = index
     }
 }
 func main() {
     s := "Escape"
     fmt.Println(s)
 }
 func Fibonacci() func() int {
     a, b := 0, 1
     return func() int {
         a, b = b, a+b
         return a
     }
 }

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

二、golang 棧

(一)golang 棧大小變更歷史

Go 語言使用用戶態線程 Goroutine 作爲執行上下文,它的額外開銷和默認棧大小都比線程小很多,然而 Goroutine 的棧內存空間和棧結構也在經過很多次變化:

第一版提交 sys·newproc 棧默認是 4K、malg commit (早期 golang 的 runtime 代碼是用 c 寫的)

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

// 2013-10-03 go1.2rc2
Significant runtime reductions:
          amd64  386
GoParse    -14%  -1%
GobDecode  -12% -20%
GobEncode  -64%  -1%
JSONDecode  -9%  -4%
JSONEncode -15%  -5%
Template   -17% -14%
Benchmark graphs at
http://swtch.com/~rsc/gostackamd64.html
http://swtch.com/~rsc/gostack386.html

第三次修改 8K -> 4K

// 2014-02-27 go1.3beta1
runtime: grow stack by copying
On stack overflow, if all frames on the stack are
copyable, we copy the frames to a new stack twice
as large as the old one.  During GC, if a G is using
less than 1/4 of its stack, copy the stack to a stack
half its size.

第四次修改 4K -> 8K

// 2014-05-20 go1.3beta2
runtime: switch default stack size back to 8kB
The move from 4kB to 8kB in Go 1.2 was to eliminate many stack split hot spots.
The move back to 4kB was predicated on copying stacks eliminating
the potential for hot spots.
Unfortunately, the fact that stacks do not copy 100% of the time means
that hot spots can still happen under the right conditions, and the slowdown
is worse now than it was in Go 1.2. There is a real program in issue 8030 that
sees about a 30x slowdown: it has a reflect call near the top of the stack
which inhibits any stack copying on that segment.
Go back to 8kB until stack copying can be used 100% of the time.
Fixes issue 8030.

最後、將最小棧內存從 8K 降低到了 2KB ,主要是爲了節省內存空間使用

// 2014-09-17 go1.4beta1
runtime: change minimum stack size to 2K.
It will be 8K on windows because it needs 4K for the OS.
Similarly, plan9 will be 4K.
On linux/amd64, reduces size of 100,000 goroutines
from ~819MB to ~245MB.
Update issue 7514

(二)分段棧

分段棧是 Go 語言在 v1.2 版本之前的實現,所有 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 是9,其他平臺是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.2 和 runtime.newstack:go1.2 創建一個新的棧空間,這些棧空間雖然不連續,但是當前 Goroutine 的多個棧空間會以鏈表的形式串聯起來,運行時會通過指針找到連續的棧片段:

一旦 Goroutine 申請的棧空間不在被需要,運行時會調用 runtime.lessstack:go1.2 和 runtime.oldstack:go1.2 釋放不再使用的內存空間。

分段棧機制雖然能夠按需爲當前 Goroutine 分配內存並且及時減少內存的佔用,但是它也存在兩個比較大的問題:

  1. 如果當前 Goroutine 的棧幾乎充滿,那麼任意的函數調用都會觸發棧擴容,當函數返回後又會觸發棧的收縮,如果在一個循環中調用函數,棧的分配和釋放就會造成巨大的額外開銷,這被稱爲熱分裂問題(Hot split);

  2. 一旦 Goroutine 使用的內存越過了分段棧的擴縮容閾值,運行時會觸發棧的擴容和縮容,帶來額外的工作量;

(三)連續棧

什麼是連續棧

連續棧可以解決分段棧中存在的兩個問題,其核心原理是每當程序的棧空間不足時,初始化一片更大的棧空間並將原棧中的所有值都遷移到新棧中,新的局部變量或者函數調用就有充足的內存空間。使用連續棧機制時,棧空間不足導致的擴容會經歷以下幾個步驟:

  1. 在內存空間中分配更大的棧內存空間;

  2. 將舊棧中的所有內容複製到新棧中;

  3. 將指向舊棧對應變量的指針重新指向新棧;

  4. 銷燬並回收舊棧的內存空間;

在擴容的過程中,最重要的是調整指針的第三步,這一步能夠保證指向棧的指針的正確性,因爲棧中的所有變量內存都會發生變化,所以原本指向棧中變量的指針也需要調整。我們在前面提到過經過逃逸分析的 Go 語言程序的遵循以下不變性 —— 指向棧對象的指針不能存在於堆中,所以指向棧中變量的指針只能在棧上,我們只需要調整棧中的所有變量就可以保證內存的安全了。

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 字節時,則自動標記爲 NOSPLIT。x86 架構處理與之類似

自動標記爲 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

gorotine 的創建

程序中執行 go func(){} 創建 gorotine 的時候,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 一個新的 gorotine 的時候,會調用 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
}

每一個 goroutine 的 g->stackguard0 都被設置爲指向 stack.lo + StackGuard 的位置。所以每一個函數在真正執行前都會將 SP 和 stackguard0 進行比較。

棧的初始化

// 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)
    }
}

在執行棧初始化的時候會初始化兩個全局變量 stackpool 和 stackLarge。stackpool 可以分配小於 32KB 的內存,stackLarge 用來分配大於 32KB 的棧空間。

棧內存分配

// 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 函數會調用 stackpoolalloc 從 stackpool 中獲取一半的空間組裝成 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 從堆上申請新的內存。

棧的擴容

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 來判斷是否要進行棧增長:

需要注意的是,由於棧是由高地址向低地址增長的,所以對比的時候,都是小於才執行擴容。

當執行棧擴容時,會在內存空間中分配更大的棧內存空間,然後將舊棧中的所有內容複製到新棧中,並修改指向舊棧對應變量的指針重新指向新棧,最後銷燬並回收舊棧的內存空間,從而實現棧的動態擴容。

具體代碼實現

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)
}

棧拷貝

在開始執行棧拷貝之前會先計算新棧的大小是原來的兩倍,然後將 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 會在調用 adjustsudogs、adjustctxt 等函數的時候判斷舊棧的內存指針位置,然後加上 delta 然後就獲取到了新棧的指針位置,這樣就可以將指針也調整到新棧了;

  4. 調用 memmove 將源棧中的整片內存拷貝到新的棧中;

  5. 然後繼續調用調整指針的函數繼續調整棧中 txt、defer、panic 位置的指針;

  6. 接下來將 G 上的棧引用切換成新棧;

  7. 最後調用 stackfree 釋放原始棧的內存空間;

棧的收縮

棧的收縮發生在 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)
}

新棧的大小會縮小至原來的一半,如果小於 _FixedStack (2KB)那麼不再進行收縮。除此之外還會計算一下當前棧的使用情況是否不足 1/4 ,如果使用超過 1/4 那麼也不會進行收縮。

最後判斷確定要進行收縮則調用 copystack 函數進行棧拷貝的邏輯。

關於 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 位 系統下棧的最大值 1GB、32 位系統是 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() {   
    println("demo3: ",demo3())
}
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, %rcx000000000105c929    cmpq    0x10(%rcx), %rsp000000000105c92d    jbe 0x105ca21000000000105c933    subq    $0x20, %rsp000000000105c937    movq    %rbp, 0x18(%rsp)000000000105c93c    leaq    0x18(%rsp), %rbp000000000105c941    movq    $0x8, 0x10(%rsp)000000000105c94a    callq   _runtime.printlock000000000105c94f    leaq    0x18e96(%rip), %rax000000000105c956    movq    %rax, (%rsp)000000000105c95a    movq    $0x22, 0x8(%rsp)000000000105c963    callq   _runtime.printstring000000000105c968    movq    0x10(%rsp), %rax000000000105c96d    movq    %rax, (%rsp)000000000105c971    callq   _runtime.printuint000000000105c976    leaq    0x16ccd(%rip), %rax000000000105c97d    movq    %rax, (%rsp)000000000105c981    movq    $0x13, 0x8(%rsp)000000000105c98a    callq   _runtime.printstring000000000105c98f    leaq    0x10(%rsp), %rax000000000105c994    movq    %rax, (%rsp)000000000105c998    callq   _runtime.printpointer000000000105c99d    nopl    (%rax)000000000105c9a0    callq   _runtime.printnl000000000105c9a5    callq   _runtime.printunlock000000000105c9aa    movq    $0x3e8, (%rsp)  000000000105c9b2    callq   _main.f000000000105c9b7    callq   _runtime.printlock000000000105c9bc    leaq    0x18bf7(%rip), %rax000000000105c9c3    movq    %rax, (%rsp)000000000105c9c7    movq    $0x21, 0x8(%rsp)000000000105c9d0    callq   _runtime.printstring000000000105c9d5    movq    0x10(%rsp), %rax000000000105c9da    movq    %rax, (%rsp)000000000105c9de    nop000000000105c9e0    callq   _runtime.printuint000000000105c9e5    leaq    0x16b62(%rip), %rax000000000105c9ec    movq    %rax, (%rsp)000000000105c9f0    movq    $0x12, 0x8(%rsp)000000000105c9f9    callq   _runtime.printstring000000000105c9fe    leaq    0x10(%rsp), %rax000000000105ca03    movq    %rax, (%rsp)000000000105ca07    callq   _runtime.printpointer000000000105ca0c    callq   _runtime.printnl000000000105ca11    callq   _runtime.printunlock000000000105ca16    movq    0x18(%rsp), %rbp000000000105ca1b    addq    $0x20, %rsp000000000105ca1f    nop000000000105ca20    retq000000000105ca21    callq   _runtime.morestack_noctxt000000000105ca26    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/

原文鏈接:https://www.jianshu.com/p/6cb9eeb41eb9

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/kAHrToRXoLZWD6_qPJpzOQ