一、文件锁:多进程协作的“交通规则”

大家好,我是你们的嵌入式老兵。今天咱们聊聊文件锁。

你想想看,多个进程同时读写同一个文件,会发生什么?数据错乱、内容覆盖、甚至文件损坏。我早年做嵌入式数据采集系统时,就吃过这个亏——两个进程同时写日志文件,结果日志内容像被猫踩过键盘一样,乱七八糟。

文件锁,说白了就是给文件加把锁。谁拿到锁,谁才能操作。没拿到的,要么等着,要么绕道走。这就是进程间协作的“交通规则”。

核心概念:文件锁是一种进程间同步机制,用于协调多个进程对同一文件的并发访问,防止数据竞争和不一致。

1.1 文件锁的分类:读锁 vs 写锁

文件锁分两种,理解起来很简单:

  • 读锁(共享锁):多个进程可以同时加读锁。大家只读不写,互不干扰。
  • 写锁(排他锁):只有一个进程能加写锁。写的时候,别人不能读也不能写。

我习惯这么记:读锁是“共享单车”,大家都能骑;写锁是“私家车”,只有车主能用。

锁类型 别称 兼容性 典型场景
读锁 共享锁 可被多个读锁兼容 配置文件读取、日志查看
写锁 排他锁 与任何锁都不兼容 数据更新、文件写入

1.2 flock 函数:简单粗暴的文件锁

flock 是 Linux 下最直接的文件锁接口。它的用法很简单:

#include <sys/file.h>

int fd = open("data.txt", O_RDWR);
// 加写锁(阻塞模式)
flock(fd, LOCK_EX);
// ... 读写操作 ...
// 解锁
flock(fd, LOCK_UN);

常用的锁操作宏:

  • LOCK_SH:加读锁
  • LOCK_EX:加写锁
  • LOCK_UN:解锁
  • LOCK_NB:非阻塞模式(与上面三个按位或)

我的经验:flock 是基于整个文件的,不能锁文件中的某一段。如果你只需要锁文件的一部分,得用 fcntl。

1.3 fcntl 函数:更精细的“手术刀”

fcntlflock 强大得多。它可以锁定文件的任意区域,粒度更细。

#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();

// 加锁(阻塞模式)
fcntl(fd, F_SETLKW, &lock);
// ... 操作 ...
// 解锁
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &lock);

这里要注意 l_type 的取值:

  • F_RDLCK:读锁
  • F_WRLCK:写锁
  • F_UNLCK:解锁

命令参数也有讲究:

  • F_SETLK:非阻塞加锁
  • F_SETLKW:阻塞加锁(W 表示 Wait)
  • F_GETLK:测试锁状态

避坑指南:我曾经在项目中混用 flock 和 fcntl,结果发现它们互不感知!flock 和 fcntl 是两套独立的锁机制,不要混用。选一套用到底。

1.4 阻塞与非阻塞:等还是不等?

加锁时有两种模式:

  • 阻塞模式:拿不到锁就挂起等待,直到锁可用。适合“我必须拿到锁才能干活”的场景。
  • 非阻塞模式:拿不到锁立即返回错误。适合“有锁就干,没锁就做别的”的场景。

flock 的非阻塞用法:

if (flock(fd, LOCK_EX | LOCK_NB) == -1) {
    if (errno == EWOULDBLOCK) {
        printf("锁被占用了,我先干点别的\n");
    }
}

fcntl 的非阻塞用法:

if (fcntl(fd, F_SETLK, &lock) == -1) {
    if (errno == EACCES || errno == EAGAIN) {
        printf("锁冲突,跳过\n");
    }
}

1.5 文件锁的继承与释放

这里有几个关键点,我当年踩过坑:

  • 继承问题fork() 创建的子进程会继承父进程的文件锁。但 exec() 系列函数会关闭锁(除非设置了 close-on-exec)。
  • 释放时机:进程退出时,所有文件锁自动释放。但别依赖这个,显式解锁才是好习惯。
  • dup 与锁:通过 dup() 复制的文件描述符共享同一个锁。关闭其中一个 fd 不会释放锁,必须关闭所有引用。

重要规则:文件锁与进程关联,而非与文件描述符关联。同一个进程多次打开同一文件,每个 fd 可以独立加锁,但彼此会冲突。

1.6 应用场景:什么时候用文件锁?

我总结了几种典型场景:

  1. 日志文件写入:多个进程写同一个日志文件,用写锁保证日志不交错。
  2. 配置文件更新:读配置时加读锁,写配置时加写锁,避免读到半成品。
  3. 数据库文件操作:简单的键值对数据库,用文件锁保证事务一致性。
  4. 单实例守护进程:用文件锁防止同一个程序启动多次。

1.7 综合案例:多进程日志写入器

来看一个完整的例子。假设我们有三个进程同时写日志,用文件锁保证不乱:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/file.h>
#include <time.h>

void write_log(const char *msg) {
    int fd = open("app.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
    if (fd == -1) {
        perror("open");
        return;
    }

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

    // 写入日志
    time_t now = time(NULL);
    char time_str[64];
    strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S", localtime(&now));
    
    dprintf(fd, "[%s] [PID=%d] %s\n", time_str, getpid(), msg);
    
    // 模拟耗时操作
    usleep(100000); // 100ms

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

int main() {
    for (int i = 0; i < 5; i++) {
        write_log("这是一条日志记录");
        sleep(1);
    }
    return 0;
}

运行三个实例:

./log_writer &
./log_writer &
./log_writer &

你会发现日志文件内容整齐有序,每条日志完整连续,不会出现交叉。

我的建议:实际项目中,日志库通常自带锁机制。但理解底层原理,能帮你排查那些“日志丢了”或“日志乱了”的诡异问题。

知识体系总览

下面这张图帮你理清文件锁的核心脉络:

文件锁知识体系 文件锁 锁的分类 锁的接口 锁的特性 读锁(共享) 写锁(排他) flock fcntl 阻塞 vs 非阻塞 区域锁 继承与释放 进程关联 应用场景:日志、配置、数据库、单实例

文件锁是进程间通信的基石之一。它不像管道或消息队列那样传输数据,但它保证了数据访问的安全性。嗯,说白了,它就是那个在后台默默维护秩序的“保安”。

下回当你遇到多进程写文件的问题时,别急着上数据库,先想想文件锁能不能搞定。很多时候,一把简单的锁就能解决大问题。


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