Go 并发编程:goroutine 与 channel 实战
并发是 Go 语言最引以为傲的特性之一。相比传统语言依赖线程、锁和回调的复杂模型,Go 用 goroutine + channel 提供了一套简洁而强大的并发原语。这篇「不要用共享内存来通信,而要用通信来共享内存」正是 Go 并发设计的核心哲学。本文将通过真实场景,带你深入理解 goroutine 与 channel 的配合使用。
一、为什么是 goroutine
操作系统线程是昂贵的资源:每个线程默认占用约 1MB 的栈空间,创建、切换、销毁都要陷入内核态,开销巨大。而 goroutine 是 Go 运行时管理的用户态轻量线程:
- 初始栈仅约 2KB,并且可以按需增长,最多可达 GB 级别;
- 创建成本极低,一个普通程序同时运行成千上万个 goroutine 毫不费力;
- 调度由 Go 运行时负责,采用 GMP 模型(Goroutine、Machine 即 OS 线程、Processor 逻辑处理器),在多个 OS 线程上动态调度;
- 阻塞不浪费线程,当 goroutine 因 I/O 或 channel 阻塞时,运行时会自动把它挂起并让出线程,去执行其他 goroutine。
启动一个 goroutine 只需一个关键字:
package main
import (
"fmt"
"time"
)
func sayHello(name string) {
for i := 0; i < 3; i++ {
fmt.Printf("Hello, %s\n", name)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go sayHello("goroutine") // 启动一个 goroutine
sayHello("main") // 主 goroutine 同步执行
}
输出的顺序是不确定的——这是并发程序的常态。理解这一点,是写好并发代码的第一步。
二、channel:goroutine 之间的通信管道
channel 是 Go 提供的类型安全的消息队列,goroutine 通过它收发数据,天然实现了同步。声明方式:
ch := make(chan int) // 无缓冲 channel
buffered := make(chan int, 10) // 有缓冲 channel,容量为 10
无缓冲 channel 的同步语义
无缓冲 channel 的发送和接收必须同时就绪,否则会阻塞,这使它天然成为一个「接力棒」:
package main
import "fmt"
func worker(done chan bool) {
fmt.Println("工作中...")
done <- true // 发送完成信号(阻塞直到主程序接收)
}
func main() {
done := make(chan bool)
go worker(done)
<-done // 等待 worker 完成
fmt.Println("任务完成")
}
有缓冲 channel 的解耦能力
有缓冲 channel 允许发送方在缓冲区未满时不必等待接收方,适合「生产-消费」场景:
package main
import (
"fmt"
"time"
)
func producer(ch chan<- int) { // 只写 channel
for i := 1; i <= 5; i++ {
ch <- i
fmt.Printf("生产: %d\n", i)
}
close(ch) // 生产完毕,关闭 channel
}
func consumer(ch <-chan int) { // 只读 channel
for v := range ch { // range 会在 channel 关闭后自动结束
fmt.Printf("消费: %d\n", v)
time.Sleep(200 * time.Millisecond)
}
}
func main() {
ch := make(chan int, 3) // 缓冲区 3
go producer(ch)
consumer(ch)
}
注意 channel 的方向类型:chan<- 表示只写,<-chan 表示只读。显式声明方向能在编译期就避免误用,是很好的工程实践。
三、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 <- "来自 ch1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "来自 ch2"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
case <-time.After(3 * time.Second): // 超时保护
fmt.Println("超时了")
return
}
}
}
select 有几个关键规则需要牢记:
- 有多个 case 就绪时,随机选择一个执行,避免饥饿;
- 没有任何 case 就绪时,如果有
default分支则执行它,否则阻塞; default分支可以让select变成非阻塞操作,常用于实现「尝试发送」。
四、实战:并发爬虫 + worker pool
把上面的概念串起来,实现一个经典的并发爬虫,用固定数量的 worker 消费任务队列:
package main
import (
"fmt"
"net/http"
"sync"
)
type result struct {
url string
status int
err error
}
func fetch(url string) result {
resp, err := http.Get(url)
if err != nil {
return result{url: url, err: err}
}
defer resp.Body.Close()
return result{url: url, status: resp.StatusCode}
}
func worker(jobs <-chan string, results chan<- result) {
for url := range jobs {
results <- fetch(url)
}
}
func main() {
urls := []string{
"https://golang.org",
"https://github.com",
"https://pinvery.com",
"https://godoc.org",
"https://pkg.go.dev",
}
jobs := make(chan string, len(urls))
results := make(chan result, len(urls))
const workerCount = 3
for i := 0; i < workerCount; i++ {
go worker(jobs, results)
}
for _, u := range urls {
jobs <- u
}
close(jobs) // 关闭 jobs,通知 worker 没有更多任务
for range urls {
r := <-results
if r.err != nil {
fmt.Printf("%s -> 错误: %v\n", r.url, r.err)
} else {
fmt.Printf("%s -> %d\n", r.url, r.status)
}
}
}
close(jobs) 是关键一步:关闭后,for url := range jobs 会在取完所有任务后自动退出,从而优雅地结束所有 worker。这比手动发送 quit 信号更简洁可靠。
五、sync.WaitGroup 与常见陷阱
当需要等待一组 goroutine 全部完成时,sync.WaitGroup 是标准做法:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // 递增计数
go func(id int) {
defer wg.Done() // 完成后递减
fmt.Printf("任务 %d 完成\n", id)
}(i) // 注意参数传递
}
wg.Wait() // 阻塞直到计数归零
fmt.Println("全部完成")
}
这段代码里有一个极其经典的陷阱——循环变量捕获。如果写成 go func() { fmt.Println(i) }() 而不把 i 作为参数传入,所有 goroutine 很可能会打印同一个值(旧的 Go 版本更是必然如此)。虽然 Go 1.22 之后修正了循环变量语义,但显式传参仍然是更稳妥、更清晰的写法。
其他常见陷阱:
- 忘记
closechannel,导致range或接收方永久阻塞; - 向已关闭的 channel 发送会 panic,所以关闭的责任应交给唯一的发送方;
- 死锁:channel 操作没配对好,运行时检测到所有 goroutine 都阻塞时会抛出
fatal error: all goroutines are asleep - deadlock!。
六、最佳实践小结
- 谁创建,谁关闭——channel 的关闭权应清晰归属,通常由发送方关闭;
- 用
select处理超时和退出,避免 goroutine 泄漏(一直阻塞的 goroutine 永远不会被回收); - 明确 channel 的方向,用
chan<-/<-chan让类型系统帮你把关; - 优先用
range遍历 channel,配合close表达「没有更多数据」; - 并发并非越多越好——面对大量小任务时,用 worker pool 限制并发度往往比无脑
go更高效; - 用
-race检测数据竞争:go test -race或go run -race main.go是排查并发的利器。
总结
goroutine 和 channel 让并发编程从「管理线程和锁」的泥潭中解放出来,把关注点重新拉回到「任务之间的协作与通信」上。掌握 channel 的同步语义、select 的多路复用、close 的优雅退出以及 WaitGroup 的等待机制,你就已经拥有了编写健壮并发 Go 程序的核心能力。
并发是一把双刃剑:它带来性能,也带来隐蔽的 bug。好在 Go 提供了 -race 检测器和清晰的心智模型,剩下要做的,就是多写、多测、多思考。