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









