并发编程基础:Goroutine入门、WaitGroup同步、Channel与Select多路复用
并发编程,是每个Go开发者绕不开的坎。说实话,我当年从Java转Go时,最震撼的就是Goroutine——这玩意儿太轻量了,几万甚至几十万个并发任务,放Java里早把JVM撑爆了,但在Go里跑得跟玩儿似的。
这一章,咱们就扎扎实实地把Go并发编程的四个核心点啃下来:Goroutine、WaitGroup、Channel、Select。别急,一个一个来。
1. Goroutine入门:go关键字
Goroutine是什么?说白了,就是Go语言里的“轻量级线程”。你只需要在函数调用前加一个 go 关键字,这个函数就会在一个新的Goroutine里异步执行。
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello from goroutine")
}
func main() {
go sayHello() // 启动一个goroutine
time.Sleep(100 * time.Millisecond) // 等一等,不然main退出了goroutine就没了
fmt.Println("Main function ends")
}
嗯,这里要注意:main函数退出时,所有Goroutine都会被强制终止。所以上面我加了个 time.Sleep,不然你根本看不到goroutine的输出。
Goroutine的调度由Go运行时管理,它运行在操作系统线程之上,但一个线程可以承载成千上万个Goroutine。我个人习惯把Goroutine看作“廉价的并发单元”,创建成本极低(栈初始只有几KB),所以你可以放心大胆地用。
2. WaitGroup同步
刚才说了,time.Sleep 不是正经的同步手段。那怎么等一组Goroutine都执行完呢?WaitGroup 就是干这个的。
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 任务完成时调用Done,计数器减1
fmt.Printf("Worker %d starting\n", id)
// 模拟工作
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // 每启动一个goroutine,计数器加1
go worker(i, &wg)
}
wg.Wait() // 阻塞,直到计数器归零
fmt.Println("All workers completed")
}
WaitGroup的原理很简单:内部维护一个计数器。Add 增加计数,Done 减少计数,Wait 阻塞直到计数为0。
wg.Add(1) 放在启动goroutine之前,而不是goroutine内部。为什么?因为如果goroutine启动得太快,可能 Wait 已经执行了,但 Add 还没调用,导致提前退出。我在项目里踩过这个坑,后来就形成了这个习惯。
另外,wg.Done() 最好用 defer 调用。万一函数中间panic了,defer还能保证计数器减掉,不然就死锁了。
3. Channel基础:无缓冲与有缓冲
Channel是Goroutine之间通信的桥梁。Go的哲学是:“不要通过共享内存来通信,而要通过通信来共享内存。” Channel就是这句话的具体实现。
3.1 无缓冲Channel
无缓冲Channel,也叫同步Channel。发送操作会阻塞,直到有接收方准备好;接收操作也会阻塞,直到有发送方发送数据。
ch := make(chan int) // 无缓冲channel
go func() {
ch <- 42 // 发送,会阻塞直到有人接收
}()
value := <-ch // 接收,会阻塞直到有人发送
fmt.Println(value) // 输出42
无缓冲Channel常用于同步场景。比如两个Goroutine需要“握手”,一个发完信号另一个才能继续。
3.2 有缓冲Channel
有缓冲Channel可以存一定数量的数据。发送操作只有在缓冲区满时才会阻塞,接收操作只有在缓冲区空时才会阻塞。
ch := make(chan int, 3) // 缓冲区大小为3
ch <- 1 // 不阻塞
ch <- 2 // 不阻塞
ch <- 3 // 不阻塞
// ch <- 4 // 如果取消注释,这里会阻塞,因为缓冲区满了
fmt.Println(<-ch) // 输出1
fmt.Println(<-ch) // 输出2
fmt.Println(<-ch) // 输出3
有缓冲Channel常用于解耦生产者和消费者。比如日志收集、任务队列等场景。
| 特性 | 无缓冲Channel | 有缓冲Channel |
|---|---|---|
| 发送阻塞条件 | 立即阻塞,直到有人接收 | 缓冲区满时才阻塞 |
| 接收阻塞条件 | 立即阻塞,直到有人发送 | 缓冲区空时才阻塞 |
| 典型用途 | 同步、信号传递 | 任务队列、数据流 |
你想想看,如果生产者比消费者快很多,用无缓冲Channel就会频繁阻塞生产者,导致性能下降。这时候有缓冲Channel就派上用场了,它能吸收短时间的流量波动。
4. Select多路复用
当你有多个Channel需要同时监听时,select 就是你的瑞士军刀。它类似于 switch,但每个case都是一个Channel操作。
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "one"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "two"
}()
select {
case msg1 := <-ch1:
fmt.Println("Received from ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("Received from ch2:", msg2)
case <-time.After(500 * time.Millisecond):
fmt.Println("Timeout! No message received within 500ms")
}
}
select会随机选择一个可执行的case。如果多个case同时就绪,Go会伪随机选择一个,避免饥饿。
select 配合 time.After 实现超时控制。比如调用外部API时,如果3秒没响应就返回超时错误,而不是一直卡着。这在微服务架构里特别重要,能防止级联故障。
还有一个经典用法:用select监听退出信号。比如一个后台worker,既要处理任务,又要能优雅退出:
for {
select {
case task := <-taskChan:
// 处理任务
process(task)
case <-quitChan:
// 收到退出信号,清理资源
fmt.Println("Worker shutting down")
return
}
}
这种模式在微服务里太常见了。每个服务启动时都会开一堆Goroutine,每个Goroutine里都有一个select循环,监听任务和退出信号。这样服务关闭时,只要发一个信号,所有Goroutine就能优雅退出。
总结
这一章我们聊了Go并发编程的四个基础组件:
- Goroutine:用
go关键字启动轻量级并发任务 - WaitGroup:同步一组Goroutine的完成
- Channel:无缓冲用于同步,有缓冲用于解耦
- Select:多路复用,处理多个Channel的读写
说实话,这些基础概念看起来简单,但真正用好需要大量实践。我记得刚学Go那会儿,总觉得Channel用起来别扭,不如锁直接。后来写多了才发现,Channel的威力在于组合——用Channel传递数据、用Select做超时控制、用WaitGroup做生命周期管理,这些组合起来就能构建出非常健壮的并发系统。
下一章,我们会深入探讨更高级的并发模式,比如扇出/扇入、管道模式、以及如何避免常见的并发陷阱。到时候见。