Golang 备忘录
{Back to Index}
Table of Contents
1. GOROOT
GOROOT 是 Go 语言的安装目录,包含:
- Go 编译器、工具链(go、gofmt 等)
- 标准库源码(如 fmt、net/http、os 等)
- 运行时文件
# 查看 GOROOT go env GOROOT # 通常是 /usr/local/go 或 /opt/homebrew/opt/go/libexec (macOS)
2. GOPATH
从 Go 1.11+ 引入 Go Modules 后,GOPATH 的重要性大大降低。
现在 GOPATH 主要用于:
$GOPATH/bin- 存放go install安装的工具$GOPATH/pkg/mod- 缓存下载的依赖模块
3. go.mod 常用命令
对于 go.mod 文件,你通常不需要手动编辑,而是通过命令行工具来管理。以下是常用的命令:
3.1. 添加新依赖
go get github.com/some/package@latest
3.2. 更新所有依赖到最新版本
go get -u ./...
3.3. 更新特定依赖
go get -u github.com/julienschmidt/httprouter
3.4. 整理依赖(移除未使用的,添加缺失的)
go mod tidy
3.5. 下载依赖到本地缓存
go mod download
下载的依赖会存放在: $GOPATH/pkg/mod/
对于日常开发,通常不需要手动运行 go mod download ,因为 go build 、 go run 等命令会自动下载缺失的依赖。
这个命令主要用于CI/CD 优化或离线开发准备。
4. 数据结构
| slice | {指向底层数组的指针, len, cap} - 24 字节 |
| map | 指向 hmap 结构的指针 - 8 字节 |
| channel | 指向 hchan 结构的指针 - 8 字节 |
| interface | {类型指针, 数据指针} - 16 字节 |
| string | {指向字节数组的指针, len} - 16 字节 |
4.1. Array
// Declare an integer array of five elements.
// Initialize each element with a specific value.
array := [5]int{10, 20, 30, 40, 50}
// Declare an integer array.
// Initialize each element with a specific value.
// Capacity is determined based on the number of values initialized.
array := [...]int{10, 20, 30, 40, 50}
// Declare an integer pointer array of five elements.
// Initialize index 0 and 1 of the array with integer pointers.
array := [5]*int{0: new(int), 1: new(int)}
// Assign values to index 0 and 1.
*array[0] = 10
*array[1] = 20
4.2. Channel
4.2.1. 声明
var ch chan in // 这种方式声明的管道,值为 nil // 读写 nil 管道均会永久阻塞。关闭的管道仍然可以读取数据,向关闭的管道写数据会触发 panic // nil channel 在某些时候有些妙用,例如在 select 的某个 case 分支 A 将其它某 case 分支 B 所操作的 channel 设置为 nil ,这将会禁用 case 分支 B
4.2.2. make()
ch1 := make(chan string) // without buffer ch1 := make(chan string, 5) // with buffer
只有一个缓冲区的管道,写入数据类似于加锁,读出数据类似于释放锁,比如:
var counter int = 0
var ch = make(chan int, 1)
func Worker() {
ch <- 1
counter++
<-ch
}
4.2.3. 数据读写
管道双向可读写,管道在函数间传递时也可以使用操作符限制管道的读写:
func ChanParamRW(ch chan int) {
// 管道可读写
}
func ChanParamR(ch <-chan int) {
// 只能从管道读取数据
}
func ChanParamW(ch chan<- int) {
// 只能向管道写入数据
}
协程读取管道时,阻塞的条件有:
- 管道无缓冲区
- 管道的缓冲区中无数据
- 管道的值为 nil
协程写入管道时,阻塞的条件有:
- 管道无缓冲区
- 管道的缓冲区已满
- 管道的值为 nil
4.2.4. close
表示关闭 channel :
- 关闭 channel 后,send 操作将导致 painc
- 关闭 channel 后,recv 操作将返回对应类型的 0 值以及一个状态码 false
- close 并非强制需要使用 close(ch) 来关闭 channel ,在某些时候可以自动被关闭
- 如果使用
close(),建议条件允许的情况下加上 defer - 只在 sender 端上显式使用
close()关闭 channel ,因为关闭通道意味着没有数据再需要发送
4.3. Slice
Figure 1: Slice internals with underlying array
var s []int
s 本质是一个结构体(slice header),包含三个字段:
// 等价于 reflect.SliceHeader
type slice struct {
Data uintptr // 指向底层数组的指针
Len int // 长度
Cap int // 容量
}
验证:
slice_var := make([]int, 3)
// 这三个是一样的
fmt.Printf("%p\n", slice_var) // 底层数组地址
fmt.Printf("%p\n", &slice_var[0]) // 第一个元素的地址
// 这个不一样
fmt.Printf("%p\n", &slice_var) // slice header 变量的地址
4.3.1. 创建
// Create a slice of strings.
// Contains a length and capacity of 5 elements.
slice := make([]string, 5)
// Create a slice of integers.
// Contains a length and capacity of 3 elements.
slice := []int{10, 20, 30}
// Create a slice of strings. (slice with a length and capacity of 100 elements)
// Initialize the 100th element with an empty string.
slice := []string{99: ""}
// Create a nil slice of integers.
var slice []int
// Create Empty Slice
// Use make to create an empty slice of integers.
slice := make([]int, 0)
// Use a slice literal to create an empty slice of integers.
slice := []int{}
Figure 2: The representation of a nil slice
Figure 3: The representation of an empty slice
4.3.2. 切片
Figure 4: Two slices sharing the same underlying array
4.3.3. append
4.3.3.1. w/ available capacity
// Create a slice of integers.
// Contains a length and capacity of 5 elements.
slice := []int{10, 20, 30, 40, 50}
// Create a new slice.
// Contains a length of 2 and capacity of 4 elements.
newSlice := slice[1:3]
// Allocate a new element from capacity.
// Assign the value of 60 to the new element.
newSlice = append(newSlice, 60)
Figure 5: The underlying array after the append operation
4.3.3.2. w/o available capacity
// Create a slice of integers.
// Contains a length and capacity of 4 elements.
slice := []int{10, 20, 30, 40}
// Append a new value to the slice.
// Assign the value of 50 to the new element.
newSlice := append(slice, 50)
Figure 6: The new underlying array after the append operation
4.4. Map
var m[int]string
m 变量本质上是一个指向 hmap (runtime/map_noswiss.go) 结构体的指针
// map 的头部结构
type hmap struct {
count int // 元素个数,len(map) 返回这个值
flags uint8 // 状态标志(是否在写入等)
B uint8 // bucket 数量 = 2^B
noverflow uint16 // 溢出桶的大致数量
hash0 uint32 // 哈希种子(随机化,防止哈希碰撞攻击)
buckets unsafe.Pointer // 指向 bucket 数组(2^B 个)
oldbuckets unsafe.Pointer // 扩容时指向旧的 bucket 数组
nevacuate uintptr // 扩容进度计数器
extra *mapextra // 可选字段
}
hmap
┌─────────────────────┐
│ count: 5 │
│ B: 2 (4 buckets) │
│ hash0: 0x12345678 │
│ buckets ────────────┼──────► bucket 数组 (2^B = 4 个桶)
└─────────────────────┘ ┌─────────────────────────────┐
│ bucket 0 │
│ ┌─────────────────────────┐ │
│ │ tophash: [8]uint8 │ │
│ │ keys: [8]keytype │ │
│ │ values: [8]valuetype │ │
│ │ overflow ───────────────┼─┼──► 溢出桶
│ └─────────────────────────┘ │
├─────────────────────────────┤
│ bucket 1 │
│ ... │
├─────────────────────────────┤
│ bucket 2 │
├─────────────────────────────┤
│ bucket 3 │
└─────────────────────────────┘
map 操作不是原子的,这意味着多个协程同时操作 map 时有可能产生读写冲突,读写冲突会触发 panic 从而导致程序退出。如果需要并发读写,则可以使用额外的锁(互斥锁、读写锁),也可以考虑使用标准库 sync 包中的 sync.Map 。
4.4.1. Create
// Create a map with a key of type string and a value of type int.
dict := make(map[string]int)
// Create a map with a key and value of type string.
// Initialize the map with 2 key/value pairs.
dict := map[string]string{"Red": "#da1337", "Orange": "#e95a22"}
4.5. Func
4.5.1. 普通函数
普通函数在编译时确定,本质就是一个代码入口地址:
func add(a, b int) int {
return a + b
}
f := add // f 是一个函数值
f (函数变量)
┌───────────────┐
│ *funcval ptr ─┼──────► funcval
└───────────────┘ ┌───────────────┐
│ fn: 0x10a0b0 ─┼──► add 函数的机器码
└───────────────┘
// runtime/runtime2.go
type funcval struct {
fn uintptr // 函数代码的入口地址
// 闭包捕获的变量紧跟其后...
}
函数变量实际是 *funcval(指向 funcval 的指针)。
4.5.2. 闭包
闭包会捕获外部变量, funcval 后面紧跟捕获的变量:
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
f := makeCounter()
f (函数变量)
┌───────────────┐
│ *funcval ptr ─┼──────► funcval (堆上分配)
└───────────────┘ ┌──────────────────┐
│ fn: 0x10b0c0 │ ← 匿名函数代码地址
│ &count: 0xc00001 │ ← 捕获的变量(指针)
└──────────────────┘
│
▼
┌──────────┐
│ count: 0 │ (逃逸到堆上)
└──────────┘
4.5.3. 方法
方法只是第一个参数为 receiver 的函数:
type Dog struct{ Name string }
func (d Dog) Bark() string {
return d.Name + ": woof!"
}
// 编译器实际生成的等价函数:
// func Dog_Bark(d Dog) string { ... }
// 以下两种调用等价
dog := Dog{"Buddy"}
dog.Bark() // 方法调用
Dog.Bark(dog) // 函数调用(方法表达式)
4.5.4. 接口中的函数
接口的方法通过 itab 分发:
var s Speaker = Dog{"Buddy"}
┌─────────────────┐
│ iface │
│ ├─ itab ────────┼──► ┌───────────────────────┐
│ └─ data ───┐ │ │ itab │
└────────────┼────┘ │ ├─ inter (interface) │
│ │ ├─ _type (real type) │
▼ │ └─ fun[0] ────────────┼──► Dog.Bark 代码
┌────────┐ └───────────────────────┘
│ "Buddy"│
└────────┘
4.6. 反射
Go 的反射建立在 interface 之上 。每个 interface 变量的底层结构是:
┌────────────────────────────┐ │ iface / eface │ ├───────────────┬────────────┤ │ type 指针 │ data 指针 │ │ (类型元数据) │ (实际值) │ └───────────────┴────────────┘
具体来说,空接口 interface{} (即 any) 对应的运行时结构是:
// runtime/runtime2.go
type eface struct {
_type *_type // 指向类型描述符
data unsafe.Pointer // 指向实际数据
}
反射就是通过拆解这两个指针来工作的。
4.6.1. reflect.TypeOf 和 reflect.ValueOf 做了什么
var x float64 = 3.14 t := reflect.TypeOf(x) // 提取 eface 中的 _type 指针 → 得到类型元数据 v := reflect.ValueOf(x) // 提取 eface 中的 data 指针 → 得到值的副本
调用 reflect.TypeOf(x) 时:
- x 被隐式装箱为
interface{}(即构造一个 eface) - TypeOf 读取
eface._type,返回 reflect.Type
调用 reflect.ValueOf(x) 时:
- 同样装箱为 interface{}
- ValueOf 同时读取
_type和data,返回 reflect.Value
源代码变量 interface{} 装箱 反射拆解
var x MyStruct → eface{ → reflect.TypeOf: 读 _type
_type: *MyStruct元数据 → Name(), Field(), Method()...
data: *x的副本 reflect.ValueOf: 读 _type + data
} → Int(), String(), Set()...
4.6.2. 反射利用的三个性质
| 变量性质 | 反射如何利用 |
|---|---|
| 类型信息编译时嵌入二进制 | Go 编译器为每个类型生成 runtime._type 结构体,包含大小、对齐、方法集、字段信息等,链接进可执行文件。反射在运行时读取这些元数据。 |
| interface 保存了 (type, value) 对 | 反射的入口就是 interface{},它天然携带类型指针和数据指针,反射只是把这对指针"拆开看"。 |
| 内存布局是类型确定的 | 知道了类型(字段偏移、大小),就能用 unsafe.Pointer 算术直接读写结构体的任意字段。 |
4.6.3. 总结
- 反射的入口必须是 interface{} ,因为只有 interface 在运行时同时携带 type 和 value。
- 反射比直接访问慢–需要通过指针间接查表,无法被编译器内联优化。
- reflect.Value.Set() 要求可寻址( addressable )–因为它需要真正修改原始内存,而不是 interface 中的值副本。
5. 流程控制
5.1. select
select 的行为模式主要是 对 channel 是否可读进行轮询 ,但也可以用来向 channel 发送数据。它的行为如下:
- 如果所有的 case 语句块评估时都被阻塞,则阻塞直到某个语句块可以被处理
- 如果多个 case 同时满足条件,则 随机 选择一个进行处理,对于这一次的选择,其它的 case 都不会被阻塞,而是处理完被选中的 case 后进入下一轮select ( 如果 select 在循环中 ) 或者结束 select ( 如果 select 不在循环中或循环次数结束 )
- 如果存在 default 且其它 case 都不满足条件,则执行 default 。 所以 default 必须要可执行且不能阻塞
5.1.1. 永久阻塞
func main() {
select {}
}
5.1.2. 限时等待
// 该函数返回一个管道,可用于在函数之间传递,但该管道会在指定时间后自动关闭
func waitForStopOrTimeout(stopCh <-chan struct{}, timeout time.Duration) <-chan struct{} {
stopChWithTimeout := make(chan struct{})
go func() {
select {
case <-stopCh: // 自然结束
case <-time.After(timeout): // 最长等待时间长
}
close(stopChWithTimeout) }()
return stopChWithTimeout
}
5.2. type Switch 结构
package main
import (
"fmt"
)
// Shaper 接口类型
type Shaper interface {
Area() float64
}
// Circle struct类型
type Circle struct {
radius float64
}
// Circle类型实现Shaper中的方法Area()
func (c *Circle) Area() float64 {
return 3.14 * c.radius * c.radius
}
// Square struct类型
type Square struct {
length float64
}
// Square类型实现Shaper中的方法Area()
func (s Square) Area() float64 {
return s.length * s.length
}
func main() {
s1 := &Square{3.3}
whichType(s1)
s2 := Square{3.4}
whichType(s2)
c1 := new(Circle)
c1.radius = 2.3
whichType(c1)
}
func whichType(n Shaper) {
switch v := n.(type) {
case *Square:
fmt.Printf("Type Square %T\n", v)
case Square:
fmt.Printf("Type Square %T\n", v)
case *Circle:
fmt.Printf("Type Circle %T\n", v)
case nil:
fmt.Println("nil value: nothing to check?")
default:
fmt.Printf("Unexpected type %T", v)
}
}
6. 并发
Figure 7: How the Go scheduler manages goroutines
6.1. Goroutine Stack
以下内容以标准 Go 编译器 gc 和 Go 1.26 runtime 为准。运行时常量和分配细节可能随版本、操作系统和架构变化。
核心结论:每个 goroutine 都有自己独立的、连续但可移动的栈。栈从较小容量开始,空间不足时分配更大的连续内存并搬迁;使用量长期较小时,可在 GC 扫描期间缩容。
goroutine 的栈属于 G=,不属于执行它的 OS 线程 =M=。因此 goroutine 可以迁移到不同线程继续执行,而它的调用栈仍随 =G 一起保存。
6.1.1. 栈布局与使用
每个 goroutine 的 g 结构记录栈的地址范围 =[stack.lo, stack.hi)=。Go 支持的常见架构上,栈从高地址向低地址增长:
高地址 stack.hi ┌────────────────────────┐ │ 最早的调用帧 │ │ ... │ │ 当前函数调用帧 │ ← SP ├────────────────────────┤ │ 尚未使用的空间 │ ├────────────────────────┤ ← stackguard0 │ NOSPLIT/扩栈路径预留区 │ │ OS 特殊处理预留区 │ ← stackSystem(部分平台) └────────────────────────┘ 低地址 stack.lo
栈帧通常包含:
- 未逃逸的局部变量;
- 返回地址、保存的寄存器和寄存器溢出区;
- 部分参数和返回值,具体取决于寄存器 ABI 和编译优化;
- defer、panic、栈对象等运行时需要的信息。
函数栈帧的大小基本在编译期确定。函数调用和返回主要通过移动 SP 完成,不需要像堆对象一样逐个申请、释放。
局部变量是否放在栈上由逃逸分析决定。编译器不能证明其生命周期受当前调用约束时,变量通常逃逸到堆:
func value() *int {
x := 10
return &x // x 通常逃逸到堆
}
使用以下命令查看逃逸分析结果:
go build -gcflags="-m=2" ./...
GC 会使用编译器生成的 stack map 精确识别活动栈帧中的指针槽。栈本身由 runtime 专门管理,但栈里的指针是 GC 根集合的一部分。
6.1.2. 创建与初始栈
go f() 会被编译器转换为调用 =runtime.newproc=。其主要流程是:
go f() ↓ runtime.newproc / runtime.newproc1 ↓ 从当前 P 的 gFree 获取可复用的 g ├─ 有:复用 g,以及尺寸合适的栈 └─ 无:创建新的 g,并分配最小栈 ↓ 初始化 SP、PC 和 goexit 返回路径 ↓ 放入 P 的 runnable 队列
Go 1.26 中 =stackMin = 2048=,表示 Go 代码所需的最小栈为 2 KiB。实际最小分配值是 =fixedStack=,还要加入部分平台的 =stackSystem=,然后向上取到 2 的幂。
“每个 goroutine 永远从 2 KiB 开始”并不完全准确。runtime 还维护 =startingStackSize=:
上一轮 GC 扫描到的栈使用量平均值 + stackGuard ↓ 限制在 fixedStack 与 maxstacksize 之间 ↓ 向上取到 2 的幂 ↓ 作为后续 goroutine 的初始栈目标
这是避免 goroutine 在生命周期早期频繁扩栈的启发式策略。全新创建 g 且没有可复用项时,源码路径仍由 malg(stackMin) 分配最小栈;复用的 g 如果没有合适栈,则按当前 startingStackSize 重新分配。
6.1.3. 栈检查与扩容
编译器会在大多数函数入口插入栈检查。概念上类似:
if SP-frameSize < stackguard0 {
runtime.morestack()
}
stackguard0 是逻辑检查边界,不等同于操作系统的不可访问 guard page。带 //go:nosplit 的函数不执行普通栈检查,因此 runtime 会为一串 NOSPLIT 调用以及扩栈路径预留空间。
发现空间不足后:
morestack保存当前 goroutine 的执行现场;- 切换到当前 M 的
g0系统栈,避免在已经不足的用户栈上分配内存; newstack通常把容量扩大为原来的 2 倍;- 如果即将进入的栈帧很大,则继续翻倍直到能够容纳;
copystack分配新的连续栈,只复制当前实际使用的部分;- 修正栈帧、defer、panic、channel 等结构中指向旧栈的指针;
- 切换到新栈,释放旧栈并恢复执行。
2 KiB → 4 KiB → 8 KiB → 16 KiB → 32 KiB → ...
单次扩容需要复制当前活动栈,成本约为 =O(已使用栈大小)=;由于容量按倍数增长,总体具有较低的摊销成本。当前默认最大栈限制在 64 位平台约为 1 GB、32 位平台约为 250 MB,超过限制会触发不可恢复的 stack overflow。
现代 gc 工具链使用的是连续、可搬迁栈。Go 1.3 起已经不再使用早期的 segmented stack 模型。
6.1.4. 栈缩容与回收
函数返回只会恢复 SP,不会立即缩小底层栈。GC 扫描 goroutine 时,如果能够安全搬迁,并且满足:
已使用空间 + stackNosplit < 当前栈容量 / 4
runtime 会尝试把栈缩小为当前容量的一半,但不会低于 =fixedStack=。一次 GC 最多缩小一级;后续 GC 仍满足条件时可以继续缩小。
处于 syscall、异步安全点或某些 channel parking 临界状态时,栈不能立即搬迁,runtime 会把缩容延迟到后续同步安全点。
goroutine 结束后:
g进入当前 P 或全局的空闲列表;- 尺寸等于当前
startingStackSize的栈可以随g保留,供后续 goroutine 复用; - 其他尺寸的栈归还给栈缓存、全局池或 runtime 页分配器。
6.1.5. 栈的底层分配
stackalloc 必须运行在 g0 系统栈上,要求分配大小为 2 的幂。goroutine 栈不是普通 GC 堆对象,但通常复用 runtime 的页分配器和 manual =mspan=:
- 小栈优先从当前 P 的
mcache.stackcache获取; - 本地缓存为空时,从全局
stackpool批量补充; - 较大的栈使用专用 manual span,并可通过大栈缓存复用;
- 完全空闲的 stack span 会重新归还给通用 heap 页分配器。
在典型 64 位 Unix 平台的 Go 1.26 中,2、4、8、16 KiB 栈走小栈缓存,32 KiB 及以上通常使用专用 span。该阈值属于 runtime 实现细节。
6.1.6. stackSystem
stackSystem 是 runtime 在每个 goroutine 栈底,为部分操作系统的信号、异常等特殊处理路径额外预留的空间。它不是另一块栈,也不是普通 Go 函数可以自由使用的额外容量。
多数 Unix 系统可以通过 sigaltstack 使用独立的信号栈。Windows、Plan 9 和 iOS 的相关路径不使用独立栈,因此即使 goroutine 已接近栈底,也必须保证信号或异常处理代码有空间执行,不能依赖此时再进行普通扩栈。
Go 1.26 中的定义为:
stackSystem = goos.IsWindows*4096 +
goos.IsPlan9*512 +
goos.IsIos*goarch.IsArm64*1024
| 平台 | stackSystem |
|---|---|
| Windows | 4096 字节 |
| Plan 9 | 512 字节 |
| iOS/arm64 | 1024 字节 |
| Linux/macOS 等 | 0 字节 |
它同时参与最小栈和 guard 边界的计算:
fixedStack = roundUpToPowerOfTwo(stackMin + stackSystem) stackGuard = stackNosplit + stackSystem + abi.StackSmall
例如:
| 平台 | stackMin + stackSystem | fixedStack |
|---|---|---|
| Linux/macOS | 2048 + 0 | 2048 |
| Windows | 2048 + 4096 | 8192 |
| Plan 9 | 2048 + 512 | 4096 |
| iOS/arm64 | 2048 + 1024 | 4096 |
因此 stackSystem 会增加最小栈分配,并把 stackguard0 向高地址移动,使普通函数更早触发扩栈,从而保留栈底空间给 OS 特殊处理路径。
6.1.7. runtime.MemStats.StackInuse 与 StackSys
两者当前的关系可以概括为:
StackInuse = runtime 保留作栈用途的 stack span 容量
StackSys = StackInuse
+ runtime 能统计到的、直接由 OS 分配的线程栈
| 指标 | 含义 |
|---|---|
| StackInuse | 当前划为栈用途的 mspan 总容量,包括 goroutine/runtime 栈、缓存、span 内空闲槽位和每个栈尚未使用的容量 |
| StackSys | StackInuse 加上 runtime 能统计到的、由 OS 直接分配的线程栈 |
StackInuse 不是所有 goroutine 当前活动栈帧字节数之和。例如 goroutine 已分配 16 KiB 栈、当前只使用 3 KiB,统计口径仍按分配容量;小栈 span 内的碎片和缓存也可能计入。
纯 Go、非 cgo 程序中,当前通常有:
StackSys == StackInuse
某些 cgo、=c-shared= 或 c-archive 场景中,直接由 OS 提供的线程栈会让 StackSys > StackInuse=。但目前 cgo 栈统计并不完整,C 代码自行创建的线程栈通常不会全部计入,所以 =StackSys 不能作为进程全部线程栈的真实总量。
例如:
StackInuse = 64 MiB // runtime 管理并保留作栈用途的内存 StackSys = 72 MiB // 再加 8 MiB 可统计到的 OS 线程栈
这不表示当前调用帧实际占用了 64 MiB,也不表示进程有 72 MiB 常驻物理内存。
没有 StackIdle 字段。stack span 完全不再用于栈之后,会归还给通用 heap 页分配器,内存分类可能从 StackInuse 转移到 =HeapIdle=:
GC/栈收缩前:StackInuse 较高 GC/栈释放后:StackInuse 下降,HeapIdle 可能上升
这种分类变化不代表内存已经归还操作系统,因此进程 RSS 不一定同步下降。
对应的 runtime/metrics 指标为:
| runtime/metrics | 含义 |
|---|---|
/memory/classes/heap/stacks:bytes |
对应 StackInuse |
/memory/classes/os-stacks:bytes |
当前约等于 StackSys - StackInuse |
/gc/scan/stack:bytes |
上一轮 GC 扫描的栈字节数,更接近活动栈规模,但不是当前时刻精确值 |
/gc/stack/starting-size:bytes |
新 goroutine 的当前初始栈目标值 |
不要使用 StackInuse / runtime.NumGoroutine() 推导单个 goroutine 的精确栈用量,因为其中包含 runtime goroutine、不同栈容量、栈缓存和 span 粒度带来的开销。
6.1.8. 实际影响
- goroutine 很轻量,但不是零成本;goroutine 泄漏会保留栈以及栈引用的堆对象;
- 深递归会持续扩栈,达到上限后导致不可恢复的 stack overflow;
- 栈会搬迁,不要把由栈地址转换得到的
uintptr长期保存;隐藏在uintptr中的地址不会随栈搬迁自动修正; StackInuse=、=StackSys都是 runtime 内存分类指标,不等同于实际活动栈大小或进程 RSS。
6.1.9. 参考资料
- runtime/stack.go:栈布局、分配、复制、扩缩容及 =stackSystem=;
- runtime/proc.go:goroutine 创建、=g= 与栈复用;
- runtime/mgcmark.go:GC 栈扫描与缩容;
- runtime.MemStats:=StackInuse=、=StackSys= 定义;
- runtime/metrics:栈相关运行时指标;
- Go 1.3 Release Notes:从 segmented stack 切换到连续、可搬迁栈。
7. 并发模式
7.1. Join
Figure 8: 单任务 Join
Figure 9: 带结果的 Join
Figure 10: 多任务 Join
Figure 11: 带超时的 Join
7.2. Notify and Wait
Figure 12: 通知并等待单个 Worker
Figure 13: 通知并等待多个 Worker
7.3. Pipe
Figure 14: Pipe 的扇出模式和扇入模式
7.4. Work Pool
一个固定大小的 goroutine 工作池。调用方只负责提交任务,Pool 负责限制并发数量、分发任务以及统一关闭 Worker。
Figure 15: 核心结构
Figure 16: 运行流程
特点:
- 通过固定数量的 Worker 限制并发度,避免为每个任务无限创建 goroutine。
- 无缓冲 channel 提供自然背压,让任务生产速度受 Worker 处理能力约束。
- 任务提交和任务完成是两个不同时间点。
- Shutdown 前必须确保不再调用 Run;关闭后继续发送会触发 panic。
- maxGoroutines 应大于 0,否则调用 Run 会一直阻塞。
7.5. Resource Pool
并发安全的资源复用池。资源必须实现 io.Closer,Pool 负责按需创建资源、缓存空闲资源,并在资源多余或 Pool 关闭时释放资源。
它与 Worker Pool 不同:Worker Pool 限制并发任务数;这里的 Resource Pool 主要限制空闲资源的缓存数量。
Figure 17: 核心流程
7.6. Runner
统一管理 一组顺序任务 ,并在任务完成、执行超时或收到操作系统中断时返回对应结果。
Figure 18: 核心流程
Figure 19: 超时语义
模式特点与限制:
- 适合按固定顺序运行一组不可并发的任务。
- 统一对外暴露正常完成、超时和中断三种结果。
- 任务函数没有错误返回值,业务错误无法通过 Runner 汇总。
- 任务 panic 没有恢复机制。
- Add 与 Start 不能安全地并发调用。
- Runner 的 timer 和 channel 都按单次运行设计,不适合重复调用 Start。
- 若需要真正取消任务,应让任务接收 context.Context 或取消 channel,并在 timeout 或 interrupt 时主动传播取消信号。
8. OOP
8.1. 结构体嵌入(Embedding)
核心思想:Go 用组合(composition) 代替继承(inheritance),嵌入是实现组合的语法糖。
8.1.1. 组合代替继承(最常见)
Go 没有继承,用嵌入实现"is-a"或"has-a"关系:
type Animal struct {
Name string
Age int
}
func (a Animal) Speak() string {
return a.Name + " makes a sound"
}
type Dog struct {
Animal // 嵌入,Dog "继承"了 Animal 的字段和方法
Breed string
}
d := Dog{Animal{"Buddy", 3}, "Labrador"}
d.Name // ✅ 直接访问,不用 d.Animal.Name
d.Speak() // ✅ 直接调用,不用 d.Animal.Speak()
8.1.2. 添加功能(装饰器模式)
给已有类型"附加"能力:
// 给任何结构体加锁能力
type SafeCounter struct {
sync.Mutex // 嵌入锁
count int
}
func (c *SafeCounter) Inc() {
c.Lock() // 直接调用,不用 c.Mutex.Lock()
defer c.Unlock()
c.count++
}
8.1.3. 接口实现委托
嵌入一个已实现接口的类型,自动满足接口:
type Reader interface {
Read(p []byte) (n int, err error)
}
type MyReader struct {
io.Reader // 嵌入,自动实现 io.Reader 接口
readCount int
}
// 可以直接传给需要 io.Reader 的地方
func process(r io.Reader) { ... }
f, _ := os.Open("file.txt")
mr := MyReader{Reader: f}
process(mr) // ✅ MyReader 实现了 io.Reader
8.1.4. 覆盖/扩展方法
嵌入后可以覆盖方法:
type Base struct{}
func (b Base) String() string { return "base" }
type Extended struct {
Base
}
func (e Extended) String() string { return "extended" } // 覆盖
e := Extended{}
e.String() // "extended"(调用 Extended 的方法)
e.Base.String() // "base"(仍可访问原始方法)
8.1.5. 嵌入指针 vs 嵌入值
// 嵌入值 - 拷贝语义
type A struct {
Inner // Outer 包含 Inner 的完整拷贝
}
// 嵌入指针 - 共享语义(更常用于大结构体)
type B struct {
*Inner // Outer 只包含指针,多个实例可共享
}
例子:
type Config struct {
Debug bool
Port int
}
type ServerB struct {
*Config // 嵌入指针
Name string
}
func TestEmbedPointer(t *testing.T) {
cfg := &Config{true, 8080}
s1 := ServerB{cfg, "server1"}
s2 := ServerB{cfg, "server2"} // 共享同一个 Config
s2.Debug = false // 修改 s2
fmt.Println(s1.Debug) // false ← s1 也变了!
fmt.Println(s2.Debug) // false
}
8.1.6. 方法提升、方法集与可寻址性
嵌入字段的方法可以提升到外层结构体,因此可以省略中间的字段选择器。方法提升并不等于继承,它仍然受 Go 方法集规则的约束。
type T1 struct{}
func (T1) T1M1() {}
func (T1) T1M2() {}
func (*T1) PT1M3() {}
type T2 struct{}
func (T2) T2M1() {}
func (T2) T2M2() {}
func (*T2) PT2M3() {}
type T struct {
T1
*T2
}
在这个例子中, T1 是值类型嵌入, *T2 是指针类型嵌入。提升后的方法集如下 :
| 外层类型 | 方法集 |
|---|---|
T |
PT2M3 、 T1M1 、 T1M2 、 T2M1 、 T2M2 |
*T |
PT1M3 、 PT2M3 、 T1M1 、 T1M2 、 T2M1 、 T2M2 |
*T 比 T 多出一个 PT1M3 。一般规则是 :
- 如果
S嵌入值类型U,那么S和*S的方法集都包含接收者为U的提升方法;只有*S的方法集还包含接收者为*U的提升方法。 - 如果
S嵌入指针类型*U,那么S和*S的方法集都包含接收者为U或*U的提升方法。
8.1.6.1. 背后的逻辑
方法集描述的是类型本身稳定拥有的能力,不能依赖某个具体表达式是否恰好可以取地址。
- 值嵌入只保存一个
U值。调用*U的方法需要取得内嵌值的地址,因此外层为*S时总能完成转发,而普通的S值不一定可寻址。 - 指针嵌入直接保存一个
*U指针。即使外层S不可寻址,也可以通过已经保存的指针调用U和*U的方法。
局部变量通常可以取地址,所以 Go 会为方法调用自动取地址 :
var t T
t.PT1M3() // 可以,等价于 (&t).PT1M3()
func newT() T {
return T{}
}
newT().PT1M3() // 编译错误:临时值不可寻址
values := map[string]T{"a": T{}}
values["a"].PT1M3() // 编译错误:map 元素不可寻址
自动取地址只是调用语法上的便利,不会改变 T 的方法集。这个区别会直接影响接口实现 :
type I interface {
PT1M3()
}
var _ I = T{} // 编译错误:T 的方法集没有 PT1M3
var _ I = (*T)(nil) // 可以:*T 的方法集包含 PT1M3
如果检查方法集的辅助函数内部使用 reflect.TypeOf(i).Elem() ,那么传入 &t 得到的是 T ,传入 &pt 得到的是 *T ,其中 pt 的类型为 *T 。额外的一层取地址只是为了配合 Elem() ,实际比较的仍然是 T 与 *T 的方法集。
8.2. interface 本质
| 概念 | 本质 |
| interface{} | eface{_type, data}(两个指针,16字节) |
| 非空 interface | iface{tab, data}(两个指针,16字节) |
| itab | 接口类型 + 具体类型 + 方法表 |
| 方法调用 | 通过 itab.fun[] 间接跳转(类似 C++ vtable) |
| 赋值 | 拷贝数据到堆 + 创建/复用 itab |
| nil 判断 | tab 和 data 都为 nil 才是 nil |
Figure 20: Method sets as described by the Go specification
Figure 21: Method sets from the perspective of the receiver type
8.2.1. 空接口 interface{} / any - eface
// runtime/runtime2.go
type eface struct {
_type *_type // 指向类型信息
data unsafe.Pointer // 指向实际数据
}
var i interface{} = 42
i (eface, 16字节)
┌─────────────────────┐
│ _type ──────────────┼──► type info (int)
│ data ──────────────┼──► 42
└─────────────────────┘
8.2.2. 非空接口 - iface
// runtime/runtime2.go
type iface struct {
tab *itab // 类型信息 + 方法表
data unsafe.Pointer // 指向实际数据
}
var w io.Writer = os.Stdout w (iface, 16字节) ┌─────────────────────┐ │ tab ───────────────┼──► itab │ data ───────────────┼──► os.Stdout 实例 └─────────────────────┘
8.2.2.1. itab 结构(核心)
type itab struct {
inter *interfacetype // 接口类型信息
_type *_type // 具体类型信息
hash uint32 // 类型哈希,用于快速类型断言
_ [4]byte
fun [1]uintptr // 方法表(实际大小按方法数量分配)
}
type _type struct { // 类型元信息
size uintptr // 类型大小
ptrdata uintptr // 包含指针的前缀大小
hash uint32 // 类型哈希
tflag tflag
align uint8 // 对齐
fieldAlign uint8
kind uint8 // 类型种类 (int, string, struct, ...)
equal func(unsafe.Pointer, unsafe.Pointer) bool
gcdata *byte
str nameOff
ptrToThis typeOff
}
itab ┌───────────────────────┐ │ inter ───► io.Writer │ (接口类型) │ _type ───► *os.File │ (具体类型) │ hash: 0x12345678 │ │ fun[0] ───► Write │ (方法地址) │ fun[1] ───► ... │ └───────────────────────┘
8.2.2.2. 完整内存布局
type Speaker interface {
Speak() string
}
type Dog struct {
Name string
}
func (d Dog) Speak() string { return d.Name + ": woof!" }
var s Speaker = Dog{"Buddy"}
s (iface)
┌──────────┐ itab interfacetype
│ tab ─────┼────► ┌─────────────────┐ ┌──────────────┐
│ data ──┐ │ │ inter ──────────┼────►│ Speaker │
└────────┼─┘ │ _type ──────────┼──┐ │ methods: │
│ │ hash │ │ │ - Speak │
│ │ fun[0]: Speak ──┼──┼──────► Dog.Speak 代码
│ └─────────────────┘ │
│ ▼
│ _type ┌──────────────┐
│ ┌──────────────┐ │ Dog 类型信息 │
│ │ size: 16 │ └──────────────┘
│ │ kind: struct │
│ └──────────────┘
▼
┌──────────────┐
│ Dog{ │ (堆上分配)
│ Name:"Buddy"│
│ } │
└──────────────┘
赋值时发生了什么
var s Speaker = Dog{"Buddy"}
// 1. 查找或创建 itab(<Speaker, Dog> 这对组合)
// 2. Dog{"Buddy"} 拷贝到堆上
// 3. iface.tab = &itab
// 4. iface.data = 堆上数据的地址
方法调用过程
s.Speak() // 实际执行: // 1. 取 s.tab → itab // 2. 取 itab.fun[0] → Dog.Speak 的地址 // 3. 调用 Speak(s.data)
用 unsafe 验证
func TestInterfaceInternal(t *testing.T) {
type iface struct {
tab uintptr
data uintptr
}
var s Speaker = Dog{"Buddy"}
// 查看 iface 结构
i := (*iface)(unsafe.Pointer(&s))
t.Logf("tab: 0x%x", i.tab) // itab 地址
t.Logf("data: 0x%x", i.data) // Dog 数据地址
// 通过 data 指针读出 Dog
d := (*Dog)(unsafe.Pointer(i.data))
t.Logf("Name: %s", d.Name) // "Buddy"
}
nil interface vs interface 包含 nil
// nil interface - tab 和 data 都是 nil var s Speaker // tab=nil, data=nil s == nil // ✅ true // 非 nil interface,但 data 是 nil var d *Dog // nil 指针 var s Speaker = d // tab=&itab(Speaker,*Dog), data=nil s == nil // ❌ false!因为 tab 不是 nil
nil interface: 包含 nil 值的 interface: ┌──────────────┐ ┌──────────────────────┐ │ tab: nil │ │ tab: &itab (非nil!) │ │ data: nil │ │ data: nil │ └──────────────┘ └──────────────────────┘ == nil ✅ == nil ❌ (经典坑!)
8.2.3. 两种赋值方式
8.2.3.1. 值 receiver - 两种都行
func (d Dog) Speak() string { return d.Name }
var s Speaker = Dog{"Buddy"} // ✅ 值类型
var s Speaker = &Dog{"Buddy"} // ✅ 指针类型
8.2.3.2. 指针 receiver - 只能用指针 (常用)
func (d *Dog) Speak() string { return d.Name }
var s Speaker = Dog{"Buddy"} // ❌ 编译错误:Dog 没实现 Speaker
var s Speaker = &Dog{"Buddy"} // ✅ *Dog 实现了 Speaker
8.2.3.3. 区别
| 特性 | Dog{"Buddy"} (值) | &Dog{"Buddy"} (指针) |
| data 指向 | Dog 的副本 | Dog 的指针 |
| 修改原变量 | 不影响接口中的值 | 影响接口中的值 |
| 拷贝成本 | 拷贝整个 Dog | 只拷贝一个指针(8字节) |
| itab | <Speaker, Dog> | <Speaker, *Dog> |
8.2.3.3.1. 值赋值
var s Speaker = Dog{"Buddy"}
赋值时,Dog 值被 拷贝到堆上:
s (iface, 栈上, 16字节)
┌──────────────────────┐
│ tab: &itab │
│ data ────────────────┼──► ┌──────────────┐ (堆上,拷贝)
└──────────────────────┘ │ Name: "Buddy"│
└──────────────┘
(这是 Dog 的副本,和原始值无关)
赋值后修改原变量不影响接口:
d := Dog{"Buddy"}
var s Speaker = d
d.Name = "Max"
// s 里的还是 "Buddy"(拷贝了一份)
8.2.3.3.2. 指针赋值
var s Speaker = &Dog{"Buddy"}
data 存的是 指向 Dog 的指针:
s (iface, 栈上, 16字节)
┌──────────────────────┐
│ tab: &itab │
│ data ────────────────┼──► ┌───────────┐ ┌──────────────┐
└──────────────────────┘ │ *Dog ptr ─┼──► │ Name: "Buddy"│
└───────────┘ └──────────────┘
(指针) (Dog 实例,堆上)
共享同一个 Dog:
d := &Dog{"Buddy"}
var s Speaker = d
d.Name = "Max"
// s 里的也变成 "Max"(共享同一个 Dog)
9. 工具
9.1. go-wrk
go install github.com/adjust/go-wrk go-wrk http://localhost:8080 #concurrency 400, thread 8, total calls 100000 go-wrk -c=400 -t=8 -n=100000 http://localhost
9.2. objdump
go tool objdump 和系统的 objdump 都能反汇编二进制中的机器指令,但关注点不同:前者面向 Go 程序,能识别 Go 包和函数符号、关联源码行,默认输出 Go/Plan 9 汇编语法;后者是通用目标文件工具,除反汇编外还可检查 ELF、Mach-O、PE 的文件头、section、符号表和重定位信息。
go build -o demo main.go # 反汇编所有 Go text symbol go tool objdump demo # 只查看 main.main,并在汇编旁显示 Go 源码 go tool objdump -S -s 'main\.main' demo # 在 Go 汇编旁附加 GNU 汇编语法(目标架构支持时) go tool objdump -gnu -s 'main\.main' demo # 使用系统 objdump 反汇编;具体选项因 GNU/LLVM 实现而异 objdump -d demo
分析某个 Go 函数生成了哪些指令时,优先使用 go tool objdump ;检查整个二进制的 section、符号、重定位或 cgo 代码时,使用系统 objdump 更合适。两者输出看起来不同通常只是汇编语法和符号展示方式不同,底层机器指令仍来自同一个二进制。