并发下载必须用writeat而非io.copy,因后者依赖易被干扰的文件指针;每个goroutine需独立校验range响应、预分配文件空间、原子更新resume文件,确保状态隔离与数据一致。

goroutine并发下载时必须用WriteAt,不能共用io.Copy
多个 goroutine 同时写一个文件,如果都调 io.Copy(dst, resp.Body),哪怕各自先 Seek(),最终也会写错位置——因为 io.Copy 内部不感知偏移,只从当前文件指针开始写,而指针会被其他 goroutine 干扰。常见现象是部分分片内容缺失、文件中间出现空洞、校验失败。
正确做法是每个 goroutine 独立负责固定区间,并用 WriteAt:
- 打开文件时不加 os.O_APPEND,只用 os.O_WRONLY | os.O_CREATE
- 每次写前调 f.Seek(offset, io.SeekStart),但**不依赖它定位写入点**
- 直接用 f.WriteAt(data, offset),它绕过文件指针,精准落盘
- 若 data 来自 resp.Body,建议先读到内存切片再 WriteAt,避免流式读写与 Seek 冲突
每个 goroutine 必须独立校验自己的 Range 响应,不能共享状态
服务端对不同 Range 请求可能返回不一致结果:比如某分片因 CDN 缓存失效返回 200,另一片返回 206;或 Content-Range 中的总长度被动态修改。若所有 goroutine 共享一个全局 expectedSize,出错后难以定位。
实操建议:
- 每个 goroutine 在收到响应后,立即解析 resp.Header.Get("Content-Range"),用正则提取起始偏移和总长(如 bytes 1024-2047/10000)
- 校验提取出的起始偏移是否等于本 goroutine 的 start,总长是否与首次 HEAD 获取的一致
- 若不一致,该 goroutine 应中止并返回错误,由外层统一处理(如重试或降级为单线程)
- 不要让 goroutine 修改共享变量(如 atomic.StoreInt64(&totalWritten, ...)),除非你明确需要汇总进度——而进度本身不应影响写入逻辑
resume 文件更新必须原子覆盖,且不能在 goroutine 里做
多个 goroutine 并发写同一个 .resume 文件极易损坏内容:比如两个 goroutine 同时读旧文件、各自加 1MB、再同时写回,结果只 +1MB 而非 +2MB。更糟的是,写入中途崩溃,resume 文件可能只剩半行文本。
安全做法只有一种:所有 goroutine 完成后,由主 goroutine 单独执行一次全量覆盖:
- 收集所有成功写入的分片区间,算出最新 offset(即最大 end)
- 调 os.WriteFile(filename+".resume", []byte(fmt.Sprintf("offset=%d\nurl=%s\netag=%s", offset, url, etag)), 0644)
- 这个操作必须在所有下载 goroutine WaitGroup.Done() 之后、合并前完成
- 切勿在 goroutine 内部调 os.WriteFile 更新 resume —— 即使加锁也难防崩溃截断
并发写文件前必须预分配空间,否则 NFS 或某些 SSD 会变慢甚至失败
Linux 下对未分配空间的文件做随机 WriteAt,内核可能按需扩展文件并填充零,导致大量磁盘 I/O 和延迟。在 NFS 或某些云盘上,这还可能触发权限错误或超时。
推荐在启动并发前一次性预分配:
- 先用 HEAD 获取真实 Content-Length
- 调 f.Truncate(contentLength),确保文件长度准确
- 若 Truncate 失败(如只读文件系统),降级为顺序写或报错退出
- 预分配后,所有 WriteAt 都是纯数据覆盖,无隐式扩容开销
- 注意:不要用 os.O_TRUNC 打开文件,它会清空已有内容,破坏已下载部分
并发续传真正的难点不在“怎么启 goroutine”,而在于每个 goroutine 是否真正隔离了输入、输出和状态边界。最容易被忽略的是:resume 文件不是进度显示器,而是恢复依据;它必须反映最终一致的状态,而不是某个瞬间的近似值。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











