多个goroutine直接写同一*os.file不安全,即使使用o_append;应通过channel将写入收束到单个goroutine,避免竞态和乱序。

多个 goroutine 直接写同一个 *os.File 是不安全的
哪怕你用 os.OpenFile 加了 O_APPEND,也不能认为并发写就“自动安全”。O_APPEND 只保证每次 Write 系统调用的起始偏移是文件末尾,但 fmt.Fprintf 或 bufio.Writer.Write 内部可能拆成多次 Write 调用;如果两个 goroutine 同时触发,仍可能在内核缓冲区或磁盘层面交错写入,出现日志行被截断、乱序甚至字符粘连(比如 "log1\nlog2\n" 变成 "loog2\n1\n")。
常见错误现象:
- 日志内容缺失、错位、无法按行解析
- panic: write /path/to/log: bad file descriptor(因文件关闭或竞态导致 fd 失效)
- fatal error: concurrent write to websocket connection 类似提示(实际是底层 file 写入冲突)
- 不要依赖
O_APPEND作为并发安全的全部保障 - 避免在多个 goroutine 中直接共享并调用
file.WriteString或fmt.Fprintln(file, ...) - 即使只读,若文件被其他进程 truncate 或重命名,
*os.File的读取行为也可能异常(虽不属 Go 并发问题,但常被误判)
用 channel 封装写入服务是最可靠的选择
Go 的并发哲学是“通过通信共享内存”,而不是“通过锁保护内存”。把文件写入逻辑收束到一个专属 goroutine,其他协程只往 channel 发请求,天然规避所有竞态。
实操要点:
- 定义写入请求结构体,例如:
type LogEntry struct { msg string; ts time.Time } - 启动一个长期运行的 goroutine,从 channel 接收
LogEntry并顺序调用fmt.Fprintln(file, ...) - channel 容量设为 1024 或使用
make(chan LogEntry, 0)(无缓冲),由生产者阻塞等待——这反而是可控背压 - 务必在 goroutine 退出前 close channel,并在接收端用
for entry := range ch安全退出 - 如果需支持动态重开文件(如 log rotate),把
*os.File也通过 channel 传入服务 goroutine,避免外部直接操作句柄
sync.Mutex 不该用于保护 *os.File 写入
给 file.Write 加 mu.Lock() 看似简单,实则低效且易出错:它把本可并行的业务逻辑强行串行化,还引入死锁风险(比如 defer 前 panic、嵌套锁、忘记 unlock)。
更关键的是,锁住写操作本身并不能解决底层系统调用的不确定性——尤其当写入量大、buffer 刷盘慢时,锁粒度与实际 I/O 延迟严重不匹配。
- 不要写这样的代码:
mu.Lock(); fmt.Fprint(file, s); mu.Unlock() - 除非你明确知道:写入频率极低(
- 若必须用锁,至少把
fmt.Sprintf格式化提前做完,只锁真正的file.Write调用 -
sync.RWMutex对写入场景毫无意义——写操作本身就要独占,读根本没机会并发
bufio.Writer 要配合 channel 一起用,不能单独加锁
bufio.Writer 的价值在于减少系统调用次数,但它内部的 buffer 是非线程安全的。多个 goroutine 共享同一个 bufio.Writer 并发调用 WriteString,会导致 buffer 数据错乱、Flush 失效,甚至 panic。
正确做法是:每个写入服务 goroutine 自带一个 bufio.Writer,并在其生命周期内独占使用;或者把 bufio.Writer 封装进 channel 请求中(例如发送已格式化好的 []byte)。
- 错误示范:
var bw *bufio.Writer; bw = bufio.NewWriter(file); go func(){ bw.WriteString("a") }(); go func(){ bw.WriteString("b") }() - 正确方式:在 channel 驱动的服务 goroutine 内初始化
bw,所有写入都经由该 goroutine 调用bw.WriteString+bw.Flush() - 注意
bw.Flush()必须显式调用,否则数据可能滞留在内存 buffer 中不落盘;可在服务 goroutine 退出前统一 flush,或每条日志后 flush(视延迟容忍度而定)
真正难的不是“怎么加锁”,而是判断哪些状态必须串行化、哪些可以天然并发。文件写入本质是对外部资源的单点输出,它天生不适合多路并发直写——接受这个事实,用 channel 把它变成一个清晰的服务边界,比任何锁技巧都更接近 Go 的设计本意。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











