
在 go 并发爬虫中,不能依赖 channel 长度或手动关闭 channel 来判断任务结束;应使用 sync.waitgroup 精确跟踪 goroutine 生命周期,确保所有爬取任务完成后再退出主程序。
实现一个健壮的并发 Web 爬虫,关键在于任务生命周期管理——既要避免重复抓取,又要准确感知“所有工作已完成”这一状态。原始代码试图通过检查 stor.Queue 的长度来决定是否关闭 channel,这是典型误区:channel 长度仅反映当前缓冲区数据量,无法反映尚未启动但已入队的任务,更无法感知 goroutine 是否仍在运行,最终导致 range 永不结束、程序死锁。
✅ 正确解法是采用 sync.WaitGroup ——它专为“等待一组 goroutine 完成”而设计:
- wg.Add(n) 在启动新 goroutine 前调用,声明将有 n 个任务需等待;
- defer wg.Done() 在每个 goroutine 结束时调用,标记该任务完成;
- wg.Wait() 在主线程中阻塞,直到所有 Add 对应的 Done 被调用。
下面是一个精简、线程安全的完整实现(已移除冗余 channel 和共享 Stor 结构体,改用包级变量+互斥控制):
package main import ( "fmt" "sync" ) var ( visited = make(map[string]int) mu sync.RWMutex // 读写锁保护 shared map wg sync.WaitGroup ) type Result struct { Url string Depth int } type Fetcher interface { Fetch(url string) (body string, urls []string, err error) } func Crawl(res Result, fetcher Fetcher) { defer wg.Done() // 标记当前 goroutine 完成 if res.Depth <= 0 { return } url := res.Url // 安全检查是否已访问(读操作) mu.RLock() if visited[url] > 0 { mu.RUnlock() fmt.Println("skip:", url) return } mu.RUnlock() // 标记为已访问(写操作) mu.Lock() visited[url]++ mu.Unlock() body, urls, err := fetcher.Fetch(url) if err != nil { fmt.Println("fetch error:", err) return } fmt.Printf("found: %s %qn", url, body) // 为每个子 URL 启动新 goroutine for _, u := range urls { wg.Add(1) // 关键:提前声明子任务数 go Crawl(Result{u, res.Depth - 1}, fetcher) } } func main() { wg.Add(1) // 主任务计入 WaitGroup Crawl(Result{"http://golang.org/", 4}, fetcher) wg.Wait() // 阻塞直至所有 goroutine 完成 fmt.Println("Crawling finished.") }
⚠️ 注意事项:
- 不要共享可变状态而不加锁:visited 是全局 map,多 goroutine 并发读写必须用 sync.RWMutex(读多写少场景推荐);
- wg.Add() 必须在 go 语句之前调用,否则可能因竞态导致 wg.Wait() 提前返回;
- 避免 channel + range 组合用于任务协调:本题本质是“树形任务分发”,而非生产者-消费者流水线,WaitGroup 更直接、无死锁风险;
- 若后续需扩展为带限速/超时/错误统计的工业级爬虫,建议引入 context.Context 和结构化错误处理,但核心终止逻辑仍由 WaitGroup 承担。
总结:判断“不再有新数据”不等于“channel 为空”,而是“所有派生任务均已结束”。sync.WaitGroup 是 Go 中表达这一语义最清晰、最可靠的方式。