为什么 Goroutines 可扩展:栈增长、编译器技巧和上下文切换

发布: (2026年1月4日 GMT+8 04:03)
7 min read
原文: Dev.to

抱歉,我需要您提供要翻译的具体文本内容(文章正文)。请把您想要翻译的文字粘贴在这里,我会按照要求保留源链接并进行简体中文翻译。

C++ 与 Java 中的线程开销

在上述语言中,线程是一种并发手段,但在上下文切换时会消耗大量 CPU 时间,并且在创建时会占用相对巨大的内存。单个线程通常会预留 ~1 MiB 的栈空间。因此,生成 100 000 个线程大约需要 100 GiB 的 RAM,这对大多数软件项目来说并不经济可行。

为了保持并发,CPU 通常使用时间片轮转(time‑slicing)来给每个线程分配相同数量的 CPU 周期。在此过程中,CPU 必须执行上下文切换,这相当昂贵:

  • 当前线程的状态保存在其 TCB(线程控制块)中。
  • 新线程的 TCB 被加载到内存中。
  • 上下文切换会破坏缓存局部性,导致频繁的 L1/L2 缓存未命中。

当线程数量达到数千时,CPU 花在切换上下文的时间会超过实际执行代码的时间。

Source:

Goroutine 如何实现优化

Goroutine 是由 Go 运行时在 用户空间 完全管理的“轻量级线程”,而不是由操作系统内核管理。

内存效率

  • 标准的 OS 线程会预留固定的 1 MiB 栈。
  • Goroutine 则只从 2 KiB 的栈开始。

Comparison showing huge 1 MiB OS thread stack vs tiny 2 KiB goroutine stack

计算方式: 2 KiB 大约是 1 MiB 的 0.2 %
影响: 与其在几千个线程上受限,你可以在普通笔记本上轻松生成 数百万 个 goroutine,而不会耗尽内存。

“无限”栈

与在创建时就确定固定栈大小的 OS 线程不同,goroutine 的栈是 动态 的:

  1. Goroutine 从 2 KiB 栈开始。
  2. 当空间不足时,运行时会分配更大的段(通常是当前大小的两倍),并把栈迁移过去。

Flowchart showing how Go runtime allocates a larger stack and copies data when limit is hit

  • OS 线程限制: 固定(≈1–8 MiB)。达到上限会导致崩溃。
  • Goroutine 限制: 动态(在 64 位系统上可达约 1 GiB)。

因此,在实际使用中,goroutine 的递归深度只受可用内存限制,而 OS 线程则受其最初预留的栈大小限制。

更快的上下文切换

Diagram illustrating goroutines running in user space vs OS threads in kernel space

OS 线程和 goroutine 在暂停时都需要保存状态,但代价差别巨大:

OS 线程切换Goroutine 切换
典型延迟~1–2 µs~200 ns(≈快 10 倍)
保存的内容所有 CPU 寄存器(包括重量级的 FP/AVX 寄存器)→TCB仅 3 个寄存器(PC、SP、DX)→一个小的 Go 结构体 (g)
发生位置内核模式(陷阱)用户空间(运行时)
缓存影响刷新缓存,失去局部性缓存保持热度,局部性得以保留

由于 goroutine 的切换停留在用户空间,开销几乎可以忽略不计。

Goroutine 栈分配是如何工作的

Go 编译器会在每个函数的开头插入一个 函数序言。该序言执行以下检查:

  1. 比较 当前栈指针 (SP) 与称为 栈守卫 的限制。
  2. 如果剩余空间不足,则跳转到 runtime.morestack
  3. runtime.morestack 为栈分配一个更大的段(通常是当前大小的 2 倍)。
  4. 运行时 复制 现有栈内容到新段,并 调整所有指针 使其指向新的地址。
  5. 执行在更大的栈上继续。

示例

package main

import "fmt"

func main() {
    fmt.Println("Hello Ayush")
}

使用 -gcflags -S 选项运行编译器会显示 main.main 的生成汇编:

main.main STEXT size=83 args=0x0 locals=0x40 funcid=0x0 align=0x0
    0x0000 00000 (/Users/ayushanand/concurrency/main.go:7)  TEXT    main.main(SB), ABIInternal, $64-0
    0x0000 00000 (/Users/ayushanand/concurrency/main.go:7)  CMPQ    SP, 16(R14)          // compare SP with stack guard
    0x0004 00004 (/Users/ayushanand/concurrency/main.go:7)  PCDATA  $0, $-2
    0x0004 00004 (/Users/ayushanand/concurrency/main.go:7)  JLS     76                    // jump to morestack if SP )    NOP
0x002d 00045 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) LEAQ    go:itab.*os.File,io.Writer(SB), AX
0x0034 00052 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) LEAQ    main..autotmp_8+40(SP), CX
0x0039 00057 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) MOVL    $1, DI
0x003e 00062 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) MOVQ    DI, SI
0x0041 00065 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) PCDATA  $1, $0
0x0041 00065 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) CALL    fmt.Fprintln(SB)
0x0046 00070 (/Users/ayushanand/concurrency/main.go:9)  ADDQ    $56, SP
0x004a 00074 (/Users/ayushanand/concurrency/main.go:9)  POPQ    BP
0x004b 00075 (/Users/ayushanand/concurrency/main.go:9)  RET
0x004c 00076 (/Users/ayushanand/concurrency/main.go:9)  NOP
0x004c 00076 (/Users/ayushanand/concurrency/main.go:7)  PCDATA  $1, $-1
0x004c 00076 (/Users/ayushanand/concurrency/main.go:7)  PCDATA  $0, $-2
0x004c 00076 (/Users/ayushanand/concurrency/main.go:7)  CALL    runtime.morestack_noctxt(SB)
0x0051 00081 (/Users/ayushanand/concurrency/main.go:7)  PCDATA  $0, $-1
0x0051 00081 (/Users/ayushanand/concurrency/main.go:7)  JMP

可以看到用于检查栈大小的汇编代码。

结束语

Goroutine 并不是“线程但更小”。它们代表了对并发管理的根本性重新思考。通过将栈管理从操作系统内核移到 Go 运行时,我们获得了:

  • 大规模可伸缩性: 从 100 k 限制提升到数百万的 goroutine。
  • 动态内存: 按实际使用付费(≈ 2 KB),而不是按可能使用付费(≈ 1 MB)。
  • 低延迟: 上下文切换快约 10 倍。

下次你键入 go func() 时,请记住:背后有一个只有 2 KB 的小栈和一个智能编译器在工作,使其看起来是“无限”的。

Back to Blog

相关文章

阅读更多 »