Go:并发编程入门,goroutine、channel、context 应该怎么配合

第一次学 Go 并发时,很快会先记住三个词:

  • goroutine
  • channel
  • context

但真正开始写代码时,又经常会同时卡在几件事上:

  • goroutine 什么时候该起,什么时候不该乱起
  • channel 是拿来传数据,还是拿来做同步
  • context 是不是“到处传一下就行”
  • 为什么代码看起来能跑,结果一超时就收不住
  • 为什么任务都执行完了,程序还挂着不退出

这类问题通常不是因为语法不会,而是因为三种能力之间的分工没建立起来

Go 并发入门真正要先看清的是:

  • goroutine 负责并发执行
  • channel 负责通信和协同
  • context 负责控制生命周期

如果把这三件事混在一起,代码很快就会变成:

  • goroutine 一路乱开
  • channel 既传数据又强行承载取消语义
  • context 传了,但没人真正响应取消

这一篇就围绕一个实际小场景来讲:做一个批量任务分发器

这个分发器要做几件事:

  1. 接收一批检查任务
  2. 并发执行这些任务
  3. 回收每个任务结果
  4. 整体超时后立即停止收口
  5. 某个关键失败时通知其它任务尽快退出

这个场景不大,但足够把 goroutine、channel、context 的配合关系一次讲清楚。

一、这篇文章要解决什么问题

读完这一篇,应该能独立回答这些问题:

  1. goroutine、channel、context 分别在解决什么问题
  2. 一个最小并发任务模型应该怎么搭
  3. 什么时候该用 channel 传结果,什么时候该用 context 取消
  4. 超时、取消和正常完成三种结束方式怎么区分
  5. 怎么给并发逻辑补最小验证
  6. 什么时候不该把“能并发”理解成“必须并发”

如果这些问题能答清,后面再学竞态、死锁、goroutine 泄漏和 HTTP 并发服务,理解会快很多。

二、先看这篇文章里的实际场景

假设现在要做一个最小任务分发器,输入一批探测任务:

  • check-order-api
  • check-user-api
  • check-billing-api

每个任务都要:

  • 执行一次检查
  • 返回成功或失败
  • 记录耗时

同时还有两个约束:

  1. 整批任务 2 秒内必须收口
  2. 某个关键任务一旦失败,剩余任务要尽快停止

这时如果完全串行执行,最直接的问题是总耗时可能太长。
但如果只是看见 Go 有 goroutine,就一路并发开出去,后面又会立刻遇到:

  • 结果怎么回收
  • 谁负责关闭通道
  • 超时后谁通知其它任务退出
  • 子任务没退出时主流程为什么会卡住

所以这一篇不按“概念定义”展开,而是直接围绕这个分发器搭并发骨架。

三、先给一个最小可运行示例

先只做最小版本:三个任务并发执行,把结果从 channel 收回来。

1
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
package main

import (
"fmt"
"time"
)

type Result struct {
Name string
Duration time.Duration
Err error
}

func runTask(name string) Result {
start := time.Now()
time.Sleep(200 * time.Millisecond)
return Result{
Name: name,
Duration: time.Since(start),
}
}

func main() {
tasks := []string{"order", "user", "billing"}
resultCh := make(chan Result)

for _, name := range tasks {
go func(taskName string) {
resultCh <- runTask(taskName)
}(name)
}

for range tasks {
result := <-resultCh
fmt.Println(result.Name, result.Duration)
}
}

输出可能像这样:

1
2
3
user 200.412ms
billing 200.557ms
order 200.671ms

这个例子先只说明一件事:

  • goroutine 把任务并发跑起来
  • channel 把结果带回来

但它还没解决超时、取消、错误传播这些更关键的问题。

四、先把一句话结论说清楚

在 Go 并发里,最容易写乱的不是语法,而是职责边界。

一个更稳的理解方式是:

  1. goroutine 负责“谁在并发执行”
  2. channel 负责“谁和谁之间交换结果或信号”
  3. context 负责“谁通知谁该结束了”

如果一个地方把这三件事全塞给同一个工具,就会开始失控。

例如:

  • 用 channel 既传结果又表示取消
  • 用 goroutine 随便包一层,却没人负责收尾
  • 用 context 传下去,但底层函数从头到尾不检查 Done

五、goroutine 到底在解决什么问题

goroutine 解决的是:把一段执行流独立跑起来。

最小写法很简单:

1
go runTask("order")

但这里要先看清一个事实:

  • 开 goroutine 不等于任务一定会完成
  • 主 goroutine 退出时,其它 goroutine 也会直接结束

