项目临近收尾,我把 Linux 应用开发中遇到的一些技术点逐步做复盘。由于这是一个多进程系统,多个进程之间的资源共享问题不可避免,文件锁(File Locking)正是解决这些问题的关键机制。
1. 多进程开发中常见的三个问题
一个多进程系统里,文件访问冲突几乎不可避免,下面先看三个比较典型的场景。
1.1 日志行交错问题
当多个进程同时向同一个日志文件写入数据时,内核 I/O 缓冲区可能导致日志行交错。例如进程 A、B 期望分别写入:
进程A写入:2025-05-01 10:00:01 [A] Start task...
进程B写入:2025-05-01 10:00:02 [B] Error: file not found
进程A写入:2025-05-01 10:00:03 [A] Task completed.
但实际日志可能变成:
2025-05-01 10:00:01 [A] Start task...2023-10-01 10:00:02 [B] Error: file not found
2025-05-01 10:00:03 [A] Task completed.
格式一旦错乱,日志可读性就会变得很差,后续排查问题也费劲。
1.2 配置文件更新导致损坏
若两个进程同时修改 config.json:
- 进程 A 写入前半部分内容后,进程 B 抢占了写入权。
- 进程 B 覆盖了进程 A 的部分内容,导致文件内容不完整。
配置文件数据错乱,严重时还会导致服务崩溃。
1.3 单例进程重复启动
守护进程(如 daemon)通常只允许一个实例运行。如果没有锁保护,用户误操作重复启动进程,就可能造成资源竞争(比如端口占用),最终导致服务不可用。
2. 文件锁的核心作用与 API
文件锁主要通过内核机制协调多进程对文件的并发访问,核心价值包括:
- 互斥访问:同一时间仅允许一个进程修改文件。
- 数据一致性:确保文件内容的完整性和操作原子性。
- 进程同步:避免资源竞争导致的不可预测行为。
Linux 系统里通常有两类文件锁:
- 建议性锁(Advisory Lock):依赖进程主动遵守锁规则,也最常用。
- 强制性锁(Mandatory Lock):由内核强制限制读写,需要特殊配置。
强制性锁效率相对不高,所以实际项目通常采用建议性锁。常用 API 有:
flock():对整个文件加锁(基于文件描述符)。
fcntl():支持对文件的某个区域加锁,粒度更细。
lockf():基于 fcntl() 的封装,简化文件锁操作。
2.1 多进程安全写入日志文件
目标:确保多个进程写入同一日志文件时按顺序进行,避免行交错。
代码实现:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <time.h>
void write_log(const char *message) {
int fd = open("app.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
if (fd == -1) {
perror("open log failed");
exit(1);
}
// 加排他锁(阻塞直到获得锁)
struct flock lock = {
.l_type = F_WRLCK,
.l_whence = SEEK_SET,
.l_start = 0,
.l_len = 0 // 锁住整个文件
};
fcntl(fd, F_SETLKW, &lock);
// 写入日志(带时间戳)
time_t now = time(NULL);
char buffer[256];
snprintf(buffer, sizeof(buffer), "[%ld] %s\n", now, message);
write(fd, buffer, strlen(buffer));
// 释放锁
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &lock);
close(fd);
}
int main() {
// 模拟多进程写入(编译后启动多个进程测试)
write_log("Process started");
sleep(1); // 模拟业务逻辑耗时
write_log("Process finished");
return 0;
}
这里每次写日志前都通过 F_WRLCK 获取排他锁,保证写入的原子性;F_SETLKW 会阻塞进程直到锁被释放。这个思路和线程同步里的读写锁有些相似。
2.2 单例进程锁文件实现
目标:通过锁文件 /var/run/myapp.pid 防止进程重复启动。
代码实现:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <errno.h>
void enforce_singleton() {
const char *lockfile = "/var/run/myapp.pid";
int fd = open(lockfile, O_WRONLY | O_CREAT, 0644);
if (fd == -1) {
perror("open lockfile failed");
exit(1);
}
// 尝试加建议性锁(非阻塞)
struct flock lock = {
.l_type = F_WRLCK,
.l_whence = SEEK_SET,
.l_start = 0,
.l_len = 0
};
if (fcntl(fd, F_SETLK, &lock) == -1) {
if (errno == EAGAIN || errno == EACCES) {
fprintf(stderr, "Error: Another instance is already running.\n");
exit(1);
} else {
perror("fcntl failed");
exit(1);
}
}
// 写入当前进程ID
char pid_str[16];
snprintf(pid_str, sizeof(pid_str), "%d\n", getpid());
ftruncate(fd, 0); // 清空文件内容
write(fd, pid_str, strlen(pid_str));
}
int main() {
enforce_singleton();
printf("Service started. PID: %d\n", getpid());
while (1) {
sleep(60); // 模拟守护进程逻辑
}
return 0;
}
启动时尝试对锁文件加排他锁,一旦失败就说明已有实例在运行,直接退出。锁文件里保存当前进程 PID,方便排查。这与设计模式中的单例模式思路类似。
你还可以通过 lslocks 命令查看系统中当前的文件锁。需要注意的是锁的粒度(全局锁 vs 区域锁)以及死锁风险。