写给已经能写 Go 但想知道"为什么"的人。这篇文章不讲语法,只聊机制。
一、并发不是并行
很多人把这两个词当同义词用。Rob Pike 那句 "Concurrency is not parallelism" 被引用了无数遍,但真正能在白板上画清楚的人不多。
并发 是程序的结构------你把任务拆成了多个可独立推进的执行流。
并行是程序的执行方式------这些执行流在物理上同时运行。
设 GOMAXPROCS=1:
时间 ──────────────────────────────────────────→
P0: ┃ G1 ┃ G3 ┃ G1 ┃ G2 ┃ G3 ┃ G1 ┃
只有一个逻辑处理器 P,任意瞬间只有一个 goroutine 在跑。但三个 goroutine 都在"推进中"------G1 让出 CPU 时 G3 接上,没有谁被饿死。这就是有并发、无并行。
把 GOMAXPROCS 调到 4,四个 P 各跑各的 goroutine,某一瞬间真的有四条指令流在不同核心上同时执行------这才是并行。
一个容易忽略的细节:GOMAXPROCS=1 不是单线程程序。系统调用(文件 I/O、CGO)会阻塞当前线程,runtime 会创建新线程让 P 继续调度。所以你依然能观察到"看起来同时发生"的 I/O 操作,但那不是 Go 用户态调度的并行,而是操作系统线程级别的重叠。
二、有缓冲 Channel 不是消息队列
我见过不少项目里这样写:
go
ch := make(chan Task, 10000) // "缓冲大一点就不会阻塞了"
这是一种幻觉。Channel 的缓冲区是编译时固定大小的环形数组,不是链表,不会扩容。把容量从 100 改成 10000,你只是把系统崩溃的时间从第 3 秒推迟到了第 5 分钟。
真正的问题是:当生产速率持续大于消费速率时,任何有限缓冲都会被填满。
填满之后:
- 发送方阻塞
- 如果发送方是 HTTP handler → 请求超时 → 客户端重试 → 雪崩
- 如果发送方是不断产生事件的 goroutine → 该 goroutine 永久挂起 → 泄漏
正确的姿势不是加大缓冲,而是明确背压策略:
go
select {
case ch <- task:
// 成功入队
default:
// 队列满了:丢弃 / 返回错误 / 落盘 / 降级
metrics.Increment("queue.dropped")
}
Channel 的定位是 goroutine 间的同步原语,不是持久化队列。需要无界缓冲就用 ring buffer + 条件变量自己实现,或者直接上 Kafka。把架构问题甩给一个语言原语,迟早翻车。
三、Worker Pool 的优雅退出
一个能被 context 取消、保证零 goroutine 泄漏的 worker pool,核心就三样东西:
go
func RunPool(ctx context.Context, workers int, tasks <-chan func()) {
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case fn, ok := <-tasks:
if !ok {
return
}
fn()
}
}
}()
}
wg.Wait()
}
退出时序:
cancel() 调用
→ ctx.Done() 关闭(广播给所有 select)
→ 每个 worker 走进 case <-ctx.Done(),return
→ defer wg.Done() 逐个触发
→ wg.Wait() 解除阻塞
→ RunPool 返回,调用方确认所有 worker 已死
防泄漏的三根支柱:
| 机制 | 作用 |
|---|---|
ctx.Done() |
让 worker 有能力感知"该退出了" |
WaitGroup |
让调用方能等到所有 worker 真正退出 |
ok 检查 |
即使没 cancel,关闭 channel 也能触发退出 |
少一根都有泄漏风险。
四、逃逸分析:编译器的生杀大权
Go 没有 new 和 malloc 的语义差别,一切由编译器的逃逸分析决定:这个变量的生命周期是否超出当前栈帧?超出就放堆上,GC 负责回收。
以下四种场景几乎必定逃逸:
4.1 返回局部变量的指针
go
func newUser() *User {
u := User{Name: "alice"}
return &u // u 必须逃逸:函数返回后栈帧销毁,但指针还活着
}
函数返回后它的栈帧就没了。如果你把局部变量的地址传出去,编译器别无选择,只能把它放到堆上。
对比值返回:
go
func newUser() User {
u := User{Name: "bob"}
return u // 拷贝到调用方栈帧,u 本身安全留在栈上(被销毁也无所谓)
}
这里的副本住在调用方的栈帧里。Go 的调用约定是:调用方在自己的栈上为返回值预留空间,被调方把数据拷贝过去。小结构体甚至直接走寄存器,连内存都不碰。
4.2 闭包捕获变量且在函数外存活
go
func makeCounter() func() int {
count := 0 // 逃逸
return func() int {
count++
return count
}
}
闭包在 Go 里是一个结构体:一个函数指针加上若干捕获变量的引用。当闭包的生命周期比定义它的函数更长时,被捕获的变量就必须逃逸。
但如果闭包只在当前函数内部使用(比如传给 sort.Slice),编译器能证明不逃逸,一切留在栈上。
4.3 赋值给接口类型
go
var w io.Writer = &bytes.Buffer{} // Buffer 逃逸
fmt.Println(42) // 42 被装箱成 interface{} → 逃逸
接口值的内部结构是 (类型指针, 数据指针)。数据那一半存的是指针,指向实际的值。所以任何值一旦被装进接口,就需要一个堆上的地址来存放。
fmt.Println 的签名是 func Println(a ...interface{}),传进去的每个参数都会被装箱。这就是为什么高性能日志库(如 zerolog)会避免 interface{} 参数。
4.4 运行时才能确定大小的分配
go
func process(n int) {
buf := make([]byte, n) // n 编译时未知 → 逃逸
// ...
}
栈帧大小必须在编译期确定。如果 make 的长度是运行时变量,编译器没法在栈上留空间。此外即使长度是常量,超过一定阈值(通常 64KB 左右)也会放堆上,防止栈溢出。
五、闭包到底是什么
把术语剥干净,闭包就是:一个函数,加上它运行所需的外部环境,打包成一个可调用对象。
go
func main() {
base := 10
add := func(x int) int { return base + x }
fmt.Println(add(5)) // 15
}
add 不只是一段代码,它还"记住了" base。如果 add 只是一个普通函数指针,调用时去哪找 base?答案是找不到。所以编译器生成的实际结构大概是:
闭包对象:
┌─────────────────────┐
│ funcptr → 代码段地址 │
│ captured: &base ─────┼──→ base 变量(栈上或堆上)
└─────────────────────┘
两个关键事实:
-
捕获的是变量本身(引用),不是值的快照。 闭包内修改变量,外部看得到;外部修改变量,闭包内也看得到。
-
闭包延长了被捕获变量的生命周期。 如果闭包从函数中返回,被捕获的局部变量就不能随栈帧死去,必须逃逸到堆上续命。
这就是"闭包导致逃逸"的根本原因------不是闭包这个语法有什么魔法,而是它改变了变量的生命周期约束。
六、一张图收束全文
编译时
│
┌────────────┼────────────┐
│ │ │
逃逸分析 调用约定 闭包生成
决定堆/栈 决定返回值位置 决定捕获方式
│ │ │
└────────────┼────────────┘
│
运行时
│
┌────────────┼────────────┐
│ │ │
GC 回收堆 调度器轮转 channel 同步
上的对象 goroutine goroutine 间通信
(并发≠并行) (有限缓冲≠队列)
Go 把很多复杂性藏在编译器和 runtime 里,写起来很轻松。但"轻松"不等于"不需要理解"。你可以不关心这些细节写出能跑的代码,但要写出不泄漏、不 OOM、能稳定跑三年的代码,这些心智模型省不了。
验证逃逸:go build -gcflags="-m -l" .
验证分配:go test -bench=. -benchmem
验证泄漏:runtime.NumGoroutine() + pprof