所以只会写 go xxx() 远远不够。
后面至少要回答:

  • 结果怎么回来
  • 结束怎么通知
  • 异常怎么处理

如果这些没有配套,goroutine 只会把原本串行的混乱改成并发的混乱。

六、channel 到底在解决什么问题

channel 解决的是:goroutine 之间怎么交换数据或同步状态。

最常见的两种用途是:

  1. 传递结果
  2. 传递完成信号

例如传结果:

1
2
resultCh := make(chan Result)
resultCh <- result

例如传完成信号:

1
2
done := make(chan struct{})
done <- struct{}{}

这里先要建立一个意识:

  • channel 不是“并发万能容器”
  • 它解决的是协作,不是所有共享问题

如果只是想保护共享内存,可能更该看 sync.Mutex
如果是想表示整体任务结束,更可能应该搭配 context

七、context 到底在解决什么问题

context 最核心的作用不是“顺手传一下”,而是:

把取消、超时和请求作用域沿调用链传下去。

最常见的入口:

1
2
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

这个 ctx 可以往下传给:

  • 任务执行器
  • HTTP 请求
  • 数据读取逻辑
  • 下游 worker

然后下游在合适的位置响应:

1
2
3
4
5
select {
case <-ctx.Done():
return ctx.Err()
default:
}

这才叫“用了 context”。
如果只是函数参数里带个 ctx,但内部根本不看 Done,那它只是一个摆设。

八、先把三者串成一个最小分发器

先做一个有超时控制的版本。

1
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
package dispatcher

import (
"context"
"fmt"
"time"
)

type Task struct {
Name string
Delay time.Duration
Critical bool
}

type Result struct {
Name string
Duration time.Duration
Err error
}

func runTask(ctx context.Context, task Task) Result {
start := time.Now()

select {
case <-time.After(task.Delay):
if task.Critical {
return Result{
Name: task.Name,
Duration: time.Since(start),
Err: fmt.Errorf("critical task failed"),
}
}
return Result{
Name: task.Name,
Duration: time.Since(start),
}
case <-ctx.Done():
return Result{
Name: task.Name,
Duration: time.Since(start),
Err: ctx.Err(),
}
}
}

func Dispatch(ctx context.Context, tasks []Task) []Result {
resultCh := make(chan Result)

for _, task := range tasks {
go func(current Task) {
resultCh <- runTask(ctx, current)
}(task)
}

results := make([]Result, 0, len(tasks))
for range tasks {
results = append(results, <-resultCh)
}
return results
}

这时已经有了三层配合:

  • goroutine 并发执行任务
  • channel 回收结果
  • context 控制任务是否继续

九、为什么这里要把循环变量显式传进去

写 goroutine 时,最常见的坑之一是:

1
2
3
4
5
for _, task := range tasks {
go func() {
resultCh <- runTask(ctx, task)
}()
}

这类写法在不同 Go 版本和不同上下文里都容易制造误判。
更稳的写法是直接把当前值传进闭包参数:

1
2
3
4
5
for _, task := range tasks {
go func(current Task) {
resultCh <- runTask(ctx, current)
}(task)
}

这里不是为了“写法好看”,而是为了把当前 goroutine 使用的对象边界写清楚。

十、为什么只会起 goroutine 还远远不够

先看一个常见的坏版本:

1
2
3
4
5
func Dispatch(tasks []Task) {
for _, task := range tasks {
go runTask(context.Background(), task)
}
}

问题是:

  • 没有结果回收
  • 没有错误处理
  • 没有结束条件
  • 没有超时边界

这种代码最常见的后果是:

  • 主流程提早结束
  • 子 goroutine 状态不可见
  • 出问题时根本不知道哪一个任务卡住了

所以一段并发代码至少要回答:

  1. 任务何时开始
  2. 结果往哪里回
  3. 何时停止等待
  4. 谁负责取消

十一、为什么取消信号更适合让 context 承担

有些人刚开始会想用一个 channel 表示取消:

1
stopCh := make(chan struct{})

这当然不是完全不能做,但一旦调用链变长,就会开始暴露问题:

  • 函数签名多出很多额外通道参数
  • 超时和取消语义要自己封装
  • 下游库函数通常不认识你的 stopCh

context 的好处是:

  • 标准库普遍认识它
  • 超时、取消、层层传递都已经有现成模型
  • 调用链可以统一响应 Done

所以更常见的边界是:

  • 用 channel 传业务结果
  • 用 context 传生命周期控制

十二、一个更完整的版本:关键失败触发全局取消

现在把“关键任务失败就停止整批执行”也补进去。

