因为syscall.Flock无原生超时参数,阻塞时无法被context或timer中断;必须用非阻塞尝试(LOCK_EX|LOCK_NB)配合轮询+超时机制,Linux/macOS用x/sys/unix.Flock,Windows用LockFileEx,且需显式解锁防残留。

Go 中用 flock 实现带超时的文件锁为什么不能直接用 syscall.Flock
因为 syscall.Flock 是阻塞式系统调用,没有原生超时参数;一旦锁被占用,它会一直挂起 goroutine,无法配合 context.WithTimeout 或 time.After 中断。强行用 runtime.LockOSThread + 信号中断既危险又不可移植,Linux/macOS 行为也不一致。
实际可行路径只有一条:用子 goroutine 调用 flock,主 goroutine 等待或超时取消。但要注意:os.File.Fd() 返回的 fd 在 fork 后可能失效,且 syscall.Flock 锁是进程级的,不是 goroutine 级的——这意味着多个 goroutine 对同一 *os.File 调用 Flock 不会互相阻塞,除非它们共享同一个底层 fd(比如都从同一个 os.OpenFile 得来)。
- 必须在加锁前确保文件以
os.O_RDWR或os.O_WRONLY打开,只读打开无法上写锁 - macOS 的
flock不支持LOCK_NB与超时组合,所以需用syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB)配合轮询 + sleep - Linux 下可依赖
syscall.Flock的非阻塞尝试,但别指望它返回精确剩余时间
用 golang.org/x/sys/unix 实现跨平台非阻塞尝试 + 轮询超时
这是目前最稳妥的做法:封装一个 TryLockWithTimeout 函数,内部用 unix.Flock(Linux/macOS)或 syscall.LockFileEx(Windows)做非阻塞尝试,失败后休眠再试,直到超时。
示例核心逻辑:
立即学习“go语言免费学习笔记(深入)”;
func TryLockWithTimeout(f *os.File, timeout time.Duration) error {
fd := int(f.Fd())
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
done := time.After(timeout)
<pre class="brush:php;toolbar:false;">for {
select {
case <-done:
return fmt.Errorf("lock timeout after %v", timeout)
case <-ticker.C:
err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB)
if err == nil {
return nil // 成功
}
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
continue // 继续轮询
}
return fmt.Errorf("flock failed: %w", err)
}
}}
- 轮询间隔不宜过短(200ms),影响响应性
- Windows 下必须用
syscall.LockFileEx,且需将*os.File的句柄转为syscall.Handle,不能直接用Fd() - 记得在业务结束后调用
unix.Flock(fd, unix.LOCK_UN)解锁,否则锁会随进程退出自动释放——这看似省事,但若进程崩溃,锁残留可能导致后续程序误判
为什么不用 sync.Mutex 替代文件锁
sync.Mutex 只在单个进程内有效,完全不跨进程。如果你有多个 Go 进程(比如重启后的服务、supervisord 管理的 worker、或不同用户启动的实例)要互斥访问同一文件,sync.Mutex 彻底无效——它连“看到对方”都做不到。
典型踩坑场景:
- 用
sync.Mutex保护日志写入,但部署了两个副本,结果日志文件内容错乱 - 用
sync.RWMutex控制配置热重载,却没意识到不同进程的 mutex 完全独立 - 把锁对象存在全局变量里,误以为 “init 函数里 new 一次就全局唯一”,其实每个进程都有自己的一份
文件锁(flock)本质是内核维护的 advisory lock,靠 inode + fd 关联,天然支持多进程协同——这才是它不可替代的核心价值。
并发写同一文件时,仅加锁还不够的三个细节
加了 flock 并不等于安全写文件。常见遗漏点:
- 写入前没用
f.Seek(0, io.SeekEnd)移动到末尾,导致多个进程覆盖写同一位置(尤其用f.Write()而非f.WriteAt()时) - 写完没调用
f.Sync(),数据还在 page cache,进程崩溃后丢失;但频繁Sync()严重影响性能,需权衡 - 没处理
ENOSPC(磁盘满)或EDQUOT(配额超限)错误,导致锁已持有时写失败,后续逻辑未回滚解锁,造成死锁假象
真正健壮的流程是:加锁 → 检查磁盘空间 → 写临时文件 → os.Rename 原子替换 → 解锁。绕过直接写目标文件,才能规避截断、权限、符号链接等中间态风险。


















