O_APPEND不能保证并发安全,因它仅确保每次write前内核定位到末尾,不控制Go层执行顺序;fmt.Fprintf等可能拆分多次write,导致日志截断、粘连;sync.Mutex需锁住最终Write调用且避免锁内耗时操作;推荐单goroutine+channel方案。

直接让多个 goroutine 调用 file.WriteString 或 fmt.Fprintln(file, ...) 写同一个 *os.File,即使加了 O_APPEND,也不安全——你会看到日志行被截断、粘连、丢失,甚至 panic: bad file descriptor。
为什么 O_APPEND 不能当并发安全的护身符
O_APPEND 只保证每次系统调用 write() 前内核自动定位到文件末尾,但它不控制 Go 层面的执行顺序。而 fmt.Fprintf、bufio.Writer.Write 等函数内部可能拆成多次 write();多个 goroutine 同时触发时,仍可能在缓冲区或磁盘层面交错写入。常见现象包括:
- "user login" 和 "error: timeout" 拼成 "user logerror: timeout"
- 某次写入只落了一半,下一行直接顶上来(无换行符)
-
go run -race报出Write at 0x... by goroutine N
更隐蔽的是:NFS、某些容器挂载卷、或 dup 复制的 fd 可能让 O_APPEND 失效。
用 sync.Mutex 锁住 *os.File 的正确姿势
它能用,但极易踩坑。关键不是“加锁”,而是锁什么、锁多久:
立即学习“go语言免费学习笔记(深入)”;
- 锁对象必须是全局或结构体字段级的
sync.Mutex,不能在函数里声明新锁 - 所有写入路径(哪怕分散在不同包)都必须走同一把锁,且
Lock()/Unlock()必须严格包裹最终的file.Write()调用 - 避免在锁内做耗时操作:字符串拼接、JSON 编码、
time.Now().Format()全部提前完成,只把最终[]byte交由锁保护写入 - 如果一次逻辑需多次
Write()(比如 header + body + footer),必须全部包在同一个锁块里,不能分段加锁 - 务必检查
file.Write()返回值:err != nil时不能静默忽略(磁盘满、权限不足都会在这里暴露)
推荐方案:单写 goroutine + channel(CSP 风格)
这是最符合 Go 并发哲学、也最易维护的做法——把文件句柄完全交给一个 goroutine 独占,其他协程只发消息。好处是:零竞态、响应不阻塞、可自然扩展缓冲/格式化/重试。
- 启动顺序必须是:先
go printOutput(),再启动生产者;否则无缓冲 channel 会永久阻塞发送方 - 通道类型建议用结构体而非
string,例如type LogEntry { msg string; ts time.Time },便于后期加字段 - 缓冲大小按压测结果设,如
make(chan LogEntry, 1024);无缓冲也可,但生产者会受写入速度背压 -
for entry := range ch会等close(ch)后才退出,所以关闭必须在所有生产者wg.Wait()之后 -
f.Close()放在消费者 goroutine 末尾并用defer是安全的,因为range退出后才执行
容易被忽略的细节
很多人以为“开了 O_APPEND 就万事大吉”,或者把 sync.Mutex 当万能胶随便套。真正难的是边界场景:进程意外崩溃时文件是否可恢复、日志轮转时如何无缝切换句柄、高吞吐下 bufio.Writer 的 Flush() 频率怎么控、以及错误发生时要不要丢弃当前条目还是重试。这些不会在简单 demo 里暴露,但上线后第一个流量高峰就会打脸。


