1
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
67
package dispatcher

import (
"context"
"fmt"
"time"
)

type Task struct {
Name string
Delay time.Duration
Critical bool
}

type Result struct {
Name string
Duration time.Duration
Err error
}

func runTask(ctx context.Context, task Task) Result {
start := time.Now()

select {
case <-time.After(task.Delay):
if task.Critical {
return Result{
Name: task.Name,
Duration: time.Since(start),
Err: fmt.Errorf("critical task failed"),
}
}
return Result{
Name: task.Name,
Duration: time.Since(start),
}
case <-ctx.Done():
return Result{
Name: task.Name,
Duration: time.Since(start),
Err: ctx.Err(),
}
}
}

func Dispatch(ctx context.Context, tasks []Task) []Result {
ctx, cancel := context.WithCancel(ctx)
defer cancel()

resultCh := make(chan Result, len(tasks))

for _, task := range tasks {
go func(current Task) {
result := runTask(ctx, current)
if current.Critical && result.Err != nil {
cancel()
}
resultCh <- result
}(task)
}

results := make([]Result, 0, len(tasks))
for range tasks {
results = append(results, <-resultCh)
}
return results
}

这里更接近实际工程的地方有两点:

  1. 子任务会响应 ctx.Done()
  2. 某个关键失败能触发整体取消

十三、buffered channel 和 unbuffered channel 这里该怎么选

这个话题在并发入门里很容易被神化。
先不用背很多口诀,只看当前场景。

如果当前写法是:

1
resultCh := make(chan Result, len(tasks))

它的直接好处是:

  • 发送结果的 goroutine 不会因为主流程暂时没接收就立刻卡住

但这不意味着“有 buffer 就更高级”。
当前选择它,只是因为:

  • 每个任务只发一次结果
  • 结果数上限已知
  • 发送端不需要和接收端强同步

如果你的模型需要强同步,或者要靠阻塞表达背压,unbuffered channel 反而更合适。

十四、为什么不能靠关闭 resultCh 来表示“所有任务结束”

这也是高频混乱点。

如果每个 worker 都想自己决定:

1
close(resultCh)

很容易直接触发:

1
panic: close of closed channel

更关键的是,在多 worker 场景下,谁拥有关闭通道的权力必须非常清楚。
一个常见原则是:

  • 谁创建通道
  • 谁明确知道“不会再有发送”
  • 谁来关闭通道

在这篇文章的最小模型里,因为主流程按任务数固定接收,所以甚至不需要强行关闭 resultCh

十五、一个更接近项目现场的小案例

假设现在有一组环境巡检任务:

  • 数据库连通性
  • 缓存连通性
  • 用户接口健康检查
  • 支付接口健康检查

要求是:

  1. 总巡检时间不能超过 3 秒
  2. 数据库检查是关键项,一旦失败立刻终止整批
  3. 非关键项超时可以记失败,但不阻断结果汇总

这时一套更合适的并发骨架通常是:

  1. 顶层创建 context.WithTimeout
  2. 每个任务单独起 goroutine
  3. 所有任务结果统一写入 resultCh
  4. 关键项失败时触发 cancel()
  5. 主流程继续收口结果,生成最终摘要

最终摘要结构可能像这样:

1
2
3
4
5
6
type Summary struct {
SuccessCount int
FailedCount int
TimeoutCount int
CanceledCount int
}

主流程根据 result.Err 决定归类:

  • nil 记成功
  • context.DeadlineExceeded 记超时
  • context.Canceled 记取消
  • 其它错误记失败

这样一来,并发就不是“快一点”这么简单,而是开始具备可控的工程边界。

十六、怎么给这类并发逻辑补最小验证

并发代码如果只靠手跑,很容易留下两个误判:

  • 偶尔跑通就以为没问题
  • 一次没卡死就以为模型正确

至少要验证这些点:

  1. 所有任务结果都能收回来
  2. 超时后任务能退出
  3. 关键失败时整批能取消

示例测试:

1
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
package dispatcher

import (
"context"
"errors"
"testing"
"time"
)

func TestDispatchReturnsAllResults(t *testing.T) {
tasks := []Task{
{Name: "order", Delay: 10 * time.Millisecond},
{Name: "user", Delay: 10 * time.Millisecond},
}

results := Dispatch(context.Background(), tasks)
if len(results) != 2 {
t.Fatalf("want 2 results, got %d", len(results))
}
}

func TestDispatchTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()

tasks := []Task{
{Name: "slow", Delay: 100 * time.Millisecond},
}

