Goroutine 和 Channel:Go 中的并发基础
1. 概述
Go 被设计用于轻松编写并发程序,无需回调地狱或复杂的线程池。两个核心构建块是 goroutine(轻量级执行单元)和 channel(goroutine 之间的通信通道)。读完这篇文章,你将了解如何启动 goroutine、用 sync.WaitGroup 同步它们、通过 channel 安全通信、使用 select 同时处理多个 channel,以及避免常见错误如 goroutine 泄漏或死锁。
所有示例使用 Go 1.22+,不需要外部库——你只需 go run main.go 即可运行。
2. Goroutine 是什么
Goroutine 是由 Go 运行时而非操作系统管理的独立运行函数。创建一个 goroutine 的开销极低(只有几 KB 的栈,自动伸缩),所以你可以创建数万个 goroutine 而不用担心耗尽资源,这与系统线程不同。
package main
import (
"fmt"
"time"
)
func sayHello(name string) {
fmt.Println("你好,", name)
}
func main() {
go sayHello("Order Service")
go sayHello("Payment Service")
time.Sleep(100 * time.Millisecond) // 等待 goroutine 完成
fmt.Println("Main 结束")
}
关键字 go 放在函数调用前会在新的 goroutine 中运行该函数,不会阻塞 main。由于 main 可能在两个 goroutine 有机会输出前就结束了,我们必须使用 time.Sleep 来暂时等待——这是不可靠的做法,仅用于演示目的。输出可能是:
你好,Payment Service
你好,Order Service
Main 结束
输出的顺序不固定,因为调度器决定哪个 goroutine 先运行。这正是并发的本质:多个任务“同时”进行,但顺序无法保证。
注意: 在实际代码中使用
time.Sleep来同步 goroutine 是反模式。第 4 节将介绍sync.WaitGroup作为替代方案。
3. Channel:goroutine 之间的通信通道
Channel 是一个类型安全的管道,用于 goroutine 发送和接收数据。Go 的哲学是:“不要通过共享内存来通信;通过通信来共享内存。”
package main
import "fmt"
func calculateTotal(amount float64, result chan<- float64) {
tax := amount * 0.1
result <- amount + tax
}
func main() {
result := make(chan float64)
go calculateTotal(1_000_000, result)
total := <-result
fmt.Printf("订单总金额:%.0f\n", total)
}
make(chan float64)创建一个类型为float64的无缓冲 channel。- 函数参数中的
chan<- float64表示 channel 仅用于发送(只发送),这样编译器可以捕获你误读它的错误。 result <- amount + tax向 channel 发送值。main中的<-result会阻塞直到有数据可用,它自然地充当了同步机制——无需Sleep。
订单总金额:1100000
3.1. 无缓冲 vs 有缓冲 channel
无缓冲 channel(make(chan T))要求发送者和接收者“相遇”——发送会阻塞直到有人接收。有缓冲 channel(make(chan T, n))允许发送最多 n 个值而不需要接收者立即准备好。
package main
import "fmt"
func main() {
orders := make(chan string, 3)
orders <- "Order-1"
orders <- "Order-2"
orders <- "Order-3"
close(orders)
for order := range orders {
fmt.Println("处理:", order)
}
}
由于缓冲区容量为 3,三次发送不会阻塞。close(orders) 表示没有更多数据,帮助 range 循环自动终止,而不是无限期地阻塞等待下一个值。
处理: Order-1
处理: Order-2
处理: Order-3
注意: 只有发送者可以
closechannel。发送到已关闭的 channel 会触发 panicsend on closed channel;从已关闭的 channel 读取仍然是合法的,会返回零值和ok == false。
4. 用 WaitGroup 同步多个 goroutine
当需要等待多个 goroutine 完成而不关心返回值时,sync.WaitGroup 是一个更简洁的选择,比起使用 channel。
package main
import (
"fmt"
"sync"
)
func processOrder(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("已处理 Order-%d\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go processOrder(i, &wg)
}
wg.Wait()
fmt.Println("所有订单处理完毕")
}
wg.Add(1)在 goroutine 运行前增加计数器——必须在go语句之前调用,而不是在 goroutine 内部调用,以避免竞态条件。defer wg.Done()在 goroutine 结束时减少计数器。wg.Wait()阻塞 main 直到计数器回到 0。
已处理 Order-2
已处理 Order-1
已处理 Order-3
所有订单处理完毕
前三行的顺序不固定,但最后一行总是最后输出——这是 WaitGroup 保证的。
4.1. 测试并发行为
你可以编写测试来确认所有 goroutine 都完成了,使用结果计数器而不是依赖输出顺序:
package orderpkg
import (
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
)
func TestProcessOrders_AllCompleted(t *testing.T) {
var wg sync.WaitGroup
var processed int32
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
atomic.AddInt32(&processed, 1)
}(i)
}
wg.Wait()
assert.Equal(t, int32(5), processed)
}
atomic.AddInt32 避免多个 goroutine 同时增加计数变量时的竞态条件——如果使用普通的 processed++,go test -race 会报告数据竞争。
测试依赖项(添加到 go.mod):
go get github.com/stretchr/testify
5. Select:同时处理多个 channel
select 像 switch 但专门用于 channel——选择哪个 case 首先准备好就执行哪个。当你需要等待多个数据源或设置超时时非常有用。
package main
import (
"fmt"
"time"
)
func fetchFromCache(ch chan<- string) {
time.Sleep(50 * time.Millisecond)
ch <- "来自缓存的数据"
}
func fetchFromDB(ch chan<- string) {
time.Sleep(200 * time.Millisecond)
ch <- "来自数据库的数据"
}
func main() {
cacheCh := make(chan string)
dbCh := make(chan string)
go fetchFromCache(cacheCh)
go fetchFromDB(dbCh)
select {
case res := <-cacheCh:
fmt.Println("收到:", res)
case res := <-dbCh:
fmt.Println("收到:", res)
case <-time.After(100 * time.Millisecond):
fmt.Println("超时,使用默认数据")
}
}
select 阻塞直到其中一个 case 有数据准备好。因为缓存在 50ms 后返回而数据库在 200ms 后返回,cacheCh case 会赢:
收到: 来自缓存的数据
time.After 创建一个在指定时间后接收值的 channel,用于为整个 select 实现超时。
6. 用 context 正确地取消 goroutine
一个常见的错误是启动 goroutine 但没有办法停止它,导致 goroutine 泄漏。context 包通过在整个调用链中传递取消信号来解决这个问题。
package main
import (
"context"
"fmt"
"time"
)
func monitorPayment(ctx context.Context, orderID string) {
for {
select {
case <-ctx.Done():
fmt.Println("停止监控订单:", orderID, "-", ctx.Err())
return
case <-time.After(500 * time.Millisecond):
fmt.Println("检查订单状态:", orderID)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
defer cancel()
go monitorPayment(ctx, "Order-99")
<-ctx.Done()
time.Sleep(50 * time.Millisecond) // 等待 goroutine 输出最后一行
}
context.WithTimeout创建一个在 1200ms 后自动调用Done()的 context。- 在
monitorPayment中,select既监听ctx.Done()又监听定期的 tick。当 context 过期时,goroutine 干净地退出,而不是永远运行。 defer cancel()确保即使函数提前结束也能释放 context 资源。
检查订单状态:Order-99
检查订单状态:Order-99
停止监控订单: Order-99 - context deadline exceeded
注意: 始终调用
cancel()(通过defer),无论是使用WithTimeout还是WithCancel。忘记这一步不会立即引起错误,但会在长期运行的循环中导致 context 泄漏。
7. 常见错误
因为 channel 没有接收者而死锁。 发送到无缓冲 channel 但没有 goroutine 在读取会导致程序hang住,Go 运行时会自动检测到:
func main() {
ch := make(chan int)
ch <- 1 // 没人读 → 死锁
}
fatal error: all goroutines are asleep - deadlock!
Goroutine 泄漏。 Goroutine 永远等待读取 channel 因为没人发送,也没有取消机制——内存会被保留到程序结束。启动长期运行的 goroutine 时始终附带 context 或“done” channel。
闭包捕获循环变量错误(Go 1.22 之前)。 如果你使用 Go < 1.22,for 循环中的循环变量 i 在所有迭代间共享,导致所有 goroutine 可能看到相同的最终值。从 Go 1.22 开始,每个循环迭代创建新的 i 变量,所以这个问题消失了——但如果你的项目使用更早的版本,请如第 4.1 节所示通过参数传递变量。
共享变量时的竞态条件。 两个 goroutine 同时读/写一个变量而不通过 channel 或 sync/atomic 是经典的竞态条件。始终在开发中运行 go test -race 或 go run -race 来及早发现它们。
何时不应使用 goroutine: 对于短期任务,创建 goroutine 和同步的开销可能大于收益。如果工作只需要几微秒,串行运行通常更快且更易读。
8. 结论
Goroutine 让你以极低的开销创建轻量级执行线程,而 channel 提供安全的通信机制来代替直接共享内存。结合 sync.WaitGroup 来同步、select 来处理多源、以及 context 来正确取消,你就拥有了构建 Go 中常见并发模式的工具——从 worker pool、pipeline 到 fan-in/fan-out。下一步应该是通过 go test -race 来养成习惯,及早发现竞态条件。