
在 go 并发爬虫中,不能依赖 channel 长度或空闲状态判断任务结束;应使用 `sync.waitgroup` 精确跟踪 goroutine 生命周期,确保所有爬取任务完成后再退出主流程,避免死锁和资源泄漏。
在实现并发 Web 爬虫(如 Go Tour 中的 Exercise: Web Crawler)时,一个常见误区是试图通过检查 channel 是否为空(如 len(stor.Queue) == 0)来决定何时关闭 channel 或终止程序。这是错误且危险的:channel 长度为 0 仅表示当前无缓冲数据,并不反映是否有 goroutine 正在执行、是否还有新 URL 将被发送——过早关闭 channel 会导致 send on closed channel panic,而永不关闭则引发死锁(如 for range chan 永不退出)。
正确的做法是解耦“任务分发”与“生命周期管理”:不再依赖 channel 作为唯一同步原语,而是引入 sync.WaitGroup 主动计数活跃的 goroutine。
✅ 核心原则:WaitGroup 三步法
- 初始化:在 main() 中声明全局或局部 var wg sync.WaitGroup;
- 计数:每次启动新 goroutine 前调用 wg.Add(1);
- 通知完成:在 goroutine 结束前(通常用 defer wg.Done())标记完成。
这样,wg.Wait() 会精确阻塞直到所有已注册的 goroutine 执行完毕,无需猜测“是否还有数据”。
✅ 改写关键逻辑(对比原代码)
原代码中通过 stor.Queue 通道传递任务,并在 main 中 for range stor.Queue 消费——这要求通道必须被显式关闭,但关闭时机无法静态判定(因为新 goroutine 可能随时向它发送数据)。重构后完全移除该 channel,改用纯 goroutine 分叉 + WaitGroup 协同:
func Crawl(res Result, fetcher Fetcher) {
defer wg.Done() // ✅ 每个 goroutine 自行上报完成
if res.Depth <= 0 {
return
}
url := res.Url
// 全局 visited map 保证去重(注意:实际生产环境需加 mutex,此处简化)
if visited[url] > 0 {
fmt.Println("skip:", url)
return
}
visited[url] = 1
body, urls, err := fetcher.Fetch(url)
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("found: %s %q\n", url, body)
// ✅ 对每个子 URL 启动新 goroutine,并提前 Add(1)
for _, u := range urls {
wg.Add(1)
go Crawl(Result{u, res.Depth - 1}, fetcher)
}
}
func main() {
wg.Add(1) // ✅ 主 goroutine 也计入
go Crawl(Result{"http://golang.org/", 4}, fetcher)
wg.Wait() // ✅ 安全等待全部完成,无死锁风险
}⚠️ 注意事项
-
共享状态需同步:示例中 visited 是全局 map,多 goroutine 并发写入存在竞态。真实场景应配合 sync.Mutex 或 sync.Map:
var mu sync.RWMutex mu.Lock() visited[url] = 1 mu.Unlock()
- 避免递归式 goroutine 泛滥:深度过大时可能创建过多 goroutine。可结合 worker pool(固定数量 goroutine 从任务队列消费)提升可控性。
- 不要混用 channel 和 WaitGroup 做同一目标:若坚持用 channel 调度(如任务队列模式),则需额外信号机制(如 done channel + select)配合 WaitGroup,增加复杂度;对本题而言,直接分叉更简洁。
✅ 总结
判断“是否还有数据/任务”的本质,不是观察 channel 的瞬时状态,而是跟踪执行单元的生命周期。sync.WaitGroup 提供了零误差、低开销、语义清晰的解决方案。掌握这一模式,不仅能解决爬虫死锁问题,更是编写健壮 Go 并发程序的基石实践。










