sync.Mutex 是并发写同一文件时最直接、最可控的防护手段,但需确保锁住所有写入路径且锁粒度恰当,避免在锁内执行耗时操作或忽略文件关闭状态。

sync.Mutex 是并发写同一文件时最直接、最可控的防护手段,但它不是万能胶——用错位置、锁粒度不当或忽略底层 I/O 特性,照样导致数据错乱或性能崩塌。
为什么直接并发写 *os.File 会出问题
Go 的 *os.File 本身不是并发安全的:它的 Write() 方法内部操作文件偏移量(offset)和缓冲区,多个 goroutine 同时调用会相互覆盖写入位置,造成内容重叠、丢失或乱序。这不是 Go 的 bug,而是 POSIX 文件语义决定的——内核不保证多线程对同一 fd 的 write() 原子性。
常见错误现象包括:
- 日志行被截断或拼接(如
"user logged in"和"error: timeout"写成"user logged error: timeout") - 文件末尾出现大量空字节或重复块
-
go run -race报出Write at 0x... by goroutine N竞态警告
用 sync.Mutex 锁住写操作的正确姿势
关键不是“加锁”,而是“锁什么”和“锁多久”。必须确保所有对同一 *os.File 的写入路径都经过同一把锁,且锁的生命周期严格包裹实际 Write() 调用。
- 锁对象应是包级或结构体字段级变量,避免函数内声明(否则每个 goroutine 拿到的是不同锁)
- 必须用
defer mu.Unlock()配合mu.Lock(),否则 panic 或死锁风险极高 - 不要在锁内做耗时操作:比如
fmt.Sprintf大量拼接、JSON 编码、或调用time.Now().Format()—— 这些应提前完成,只把最终[]byte交由锁保护写入 - 如果写入逻辑涉及多次
Write()(如 header + body + footer),必须全部包在同一个Lock()/Unlock()块里,不能分段加锁
示例片段:
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
var logFile *os.File
var logMu sync.Mutex
func safeLog(msg string) {
logMu.Lock()
defer logMu.Unlock()
// 此处已排除竞态:仅做纯写入
logFile.Write([]byte(msg + "\n"))
}
比 sync.Mutex 更适合日志类场景的替代方案
如果你的场景是「高频小写入 + 多读少写」(比如 Web 请求日志),sync.Mutex 会让所有写请求排队,吞吐受限。这时更合理的做法是:
- 用
sync.RWMutex—— 但注意:它对写操作仍是排他的,只优化读并发;对纯写场景无提升 - 改用 channel + 单 writer goroutine:生产者发
[]byte到 channel,一个 dedicated goroutine 从 channel 收并顺序写盘。这天然消除了锁,还能批量写、加缓冲、做落盘控制 - 让每个 goroutine 写独立临时文件,最后用
os.Rename()原子合并(适用于批处理,不适用于实时流式日志)
真正容易被忽略的一点:os.OpenFile(..., os.O_APPEND) 并不能代替锁。虽然 O_APPEND 保证每次 Write() 自动 seek 到末尾,但 Go 的 Write() 实现仍可能拆分成多次系统调用,中间仍可能被其他 goroutine 插入——实测中仍有错行风险,不可依赖。
文件句柄泄漏与锁生命周期的隐性耦合
很多人把 sync.Mutex 和 *os.File 放在同一个 struct 里,然后在 Close() 之后还试图写入,结果触发 panic 或静默失败。这不是锁的问题,而是资源生命周期管理缺失。
-
os.File.Close()后再调用Write()会返回io.ErrClosedPipe或类似错误,但若没检查返回值,就以为写成功了 - 锁本身不感知文件是否关闭;你必须在写之前确认
logFile != nil && !isClosed(logFile) - 推荐封装为方法:如
logger.Write()内部先检查状态,再加锁,再写,再检查 error
最危险的情况是:goroutine 在锁内阻塞等待磁盘 I/O,而主流程已调用 Close() —— 此时该 goroutine 可能永远卡在系统调用里,直到超时或进程退出。

