results := Dispatch(ctx, tasks)
if len(results) != 1 {
t.Fatalf("want 1 result, got %d", len(results))
}
if !errors.Is(results[0].Err, context.DeadlineExceeded) {
t.Fatalf("want deadline exceeded, got %v", results[0].Err)
}
}

这组测试很小,但已经能把并发模型最容易失控的两条边守住。

十七、怎么验证这段代码是不是真的响应了取消

很多代码表面上已经传了 ctx,但实际并没有响应取消。
一个最直接的检查方式是:

  1. 人为把任务延迟拉长
  2. WithTimeout 时间压短
  3. 看最终返回的是不是 context.DeadlineExceeded
  4. 看程序会不会继续悬挂不退出

如果超时以后:

  • 结果迟迟不回来
  • 程序还挂着
  • 某些 goroutine 继续睡眠很久

那就说明底层根本没有在合适位置检查 ctx.Done()

十八、一个高频排错场景:为什么主流程已经结束了,程序却不退出

这类问题通常有几种高频原因:

  1. 某个 goroutine 还在阻塞发送
  2. 某个 goroutine 永远等不到 channel 数据
  3. 某个任务内部根本不响应 context 取消

排查顺序通常是:

  1. 先看哪个 channel 上有 send / receive 卡住
  2. 再看有没有 goroutine 没有退出条件
  3. 再看 ctx.Done() 有没有真正被 select 到

在这类问题里,最常见的根因不是 Go 并发“太玄学”,而是:

  • 通信边界没画清
  • 生命周期控制没收住

十九、一个高频误区:把 channel 当共享状态表来用

例如有人会想把所有状态都塞进一个 channel 流里,然后在多个地方读写,希望它顺便承担:

  • 任务队列
  • 取消信号
  • 结果汇总
  • 当前状态同步

这通常会把模型越写越乱。

更常见有效的思路是:

  • channel 只承担明确的一种协作语义
  • 结果通道就是结果通道
  • 取消交给 context
  • 共享状态单独考虑是否需要锁或归并到单 goroutine 管理

二十、什么时候不该并发

这是并发入门里特别值得先建立的边界。

如果当前任务具备这些特征:

  • 总量很小
  • 完全没有 I/O 等待
  • 顺序必须严格一致
  • 并发后排障成本反而更高

那它可能根本不值得并发。

并发不是默认更高级,而是:

  • 有等待可以重叠
  • 有吞吐要提升
  • 有生命周期需要统一控制

才值得引入。

二十一、一个实际练习

可以直接把这一篇改成一个完整练习。

练习目标:做一个最小环境巡检分发器。

要求:

  1. 同时执行 3 到 5 个任务
  2. resultCh 回收每个任务结果
  3. context.WithTimeout 控制整体时间
  4. 至少定义一个关键任务,失败时触发 cancel()
  5. 补两组最小测试:正常返回和超时返回
  6. 故意写一个不响应 ctx.Done() 的版本,再修回来

如果这个练习能独立做完,说明并发基础已经不只是“会开 goroutine”,而是开始有收口意识了。

二十二、这篇文章的边界在哪里

这一篇重点讲的是并发入门骨架:

  • goroutine 怎么起
  • channel 怎么收
  • context 怎么控

先不展开这些更复杂的话题:

  • sync.WaitGroupMutexCond
  • worker pool 的限流和背压
  • 竞态条件和死锁的系统排查
  • 更复杂的 fan-out / fan-in 设计

这些更适合放到后面的并发问题和工程实践文章里继续展开。

二十三、这篇文章学完以后,下一步应该补什么

这一篇解决的是:

  • goroutine、channel、context 的基本分工
  • 一个最小并发任务模型怎么搭起来

接下来最适合继续补的是:

  1. 竞态、死锁、goroutine 泄漏、超时和取消怎么排查
  2. ioosnet/httpjson 这些标准库能力怎么和并发配合
  3. 项目继续长大后,目录、层次和入口怎么组织

因为到这一步,并发代码已经能跑起来了。
后面真正会卡住的,是它能不能长期稳定、能不能排查、能不能被工程化接住。

二十四、结语

Go 并发入门真正关键的,不是先记住多少术语,而是先把三件事分开:

  • goroutine 负责执行
  • channel 负责协作
  • context 负责生命周期

只要这个分工先立住,后面无论是写批量任务、环境巡检,还是 HTTP 请求并发处理,代码都不会那么容易一上来就缠在一起。

并发从来不是“多写一个 go”这么简单。
真正让代码稳下来的,是启动、通信、取消和收口这四件事有没有同时被设计进去。