终于到 Go 最吸引人的部分了。记得当初决定学 Go,很大原因就是听说了它“天生支持并发”“用 goroutine 写并发像开挂一样”。但真正开始写的时候才发现,并发编程哪有那么简单,goroutine 用起来爽,channel 用不好照样死锁、泄漏、数据竞争。这篇就把我入门并发时踩过的坑和搞懂的概念梳理一遍。
1. goroutine:比线程更轻量的“协程”
1.1 启动一个 goroutine
在 Go 里启动一个并发执行的函数非常简单,只需要在函数调用前加一个go关键字:
func sayHello() { fmt.Println("hello from goroutine") } func main() { go sayHello() // 启动一个 goroutine fmt.Println("hello from main") time.Sleep(time.Second) // 给 goroutine 一点执行时间 }输出可能是:
hello from main hello from goroutine也可能是:
hello from goroutine hello from main顺序不确定,因为 goroutine 是并发执行的。
注意:如果main函数执行完了,所有 goroutine 都会被强制结束,不管它们有没有执行完。所以上面故意加了个time.Sleep,否则可能看不到 goroutine 的输出。实际项目中不会用time.Sleep来等待,而是用 channel 或者sync.WaitGroup(后面细说)。
1.2 goroutine 和线程的区别
我一开始以为 goroutine 就是 Go 版的线程,后来看了些资料才明白完全不是一回事。
线程:由操作系统调度,每个线程有固定的栈空间(一般 1MB 以上),创建和切换的开销比较大。
goroutine:由 Go 运行时调度,初始栈空间只有 2KB 左右,可以动态增长。创建和销毁的成本极低,轻松启动成千上万个 goroutine。
Go 的调度器会把多个 goroutine 分配到几个操作系统线程上执行,这就是所谓的M:N 调度模型。所以写 Go 并发,通常不需要关心底层线程,直接go func()就完事。
1.3 匿名函数 goroutine
最常用的写法是直接 go 一个匿名函数:
go func() { fmt.Println("我在 goroutine 里") }()但要注意闭包捕获变量的问题,前面讲 for 循环时提过,Go 1.22 之前循环变量捕获有坑,1.22 修复了。不过为了兼容老代码,了解这个坑很重要。
2. channel:goroutine 之间的通信管道
光有 goroutine 还不够,它们之间需要通信。Go 的哲学是:
不要通过共享内存来通信,而应该通过通信来共享内存。
这句话我花了好长时间才真正理解。简单说就是:与其用锁保护共享变量,不如把数据通过 channel 从一个 goroutine 传给另一个 goroutine,这样天然避免了数据竞争。
2.1 创建 channel
用make创建:
ch := make(chan int) // 无缓冲 channel,只能传 int 类型2.2 发送和接收
ch <- 42 // 发送数据到 channel value := <-ch // 从 channel 接收数据箭头方向很直观:ch <-表示把数据塞进 channel,<-ch表示从 channel 取数据。
2.3 无缓冲 channel:同步阻塞
无缓冲 channel 的特点是发送和接收必须同时准备好,否则会阻塞。
看这个例子:
func main() { ch := make(chan int) go func() { fmt.Println("goroutine 准备发送") ch <- 100 // 发送后阻塞,直到有人接收 fmt.Println("发送完成") }() time.Sleep(time.Second) // 先让 goroutine 执行到发送 fmt.Println("main 准备接收") value := <-ch // 接收,唤醒发送者 fmt.Println("收到:", value) time.Sleep(time.Second) }输出:
goroutine 准备发送 main 准备接收 收到: 100 发送完成可以看到,发送者在接收者出现之前一直阻塞,这就是无缓冲 channel 的同步特性。这种特性天然适合用来做信号传递,比如“我完成了,你继续”。
2.4 有缓冲 channel:异步队列
创建时指定容量:
ch := make(chan int, 3) // 缓冲区大小 3有缓冲 channel 就像一个队列,发送方在缓冲区满之前不会阻塞,接收方在缓冲区空之前不会阻塞。
ch <- 1 // 不会阻塞 ch <- 2 ch <- 3 // ch <- 4 // 缓冲区满了,会阻塞 fmt.Println(<-ch) // 1 fmt.Println(<-ch) // 2有缓冲 channel 适用于异步通信,比如生产者消费者模型,生产者和消费者速度不匹配时可以暂时缓冲。
如何选择?我的经验是:除非有明确的性能需求,否则先用无缓冲 channel,因为无缓冲的同步语义更清晰,不容易写出隐藏的并发 bug。等需要优化吞吐量时再改成有缓冲。
3. select:监听多个 channel
如果同时要等待多个 channel,可以用select。它和switch语法类似,但每个 case 都是一个 channel 操作。
func main() { ch1 := make(chan string) ch2 := make(chan string) go func() { time.Sleep(time.Second) ch1 <- "从 ch1 来" }() go func() { time.Sleep(2 * time.Second) ch2 <- "从 ch2 来" }() select { case msg1 := <-ch1: fmt.Println(msg1) case msg2 := <-ch2: fmt.Println(msg2) } }select会阻塞,直到某个 case 可以执行。如果多个 case 同时就绪,随机选择一个执行。
3.1 default 分支:非阻塞操作
加上default后,如果所有 case 都阻塞,立刻执行 default:
select { case msg := <-ch: fmt.Println("收到:", msg) default: fmt.Println("没有消息,不阻塞") }这个在写非阻塞 I/O和超时控制时很有用。
3.2 超时控制
select { case msg := <-ch: fmt.Println(msg) case <-time.After(3 * time.Second): fmt.Println("超时了") }time.After返回一个 channel,3 秒后会发送一个值,配合 select 实现超时等待。
4. channel 的关闭与遍历
4.1 关闭 channel
用close函数关闭:
ch := make(chan int) go func() { for i := 0; i < 5; i++ { ch <- i } close(ch) // 发送完关闭 }() for v := range ch { // range 会自动在 channel 关闭后退出 fmt.Println(v) }关闭规则:
- 只有发送方应该关闭 channel,接收方不要关闭。
- 不能关闭已经关闭的 channel,会 panic。
- 向已关闭的 channel 发送数据会 panic。
- 从已关闭的 channel 接收数据,如果缓冲区还有数据,会先取完;取完后会得到零值,并且第二个返回值(ok)为 false。
v, ok := <-ch if !ok { fmt.Println("channel 已关闭且没有数据") }我一开始总忘记检查ok,结果 channel 关闭后还一直收到零值,逻辑出错。这个要形成肌肉记忆。
4.2 判断 channel 是否关闭
没有直接的“isClosed”函数,只能用接收时的ok来判断。这是 Go 设计上的一个特点,避免竞态。
5. 几个必踩的坑
5.1 死锁
无缓冲 channel 在同一个 goroutine 里发送和接收会死锁:
ch := make(chan int) ch <- 1 // 死锁!因为没有人接收,当前 goroutine 阻塞,程序崩溃 fmt.Println(<-ch)报错:fatal error: all goroutines are asleep - deadlock!
所有 goroutine 都阻塞时,Go 运行时会检测到死锁并 panic。修复方法就是让发送和接收在不同的 goroutine 里,或者用有缓冲 channel。
5.2 向 nil channel 发送或接收
var ch chan int // nil channel ch <- 1 // 永久阻塞 <-ch // 永久阻塞nil channel 是永久阻塞的,不会 panic,但也干不了活。这在 select 里其实有用,比如可以用一个 nil channel 暂时禁用某个 case。
5.3 循环变量捕获(Go 1.22 之前)
之前提过,这里再强调一下,因为并发场景下特别容易踩:
for i := 0; i < 5; i++ { go func() { fmt.Println(i) // 可能都打印 5 }() }修复方法:在 goroutine 启动前复制变量:
for i := 0; i < 5; i++ { i := i // 复制 go func() { fmt.Println(i) }() }或者通过参数传递:
for i := 0; i < 5; i++ { go func(n int) { fmt.Println(n) }(i) }好在 Go 1.22 已经修复了,但如果面试被问到,能讲出这个历史问题和解决思路很加分。
5.4 goroutine 泄漏
如果 goroutine 因为某些原因永远阻塞,它就不会被回收,导致内存泄漏。比如:
func worker(ch chan int) { // 等待 channel 数据,但 channel 永远没人发,goroutine 永远阻塞 <-ch }实际项目中,goroutine 泄漏通常发生在没有设置超时或者忘记关闭 channel的情况。排查方法用runtime.NumGoroutine()查看数量。
6. sync.WaitGroup:等待所有 goroutine 完成
虽然这篇主要讲 channel,但sync.WaitGroup也是并发必备工具,简单说两句。
var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) // 计数器 +1 go func(n int) { defer wg.Done() // 完成后计数器 -1 fmt.Println(n) }(i) } wg.Wait() // 阻塞,直到计数器为 0 fmt.Println("所有 goroutine 执行完毕")Add、Done、Wait三件套。常用于等待一批 goroutine 完成。
坑:Add要在 goroutine 启动前调用,不要在 goroutine 内部调用,否则可能Wait提前返回。
7. 小结
这篇把 goroutine 和 channel 的基础过了一遍,核心要点:
go关键字启动 goroutine,轻量、便宜,但需要管理生命周期。channel 用于 goroutine 间通信,无缓冲同步,有缓冲异步。
select多路复用,配合time.After做超时。关闭 channel 要小心,只能发送方关闭,接收方用
ok判断。死锁、nil channel、循环变量捕获、goroutine 泄漏都是新手常见坑。
sync.WaitGroup用于等待所有 goroutine 完成。
并发编程是 Go 的核心竞争力,也是面试必考。下一篇我准备写 Go 标准库里的几个常用包,比如sync的互斥锁、context的超时控制,以及实际项目中怎么组织并发代码。慢慢来,先把基础打牢。
如果这篇文章对你有帮助,欢迎点赞收藏,评论区一起交流。