18、文件锁:flock(Linux)、LockFileEx(Windows)、fcntl锁、并发写入的防护

多进程同时写一个文件,这事儿我年轻时干过。结果呢?数据乱成一锅粥,日志文件里一半是乱码。嗯,从那以后我就记住了——并发写入不加锁,等于在雷区里跳舞。

文件锁,说白了就是给文件上个「门禁」。谁拿到了锁,谁才能进去写。其他人?老老实实在外面等着。今天咱们就聊聊 Linux 和 Windows 下那几套锁机制,以及怎么用它们来保护你的数据。

核心要点:文件锁不是用来「锁文件」的,而是用来「协调访问」的。你锁的是操作,不是文件本身。

18.1 Linux 下的 flock:轻量级文件锁

flock 是 Linux 下最简单粗暴的锁。它基于文件描述符工作,说白了就是:你打开一个文件,拿到 fd,然后对这个 fd 加锁。

#include <sys/file.h>

int fd = open("data.log", O_RDWR | O_CREAT, 0644);
if (fd == -1) { perror("open"); return; }

// 加锁:阻塞等待
if (flock(fd, LOCK_EX) == -1) {
    perror("flock");
    close(fd);
    return;
}

// 写文件...
write(fd, buf, len);

// 解锁
flock(fd, LOCK_UN);
close(fd);

这里有个坑:flock 的锁是和文件描述符绑定的。如果你 fork 了一个子进程,子进程会继承这个 fd,锁也会被继承。我曾经在项目中遇到过这个情况——父进程加了锁,子进程一 fork,锁就乱了。解决方案?要么在 fork 后重新加锁,要么用 fcntl 替代。

flock 模式含义说明
LOCK_EX排他锁(写锁)一次只能一个进程持有
LOCK_SH共享锁(读锁)多个进程可以同时读
LOCK_UN解锁释放锁
LOCK_NB非阻塞拿不到锁就返回错误,不等待

我的习惯:写日志文件时,我一般用 LOCK_EX | LOCK_NB。拿不到锁就立刻返回,不阻塞主流程。日志丢了可以补,程序卡死了可不行。

18.2 Windows 下的 LockFileEx:API 风格不同

Windows 的锁机制和 Linux 差别挺大。它用 LockFileExUnlockFileEx,而且锁的是文件的「字节范围」,不是整个文件。你想想看,这其实更灵活——你可以只锁文件的前 100 字节,后面让别人写。

#include <windows.h>

HANDLE hFile = CreateFile(
    "data.dat",
    GENERIC_READ | GENERIC_WRITE,
    FILE_SHARE_READ | FILE_SHARE_WRITE,
    NULL,
    OPEN_ALWAYS,
    FILE_ATTRIBUTE_NORMAL,
    NULL
);

OVERLAPPED ov = {0};
ov.Offset = 0;      // 从文件开头锁
ov.OffsetHigh = 0;

// 加锁:锁住前 1024 字节
if (!LockFileEx(hFile, LOCKFILE_EXCLUSIVE_LOCK, 0, 1024, 0, &ov)) {
    // 加锁失败
    CloseHandle(hFile);
    return;
}

// 写文件...
WriteFile(hFile, buf, len, &written, NULL);

// 解锁
UnlockFileEx(hFile, 0, 1024, 0, &ov);
CloseHandle(hFile);

注意看,Windows 的锁需要你指定一个 OVERLAPPED 结构体。这个结构体告诉系统你要锁哪个范围。我刚开始用的时候老忘记初始化这个结构体,结果锁了半天锁了个寂寞……

我曾经踩过的坑:Windows 下如果进程崩溃退出,锁会自动释放。但如果你用 TerminateProcess 强行杀掉进程,锁可能不会立即释放。所以,优雅退出很重要。

18.3 fcntl 锁:跨进程的「正规军」

fcntl 锁是 POSIX 标准,Linux、macOS、各种 Unix 都支持。它比 flock 更强大——可以锁文件的任意字节范围,而且锁是和进程关联的,不是和文件描述符。

#include <fcntl.h>

struct flock lock;
lock.l_type = F_WRLCK;      // 写锁
lock.l_whence = SEEK_SET;   // 从文件头开始
lock.l_start = 0;           // 偏移 0
lock.l_len = 100;           // 锁 100 字节
lock.l_pid = getpid();      // 当前进程 PID

int fd = open("data.bin", O_RDWR);
if (fcntl(fd, F_SETLKW, &lock) == -1) {
    perror("fcntl");
    close(fd);
    return;
}

// 写文件...
write(fd, buf, 100);

// 解锁
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &lock);
close(fd);

这里有个细节:F_SETLKW 是阻塞等待,F_SETLK 是非阻塞。我个人习惯用 F_SETLK 加一个重试循环,这样更可控。

锁类型fcntl 常量说明
读锁F_RDLCK多个进程可同时读
写锁F_WRLCK排他,只能一个进程写
解锁F_UNLCK释放锁

为什么推荐 fcntl?因为它是 POSIX 标准,跨平台性好。而且它支持「记录锁」——你可以只锁文件中的某几条记录,而不是整个文件。这在数据库场景下特别有用。

18.4 并发写入的防护策略

光知道怎么加锁还不够,你得知道什么时候该用哪种锁。我总结了一套经验,分享给你:

  • 简单日志写入:flock 就够了。轻量、简单、不容易出错。
  • 数据库或结构化文件:fcntl 记录锁。只锁你正在修改的那条记录,别锁整个文件。
  • Windows 环境:LockFileEx。别想着跨平台兼容了,Windows 的 API 就是不一样。
  • 高性能场景:考虑无锁设计。比如每个进程写自己的文件,最后再合并。

说到无锁设计,我记得有一次做嵌入式日志系统,多个传感器同时写日志。如果用文件锁,性能根本扛不住。后来我改成每个传感器写自己的文件,文件名带时间戳,最后统一合并。嗯,效果很好。

避坑指南:千万不要在信号处理函数里加文件锁。信号处理函数应该尽量简单,加锁容易导致死锁。我曾经因为这个 bug 排查了整整两天……

18.5 三种锁的对比

我把三种锁的核心差异整理成了一张表,方便你快速查阅:

特性flockfcntlLockFileEx
平台Linux/UnixPOSIX 标准Windows
锁粒度整个文件字节范围字节范围
与 fd 关系绑定 fd绑定进程绑定句柄
fork 后行为继承锁子进程无锁不适用
性能

你想想看,如果你的程序只需要在 Linux 上跑,flock 是最省事的。但如果你要写跨平台代码,fcntl 是更稳妥的选择。Windows 嘛……单独处理就好。

文件锁知识体系 文件锁 flock (Linux) fcntl (POSIX) LockFileEx (Win) LOCK_EX / LOCK_SH 锁整个文件 F_RDLCK / F_WRLCK 字节范围锁 LOCKFILE_EXCLUSIVE_LOCK OVERLAPPED 结构体 核心原则:锁操作,不锁文件 根据场景选择合适锁,避免死锁和性能瓶颈

最后说一句:文件锁不是银弹。如果你的并发写入量特别大,考虑用数据库、消息队列,或者干脆用内存数据库。文件锁适合「偶尔冲突」的场景,不适合「每秒几千次写入」的高并发。

嗯,今天就聊到这儿。记住一句话:不加锁的并发写入,迟早要还的。


公众号:蓝海资料掘金营,微信deep3321