为什么 Go 的并发如此特别?
在传统语言中,并发编程往往意味着复杂的线程管理和锁竞争。而 Go 从语言层面内置了并发原语:goroutine 和 channel,让并发变得轻量且优雅。但这也带来了新的挑战:如何正确同步、如何避免 goroutine 泄漏、如何选择锁还是 channel?
本文将通过一个完整的实战案例——并发爬虫(模拟),带你掌握 Go 并发编程的核心技能。
环境准备
- Go 1.20+(本文使用 Go 1.21)
- 任意编辑器
创建一个新目录并初始化模块:
mkdir go-concurrency-demo && cd go-concurrency-demo
go mod init demo
第一步:理解 goroutine 与 WaitGroup
goroutine 是由 Go 运行时管理的轻量级线程,启动一个 goroutine 只需 go 关键字。
但主 goroutine 不会等待其他 goroutine 结束,因此我们需要 sync.WaitGroup 来同步。
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 确保计数器减一
fmt.Printf("Worker %d starting\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // 必须在启动 goroutine 前调用
go worker(i, &wg)
}
wg.Wait() // 阻塞直到所有 worker 完成
fmt.Println("All workers finished")
}
注意:wg.Add(1) 必须在 go 语句之前调用,否则可能主 goroutine 已经执行到 wg.Wait() 而 worker 还没启动,导致 panic。
第二步:channel 基础与方向
Channel 是 goroutine 之间的通信管道。默认是双向的,但可以指定方向提高代码安全性。
func producer(ch chan<- int) { // 只发送
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // 发送方关闭 channel
}
func consumer(ch <-chan int) { // 只接收
for v := range ch {
fmt.Println("Received:", v)
}
}
func main() {
ch := make(chan int)
go producer(ch)
consumer(ch)
}
💡 最佳实践:永远由发送方关闭 channel,接收方不要关闭。向已关闭的 channel 发送数据会 panic。
第三步:带缓冲的 channel 与 select
无缓冲 channel 是同步的,发送和接收必须同时准备好。带缓冲的 channel 可以异步,但要注意缓冲区满时发送会阻塞。
select 可以同时等待多个 channel 操作,类似 switch 但用于 channel。
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "from ch1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "from ch2"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
}
}
}
注意:select 如果没有 default 分支,会一直阻塞直到某个 case 就绪。如果所有 channel 都阻塞且没有 default,则 select 永久阻塞。
第四步:互斥锁与竞态检测
当多个 goroutine 访问共享变量时,必须加锁。Go 提供了 sync.Mutex 和 sync.RWMutex。
var counter int
var mu sync.Mutex
func increment(wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 1000; i++ {
mu.Lock()
counter++
mu.Unlock()
}
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println("Counter:", counter) // 应为 10000
}
检测竞态:使用 go run -race main.go 可以检测数据竞争,强烈建议在测试时开启。
第五步:实战——生产者-消费者模型
这是一个经典并发模式,使用带缓冲 channel 解耦生产者和消费者。
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
func producer(id int, ch chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 5; i++ {
num := rand.Intn(100)
fmt.Printf("Producer %d produced: %d\n", id, num)
ch <- num
time.Sleep(time.Millisecond * 200)
}
}
func consumer(id int, ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for num := range ch {
fmt.Printf("Consumer %d consumed: %d\n", id, num)
time.Sleep(time.Millisecond * 300)
}
}
func main() {
ch := make(chan int, 10) // 缓冲区大小为 10
var wg sync.WaitGroup
// 启动 2 个生产者
for i := 1; i <= 2; i++ {
wg.Add(1)
go producer(i, ch, &wg)
}
// 启动 3 个消费者
var consumerWg sync.WaitGroup
for i := 1; i <= 3; i++ {
consumerWg.Add(1)
go consumer(i, ch, &consumerWg)
}
// 等待所有生产者完成,然后关闭 channel
wg.Wait()
close(ch)
// 等待所有消费者完成
consumerWg.Wait()
fmt.Println("All done")
}
关键点:
- 生产者完成后关闭 channel,消费者通过
range自动检测关闭。 - 使用两个 WaitGroup 分别管理生产者和消费者,避免过早关闭 channel。
第六步:超时控制与 context
并发操作经常需要超时控制。context 包是标准做法。
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
result := make(chan string)
go func() {
time.Sleep(3 * time.Second) // 模拟耗时操作
result <- "done"
}()
select {
case res := <-result:
fmt.Println(res)
case <-ctx.Done():
fmt.Println("Timeout:", ctx.Err())
}
}
💡 最佳实践:将 ctx 作为函数的第一个参数传递,并在所有阻塞操作中监听 ctx.Done()。
常见坑与避坑指南
- goroutine 泄漏:启动的 goroutine 如果永远阻塞在 channel 上,会导致内存泄漏。使用
context或select加超时来避免。 - 死锁:例如两个 goroutine 互相等待对方发送数据。使用
go run -race和仔细设计 channel 方向来预防。 - 关闭已关闭的 channel:会 panic。只由发送方关闭,且确保只关闭一次。
- 锁的粒度:锁的临界区应尽可能小,但也要避免过度加锁导致性能下降。
总结
本文从 goroutine 和 WaitGroup 出发,逐步深入到 channel、select、锁、context,最后实现了一个完整的生产者-消费者模型。掌握这些核心概念后,你可以应对大多数并发场景。
下一步方向:
- 学习
sync.Pool减少内存分配 - 了解
errgroup包管理 goroutine 错误 - 研究 Go 调度器原理(GMP 模型)
并发编程的核心是共享内存通过通信来实现,牢记这一哲学,你的 Go 程序会更加健壮。