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