Go 并发:Goroutine、Channel 与 Context
# Go 并发:Goroutine、Channel 与 Context
理解 Goroutine、Channel、Select、Mutex 和 Context 的职责,能写出会停止、可超时且不会无限创建任务的并发代码。
# 先记住一句话
Goroutine 是并发执行单元,Channel 用于通信与同步,Context 传播截止时间和取消信号;共享内存仍需要明确的所有权或锁。
请求 Context
├─ Goroutine A → 调用搜索服务
├─ Goroutine B → 调用用户服务
└─ 截止时间到达 → 同时通知 A、B 停止
2
3
4
使用 Goroutine 不代表必须同时使用 Channel、Context 和共享数据,它们解决的是不同问题:
- Goroutine:让一个函数并发执行;
- Channel:在 Goroutine 之间传递数据、事件或完成信号;
- Context:通知调用链中的 Goroutine 取消或超时;
- 共享数据:多个 Goroutine 访问同一份内存,需要通过 Mutex、atomic 或清晰的所有权规则保证安全。
应根据任务关系选择工具:
- 只等待任务完成:使用
sync.WaitGroup; - 需要在 Goroutine 之间传递结果:使用 Channel;
- 需要通知任务取消或超时:传入 Context;
- 需要保护共享状态:使用 Mutex 或 atomic;
- 一组任务既可能失败又要联动取消:通常使用
errgroup.WithContext。
无论选择哪种工具,启动 Goroutine 前都必须回答三个问题:它怎样结束、由谁等待、错误怎样处理。 只写 go 而不管理生命周期,容易造成 Goroutine 泄漏、错误丢失或程序提前退出。
# Channel 的含义与收发语法
Channel (opens new window) 通常译为通道或信道,是 Go 为 Goroutine 提供的类型,用来传递指定类型的数据并进行同步。它不是操作系统的 Pipe(管道),也不等于 Kafka 等跨进程消息队列。
// 创建一个最多暂存 1 个 string 值的 Channel。
messages := make(chan string, 1)
// channel <- value:把右侧的值发送进 Channel。
messages <- "done"
// value := <-channel:从 Channel 接收一个值并保存到变量。
message := <-messages
// 此时 message 的值是 "done",Channel 缓冲区重新变为空。
2
3
4
5
6
7
8
9
已有请求 Context 时,<-ctx.Done() 表示等待 ctx 发出取消信号,但不保存接收到的值;本页并发查询中的 ctx 来自 FetchAll 的函数参数。
<- 的位置表示数据方向:
| 写法 | 含义 | 标准空格 |
|---|---|---|
channel <- value | 将 value 发送进 channel | <- 两侧有空格 |
value := <-channel | 从 channel 接收值并保存 | <- 紧贴右侧的 Channel |
<-channel | 接收一个值,但不保存 | <- 紧贴右侧的 Channel |
空格差异不是功能开关,而是 gofmt 的标准格式。发送时,<- 位于 Channel 和数据之间;接收时,<- 放在 Channel 表达式前面,写成 <-channel。
Channel 类型还可以限制允许的操作,通常用于函数参数:让负责生产数据的函数只能发送,让负责消费数据的函数只能接收。这样如果写反方向,编译器就会报错。
package main
import "fmt" // 输出从 Channel 中接收到的消息。
// out 的类型是 chan<- string,只允许向它发送 string。
func sendMessage(out chan<- string) {
out <- "done" // 有缓冲区空位时发送立即完成;无空位时会阻塞等待接收方。
// value := <-out // 编译错误:不能从只发送 Channel 接收数据。
}
// in 的类型是 <-chan string,只允许从它接收 string。
func receiveMessage(in <-chan string) {
value := <-in // 接收 Channel 中的下一条 string;没有数据时会阻塞等待。
fmt.Println(value) // 输出:done。
// in <- "again" // 编译错误:不能向只接收 Channel 发送数据。
}
func main() {
// chan string 是双向 Channel,本身既能发送,也能接收。
messages := make(chan string, 1)
// 同一个双向 Channel 传入函数后,分别被限制为只发送和只接收。
sendMessage(messages)
receiveMessage(messages)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
可以按箭头方向记忆:
chan<- string // Channel ← string:只允许把 string 发送进去。
<-chan string // 变量 ← Channel:只允许从中接收 string。
2
方向限制不会创建新的 Channel,也不会改变底层数据;它只是缩小当前函数对同一个 Channel 的操作权限,让职责更清楚并防止误用。
无缓冲 Channel 在发送方和接收方同时准备好时才能完成通信;有缓冲 Channel 可以先暂存有限数量的值。缓冲区已满时继续发送会阻塞,没有数据时接收也会阻塞。
# 什么时候使用 Channel
当一个 Goroutine 需要把数据、事件或完成信号交给另一个 Goroutine 时,可以考虑使用 Channel。常见场景包括:
- 多个 Goroutine 并发查询,由主 Goroutine 收集结果或错误;
- 生产者持续产生任务,多个 Worker 从 Channel 中领取并处理;
- 等待任务完成、超时或取消,并使用
select处理最先到达的事件; - 通过传递数据,让同一时刻只有负责接收的 Goroutine 操作它,减少共享状态。
例如,本页下面的 FetchAll 会启动多个查询 Goroutine。每个 Goroutine 通过 results 或 errorsCh 发送结果,主 Goroutine 通过 select 接收,这就是 “并发执行任务,集中收集结果” 的典型场景。
以下情况通常不需要 Channel:
- 只是普通的同步函数调用,直接返回结果更简单;
- 多个 Goroutine 只需保护一小段共享状态,
sync.Mutex通常更直接; - 只需等待一组可能失败的任务,
errgroup往往比手写多个 Channel 更清晰; - 数据需要跨进程、持久化或失败重试,应使用 Kafka、RabbitMQ 等外部消息系统。
可以简单判断:需要在 Goroutine 之间传递数据或事件时考虑 Channel;只是保护共享数据时优先考虑锁。
Channel 只存在于当前进程内,进程退出后数据消失。它适合 Goroutine 之间传值和同步,不提供 Kafka、RabbitMQ 等外部消息系统的持久化、重试和跨服务消费能力。
关闭 Channel 表示以后不会再发送值,通常应由发送方关闭。向已经关闭的 Channel 发送会 panic;重复关闭也会 panic。接收方不应为了结束自己而随意关闭共享 Channel。
# Context 负责生命周期
Context (opens new window) 是 Go 标准库中负责请求生命周期控制的包。它定义了 context.Context 接口,用来沿函数调用链向下传递取消信号、截止时间和少量请求范围元数据。开发中通常直接称它为 “Context”,并把变量命名为 ctx。
| 写法 | 表示什么 |
|---|---|
context | 导入的标准库包名 |
context.Context | 该包定义的 Context 接口类型 |
ctx | 开发中约定俗成的 Context 变量名 |
可以把 Context 理解成随请求一起向下传递的生命周期通知单:上层请求被取消、超时或到达截止时间后,下层正在执行数据库查询、HTTP 调用或其他耗时任务的函数都能收到通知并尽快停止。
// Background 创建不带取消和超时的根 Context;WithTimeout 在它上面增加 2 秒期限。
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // 即使提前成功也释放定时器和关联资源。
// client 是由上层创建并注入的请求客户端;:= 同时接收结果和错误。
result, err := client.Fetch(ctx, "agent")
// result 是请求结果;err 用于判断请求成功、超时还是被取消。
2
3
4
5
6
7
下层函数通常监听 ctx.Done(),并在收到取消信号时停止等待:
func waitForMessage(ctx context.Context, messages <-chan string) (string, error) {
// select 会阻塞等待,直到下面任意一个 Channel 操作可以执行。
select {
case message := <-messages:
return message, nil // 先收到消息时返回内容,nil 表示没有错误。
case <-ctx.Done():
// Done Channel 收到信号,说明请求已取消、超时或超过截止时间。
return "", ctx.Err()
}
}
2
3
4
5
6
7
8
9
10
常见创建方式包括:
context.Background():创建根 Context,常用于程序入口;context.WithCancel(parent):创建可以主动取消的子 Context;context.WithTimeout(parent, duration):到达指定时长后自动取消;context.WithDeadline(parent, deadline):到达指定时间点后自动取消;context.WithValue(parent, key, value):附加少量请求范围元数据,不应代替普通函数参数。
Context 应作为请求链第一个参数显式向下传递,不要保存进长期存在的 Struct。它适合携带取消信号、截止时间和 Trace ID 等少量请求范围元数据,不适合承载查询条件、用户对象等业务参数。
对于前端开发,需要特别注意:Go Context 与 React Context 职责不同。React Context 主要在组件树中共享数据;Go Context 主要沿函数调用链传播请求的取消、超时和截止时间。
# 共享状态与数据竞争
// Counter 把共享数值与保护它的互斥锁放在同一个 Struct 中。
type Counter struct {
mu sync.Mutex // 保护 value 的互斥锁;访问 value 的代码都必须使用同一把锁。
value int // 多个 Goroutine 可能同时修改的共享计数。
}
// *Counter 是指针接收者,方法会直接修改原 Counter 实例。
func (c *Counter) Increment() {
// 同一时刻只允许一个 Goroutine 进入受保护区域。
c.mu.Lock()
defer c.mu.Unlock() // 确保后续逻辑即使调整或提前返回也会解锁。
c.value++ // 只有持有 mu 的 Goroutine 才能执行这次读改写操作。
}
2
3
4
5
6
7
8
9
10
11
12
13
数据竞争是多个 Goroutine 并发访问同一内存,且至少一个进行写入却没有正确同步。它不只是结果偶尔不准,还会让程序行为失去可靠保证。
# 在测试期间启用 Race Detector,发现实际执行路径中的数据竞争。
go test -race ./...
2
并发不是越多越快
每个请求启动无限 Goroutine 会放大数据库连接、内存和下游限流压力。生产系统要设置 Worker 数、信号量或连接池上限,并让排队、拒绝和超时策略保持一致。
# 一个有边界的并发查询
package search
import (
"context" // 接收上层取消或超时信号,使并发查询能够提前结束。
"fmt" // 使用 Errorf 为查询错误补充数据源名称。
)
// Result 表示一个数据源返回的查询结果。
type Result struct {
Source string // 结果来自哪个数据源。
Text string // 数据源返回的正文。
}
// Fetcher 是具名函数类型:接收 Context 和查询词,返回 Result 与 error。
// map 中可以保存不同数据源的查询函数,只要它们的签名满足 Fetcher。
type Fetcher func(ctx context.Context, query string) (Result, error)
// FetchAll 同时调用所有 fetchers,收集成功结果;任一错误或 Context 取消都会提前返回。
func FetchAll(
ctx context.Context, // 上层传入的请求 Context。
query string, // 每个数据源都要查询的关键词。
fetchers map[string]Fetcher, // Key 是数据源名称,Value 是对应查询函数。
) ([]Result, error) {
// 缓冲区等于任务数,避免函数因提前返回而把发送方永久阻塞。
// make(chan T, n) 创建可容纳 n 个 T 值的缓冲 Channel。
results := make(chan Result, len(fetchers))
errorsCh := make(chan error, len(fetchers))
for name, fetch := range fetchers {
// 为本轮循环创建独立变量,避免 Goroutine 意外共享随后变化的循环变量。
name, fetch := name, fetch
// go 关键字异步启动 Goroutine;匿名函数末尾的 () 表示立即调用它。
go func() {
// 调用当前数据源的 Fetcher;它应继续观察同一个 ctx 的取消信号。
result, err := fetch(ctx, query)
if err != nil {
// Channel 的箭头表示数据流向:channel <- value 表示把右侧的值发送进 Channel。
// 这里把带有数据源名称的错误发送进 errorsCh,等待下面的 select 接收。
errorsCh <- fmt.Errorf("fetch %s: %w", name, err)
return
}
// 查询成功时,把 result 发送进 results Channel。
results <- result
}()
}
// 结果数量最多等于任务数,因此预留容量以减少 append 扩容。
collected := make([]Result, 0, len(fetchers))
// 这里只需要等待固定次数,因此忽略 range 产生的 Map 键。
for range fetchers {
// select 等待多个 Channel 操作,哪个先就绪就执行哪个 case。
select {
// <-channel 表示从 Channel 接收一个值;:= 把它保存到新变量 result。
case result := <-results:
collected = append(collected, result)
// 从 errorsCh 接收一个错误;收到后直接结束 FetchAll 并向上返回错误。
case err := <-errorsCh:
return nil, err
// ctx.Done() 返回一个 Channel;这里只等待取消信号,不需要保存接收到的值。
case <-ctx.Done():
return nil, ctx.Err() // 返回 context.Canceled 或 context.DeadlineExceeded。
}
}
return collected, nil // 固定次数全部接收成功后返回结果;顺序取决于完成先后。
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
这个示例展示基本通信,但生产代码通常会使用 errgroup 一类结构统一等待、错误传播和取消,减少手写协调逻辑。无论使用哪种库,都要回答:谁启动任务?谁等待结束?失败后谁通知其他任务停止?
# 高频面试题与回答
1. Channel 和 Mutex 怎样选择?参考答案
任务之间需要传递数据、所有权或完成信号时适合 Channel;保护一小段共享内存的一致性时 Mutex 往往更直接。不要为了使用 Go 特性,把简单计数器强行改成复杂 Channel 流程。
2. 怎样避免 Goroutine 泄漏?参考答案
启动时就定义退出路径,让阻塞的发送、接收和外部调用都能观察 Context 取消或超时,并确保上层等待任务结束。只创建 Goroutine 却不知道它何时退出,是最常见的泄漏来源。
3. 无缓冲 Channel 和有缓冲 Channel 有什么区别?参考答案
无缓冲 Channel 的发送必须与接收者直接交接,天然形成同步点;有缓冲 Channel 在容量未满时允许发送者继续,适合吸收有限突发。缓冲只能改变阻塞时机,不能替代容量规划和背压,也不应靠随意增大缓冲掩盖消费过慢。
4. 从已经关闭的 Channel 接收会发生什么?参考答案
接收方会先读完缓冲区中的值,之后立即得到元素类型的零值,并且双返回值中的 ok 为 false;继续发送到已关闭 Channel 会 panic。关闭表示 “不会再发送” ,不是清空数据,也不是必须由接收方执行。
5. Context 主要解决什么问题?参考答案
Context 在调用链中传播取消、截止时间和少量请求级元数据,让下游 I/O 和 Goroutine 随上层请求结束。它通常作为第一个参数显式传递,不存入结构体,也不用于传递普通可选业务参数;创建可取消 Context 后还应调用 cancel 释放关联资源。
6. nil Channel 有什么行为?参考答案
对 nil Channel 的直接发送和接收都会永久阻塞,关闭 nil Channel 会 panic。在 select 中,nil Channel 对应的分支永远不会就绪,因此可以把某个 Channel 设为 nil 来动态禁用该分支,但独立使用时很容易造成泄漏或死锁。
# 接下来学什么
下一篇学习 Go 并发模式与 Goroutine 生命周期,进一步掌握 Worker Pool、Pipeline、背压和正确关闭。