os.File.WriteAt是唯一安全的并发写入方式,因它调用pwrite64(2)不依赖文件偏移量;但需禁用O_APPEND、预分配空间、静态划分偏移、校验返回值并共享同一文件句柄。

os.File.WriteAt 是唯一安全的并发写入方式
直接用 os.File.Write 多 goroutine 写同一文件必然出错——不是 Go 的问题,是 Linux 内核对共享 fd 的 write(2) 强制串行化:所有 goroutine 共享一个文件偏移量,lseek + write 过程中极易被中断,导致数据覆盖或错位。唯一绕过该限制的是 WriteAt,它底层调用 pwrite64(2),不依赖当前偏移,只认你传的 off int64。
但必须注意:os.O_APPEND 和 WriteAt 互斥。一旦打开文件时加了 os.O_APPEND,再调 WriteAt 会返回 ESPIPE 错误。所以打开文件只能用 os.O_WRONLY | os.O_CREATE(或加 os.O_TRUNC),绝对不能带 O_APPEND。
必须预分配文件空间,否则生成稀疏文件
不预分配就直接 WriteAt(data, offset) 写远端偏移(比如 size-1),ext4/xfs 等文件系统会创建稀疏文件:中间空洞读出来全是零,且某些场景下 WriteAt 返回成功,但磁盘实际没落盘。
-
f.Truncate(size)最常用,但不是原子操作——若中途崩溃,文件可能被截短 - 更稳妥的是先占位:
f.WriteAt([]byte{0}, size-1),确保末尾字节写入成功,再逐段填充 - 预分配大小必须等于最终文件总长,不能靠
os.Stat动态查——并发写期间长度不可信
偏移量必须静态划分,不能动态计算
任何基于 f.Seek(0, io.SeekCurrent) 或 os.Stat 获取“当前偏移”的做法,在并发下完全失效。其他 goroutine 可能在你 Seek 后立刻写入一段,你的计算立刻过期。
立即学习“go语言免费学习笔记(深入)”;
正确做法是主 goroutine 静态划分区间:
- 按
chunkSize = totalSize / runtime.NumCPU()计算块大小 - 显式生成
[]struct{ data []byte; offset int64 }切片,每个子 goroutine 接收固定data和offset - 每个子 goroutine 调用
n, err := f.WriteAt(data, offset)后,必须校验n == len(data);否则说明磁盘满、权限不足或设备异常
结果收集别用 sync.Map,用预分配切片 + 原子索引
分片编号是固定的(第 0 片、第 1 片……),sync.Map.Store 或 map + mutex 属于过度设计:写放大明显、无序、且没必要。
主 goroutine 预分配 results := make([]Result, len(chunks)),每个子 goroutine 直接写入对应下标(只要确保不重叠);若需按完成顺序消费,才考虑带缓冲 channel(容量 = 分片数),但仅当真有流式消费需求时才值得引入。
最易被忽略的点是:预分配和写入必须用同一文件句柄,且所有 goroutine 共享该 *os.File;各自 os.OpenFile 同一路径(即使都 O_WRONLY)会拿到不同 fd,无法保证物理文件一致性。


















