Go如何在并发场景下读写文件_Go并发文件访问注意事项

多个goroutine并发写同一文件会导致内容覆盖、截断或交错,因O_TRUNC重置文件且write()非原子;需复用*os.File并用sync.Mutex保护,配合bufio.Writer缓冲及显式flush,同时检查Write返回值。

多个 goroutine 同时写同一个文件会出什么问题

直接并发调用 os.WriteFile 或反复 os.OpenFile(..., os.O_WRONLY|os.O_CREATE|os.O_TRUNC) 写同一路径,会导致文件内容被随机覆盖或截断——因为每次打开都带 O_TRUNC,且写入无顺序保证。更隐蔽的是用 os.O_APPEND 看似安全,但在 Linux 上若文件描述符未设 O_APPEND(比如通过 dup 复制),或在 NFS 等特殊文件系统上,仍可能产生交错写入。

  • 现象:日志文件出现乱码、半截 JSON、缺失字段,甚至空文件
  • 根本原因:系统调用 write() 本身不是原子的,尤其当写入超过 PIPE_BUF(通常 4KB)时,内核可能拆成多次 syscall
  • 不要依赖 os.O_APPEND 做“并发安全”的错觉——它只保证每次 write() 追加到当前 EOF,不保证多 goroutine 调用间的执行顺序

sync.Mutex 保护文件句柄是否足够

对单个 *os.File 实例加锁能避免竞态,但要注意锁的粒度和生命周期。如果每次写都 os.OpenFile → 写 → Close,锁没意义

;必须复用文件句柄,并确保所有写操作都走同一把锁。

  • 正确做法:在初始化时打开一次文件(如用 os.O_WRONLY | os.O_CREATE | os.O_APPEND),全局持有一个 *os.File 和一个 sync.Mutex
  • 错误做法:锁包裹 os.WriteFile——它内部会打开/关闭文件,锁完全无效
  • 注意 defer f.Close() 不能放在 goroutine 内部,否则可能提前关闭;应在程序退出前统一关闭
var (
    logFile *os.File
    logMu   sync.Mutex
)

func init() {
    f, err := os.OpenFile("app.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
    if err != nil {
        log.Fatal(err)
    }
    logFile = f
}

func writeLog(msg string) {
    logMu.Lock()
    defer logMu.Unlock()
    logFile.Write([]byte(msg + "\n"))
}

高并发下推荐用 bufio.Writer 配合定期 flush

Write 系统调用开销大,尤其小数据高频写。用 bufio.NewWriterSize(f, 4096) 缓冲后批量落盘,能显著降低 syscall 次数和锁争用时间。

  • 缓冲区大小建议设为 4KB 或 8KB;太小失去缓冲意义,太大增加延迟和内存占用
  • 必须显式调用 w.Flush(),否则内容可能滞留在内存中不写入磁盘
  • 可在定时器(time.Ticker)或写入量达到阈值时触发 flush,避免日志丢失(如进程崩溃)
  • 注意:bufio.Writer 本身不是并发安全的,仍需外部锁保护

真正需要隔离写入时,考虑按 goroutine 分文件或用 channel 聚合

当写入逻辑差异大(如不同模块日志格式不同)、或单文件 I/O 成为瓶颈时,硬塞进一个文件+一把锁反而降低吞吐。此时应让并发写入“解耦”。

  • 方案一:每个 goroutine 写独立临时文件(如 log_worker_12345.log),由后台 goroutine 定期合并
  • 方案二:所有写请求发到一个带缓冲的 chan string,单个消费者 goroutine 串行处理并写入——本质是把并发转为生产者-消费者模型
  • 方案三:用第三方库如 lumberjack(支持轮转、压缩、并发安全),它内部已封装了文件锁和缓冲策略

最易被忽略的一点:无论用哪种方式,都要检查 Write 的返回值。磁盘满、权限不足、NFS 挂载失效等错误不会 panic,但会静默失败——尤其在大量 goroutine 中,一个 err != nil 被忽略,可能让整条日志链路中断而不自知。