
本文提供 go tour 中 web crawler 练习的标准解法:使用 sync.waitgroup 精确管理 goroutine 生命周期,配合互斥锁保护共享缓存,避免死锁与竞态,实现安全、可终止的并行爬取。
本文提供 go tour 中 web crawler 练习的标准解法:使用 sync.waitgroup 精确管理 goroutine 生命周期,配合互斥锁保护共享缓存,避免死锁与竞态,实现安全、可终止的并行爬取。
Go Tour 的 Exercise: Web Crawler 是初学者理解 Go 并发模型的关键一课——它要求你改造串行爬虫为并发、去重、可终止的版本。许多初学者(如问题中所示)尝试用 channel 直接驱动主循环,却陷入死锁:main 无限 select 等待,而 ress/errs 通道从未关闭;goroutine 泄漏导致程序无法自然退出。根本原因在于:channel 关闭时机不可靠,且无法反映 goroutine 实际执行状态。
✅ 正确解法的核心原则是:用 sync.WaitGroup 跟踪活跃任务,而非依赖 channel 状态。
为什么 WaitGroup 是唯一可靠方案?
-
len(ch) == 0或ch == nil无法判断是否还有 goroutine 正在运行(可能刚启动、正在 Fetch、或已入队但未读取); - 提前关闭 channel 会导致后续
sendpanic; - 不关闭 channel 则
range ch永不结束,select无限阻塞。
WaitGroup 通过显式计数,让“所有任务完成”这一逻辑变得确定、无歧义。
完整可运行示例(已修复死锁)
package main
import (
"fmt"
"sync"
)
type Fetcher interface {
Fetch(url string) (body string, urls []string, err error)
}
// SafeCache 使用 RWMutex 提升读多写少场景性能
type SafeCache struct {
cache map[string]bool
rwmu sync.RWMutex // 替代 Mutex:允许多读并发
}
func (c *SafeCache) Set(url string) {
c.rwmu.Lock()
c.cache[url] = true
c.rwmu.Unlock()
}
func (c *SafeCache) Get(url string) bool {
c.rwmu.RLock()
defer c.rwmu.RUnlock()
return c.cache[url]
}
var sc = SafeCache{cache: make(map[string]bool)}
// Crawl 启动 goroutine,并由 wg 统一管理生命周期
func Crawl(url string, depth int, fetcher Fetcher, wg *sync.WaitGroup) {
defer wg.Done() // 确保每次调用必执行 Done()
if depth <h3>关键改进说明</h3>
| 问题点 | 修复方式 | 原因 |
|---|---|---|
| 死锁 | 移除 errs/ress channel,改用 fmt.Printf 直接输出 |
避免 channel 管理复杂性;真实项目中可用带缓冲 channel + close() 配合 wg.Wait(),但本练习无需异步通信 |
| 竞态风险 |
SafeCache 改用 sync.RWMutex
|
Get 频率远高于 Set,读锁允许多个 goroutine 并发检查,显著提升吞吐 |
| goroutine 泄漏 |
defer wg.Done() + wg.Add(1) 严格配对 |
每个 go Crawl(...) 前必 Add(1),函数退出前必 Done(),wg.Wait() 精确等待零计数 |
| 重复抓取 |
Get → Set 原子化检查+标记 |
防止两个 goroutine 同时通过 Get 判断后并发 Fetch 同一 URL |
进阶提示(生产环境参考)
- 若需收集结果,可定义
results chan 参数,在 <code>Crawl中results ,并在 <code>main启动独立 goroutinego func(){ ... for range results {}}()处理,最后close(results); - 添加
context.Context控制超时与取消,防止某个Fetch卡死拖垮整体; - 使用
http.Client自定义超时与重定向策略,避免默认行为引发意外跳转; - 对高频访问的
SafeCache,可考虑sync.Map(适用于读远多于写的场景),但需注意其LoadOrStore语义与原生 map 不同。
✅ 总结:Go 并发编程的第一铁律是——用 WaitGroup 管理 goroutine 生命周期,用 channel 传递数据,绝不混用二者职责。本练习不是为了炫技,而是建立对“并发安全”和“确定性终止”的正确认知。跑通这段代码,你就真正跨过了 Go 并发的第一道门槛。










