写 Go 的人大概率是被它的并发模型吸引来的。一个 go 关键字就能起一个协程,channel 天然做通信同步,代码看起来比 Java 的线程池和锁优雅太多。但真正在生产环境用起来,会发现并发编程的坑一个都没少------goroutine 泄漏、data race、死锁,该踩的一个都跑不掉。这篇把 goroutine 和 channel 的核心用法、sync 包和 context 包的实战经验,以及常见并发陷阱聊清楚。
截至 2026 年,Go 最新稳定版是 1.26(2026 年 2 月发布)。1.26 的一个亮点是实验性的 goroutine leak profile,能直接在 pprof 里看到泄漏的 goroutine,对排查并发问题帮助很大。不过并发编程的核心概念从 Go 1.0 到现在基本没变,下面的内容适用于所有 1.x 版本。
goroutine 轻量在哪
goroutine 的轻量是相对于 OS 线程说的。一个 Java 线程的栈默认是 1MB,你开 10000 个线程就吃掉 10GB 内存。goroutine 的初始栈只有 2KB,而且可以按需增长和收缩。同一台机器上跑几十万个 goroutine 不是什么稀奇事。
Go runtime 用 GMP 调度模型管理 goroutine:G 是 goroutine,M 是 OS 线程,P 是处理器(逻辑 CPU)。runtime 把 goroutine 调度到少量的 OS 线程上执行,一个线程阻塞了(比如做系统调用),runtime 会把那个线程上的其他 goroutine 迁移到新线程上。这套机制让你写并发代码时不用操心线程管理,只管 go func() 就行。
go
func main() {
go func() {
fmt.Println("hello from goroutine")
}()
// 主 goroutine 要等一下,否则可能直接退出
time.Sleep(time.Second)
}
这段代码能跑,但用 time.Sleep 等待 goroutine 完成是反模式。生产代码里应该用 sync.WaitGroup 或者 channel 来同步,后面会讲。
channel 和 CSP 模型
Go 的并发哲学来自 CSP(Communicating Sequential Processes)------不要通过共享内存来通信,而是通过通信来共享内存。channel 就是这个哲学的实现。
channel 是 goroutine 之间传递数据的管道。发送方往里塞数据,接收方从里取数据,默认是同步的------发送方塞了数据要等有人取走才继续,接收方没数据就等着。
go
func main() {
ch := make(chan string)
go func() {
ch <- "hello" // 发送
}()
msg := <-ch // 接收
fmt.Println(msg)
}
无缓冲 channel 就是上面这样,发送和接收严格配对,天然提供同步语义。适合做 goroutine 之间的 rendezvous(汇合点)。
有缓冲 channel 在 make 时指定缓冲区大小,发送方在缓冲区没满之前不阻塞:
go
ch := make(chan int, 3)
ch <- 1 // 不阻塞
ch <- 2 // 不阻塞
ch <- 3 // 不阻塞
ch <- 4 // 阻塞,缓冲区满了
有缓冲 channel 适合做生产者-消费者模型的解耦。但缓冲区大小不是越大越好------太大了相当于把问题藏起来了,积压的数据可能已经过期。实际项目中 1 到 10 的缓冲区通常够用。
select 做多路复用
当你同时跟多个 channel 打交道时,select 语句就派上用场了。它类似 switch,但 case 是 channel 操作,哪个先 ready 就执行哪个。
go
func waitForData(timeout <-chan time.Time, data <-chan string) {
select {
case msg := <-data:
fmt.Println("收到:", msg)
case <-timeout:
fmt.Println("超时了")
}
}
这段代码在数据和超时之间做竞争,谁先到走谁的分支。这是 Go 里实现超时控制最简洁的方式。
select 还有个 default 分支,加上之后如果没有任何 channel ready,就直接走 default 不阻塞。这个在非阻塞读取场景很有用:
go
select {
case msg := <-ch:
fmt.Println("收到:", msg)
default:
// 没数据,不等了
}
sync 包:WaitGroup、Mutex、Once
channel 不是万能的,有时候你就是需要传统的同步原语。sync 包提供了这些。
WaitGroup 是最常用的,用来等一组 goroutine 全部完成:
go
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("goroutine %d done\n", id)
}(i)
}
wg.Wait() // 等所有 goroutine 完成
}
Add(1) 在起 goroutine 之前调,Done() 在 goroutine 里用 defer 调,Wait() 阻塞到计数器归零。三个方法配合使用,比 time.Sleep 靠谱太多。
Mutex 保护共享数据。当多个 goroutine 要读写同一个变量时,不加锁就会出 data race:
go
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
有个常见的优化是读写锁 sync.RWMutex------读多写少的场景下,多个读操作可以并行,只有写操作需要独占。
Once 用来保证某段代码只执行一次,典型的单例模式场景:
go
var (
once sync.Once
config *Config
)
func GetConfig() *Config {
once.Do(func() {
config = loadConfig()
})
return config
}
不管多少个 goroutine 同时调 GetConfig,loadConfig 只会执行一次。
context 做超时和取消
生产环境里,一个 HTTP 请求可能衍生出几十个 goroutine 去查数据库、调下游服务。如果客户端断开连接了,这些 goroutine 全都该取消。context 包就是干这个的。
go
func handler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
result, err := fetchData(ctx, "https://api.example.com/data")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(result)
}
func fetchData(ctx context.Context, url string) ([]byte, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
WithTimeout 创建一个 3 秒后自动取消的 context。fetchData 里用 NewRequestWithContext 把 context 传给 HTTP 客户端,超时了请求会自动中断。defer cancel() 是必须的------即使超时了也要调 cancel 释放资源。
context 的取消是级联的。父 context 取消,所有子 context 都取消。这样你在请求入口创建一个 context,一层层传下去,入口取消就全链路取消。
三个最常见的并发陷阱
goroutine 泄漏。goroutine 启动了但永远不退出,占用内存和调度资源。最典型的场景是往一个没有接收方的 channel 发送数据,或者从一个没有发送方的 channel 接收数据:
go
// 泄漏:没人从 ch 读数据,goroutine 永远阻塞在这里
func leak() {
ch := make(chan int)
go func() {
ch <- 42 // 永远阻塞
}()
}
Go 1.26 新增的 goroutine leak profile 能帮你在 pprof 里直接看到这些泄漏的 goroutine 及其调用栈,比以前靠猜和数 goroutine 数量靠谱多了。但根本上还是要养成习惯:每个 goroutine 都要有明确的退出路径,用 context 或 done channel 控制。
data race 。多个 goroutine 同时读写同一个变量,至少一个是写操作,就会产生 data race。结果是不可预测的------每次运行可能得到不同的值。Go 自带了 race detector,go test -race 或 go run -race 就能检测出来:
go
// 有 data race
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // 多个 goroutine 同时写
}()
}
wg.Wait()
fmt.Println(counter) // 可能不是 1000
}
用 go run -race main.go 跑一下就能看到 race 报告。修复方法是用 Mutex 保护,或者用 sync/atomic 包的原子操作。
死锁。所有 goroutine 都在互相等对方,谁也动不了。Go runtime 能检测到全死锁的情况(所有 goroutine 都阻塞),运行时会直接 panic 报错。但部分死锁------比如 goroutine 泄漏导致的------runtime 检测不到。
最常见的死锁场景是:一个 goroutine 拿了锁 A 等锁 B,另一个拿了锁 B 等锁 A。避免方法是统一锁的获取顺序,或者尽量缩小锁的持有范围,用 channel 替代锁来协调。
并发编程说到底就是两件事:把任务拆开并行跑,把结果安全地汇总回来。goroutine 负责拆,channel 和 sync 负责合。理解了 CSP 的思路------通过通信共享数据而不是通过共享内存通信------大部分设计决策就有方向了。剩下的就是多写多踩坑,race detector 和 goroutine leak profile 是你最好的调试工具。