主题
06 并发原语:goroutine、channel、select、sync、context
一句话:goroutine 把并发任务的创建成本压得很低,channel/select 负责传递数据与取消信号,Mutex/WaitGroup 负责共享状态和生命周期;真正的难点不是语法,而是「谁拥有数据、谁负责关闭、取消怎么传播」。
适用 Go 1.22+;context 的取消传播语义稳定,errgroup 在 golang.org/x/sync 外部包。调度器细节见 07。
为什么需要
一请求一线程时,线程栈和执行切换的成本都很高;纯回调又把控制流拆碎,超时和错误处理难写。goroutine 让并发代码看起来像顺序代码,但共享状态、关闭顺序和取消传播仍需显式管理。并发 bug 往往不是「不会开 goroutine」,而是泄漏、重复关闭、取消没传下去。
核心机制
goroutine:便宜但有成本
初始栈约 2 KB,按需增长;创建和切换在用户态,成本远低于线程。但每个 goroutine 都有栈、调度和 GC 扫描成本,无界启动只是把内存和延迟问题藏起来。go f() 启动即异步,不保证何时执行,也不传递返回值和错误;用 channel 或 WaitGroup 汇合。
channel:同步与所有权
go
ch := make(chan int) // 无缓冲:发送和接收配对才继续
buf := make(chan int, 10) // 有缓冲:满或空才阻塞无缓冲是「交接」,有缓冲是「队列」,缓冲大小决定背压能藏多少。方向类型 <-chan T(只收)和 chan<- T(只发)用来约束函数职责。关闭规则只有一条主线:由发送方关闭;接收方关闭会 panic,多个发送方时用额外的 done channel 或 sync.Once 协调。
已关闭的 channel:接收立即返回零值和 ok=false,for range 结束;发送会 panic,重复关闭会 panic,关闭 nil 也会 panic。nil channel 永远阻塞,在 select 里等于永久禁用该分支。
select:多路等待
go
select {
case v := <-ch:
_ = v
case ch <- x:
case <-ctx.Done():
return
default:
}多个 case 就绪时伪随机选一个,不要依赖顺序。default 让 select 非阻塞;没有 default 且没有就绪 case 就阻塞。select{} 永久阻塞,常用来保活 main,不是忙等。超时优先用 context.WithTimeout;循环里反复 time.After 会不断创建定时器,Go 1.23 起不可达的 Timer 可以被回收,但分配仍在,以官方文档为准。
sync:共享状态
Mutex保护临界区,defer mu.Unlock()防漏;不要复制含锁的 struct。RWMutex适合读多写少;写锁优先,递归读锁可能死锁,临界区短才划算。WaitGroup的Add必须在启动 goroutine 之前、Wait之前调用;Done配对;计数归零后可复用,但不要在Wait未返回时复用。Once只执行一次,适合初始化;Go 1.21+ 的OnceValue/OnceValues直接返回值。
context:取消传播
Done()返回一个关闭即取消的 channel,Err()说明原因(Canceled或DeadlineExceeded)。WithCancel/WithTimeout/WithDeadline派生子 context;父取消会传递给所有子;cancel必须调用,释放定时器和引用。- 只把 context 用作请求级取消、超时和少量元数据;不要传业务参数,也不要存可变状态。
- 下游函数第一个参数收
ctx,在阻塞点 selectctx.Done();只创建不检查等于没取消。
「不要通过共享内存通信」的实际含义
不是说 Mutex 不能用,而是让数据所有权尽量只在一个 goroutine 手里,通过 channel 转移。需要多方读写同一份状态时,锁往往更直接。判断标准:传事件、任务、所有权用 channel;保护一块被多方读写的状态用锁。channel 本身也是共享内存,只是把同步封装进了收发操作。
最小上手
go
package main
import (
"context"
"fmt"
"sync"
"time"
)
func worker(ctx context.Context, id int, jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Println("stop", id, ctx.Err())
return
case j, ok := <-jobs:
if !ok {
return // 发送方已关闭
}
fmt.Println("worker", id, "job", j)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
jobs := make(chan int, 5)
var wg sync.WaitGroup
for i := 1; i <= 2; i++ {
wg.Add(1)
go worker(ctx, i, jobs, &wg)
}
for i := 1; i <= 5; i++ {
jobs <- i
}
close(jobs) // 只由发送方关闭
wg.Wait()
var once sync.Once
for i := 0; i < 3; i++ {
once.Do(func() { fmt.Println("init once") })
}
}完整版本见 examples/06_concurrency/main.go。运行:go run examples/06_concurrency/main.go。
生产动作
- 有界并发:worker pool 加有界 channel,或
errgroup.SetLimit(n)、semaphore.Weighted;不要无界go func()。 - 每个 goroutine 都要有退出路径:ctx、关闭的 channel 或明确结束条件;启动时想清楚谁负责等它。
- 关闭纪律:发送方关闭;多个发送方用 coordinator;接收方只读。
- 锁内不做 IO:临界区只包必要操作,不调用外部服务;多把锁固定顺序,避免死锁。
- 检查:
go test -race ./...、go vet ./...、goleak;pprof goroutine?debug=2看阻塞栈;runtime.NumGoroutine()看趋势而不是绝对值。 - 超时从 ctx 取:HTTP、数据库、RPC 都用请求的 deadline,不要用固定
sleep当同步。
边界
- goroutine 泄漏不会被 GC:阻塞的栈是根,必须有退出条件。
- channel 不是队列替代品:无界缓冲会把背压藏起来,内存和延迟一起涨。
sync.Map只适合读多写少、key 稳定;通用场景用map加RWMutex。context.Value不是依赖注入;key 用自定义类型,避免碰撞。WaitGroup不能复制;Add与Wait并发且计数为 0 是误用。- 向已关闭 channel 发送的 panic 不能靠 recover 当成正常关闭,职责要在设计时划清。
讲清楚
goroutine 是运行时的用户态任务,初始栈很小,所以能开很多,但每个都有内存和调度成本,无界启动只是把问题推迟。channel 的核心是所有权转移:无缓冲是交接,有缓冲是队列,关闭只能由发送方做,关闭后接收返回零值、发送会 panic。select 在多个就绪分支里伪随机选择,default 让它非阻塞,nil channel 永远不选。共享状态用 Mutex/RWMutex,生命周期用 WaitGroup,取消用 context 从父传到子,创建 ctx 就必须调用 cancel 并在阻塞点检查 Done。所谓「不要通过共享内存通信」,是优先让数据只有一个所有者,channel 传所有权,锁保护多方共享的状态。
适用 Go 1.22+ | 初稿 2026-10