在多进程服务架构中,多个工作进程往往需要把运行日志、统计数据或临时结果写入同一个文件。如果没有任何并发控制,写操作交错会导致文件内容断裂、记录丢失,甚至生成无法解析的脏数据。文件锁正是为了解决这种资源竞争而存在的机制,它让同一时刻只有一个进程能够获得对文件的写入权限。

为什么多进程写文件必须加锁
从操作系统角度看,进程各自拥有独立的用户空间,但打开的文件描述符指向的是同一个内核中的文件对象。当进程 A 调用 write 写入 100 字节时,内核并非一次性把数据刷到磁盘,而是先放入页缓存,再择机落盘。如果进程 B 在 A 的写入中途也发起写请求,两个进程的字节流就会在文件中穿插,最终形成混乱的内容。
很多开发者误以为单条 write 调用是原子的,实际上只有在写入长度小于 PIPE_BUF 且目标是管道时才有部分保证,普通文件完全没有这种承诺。更严重的是,标准库如 Python 的 print 或 Node 的 fs.write 在底层可能拆成多次系统调用,进一步放大了竞争窗口。因此,必须依赖文件锁把临界区保护起来,才能确保每条记录完整且顺序可控。
除了数据错乱,不加锁还可能引发逻辑错误。例如进程 A 读取文件末尾偏移量准备追加,此时进程 B 插入了内容,A 仍按旧偏移写入,就会覆盖 B 的数据。文件锁不仅能阻止并发写,还可以配合锁内的读取-计算-写入模式,实现安全的读改写流程,避免脏读和丢失更新。
Linux 与 macOS 下用 fcntl 实现咨询锁
在类 Unix 系统中,fcntl 系统调用提供了 POSIX 记录锁(也称咨询锁)。所谓咨询锁,是指操作系统不会强制阻止未加锁的进程访问文件,所有参与者必须自愿遵守锁协议。这种锁的优势是粒度灵活,可以锁整个文件,也可以锁某个字节区间,非常适合日志追加场景。
Python 通过 fcntl 模块暴露了 fcntl.flock 和 fcntl.lockf 两种接口,后者基于 POSIX 锁,功能更精细。下面示例展示如何用 fcntl.lockf 在写入前获取排他锁,写完后释放:
import fcntl
import os
import time
def safe_write(path, content):
# 以追加模式打开文件,获取文件描述符
with open(path, 'a') as f:
# 对整文件加排他锁,阻塞直到获取为止
fcntl.lockf(f, fcntl.LOCK_EX)
try:
# 模拟耗时写入,此时其他进程会被挡在锁外
f.write(content + 'n')
f.flush()
os.fsync(f.fileno())
finally:
# 释放锁,关闭文件也会自动释放
fcntl.lockf(f, fcntl.LOCK_UN)
if __name__ == '__main__':
for i in range(3):
safe_write('/tmp/test.log', 'process_line_' + str(i))
time.sleep(0.1)
上述代码使用 LOCK_EX 表示排他锁,若不想阻塞可改用 LOCK_EX | LOCK_NB 并捕获 IOError 做重试。需要注意,fcntl 锁与进程绑定,fork 出的子进程会继承锁状态,但任一进程关闭描述符都会释放该进程持有的锁,因此要避免在复杂调用中意外关闭文件。
另一个易错点是锁的释放时机。如果程序因异常崩溃而未执行 LOCK_UN,操作系统在进程退出时会自动清理锁,所以相比死锁,更常见的问题是误以为锁能跨文件描述符生效。实际上同一进程内多个描述符指向同一文件时,关闭其中一个就可能提前释放锁,推荐每个写入点独立加锁并保持短临界区。
Windows 平台使用 msvcrt 文件锁
Windows 没有 fcntl,但 C 运行库 msvcrt 提供了 locking 函数,Python 可通过 msvcrt 模块调用。它同样属于建议性锁,且锁定单位是字节范围,适合在 Windows 服务或多进程 GUI 应用中保护日志文件。
下面的例子演示了在 Windows 下用 msvcrt.locking 实现追加写。由于 msvcrt 锁需要指定起始位置和长度,我们用超大长度覆盖整个文件区域:
import msvcrt
import os
import time
def safe_write_win(path, content):
# 以读写追加二进制模式打开,msvcrt 要求文件描述符
f = open(path, 'ab+')
try:
fd = f.fileno()
# 将文件指针移到开头,锁定从 0 到超大长度
msvcrt.locking(fd, msvcrt.LK_LOCK, 0x7fffffff)
f.seek(0, os.SEEK_END)
f.write((content + 'n').encode('utf-8'))
f.flush()
# 解锁同样需要回到起始位置并指定相同长度
f.seek(0)
msvcrt.locking(fd, msvcrt.LK_UNLCK, 0x7fffffff)
finally:
f.close()
if __name__ == '__main__':
for i in range(3):
safe_write_win('C:\test\app.log', 'win_line_' + str(i))
time.sleep(0.1)
与 fcntl 不同,msvcrt 的 LK_LOCK 会一直阻塞直到锁可用,而 LK_NBLCK 则立即返回错误。Windows 锁也是进程级别,且如果进程退出,系统会回收锁。但 msvcrt 锁对网络文件系统支持不稳定,若在共享盘上运行,建议改用命名互斥量或第三方库如 portalocker 做抽象。
在跨平台项目中,可以封装一个统一接口,根据 os.name 判断加载 fcntl 或 msvcrt,并对外暴露 acquire 与 release 方法。这样业务代码无需关心底层差异,也能在 Linux 容器和 Windows 服务器上获得一致的文件安全写入能力。
方案对比与生产环境建议
从功能维度看,fcntl 支持更细的记录锁和命令式控制,适合数据库式文件;msvcrt 接口简单但文档较少,适合轻量日志。两者都是咨询锁,意味着恶意或遗漏加锁的进程仍能破坏文件,所以务必在团队规范中强制所有写入路径走统一锁封装。
在生产环境,如果写入频率极高,文件锁的系统调用开销和阻塞等待可能成为瓶颈。此时可考虑将日志改为各进程写独立文件,再由收集器合并;或者使用消息队列、套接字转发给单一写进程。文件锁更适用于中低频、强一致要求的场景,例如定时任务生成报表、安装程序写配置等。
最后提醒,使用文件锁时不要忽略权限问题。若进程以不同用户运行,目标文件需有可写权限,且锁元数据依赖文件系统支持,在 NFS 等网络文件系统上 fcntl 锁可能失效。部署前应在真实环境做并发压测,确认无交错写入,再正式上线。