如何正确使用 Go 语言中的 sync.WaitGroup 避免程序永不退出

本文详解 `sync.waitgroup` 常见误用导致程序卡在 `wg.wait()` 不返回的问题,重点说明值传递 vs 指针传递、`defer wg.done()` 的调用时机等关键陷阱,并提供可立即修复的代码示例。

在 Go 并发编程中,sync.WaitGroup 是协调多个 goroutine 完成等待的经典工具。但若使用不当,极易引发程序“假死”——看似所有任务已完成,wg.Wait() 却永不返回。你提供的代码正是典型反例,问题根源在于两个关键错误:

? 错误一:WaitGroup 被值传递(copy),导致 Done() 失效

函数 downloadFromURL(url string, wg sync.WaitGroup) 的第二个参数是 值类型,Go 会复制整个 WaitGroup 结构体传入。后续在 goroutine 中调用 wg.Done(),实际操作的是副本,对 main 中原始 wg 的计数器 零影响。因此 wg.Wait() 永远等待未完成的 goroutine。

✅ 正确做法:必须传递指针

go downloadFromURL(url, &wg) // 传地址

并同步更新函数签名:

func downloadFromURL(url string, wg *sync.WaitGroup) error {
    // ...
}

? 错误二:defer wg.Done() 放置位置不当

当前代码中 defer wg.Done() 写在函数末尾(return nil 之前),看似合理,实则危险:一旦函数因错误提前 return(如文件创建失败、HTTP 请求异常),defer 将被跳过,Done() 永不执行,WaitGroup 计数器无法归零。

✅ 正确做法:defer wg.Done() 应置于函数最开始
它应是 goroutine 启动后立即注册的“收尾承诺”,确保无论函数以何种路径退出,计数器必减一:

func downloadFromURL(url string, wg *sync.WaitGroup) error {
    defer wg.Done() // ✅ 第一行就声明:我结束时必调用 Done()

    tokens := strings.Split(url, "/")
    fileName := tokens[len(tokens)-1]
    fmt.Printf("Downloading %v to %v \n", url, fileName)

    content, err := os.Create("temp_docs/" + fileName)
    if err != nil {
        fmt.Printf("Error while creating %v because of %v\n", fileName, err)
        return err // 此处 return → defer wg.Done() 仍会执行
    }
    defer content.Close() // 别忘了关闭文件!

    resp, err := http.Get(url)
    if err != nil {
        fmt.Printf("Could not fetch %v because %v\n", url, err)
        return err // 同样,defer wg.Done() 保证执行
    }
    defer resp.Body.Close()

    _, err = io.Copy(content, resp.Body)
    if err != nil {
        fmt.Printf("Error while saving %v from %v\n", fileName, url)
        return err
    }

    fmt.Printf("Download complete for %v \n", fileName)
    return nil
}

⚠️ 其他重要注意事项

  • wg.Add(1) 必须在 go 语句前调用:你的代码已正确(wg.Add(1) 在 go downloadFromURL(...) 之前),这是安全的;若放在 goroutine 内部,则存在竞态风险。
  • 避免重复 Add/Over-add:确保每个 go 启动的 goroutine 对应且仅对应一次 Add(1)。
  • WaitGroup 不可复制或重用:一旦 Wait() 返回,该 WaitGroup 实例不应再被 Add() 或再次 Wait();如需复用,请重新声明变量。
  • 调试技巧:sync.WaitGroup 本身不提供公开 API 查询当前计数,但可通过 unsafe 或 reflect(不推荐生产环境)窥探;更实用的方法是添加日志:在 Add(1) 和 Done() 处打印计数变化,或使用 go tool trace 分析 goroutine 生命周期。

修复后的完整 main 函数逻辑清晰、健壮可靠:

func main() {
    links := parseLinks()
    var wg sync.WaitGroup

    for _, url := range links {
        if isExcelDocument(url) {
            wg.Add(1)
            go downloadFromURL(url, &wg) // ✅ 传指针
        } else {
            fmt.Printf("Skipping: %v\n", url)
        }
    }
    wg.Wait() // ✅ 现在能正确返回
    fmt.Println("All downloads completed.")
}

遵循这两条铁律——指针传递 WaitGroup + defer Done() 置顶——即可彻底规避 WaitGroup 永不完成的陷阱,写出稳定、可维护的并发 Go 程序。