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 差别挺大。它用 LockFileEx 和 UnlockFileEx,而且锁的是文件的「字节范围」,不是整个文件。你想想看,这其实更灵活——你可以只锁文件的前 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 三种锁的对比
我把三种锁的核心差异整理成了一张表,方便你快速查阅:
| 特性 | flock | fcntl | LockFileEx |
|---|---|---|---|
| 平台 | Linux/Unix | POSIX 标准 | Windows |
| 锁粒度 | 整个文件 | 字节范围 | 字节范围 |
| 与 fd 关系 | 绑定 fd | 绑定进程 | 绑定句柄 |
| fork 后行为 | 继承锁 | 子进程无锁 | 不适用 |
| 性能 | 高 | 中 | 中 |
你想想看,如果你的程序只需要在 Linux 上跑,flock 是最省事的。但如果你要写跨平台代码,fcntl 是更稳妥的选择。Windows 嘛……单独处理就好。
最后说一句:文件锁不是银弹。如果你的并发写入量特别大,考虑用数据库、消息队列,或者干脆用内存数据库。文件锁适合「偶尔冲突」的场景,不适合「每秒几千次写入」的高并发。
嗯,今天就聊到这儿。记住一句话:不加锁的并发写入,迟早要还的。