贝利信息

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

日期:2026-01-07 00:00 / 作者:心靈之曲

在 go 并发爬虫中,不能依赖 channel 长度或手动关闭 channel 来判断任务结束;应使用 sync.waitgroup 精确跟踪 goroutine 生命周期,确保所有爬取任务完成后再退出主程序。

实现一个健壮的并发 Web 爬虫,关键在于任务生命周期管理——既要避免重复抓取,又要准确感知“所有工作已完成”这一状态。原始代码试图通过检查 stor.Queue 的长度来决定是否关闭 channel,这是典型误区:channel 长度仅反映当前缓冲区数据量,无法反映尚未启动但已入队的任务,更无法感知 goroutine 是否仍在运行,最终导致 range 永不结束、程序死锁。

✅ 正确解法是采用 sync.WaitGroup ——它专为“等待一组 goroutine 完成”而设计:

下面是一个精简、线程安全的完整实现(已移除冗余 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.")
}

⚠️ 注意事项:

总结:判断“不再有新数据”不等于“channel 为空”,而是“所有派生任务均已结束”。sync.WaitGroup 是 Go 中表达这一语义最清晰、最可靠的方式。