如何正确判断 Go 并发爬虫中任务队列是否已空并安全结束?

在 go 并发爬虫中,不能依赖 channel 长度或盲目关闭 channel 来判断任务结束;应使用 `sync.waitgroup` 精确跟踪活跃 goroutine 数量,确保所有爬取任务完成后再退出。

在实现类似 Go Tour 并发练习:Web Crawler 的任务时,一个常见误区是试图通过检查 channel 缓冲区长度(如 len(stor.Queue) == 0)来判断“是否还有任务”,甚至提前关闭 channel——这不仅逻辑错误(channel 关闭后无法再发送,但新 URL 可能仍在生成),更会导致死锁或 panic。

根本问题在于:channel 本身不表达“任务完成”的语义;它只是数据传递的管道。真正需要回答的是:“所有已启动的 goroutine 是否都已执行完毕?”——这正是 sync.WaitGroup 的设计目标。

✅ 正确做法:用 WaitGroup 管理生命周期

WaitGroup 提供三个核心方法:

  • Add(n):增加待等待的 goroutine 计数;
  • Done():标记一个 goroutine 完成(需在 defer 中调用,确保异常退出也能计数);
  • Wait():阻塞直到计数归零。

在爬虫中,我们只需:

  • 每次启动新 goroutine 前调用 wg.Add(1);
  • 在 Crawl 函数末尾 defer wg.Done();
  • 主函数中 wg.Wait() 等待全部完成。

以下是精简、线程安全的完整实现(移除了易出错的 channel 队列,改用纯 goroutine 分发):

package main

import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup
var visited = make(map[string]int) // 全局共享,注意:实际生产环境需加 mutex,本例因无并发写冲突可暂省略

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() // 确保无论成功/失败都计数减一

    if res.Depth <= 0 {
        return
    }

    url := res.Url
    if visited[url] > 0 { // 已访问过,跳过
        fmt.Println("skip:", url)
        return
    }
    visited[url] = 1

    body, urls, err := fetcher.Fetch(url)
    if err != nil {
        fmt.Println("fetch error:", url, err)
        return
    }
    fmt.Printf("found: %s %q\n", url, body)

    // 并发处理子链接
    for _, u := range urls {
        wg.Add(1) // 关键:为每个新 goroutine 预先计数
        go Crawl(Result{u, res.Depth - 1}, fetcher)
    }
}

func main() {
    wg.Add(1)                // 启动初始爬取任务
    Crawl(Result{"http://golang.org/", 4}, fetcher)
    wg.Wait()                // 阻塞等待所有 goroutine 结束
    fmt.Println("Crawling finished.")
}

⚠️ 注意事项与进阶建议

  • 竞态风险:本例中 visited 是全局 map,多个 goroutine 同时写入存在数据竞争。真实项目中必须加 sync.Mutex 或改用 sync.Map(适用于读多写少场景)。
  • 避免 channel 误用:原代码中 stor.Queue 本质是模拟任务队列,但未配合同步机制(如 close() 时机难控、消费者无法感知“最后一条”),反而增加复杂度。纯 goroutine 分发 + WaitGroup 更简洁可靠。
  • 深度控制与终止条件:Depth 是天然的递归终止条件,配合 visited 去重,即可保证有限图上的收敛。
  • 扩展性提示:若需限速、超时、错误重试或结果收集,可在 Crawl 中引入 context.Context 和带缓冲的 result channel,但 WaitGroup 仍是基础同步原语。

总之,判断“何时不再有数据”在并发爬虫中,不是问 channel 还有没有值,而是问“所有工作单元是否已退出”。sync.WaitGroup 是 Go 标准库为此场景提供的最直接、最可靠的工具。