本文基于 Go 并发编程中的高频问题,系统梳理 goroutine、channel、select、context 等核心知识,并总结工程实践中的典型陷阱与最佳实践。
一、并发哲学:Go 为什么用 CSP 模型?
Go 的并发设计遵循 CSP(Communicating Sequential Processes) 模型,核心思想是:
"不要通过共享内存来通信,而要通过通信来共享内存。"
这意味着 Go 不鼓励用锁和全局变量来协调并发,而是鼓励用 channel 在 goroutine 之间传递数据和状态。
二、Goroutine:轻量级的执行单元
2.1 基础用法
go
go func() {
fmt.Println("Hello from goroutine")
}()
goroutine 是用户态轻量线程,初始栈仅 2KB,由 Go 运行时调度,比操作系统线程(pthread)轻量得多。
2.2 生命周期与主线程关系
关键规则 :main 函数返回时,整个进程会立即终止,所有 goroutine 都会被强制杀掉,不会报错,也不会等待。
go
func main() {
go func() {
time.Sleep(10 * time.Second)
fmt.Println("这行永远不会打印")
}()
fmt.Println("main 结束")
}
输出:
less
main 结束
// 进程直接退出,子协程被强制终止
结论 :如果主线程需要等待子协程,必须使用 sync.WaitGroup 或 channel 通信。
三、Channel:并发的"血管"
Channel 是类型安全的、用于 goroutine 间通信和同步的管道。
3.1 声明与创建
go
ch1 := make(chan int) // 无缓冲通道(同步)
ch2 := make(chan string, 3) // 有缓冲通道(异步,容量3)
3.2 核心操作
go
ch <- 42 // 发送
x := <-ch // 接收并赋值
<-ch // 接收并丢弃
x, ok := <-ch // ok=false 表示通道已关闭且已读完
close(ch) // 关闭(只有发送方能关闭)
3.3 无缓冲 vs 有缓冲:本质区别
| 特性 | 无缓冲 make(chan T) |
有缓冲 make(chan T, n) |
|---|---|---|
| 同步性 | 强同步:发送和接收必须同时握手 | 异步:发送方填满缓冲前不阻塞 |
| 用途 | 信号通知、同步协调 | 任务队列、解耦生产消费速度 |
| 死锁风险 | 高(必须严格配对) | 较低(容量耗尽才阻塞) |
无缓冲通道示例(同步握手)
go
func main() {
done := make(chan bool)
go func() {
fmt.Println("子 goroutine 干活...")
time.Sleep(time.Second)
done <- true // 阻塞!直到 main 接收
}()
<-done // 阻塞!直到子 goroutine 发送
fmt.Println("收到完成信号")
}
本质 :无缓冲通道的每次通信都是一次同步事件,确保双方"碰面"。
有缓冲通道示例(异步队列)
go
func main() {
jobs := make(chan int, 3)
jobs <- 1
jobs <- 2
jobs <- 3
// jobs <- 4 // 阻塞!buf 已满
fmt.Println(<-jobs) // 取出 1,腾出空间
}
3.4 通道满了的唤醒机制
当缓冲通道已满时,后续发送操作会阻塞,对应的 goroutine 进入发送等待队列(sendq)。一旦有人执行接收:
- 把缓冲区头部的数据拷贝给接收方
- 把 sendq 队列头部的 goroutine 唤醒,将其数据填入刚空出的缓冲区位置
关键点 :被阻塞的发送者按 FIFO 顺序排队,不会重新竞争。
四、Select:多路复用的"交通信号灯"
Channel 是单路管道,Select 是多路开关。
4.1 为什么需要 select?
go
ch1 := make(chan int)
ch2 := make(chan string)
// 不用 select:串行阻塞,ch2 有数据也收不到
<-ch1
<-ch2
// 用 select:并行监听,谁先到处理谁
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
}
4.2 核心特性
1. 单次执行,不是持续监听
go
ch := make(chan int, 8)
for i := 0; i < 8; i++ {
ch <- i
}
select {
case v := <-ch:
fmt.Println(v) // 只读 1 个!
}
// select 结束,ch 里还剩 7 个
select 是单次检查,不是循环。 要持续监听必须包在 for 循环里。
2. 随机公平选择
如果多个 case 同时就绪,select 随机挑一个执行。
3. nil channel 在 select 中永久阻塞
go
var ch chan int // nil
select {
case <-ch: // 永远不会执行
fmt.Println("收不到")
}
利用这个特性可以实现动态禁用分支:
go
func worker(ch chan int, enable bool) {
var in <-chan int
if enable {
in = ch
}
select {
case v := <-in:
fmt.Println(v)
case <-time.After(time.Second):
fmt.Println("超时")
}
}
4.3 超时控制
go
select {
case v := <-ch:
fmt.Println("收到:", v)
case <-time.After(1 * time.Second):
fmt.Println("超时,不等了")
}
4.4 非阻塞收发(配合 default)
go
select {
case ch <- 1:
fmt.Println("发送成功")
default:
fmt.Println("通道满了或没有接收方,直接走这里")
}
五、通道死锁:典型场景与排查
Go 的死锁判定标准:所有 goroutine 都陷入永久不可唤醒的阻塞。
5.1 高频死锁场景
1. 单 goroutine 自锁
go
func main() {
ch := make(chan int)
ch <- 1 // 阻塞!等接收方,但接收方只能是自己
<-ch // 永远执行不到
}
2. 有缓冲通道:只发不收
go
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
ch <- 3 // 阻塞!buf 已满,且没有任何 goroutine 会接收
}
3. 顺序错误:先阻塞,后启动 goroutine
go
func main() {
ch := make(chan int)
ch <- 1 // 先阻塞!main 卡死
go func() {
<-ch // 永远不会执行
}()
}
4. 交叉依赖(循环等待)
go
func main() {
a := make(chan int)
b := make(chan int)
go func() {
<-a // G1 等 a
b <- 1 // 然后给 b 发
}()
<-b // main 等 b
a <- 1 // 然后给 a 发
}
死锁链 :G1 阻塞在 <-a,main 阻塞在 <-b,互相等待。
六、Context:协程的"遥控器"
Channel 能传数据,但没法方便地传"停下来"的信号。context 就是干这个的。
6.1 手动取消
go
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("收到指令,停止工作")
return
default:
fmt.Println("工作中...")
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
time.Sleep(2 * time.Second)
cancel() // 广播:所有监听 ctx.Done() 的协程都会收到
}
6.2 超时自动取消
go
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", "http://slow.com", nil)
七、Sync 包:更底层的同步工具
7.1 WaitGroup:等所有协程收工
go
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("worker", id, "done")
}(i)
}
wg.Wait() // 阻塞,直到计数归零
注意 :Add 要在启动 goroutine 之前调用。
7.2 Mutex:互斥锁
go
var mu sync.Mutex
var count int
func increment() {
mu.Lock()
defer mu.Unlock()
count++
}
7.3 原子操作:比锁更快
go
var counter int64
atomic.AddInt64(&counter, 1)
fmt.Println(atomic.LoadInt64(&counter))
八、工程避坑指南
8.1 Goroutine 泄漏
这是生产环境最常见的坑。
go
func leak() {
ch := make(chan int)
go func() {
ch <- 42 // 没人接收,永远阻塞,这个 goroutine 泄漏了
}()
}
排查 :用 go test -race 或 pprof 看 goroutine 数量是否只增不减。
8.2 闭包陷阱:循环变量被共享
go
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // 输出大概率是 3 3 3
}()
}
修正:把变量传进参数,形成副本。
go
for i := 0; i < 3; i++ {
go func(v int) {
fmt.Println(v) // 0 1 2
}(i)
}
8.3 竞态检测
go
var count int
func main() {
for i := 0; i < 1000; i++ {
go func() {
count++ // 并发写,危险!
}()
}
}
检测:
bash
go run -race main.go
修复 :用 sync.Mutex 或 atomic。
九、GMP 调度模型(了解即可)
Go 的 goroutine 不是操作系统线程,而是用户态轻量线程:
- G:Goroutine(协程)
- M:Machine(操作系统线程)
- P:Processor(逻辑处理器,默认等于 CPU 核数)
一个 M 要执行 G,必须先绑定一个 P。P 维护了一个本地可运行队列,调度器会把 G 均衡地分配到各个 P 上。
十、最佳实践总结
| 场景 | 推荐方案 |
|---|---|
| 等待单个 goroutine 完成 | ch <- result / <-ch |
| 收集多个 goroutine 结果 | sync.WaitGroup + channel |
| 任务队列/限流 | Buffered channel 作为缓冲区和信号量 |
| 超时/取消控制 | select + context + time.After |
| 复杂流水线 | Pipeline 模式,每个阶段都是 chan <- chan |
| 防止 goroutine 泄漏 | 有缓冲通道,或确保有接收方 |
| 简单并发任务 | errgroup + context |
结语
Go 的并发设计大道至简,但"简单"不等于"容易"。掌握 channel 的同步语义、select 的多路复用、context 的生命周期管理,以及常见的泄漏和竞态陷阱,才能写出高可靠、易维护的并发代码。
记住:goroutine 负责计算,channel 负责传递数据和状态,context 负责控制生命周期。三者配合,就能构建安全、清晰、可扩展的并发系统。
如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区交流你的并发踩坑经历。