
本文提供一个基于sync.waitgroup和channel协调的轻量级go爬虫实现,解决原代码中因goroutine未同步退出导致的死锁问题,兼顾可读性与工程健壮性。
本文提供一个基于sync.waitgroup和channel协调的轻量级go爬虫实现,解决原代码中因goroutine未同步退出导致的死锁问题,兼顾可读性与工程健壮性。
在Go Tour的Web Crawler练习中,初学者常陷入“并发即正确”的误区:盲目启动大量goroutine却忽略生命周期管理,最终触发死锁(如fatal error: all goroutines are asleep - deadlock!)。核心症结在于——没有明确的完成信号机制:主goroutine无限select等待channel数据,而子goroutine爬完后悄然退出,无人关闭channel,亦无计数器告知“工作已全部结束”。
下面是一个简洁、可运行、无死锁的重构方案,仅增加少量关键逻辑,彻底规避原问题:
✅ 正确的并发协调模型
我们采用 sync.WaitGroup + 关闭channel 的双保险策略:
-
WaitGroup跟踪所有活跃爬取goroutine数量; - 所有goroutine结束后,由main显式关闭结果通道,使
range或select能自然退出; - 避免使用未缓冲/未关闭的channel进行无限阻塞等待。
package main
import (
"fmt"
"sync"
)
type Fetcher interface {
Fetch(url string) (body string, urls []string, err error)
}
// SafeCache 使用 RWMutex 提升读多写少场景性能(推荐替代原Mutex)
type SafeCache struct {
cache map[string]bool
rwmu sync.RWMutex // 读操作无需互斥,提升并发效率
}
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, results chan<- string, errors chan<- error) {
defer wg.Done() // 确保无论成功/失败都标记完成
if depth <= 0 {
return
}
if sc.Get(url) {
errors <- fmt.Errorf("duplicate url: %s", url)
return
}
sc.Set(url)
body, urls, err := fetcher.Fetch(url)
if err != nil {
errors <- fmt.Errorf("fetch %s failed: %v", url, err)
return
}
results <- fmt.Sprintf("found: %s %q", url, body)
// 为每个子链接启动goroutine,并递增WaitGroup计数
for _, u := range urls {
wg.Add(1)
go Crawl(u, depth-1, fetcher, wg, results, errors)
}
}
func main() {
const depth = 4
results := make(chan string, 100) // 建议设缓冲,防goroutine阻塞
errors := make(chan error, 100)
var wg sync.WaitGroup
wg.Add(1)
go Crawl("http://golang.org/", depth, fetcher, &wg, results, errors)
// 启动goroutine监听结果,避免main阻塞
go func() {
wg.Wait() // 等待所有爬取goroutine完成
close(results) // 关闭通道,通知消费者终止
close(errors)
}()
// 安全消费结果(使用range自动处理关闭)
for res := range results {
fmt.Println(res)
}
for err := range errors {
fmt.Println("Error:", err)
}
}
// fakeFetcher 实现(同原题,略作格式优化)
type fakeFetcher map[string]*fakeResult
type fakeResult struct {
body string
urls []string
}
func (f fakeFetcher) Fetch(url string) (string, []string, error) {
if res, ok := f[url]; ok {
return res.body, res.urls, nil
}
return "", nil, fmt.Errorf("not found: %s", url)
}
var fetcher = fakeFetcher{
"http://golang.org/": &fakeResult{
"The Go Programming Language",
[]string{
"http://golang.org/pkg/",
"http://golang.org/cmd/",
},
},
"http://golang.org/pkg/": &fakeResult{
"Packages",
[]string{
"http://golang.org/",
"http://golang.org/cmd/",
"http://golang.org/pkg/fmt/",
"http://golang.org/pkg/os/",
},
},
"http://golang.org/pkg/fmt/": &fakeResult{
"Package fmt",
[]string{
"http://golang.org/",
"http://golang.org/pkg/",
},
},
"http://golang.org/pkg/os/": &fakeResult{
"Package os",
[]string{
"http://golang.org/",
"http://golang.org/pkg/",
},
},
}? 关键改进点说明
| 原问题 | 修复方式 | 原理 |
|---|---|---|
select { case 无限等待未关闭channel |
改用 for range ch + 显式 close()
|
channel关闭后range自动退出,无死锁风险 |
wg缺失导致无法判断goroutine是否结束 |
wg.Add(1)/wg.Done()配对 + wg.Wait()阻塞主goroutine |
精确控制并发生命周期,避免过早退出或永久等待 |
SafeCache读操作加写锁,性能低 |
改用sync.RWMutex,读共享、写独占 |
符合爬虫缓存“读远多于写”的典型场景,提升吞吐 |
| channel无缓冲,易因消费者慢导致goroutine阻塞 | 设置合理缓冲(如100) | 防止生产者goroutine因channel满而挂起 |
⚠️ 注意事项
-
永远不要在未关闭的channel上
range或无限select—— 这是死锁头号原因; -
wg.Add()必须在go语句之前调用(否则存在竞态); -
defer wg.Done()应置于函数开头,确保panic时也能释放计数; - 生产环境建议增加超时控制(如
context.WithTimeout)和重试逻辑,但本练习以清晰性为先。
该方案在保持代码简洁性的同时,严格遵循Go并发最佳实践,是理解WaitGroup与channel协作的经典范例。掌握它,你就真正迈过了Go并发编程的第一道门槛。
立即学习“go语言免费学习笔记(深入)”;


















