应避免递归中直接启动goroutine,而应在同层兄弟节点间并发,用errgroup.group统一管理生命周期并配合context实现安全取消。

递归 + goroutine 容易导致栈溢出和 goroutine 泄漏
Go 的 goroutine 轻量,但不等于可以无节制 spawn。树深度大时,每层都起 go f(),会瞬间创建成百上千个协程,而递归本身又在栈上累积调用帧——两者叠加,既可能耗尽内存,也可能因调度延迟导致子协程在父节点返回后仍在运行,形成泄漏。
关键不是“能不能并发”,而是“在哪一层并发、谁负责等待、谁控制生命周期”。真实场景(比如遍历 AST 或目录树)中,往往只需要在**宽层(siblings)并发,而非深层(children)递归并发**。
- 避免在递归函数内部直接用
go traverse(node.Child),这会让调用栈和协程数同步爆炸 - 改用“主协程递归 + worker 协程池处理兄弟节点”:每个层级只对
node.Children启动固定数量的goroutine,子树递归仍在当前协程完成 - 务必用
sync.WaitGroup或errgroup.Group等明确等待所有兄弟任务结束,不能靠time.Sleep或忽略返回值
用 errgroup.Group 控制并发树遍历更安全
errgroup.Group 天然适合这种“一个父任务启动多个子任务并统一收口”的模式,它自动处理错误传播、上下文取消和等待,比裸写 WaitGroup 少踩坑。
典型误用是把整个递归逻辑塞进 eg.Go() 里,结果每个节点都开新协程,失控。正确做法是:只对同一级的多个子节点并发,递归调用保留在函数体内。
立即学习“go语言免费学习笔记(深入)”;
- 初始化
eg, _ := errgroup.WithContext(ctx),传入带超时或取消的ctx - 遍历
node.Children时,对每个child调用eg.Go(func() error { return traverse(child) }) - 递归函数
traverse()自身仍是普通同步函数,不包含go关键字 - 最终用
eg.Wait()阻塞直到所有兄弟任务完成或出错
func traverse(node *Node) error {
// ... 处理当前节点
eg, _ := errgroup.WithContext(ctx)
for _, child := range node.Children {
child := child // 避免循环变量复用
eg.Go(func() error {
return traverse(child) // 这里是同步递归,不是 goroutine
})
}
return eg.Wait() // 等所有子树完成
}
深度优先 vs 广度优先:并发策略取决于你的瓶颈
DFS 递归天然适合栈式处理,但加了并发后,如果目标是尽快发现某个匹配节点(如查找),DFS+并发反而可能因调度延迟错过近层结果;如果是聚合统计(如计算总大小),BFS 分批处理兄弟节点更利于内存局部性和错误隔离。
Go 标准库没有内置并发 BFS,但用 channel + for range 可轻松模拟。此时“并发单元”变成一批同层节点,而不是单个节点。
- DFS 并发:适合 I/O 密集型子树(如每个子节点要 HTTP 请求),且你信任子树深度可控
- BFS 并发:适合 CPU 密集型或深度不可控场景,能自然限流(每次只并发处理 N 个同层节点)
- 别混用:不要在 BFS 循环里又对每个节点做 DFS 递归并发,这又回到第一点的泄漏风险
context.WithCancel 是树形并发的保险丝
树遍历一旦开始,中途想停(比如用户取消、超时、发现错误提前退出),没有 context 就只能等全部跑完。而 errgroup.Group 的 WithContext 正是为此设计——任意子任务返回错误或 ctx 被取消,其余正在运行的协程会收到通知并尽快退出。
容易被忽略的是:子协程必须主动检查 ctx.Done(),尤其在有阻塞操作(如 http.Get、time.Sleep、channel 收发)的地方。否则取消信号会被忽略,协程卡住。
- 每个递归入口第一行加
select { case - HTTP 请求必须用
http.NewRequestWithContext(ctx, ...),不能用老式http.Get - 自定义 channel 操作需配合
select和ctx.Done(),例如select { case v :=
树越深、分支越多,context 传递越关键。漏掉一次检查,整棵子树就可能成为孤儿协程。










