Go 的并发模型常被一句话概括:"不要通过共享内存来通信,而要通过通信来共享内存。"这句话很漂亮,但新手听完往往还是不会用。这篇文章不停留在口号,而是把 goroutine、channel、select 三件套讲透 —— 它们各自解决什么问题,怎么配合,以及最容易踩的几个坑。
goroutine:几乎免费的并发单元
goroutine 是 Go 运行时管理的轻量级线程。它"轻"在哪里?一个操作系统线程初始栈通常是 1~8MB,而一个 goroutine 初始栈只有 2KB,且能按需增长收缩。所以一个程序同时跑几十万个 goroutine 是完全正常的。
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s, i)
time.Sleep(10 * time.Millisecond)
}
}
func main() {
go say("world") // 启动一个 goroutine,go 关键字就这么简单
say("hello") // 主 goroutine 继续跑
}
但这段代码有个隐藏问题:main 函数返回时,整个程序退出,不会等待其他 goroutine。如果 say("hello") 比 say("world") 先结束,"world" 可能只打印了一部分。这引出第一个核心问题:如何等待 goroutine 完成?
用 sync.WaitGroup 等待一组 goroutine
import "sync"
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // 计数 +1,必须在 go 之前调用
go func(id int) {
defer wg.Done() // 完成时计数 -1,defer 保证一定执行
fmt.Printf("worker %d done\n", id)
}(i) // 注意:把 i 作为参数传进去
}
wg.Wait() // 阻塞,直到计数归零
fmt.Println("all done")
}
这里有个经典坑:循环变量捕获。在 Go 1.22 之前,如果直接在闭包里用 i 而不是作为参数传入,所有 goroutine 会共享同一个 i,打印出来可能全是 5。Go 1.22 起循环变量每轮迭代是新的,但显式传参依然是更清晰、更不易错的写法,推荐保留这个习惯。
channel:goroutine 之间的安全管道
channel 是 Go 并发的核心。它是一个有类型的、并发安全的队列,goroutine 通过它传递数据,而不需要自己加锁。
ch := make(chan int) // 无缓冲 channel
ch := make(chan int, 10) // 容量为 10 的有缓冲 channel
ch <- 42 // 发送:把 42 放进 channel
v := <-ch // 接收:从 channel 取出一个值
close(ch) // 关闭 channel
无缓冲 vs 有缓冲
这个区别决定了 channel 的行为,务必分清:
- 无缓冲 channel:发送和接收必须同时就绪才能完成,否则先到的一方阻塞等待。它本质是一次"同步握手"—— 发送成功意味着对方一定收到了。
- 有缓冲 channel:缓冲区没满,发送就不阻塞;缓冲区不空,接收就不阻塞。它更像一个有容量的队列,起到削峰、解耦的作用。
// 无缓冲:用作同步信号
done := make(chan struct{})
go func() {
doWork()
done <- struct{}{} // 发送完成信号
}()
<-done // 主 goroutine 在此等待,直到上面发信号
// 有缓冲:用作任务队列
jobs := make(chan int, 100)
for i := 0; i < 100; i++ {
jobs <- i // 前 100 个不会阻塞
}
range 和 close 配合遍历 channel
func producer(ch chan<- int) { // chan<- int 表示只能发送
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // 生产完毕,关闭 channel
}
func main() {
ch := make(chan int)
go producer(ch)
for v := range ch { // range 会一直收,直到 channel 被关闭
fmt.Println(v)
}
}
关于 close 的三条铁律:只有发送方应该 close,接收方不要 close;向已关闭的 channel 发送会 panic;从已关闭的 channel 接收会立刻返回零值,用 v, ok := <-ch 的 ok 判断 channel 是否还开着。
select:同时等待多个 channel
当一个 goroutine 需要同时和多个 channel 打交道时,select 登场。它会阻塞,直到其中某个 case 可以执行;如果多个同时就绪,随机选一个。
select {
case v := <-ch1:
fmt.Println("从 ch1 收到", v)
case ch2 <- 100:
fmt.Println("向 ch2 发送了 100")
case <-time.After(time.Second):
fmt.Println("超时了") // 超时控制的经典写法
default:
fmt.Println("没有 channel 就绪") // 加了 default,select 就不阻塞了
}
用 select 做超时控制
func fetchWithTimeout() (string, error) {
result := make(chan string, 1) // 缓冲为 1,防止 goroutine 泄漏(见后文)
go func() {
result <- slowQuery()
}()
select {
case r := <-result:
return r, nil
case <-time.After(2 * time.Second):
return "", fmt.Errorf("query timeout")
}
}
context:取消信号的标准方式
time.After 能做单点超时,但真实服务里需要把"取消"信号一层层往下传 —— 一个 HTTP 请求被取消,它派生出的所有下游 goroutine 都应该停下来。这是 context 的职责。
import "context"
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done(): // 收到取消信号
fmt.Printf("worker %d 退出:%v\n", id, ctx.Err())
return
default:
doSomething()
time.Sleep(100 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel() // 务必调用,释放 context 关联的资源
for i := 0; i < 3; i++ {
go worker(ctx, i)
}
<-ctx.Done()
time.Sleep(50 * time.Millisecond) // 给 worker 一点退出时间
}
规约:context 作为函数第一个参数,命名为 ctx;不要把 context 存进结构体;WithCancel / WithTimeout 返回的 cancel 函数一定要调用(用 defer cancel()),否则会泄漏。
worker pool:最实用的并发模式
把上面的知识组合起来,就能写出生产级的 worker pool —— 用固定数量的 goroutine 消费一个任务队列。这能限制并发度,避免瞬间起几万个 goroutine 把下游打垮。
func workerPool(numWorkers int, jobs []int) []int {
jobCh := make(chan int, len(jobs))
resultCh := make(chan int, len(jobs))
var wg sync.WaitGroup
// 启动固定数量的 worker
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for job := range jobCh { // 从队列里取任务,直到 jobCh 关闭
resultCh <- job * job // 处理并把结果写回
}
}(w)
}
// 投递所有任务,然后关闭任务队列
for _, j := range jobs {
jobCh <- j
}
close(jobCh)
// 单独起一个 goroutine,等所有 worker 完成后关闭结果队列
go func() {
wg.Wait()
close(resultCh)
}()
// 收集结果
var results []int
for r := range resultCh {
results = append(results, r)
}
return results
}
这个模式里每一处设计都有道理:jobCh 关闭后 range 自动退出,worker 自然结束;用 WaitGroup 确认所有 worker 退出后才关 resultCh,否则向已关闭的 channel 发送会 panic;结果 channel 有缓冲,worker 不会因为没人收结果而卡住。
三个最常见的并发坑
坑一:goroutine 泄漏
goroutine 不会被 GC"杀掉",只要它还阻塞在某个 channel 上,它就永远占着内存。最常见的泄漏:超时后主流程走了,但子 goroutine 还卡在向无人接收的 channel 发送。
// 泄漏版本:超时返回后,goroutine 永远阻塞在 result <- ...
func leaky() string {
result := make(chan string) // 无缓冲!
go func() {
result <- slowQuery() // 主流程超时走了,这里永远发不出去
}()
select {
case r := <-result:
return r
case <-time.After(time.Second):
return "timeout" // 返回了,但上面的 goroutine 泄漏了
}
}
// 修复:把 channel 改成有缓冲(容量 1),发送方就不会阻塞
result := make(chan string, 1)
坑二:对共享变量的数据竞争
// 有数据竞争:多个 goroutine 同时写 counter
var counter int
for i := 0; i < 1000; i++ {
go func() { counter++ }() // counter++ 不是原子操作
}
// 修复 1:用 sync.Mutex
var mu sync.Mutex
mu.Lock(); counter++; mu.Unlock()
// 修复 2:用原子操作
import "sync/atomic"
var counter int64
atomic.AddInt64(&counter, 1)
// 修复 3:更 Go 的方式 —— 用 channel,让一个 goroutine 独占这个变量
排查数据竞争有神器:运行时加 -race 标志,go run -race main.go 或 go test -race,它会在运行时检测并精确报告竞争发生的位置。上线前的代码都应该跑一遍。
坑三:死锁
// 死锁:无缓冲 channel,没有任何 goroutine 接收
func main() {
ch := make(chan int)
ch <- 1 // 阻塞,永远等不到接收方
fmt.Println(<-ch) // 到不了这里
}
// 运行报:fatal error: all goroutines are asleep - deadlock!
Go 运行时能检测到"所有 goroutine 都睡死了"这种全局死锁并直接报错。但局部死锁(部分 goroutine 卡死)它检测不到,需要靠 -race、pprof 或者超时机制来兜底。
什么时候用 channel,什么时候用锁
"通过通信共享内存"不是说锁是错的。经验上的分工是:需要在 goroutine 之间传递数据所有权、做任务分发、做事件通知,用 channel;只是保护一小段临界区、一个计数器、一个 map,用 sync.Mutex 或 atomic 反而更简单直接、性能更好。不要为了"显得 Go"而把所有同步都硬塞进 channel。
errgroup:带错误传播的 WaitGroup
sync.WaitGroup 能等一组 goroutine,但它不管错误 —— 如果某个 goroutine 失败了,你得自己想办法把错误传出来。golang.org/x/sync/errgroup 解决了这个痛点:任意一个 goroutine 返回错误,整组的 Wait() 就返回那个错误,还能配合 context 让其他 goroutine 提前取消。
import (
"context"
"golang.org/x/sync/errgroup"
)
func fetchAll(ctx context.Context, urls []string) ([]string, error) {
g, ctx := errgroup.WithContext(ctx)
results := make([]string, len(urls))
for i, url := range urls {
i, url := i, url // 经典:为每个 goroutine 复制循环变量
g.Go(func() error {
data, err := fetch(ctx, url)
if err != nil {
return err // 返回错误,会触发整组的 ctx 取消
}
results[i] = data // 每个 goroutine 写自己的下标,无竞争
return nil
})
}
if err := g.Wait(); err != nil { // 任意一个失败,这里就拿到第一个错误
return nil, err
}
return results, nil
}
注意 results[i] = data 这里没有数据竞争 —— 虽然多个 goroutine 同时写同一个 slice,但每个只写自己专属的下标,互不重叠。这是一个常用且安全的模式,比给整个 slice 加锁高效得多。
sync.Once 与 sync.Pool:两个被低估的工具
sync.Once 保证某段代码在并发环境下"只执行一次",是实现并发安全的懒加载/单例的标准方式:
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
// 即使 1000 个 goroutine 同时调 GetConfig,这里也只跑一次
instance = loadConfigFromDisk()
})
return instance
}
sync.Pool 是一个临时对象池,用来复用那些"创建成本高、生命周期短、会被频繁分配"的对象,从而减轻 GC 压力。典型场景是复用大的 buffer:
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer) // 池子空时,用 New 造一个
},
}
func handleRequest(data []byte) string {
buf := bufPool.Get().(*bytes.Buffer) // 取一个,可能是复用的,也可能是新的
buf.Reset() // 复用前务必重置状态
defer bufPool.Put(buf) // 用完放回池子
buf.Write(data)
buf.WriteString(" processed")
return buf.String()
}
关于 sync.Pool 有两个必须知道的点:一是池里的对象随时可能被 GC 回收,所以它只适合放"丢了也无所谓、重新造一个就行"的临时对象,不能用来做连接池这种需要保证存活的场景;二是 Get 出来的对象状态是未知的(可能是别人用过放回来的),用之前一定要 Reset。用对了,在高 QPS 服务里它能显著降低内存分配次数和 GC 停顿。
竞争检测的实战流程
最后补一个工程习惯。并发 bug 的特点是"偶现",靠肉眼 review 很难抓全。把下面这条加进你的 CI:
# 测试时开启竞争检测器,它会插桩监控所有内存访问
go test -race ./...
# 压测时也可以开,更容易触发竞争
go test -race -run=TestConcurrent -count=100 ./...
# 注意:-race 会让程序慢 5~10 倍、内存涨 5~10 倍,
# 所以它用于测试和预发环境,不要带到生产构建里
-race 不是"可能有竞争"的猜测,它报告的是确实发生了的竞争访问,并精确到两个冲突访问各自的代码行和 goroutine 创建位置。把"提交前 go test -race"变成肌肉记忆,能帮你拦掉绝大多数并发隐患。
写在最后
把这套模型收拢一下:goroutine 提供廉价的并发执行单元,channel 提供它们之间安全传值的管道,select 让一个 goroutine 能同时应对多个 channel,context 把取消信号沿调用链往下传,WaitGroup / Mutex / atomic 补上"等待"和"轻量同步"这两块。worker pool 是这些零件最典型的组合。
而真正的功力,体现在你对那三个坑的警觉上:每起一个 goroutine,就问自己"它一定会退出吗";每碰一个共享变量,就问"有没有竞争";每用一个无缓冲 channel,就问"对面一定有人收吗"。把这三个问题变成本能,你写的 Go 并发代码就稳了一大半。上线前别忘了 -race。
—— 别看了 · 2026