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

注意: 只有发送者可以 close channel。发送到已关闭的 channel 会触发 panic send 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 来养成习惯,及早发现竞态条件。

golanggoroutinechannelconcurrency