🪁 先讲个生活故事:放风筝 vs 开团队
想象你在组织一次团队活动:
❌ 无结构化并发(Go 原生 go)
你:大家分头去采购!
A:我去买菜(转身就走)
B:我去买饮料(转身就走)
C:我去买餐具(转身就走)
10分钟后...
你:🤔 人呢?谁回来了?谁出问题了?
✅ 结构化并发
你:大家分头采购,30分钟后门口集合,谁有问题随时喊我!
A/B/C:收到!
→ 有人提前回来:在门口等其他人
→ 有人发现问题:立刻通知大家停止
→ 时间到了:确认所有人都回来了再下一步
核心区别:前者是"放飞不管",后者是"有组织有纪律"。
这就是 Structured Concurrency(结构化并发) 要解决的问题。
🔍 什么是结构化并发?
用四句话概括:
| 规则 | 人话解释 |
|---|---|
| 🎯 任务不能超出作用域 | 子任务生命周期 ≤ 父任务 |
| 🔥 一人失败,全员撤退 | 任意任务出错,其他任务收到取消信号 |
| ⏳ 等所有人都回来 | 作用域退出前,必须等所有子任务完成 |
| 📬 错误能传回给老大 | 子任务的错误能正确传播到调用方 |
💡 这个理念最早由 Martin Sustrik 提出,后来被 Python/Kotlin 内置支持,而 Go 选择"让你自己组装"。
🐍🦀 先看别人家:Python 和 Kotlin 怎么做
Python 的 TaskGroup(3.11+)
async with asyncio.TaskGroup() as tg: # ① 创建一个"团队作用域"
tg.create_task(fetch("/users")) # ② 任务自动加入团队
tg.create_task(fetch("/orders"))
# ③ 任意任务失败 → 其他任务自动取消 → 等所有人退出 → 才继续执行
Kotlin 的 coroutineScope
coroutineScope { // ① 创建一个"团队作用域"
launch { fetch("/users") } // ② 任务自动加入团队
launch { fetch("/orders") }
// ③ 行为同 Python:失败传播 + 自动取消 + 等待完成
}
共同特点:
- ✅ 语法糖:
async with/coroutineScope自动管理生命周期 - ✅ 取消点:
await/delay等挂起点自动检查取消信号 - ✅ 错误收集:自动聚合多个任务的错误
🐹 Go 的方式:没有魔法,但有积木
Go 的选择很"Go":不内置结构化并发,但给你足够的积木让你自己搭。
场景 1:一人失败,全员撤退 → 用 errgroup
func run() error {
// ① 创建一个带 context 的组:一人失败,context 自动 cancel
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
// ② 每个 goroutine 主动检查 ctx.Done()
select {
case <-ctx.Done():
return ctx.Err() // 收到取消信号,优雅退出
case <-time.After(100 * time.Millisecond):
fmt.Println("✅ fetched /users")
return nil
}
})
g.Go(func() error {
return fmt.Errorf("❌ /orders failed") // ③ 这个任务失败了
})
g.Go(func() error {
select {
case <-ctx.Done(): // ④ 因为上面失败了,ctx 已 cancel,这里会收到信号
return ctx.Err()
case <-time.After(100 * time.Millisecond):
fmt.Println("✅ fetched /products")
return nil
}
})
// ⑤ 等所有任务完成,返回第一个错误
return g.Wait()
}
💡 关键区别:Python/Kotlin 的
await/delay自动检查取消,Go 需要你手动写select { case <-ctx.Done() }。
场景 2:各干各的,互不影响 → 用 WaitGroup
func run() []error {
var (
wg sync.WaitGroup
mu sync.Mutex // 保护 errs 切片
errs []error
)
urls := []string{
"/users", "/orders", "/products"}
for _, url := range urls {
wg.Add(1)
go func(url string) {
// ⚠️ 注意:url 要传参,避免闭包陷阱
defer wg.Done()
time.Sleep(100 * time.Millisecond)
if url == "/orders" {
mu.Lock()
errs = append(errs, fmt.Errorf("❌ %s failed", url))
mu.Unlock()
return // ❌ 失败但不影响其他人
}
fmt.Println("✅ fetched", url)
}(url)
}
wg.Wait() // 等所有人都干完
return errs
}
💡 这就像 Kotlin 的
supervisorScope:一个任务挂了,其他继续跑。
🤔 为什么 Go 不内置结构化并发?
根本原因:goroutine ≠ coroutine
| 特性 | Python/Kotlin 协程 | Go goroutine |
|---|---|---|
| 调度方式 | 协作式(cooperative) | 抢占式(preemptive) |
| 取消点 | await/suspend 自动注入 |
需要手动 select { <-ctx.Done() } |
| 运行时感知 | 知道你在"等待",可中断 | 不知道你在忙啥,不敢随便停 |
举个🌰:CPU 密集型任务
// ❌ 这个 goroutine 永远检查不到取消信号
func busyWorker(ctx context.Context) {
for {
heavyComputation() // 纯 CPU 计算,不查 ctx.Done()
}
}
// ✅ 正确姿势:主动让出 + 检查取消
func cooperativeWorker(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // 收到取消,优雅退出
default:
doOneChunk() // 只做一小块工作,然后循环回来检查
}
}
}
💡 设计哲学 #1:显式优于隐式
Go 认为:取消是你的业务逻辑,不该由运行时"偷偷"注入。你更清楚什么时候可以安全中断。
🛠️ Go 结构化并发最佳实践
✅ 原则:每个 goroutine 都要有"主人"和"退出条件"
func processItems(ctx context.Context, items []string) error {
g, ctx := errgroup.WithContext(ctx) // ① 组 + context 绑定生命周期
for _, item := range items {
g.Go(func() error {
// ② 每个任务都检查取消信号
select {
case <-ctx.Done():
return ctx.Err()
default:
return handleItem(ctx, item) // ③ 业务逻辑也透传 ctx
}
})
}
return g.Wait() // ④ 等所有人完成
}
✅ 让调用方决定并发方式(库函数设计)
// ❌ 库函数内部偷偷起 goroutine(不好!)
func FetchData(url string) ([]byte, error) {
go func() {
// 🚨 调用方无法控制生命周期
// ...
}()
// ...
}
// ✅ 返回结果,让调用方决定怎么并发(好!)
func FetchData(ctx context.Context, url string) ([]byte, error) {
// 纯同步逻辑
// 调用方可以:直接调用 / 用 errgroup / 用 goroutine + channel
}
💡 设计哲学 #2:组合优于约定
Go 不强制你用什么模式,但给你errgroup/WaitGroup/context/channel这些正交积木,自由组合。
🐛 如何避免"风筝线断了"(goroutine 泄漏)
常见陷阱 + 解决方案
| 问题 | 现象 | 解法 |
|---|---|---|
❌ 忘记检查 ctx.Done() |
goroutine 永远运行 | 每个循环/阻塞点加 select |
❌ go func() 无等待 |
主函数退出,子 goroutine 还在跑 | 用 errgroup 或 WaitGroup 管理 |
| ❌ 闭包变量陷阱 | 所有 goroutine 拿到同一个变量值 | go func(url string) { ... }(url) 传参 |
| ❌ 错误被吞掉 | 子任务失败但主流程不知道 | g.Go 返回 error,Wait() 收集 |
💭 最后聊聊设计哲学
"Never start a goroutine without knowing when it will stop."
— Dave Cheney
Go 的选择其实很"Go":
| 语言 | 默认策略 | 哲学 |
|---|---|---|
| Python/Kotlin | 内置结构化 | 安全第一,帮你管好 |
| Go | 裸 go + 手动组装 |
灵活第一,信任程序员 |
没有绝对的对错,只有不同的权衡:
- ✅ Go 的方式:更灵活,适合系统级编程,但需要更多纪律
- ✅ Python/Kotlin 的方式:更安全,适合业务开发,但牺牲部分控制力