
本文详解如何在 go 中安全、可靠地使用 goroutine 和 sync.waitgroup 实现并发文件下载,重点解决因资源释放顺序错误、错误未处理导致的文件截断或空文件问题。
本文详解如何在 go 中安全、可靠地使用 goroutine 和 sync.waitgroup 实现并发文件下载,重点解决因资源释放顺序错误、错误未处理导致的文件截断或空文件问题。
在 Go 中利用 goroutine 并发下载多个文件是提升 I/O 效率的常见做法,但若对资源生命周期和同步机制理解不足,极易引发静默失败——例如文件为空、内容截断、部分下载后中断等。问题根源往往不在并发本身,而在于 defer 执行时机、错误忽略、以及 WaitGroup 的误用。
✅ 正确的资源管理与错误处理
原代码中存在两处关键隐患:
defer outFile.Close() 和 defer resp.Body.Close() 放置位置不当:
defer 语句在函数返回时才执行,但若 os.Create 或 http.Do 失败,后续代码(如 io.Copy)不会执行,而 defer 仍会触发——此时 outFile 或 resp.Body 可能为 nil,导致 panic 或未定义行为。io.Copy 错误被完全忽略:
io.Copy(dst, src) 返回 (int64, error),网络中断、磁盘满、权限不足等均会导致写入提前终止,但无错误检查意味着程序“假装成功”,生成零长或不完整文件。
以下是修复后的健壮实现:
func imageDownloader(uri, filename string, wg *sync.WaitGroup) {
defer wg.Done() // ✅ 确保无论成功或 panic,wg 都能正确计数
fmt.Printf("Starting download: %s → %s\n", uri, filename)
// 1. 创建输出文件
outFile, err := os.Create(filename)
if err != nil {
fmt.Printf("Failed to create file %s: %v\n", filename, err)
return
}
defer func() {
if closeErr := outFile.Close(); closeErr != nil {
fmt.Printf("Warning: failed to close %s: %v\n", filename, closeErr)
}
}()
// 2. 发起 HTTP 请求
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("GET", uri, nil)
if err != nil {
fmt.Printf("Invalid URI %s: %v\n", uri, err)
return
}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("HTTP request failed for %s: %v\n", uri, err)
return
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
fmt.Printf("Warning: failed to close response body for %s: %v\n", uri, closeErr)
}
}()
if resp.StatusCode != http.StatusOK {
fmt.Printf("HTTP %d for %s\n", resp.StatusCode, uri)
return
}
// 3. 带进度的复制(可选),并严格检查错误
bar := pb.Full.Start64(resp.ContentLength)
bar.SetUnits(pb.U_BYTES)
rd := bar.NewProxyReader(resp.Body)
written, err := io.Copy(outFile, rd)
bar.Finish()
if err != nil {
fmt.Printf("Copy failed for %s: %v (wrote %d bytes)\n", uri, err, written)
return
}
fmt.Printf("Download completed: %s (%d bytes)\n", filename, written)
}
✅ 正确使用 WaitGroup 的模式
WaitGroup 的核心原则是:Add() 必须在 go 语句之前调用,且数值必须与实际启动的 goroutine 数量严格一致。常见反模式包括:
- 在循环外 Add(1) 却启动多个 goroutine(如问题中所述);
- Add() 调用晚于 go 启动(竞态风险);
- Done() 被遗漏或重复调用。
标准用法示例(下载多个图片):
func main() {
urls := []string{
"https://example.com/img1.jpg",
"https://example.com/img2.jpg",
"https://example.com/img3.jpg",
}
var wg sync.WaitGroup
for i, url := range urls {
wg.Add(1) // ✅ 每个 goroutine 启动前 Add(1)
go imageDownloader(url, fmt.Sprintf("img_%d.jpg", i), &wg)
}
wg.Wait() // 阻塞直到全部完成
fmt.Println("All downloads finished.")
}
? 调试与增强建议
- 添加超时控制:HTTP 客户端务必设置 Timeout,避免单个请求无限阻塞。
- 重试机制:对临时性错误(如 503、网络抖动)可封装简单指数退避重试。
- 并发限制:使用带缓冲的 channel 或 semaphore 控制最大并发数(如 golang.org/x/sync/semaphore),防止打开过多连接耗尽系统资源。
- 日志结构化:生产环境建议使用 zap 或 logrus 替代 fmt.Printf,便于追踪与聚合。
⚠️ 关键总结:Goroutine 不是“魔法开关”,它放大了所有已存在的资源管理缺陷。真正的并发安全 = 显式错误处理 + 确定性资源释放 + 正确同步原语使用。从单 goroutine 开始验证逻辑,再扩展并发,是避免此类问题的最有效路径。











