
在 go 并发爬虫中,不能依赖 channel 长度或盲目关闭 channel 来判断任务结束;应使用 `sync.waitgroup` 精确跟踪活跃 goroutine 数量,并在所有任务完成后统一关闭 channel 或终止主流程。
在实现并发 Web 爬虫(如 Go Tour 中的 Exercise: Web Crawler)时,一个常见误区是试图通过检查 channel 缓冲区长度(如 len(stor.Queue) == 0)来判断“是否还有任务待处理”,进而决定何时关闭 channel 或退出主循环。这种做法不仅逻辑错误(channel 长度仅反映当前缓冲区中未读数据量,无法反映已启动但尚未入队、或正在执行中的 goroutine),而且极易导致死锁或提前终止——正如原代码中注释所警示的那样。
正确的方案是:用 sync.WaitGroup 显式管理 goroutine 生命周期。其核心思想是:
- 每启动一个新 goroutine 执行 Crawl,就调用 wg.Add(1);
- 每个 Crawl 函数结束前,必须调用 defer wg.Done();
- 主 goroutine 调用 wg.Wait() 阻塞等待,直到所有子任务完成。
这种方式不依赖 channel 状态,也不需要共享 channel 引用或手动关闭 channel,彻底规避了竞态与死锁风险。
以下是关键实践要点与优化后的结构化示例:
✅ 推荐写法(无 channel 队列,纯 WaitGroup 驱动):
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
var visited = make(map[string]int) // 全局共享,需注意并发安全(本例中无写竞争,因每个 URL 首次访问才写)
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.Printf("fetch error %s: %v\n", url, err)
return
}
fmt.Printf("found: %s %q\n", url, body)
// 为每个新 URL 启动并发爬取
for _, u := range urls {
wg.Add(1)
go Crawl(Result{u, res.Depth - 1}, fetcher)
}
}
func main() {
wg.Add(1)
go Crawl(Result{"http://golang.org/", 4}, fetcher)
wg.Wait() // 主协程在此阻塞,直到所有爬取完成
}⚠️ 注意事项:
- 避免共享可变状态的竞争:本例中 visited 是全局 map,虽当前逻辑保证每个 URL 最多被一个 goroutine 首次写入(因 visited[url] > 0 判断在写之前),但在更复杂场景中,应使用 sync.Map 或 mutex 保护。
- *不要传递 `Stor或 channel 给每个 goroutine**:原设计中stor.Queue` 作为任务分发通道,反而引入了关闭时机难题;WaitGroup 方案更简洁、正交,职责分离清晰(goroutine 管理 vs 数据通信)。
- 若仍需 channel 收集结果:可在 Crawl 中将结果发送到一个只读 channel(如 results chan
? 总结:判断“是否还有数据/任务”的本质,不是观察 channel 的瞬时状态,而是追踪任务的生命周期。sync.WaitGroup 是 Go 官方推荐、轻量且可靠的同步原语,适用于所有“主协程等待一组子协程完成”的场景。在并发爬虫中,它让逻辑更健壮、代码更易读、调试更简单。










