跳到正文

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,在阻塞点 select ctx.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

把一门技术讲透到既懂也会用