Go 并发爬虫中如何正确判断任务完成并安全终止?

13次阅读

Go 并发爬虫中如何正确判断任务完成并安全终止?

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 中表达这一语义最清晰、最可靠的方式。

text=ZqhQzanResources