文件锁的作用:当第一个进程正在读或修改文件的某个部分时,使用文件锁可以阻止其他进程修改文件的相同部分。

因此文件锁可用于多个进程之间进行同步,防止进程间的竞争状态。

Linux系统支持两组给文件加锁的不同API,分别是fcntlflock。本节主要记录fcntl的实现原理以及使用方式。

任意多个进程在同一个给定字节上都可以有一把共享读锁,但一个给定字节上仅能有一个进程持有一把独占写锁

锁类型

文件锁分为两种,分别是建议性锁强制性锁

欲使建议性锁模型能够正常工作,所有访问文件的进程都需要进行配合,采用一致的方法来进行文件I/O,即在进行文件I/O之前先设置一把锁。这些相互配合的进程称为合作进程

建议性锁并不能阻止其他进程在文件已加锁的情况下,不获得锁而强制执行与锁类型冲突的文件I/O

强制性锁会让内核检查每一个openreadwrite调用,验证调用进程是否违背了正在访问的文件上的某一把锁。

启用强制性锁需要满足两个条件:

  1. 文件系统支持强制性锁,挂载文件系统时启用强制性锁mount -o mand)。
  2. 为锁定的文件设置标志位,即打开其设置组ID位,关闭其组执行位chmod g+s, g-x FILE)。

自Linux 4.5以来,强制性锁定已成为一个可选功能,受配置选项(CONFIG_MANDATORY_FILE_LOCKING)的控制。此功能在Linux 5.15及以上版本中不再受支持。

fcntl()

POSIX.1标准支持的是fcntl()方法,可以对文件中任意字节数进行加锁,长至整个文件,短至一个字节。

fcntl函数的原型如下:

#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* struct flock *flockptr */ );
// 返回值:失败返回-1, 成功则取决于cmd的值

cmd参数可取值有F_GETLKF_SETLKF_SETLKW

第3个参数flockptr是一个指向flock结构体的指针,flock结构体内容如下:

struct flock
{
    short int l_type;    // 锁的类型:F_RDLCK、F_WRLCK以及F_UNLCK
    short int l_whence;  // 文件相对位置:SEEK_SET、SEEEK_CUR以及SEEK_END
    off_t l_start;       // 相对于l_whence的偏移量
    off_t l_len;         // 要锁定文件字节数,0表示锁定到文件尾
    pid_t l_pid;         // 持有锁的进程id,仅当cmd=F_GETLK时返回
};

锁类型分为三种:F_RDLCK(共享读锁)、F_WRLCK(独占性写锁)以及F_UNLCK(解锁)

注意:

  1. 锁可以在当前文件尾端处开始或者越过尾端处开始,但不能在文件起始位置之前开始。
  2. 如果l_len为0,则表示锁的范围可以扩展到最大可能偏移量。这意味着不管向文件中追加多少数据,都处于锁的范围内,而且起始位置可以是文件中的任意一个位置。
  3. 为了对整个文件加锁,可以设置l_startl_whence指向文件的起始位置,并且指定长度l_len为0。

锁的基本规则是:任意多个进程在同一个给定字节上都可以有一把共享读锁,但一个给定字节上仅能有一个进程持有一把独占写锁

话句话说,如果一个字节上已经有一把或多把读锁,则不能在该字节上再加写锁(可继续加读锁)。如果一个字节上已经有一把独占性写锁,则不能再对其加任何锁。这个规则适用于不同进程之间的锁请求

如果一个进程对一个文件区间已经有了一把锁,后来该进程有企图再同一文件区间再加一把写锁,那么新锁将替换已有锁

加读锁时描述符必须是读打开,加写锁时描述符必须是写打开

cmd参数作用

cmd作用
F_GETLK判断是否存在一把锁,阻塞创建flockptr描述的锁。如果存在这样一把锁,那么该现有锁的信息将重写flockptr指向的信息;如果不存在这样一把锁,则除了将flockptr指向锁的l_type设置为F_UNLCK,其余信息不变。
F_SETLK设置flockptr所描述的锁。如果加锁规则不允许,则fcntl立即返回,并设置errnoEACCESEAGAIN。此命令也用来清除锁F_UNLCK
F_SETLKW设置flockptr所描述的锁。如果加锁规则不允许,则调用进程阻塞休眠。直到加锁规则允许,该进程会被唤醒。

F_GETLK测试是否能够建立一把锁,F_SETLKF_SETLKW直接创建一把锁,这两者不是原子操作。不能保证在两次调用fcntl之间不会有其他进程创建相同的锁。

如果不希望创建锁时进程可能阻塞,那么就需要使用F_SETLK,并处理可能的错误返回。

进程饿死现象

如果一个进程在某个文件的一个区间上设置读锁,而第二个进程试图对同一文件区间设置写锁时阻塞,随后多个进程持续不断地在该文件区间上设置读锁,使得该文件区间始终存在一把或多把读锁,那么第二个进程设置写锁地请求将会一直得不到满足,该进程就会等待很长时间,这个现象被称为进程饿死。

设置或释放锁

在设置或释放文件上的一把锁时,系统按要求组合或分裂相邻区

例如,第100199字节是已经加锁的区,当前需要解锁第150字节,那么内核将会维持两把锁,一把用于第100149字节,一把用于第151~199字节。

随后又对第150字节加锁,那么系统将会再把三个相邻的加锁区合并为一个区,并维持一把锁。其结果与加锁前是一致的。

示例1:验证加锁或解锁文件区域

下面的例子中,先创建了一个templock文件,并向文件中写入了两个字节,随后对整个文件加上了独占性写锁。

之后该示例进程执行两次,第二次获取写锁时会提示获取失败,并立即返回。

fcntl调用失败会返回-1,调用成功则取决于第二个参数cmd

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int lock_reg(int fd, int cmd, int type, off_t offset, int whence, off_t len)
{
    struct flock lock;
    lock.l_type = type;      // 锁类型
    lock.l_whence = whence;  // 文件相对位置
    lock.l_start = offset;   // 相对于whece的偏移
    lock.l_len = len;        // 加锁的字节数

    // fcntl调用失败返回-1,调用成功返回值取决于cmd
    return (fcntl(fd, cmd, &lock));
}

// 非阻塞方式设置读锁
#define read_lock(fd, offset, whence, len) lock_reg((fd), F_SETLK, F_RDLCK, (offset), (whence), (len))
// 阻塞方式设置读锁
#define readw_lock(fd, offset, whence, len) lock_reg((fd), F_SETLKW, F_RDLCK, (offset), (whence), (len))
// 非阻塞方式设置写锁
#define write_lock(fd, offset, whence, len) lock_reg((fd), F_SETLK, F_WRLCK, (offset), (whence), (len))
// 阻塞方式设置写锁
#define writew_lock(fd, offset, whence, len) lock_reg((fd), F_SETLKW, F_WRLCK, (offset), (whence), (len))
// 解锁
#define un_lock(fd, offset, whence, len) lock_reg((fd), F_SETLK, F_UNLCK, (offset), (whence), (len))

int main()
{
    int fd;
    if ((fd = creat("templock", S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH)) < 0)
    {
        perror("creat");
        exit(-1);
    }
    if (write(fd, "ab", 2) != 2)
    {
        perror("write");
        exit(-1);
    }

    // 对文件所有内容加锁,即使后面追加的内容
    if (write_lock(fd, 0, SEEK_SET, 0) < 0)
    {
        perror("write_lock");
        exit(-1);
    }

    while (1)
        sleep(1);

    un_lock(fd, 0, SEEK_SET, 0);
    close(fd);
}
// 同时执行两次,第二次会提示失败
// 执行结果如下
// $ ./set_lock
// ^C
// $ ./set_lock
// write_lock: Resource temporarily unavailable

从结果来看,第二次获取写锁返回错误,其中errno被设置为11,提示资源不可用。

示例2:测试锁

在下面的例子中,使用F_GETLK命令来测试是否已经有锁存在,并阻止设置当前锁。

根据fcntl函数的用法,如果已经有锁存在,则用已存在的锁重写传入的lockptr指针内容,并写入进程ID。如果没有锁存在,则将l_type设置为F_UNLCK。当然这两种情况的前提是fcntl函数调用成功,即返回值不是-1。

代码示例如下:

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

// 如果存在一把锁阻塞当前的锁,则返回持有锁的进程id,否则返回0
pid_t lock_test(int fd, int type, off_t offset, int whence, off_t len)
{
    struct flock lock;
    lock.l_type = type;
    lock.l_whence = whence;
    lock.l_start = offset;
    lock.l_len = len;

    if (fcntl(fd, F_GETLK, &lock) < 0)
    {
        // 调用出错,立即退出进程
        perror("fcntl");
        exit(-1);
    }

    // 当不存在锁时,会将l_type设置为F_UNLCK
    if (lock.l_type == F_UNLCK)
        return 0;
    // 存在锁时会用已有锁重写lock,并写入进程id
    return lock.l_pid;
}

#define is_read_lockable(fd, offset, whence, len) lock_test((fd), F_RDLCK, (offset), (whence), (len))
#define is_write_lockable(fd, offset, whence, len) lock_test((fd), F_WRLCK, (offset), (whence), (len))

int main()
{
    int fd;
    if ((fd = creat("templock", S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH)) < 0)
    {
        perror("creat");
        exit(-1);
    }
    pid_t pid = is_write_lockable(fd, 0, SEEK_SET, 0);
    if (pid != 0)
        printf("file region is locked by another proc, pid=%d\n", pid);
    else
        printf("file region isn't lock by another proc\n");

    close(fd);
}

直接运行示例程序结果如下:

$ ./lock_test
file region isn't lock by another proc

先运行示例1,然后再运行当前程序,则执行结果如下:

$ ./set_lock

# 在另一个终端运行
$ ./lock_test
file region is locked by another proc, pid=3387258
$ ps -ef | grep set_lock
xxx   3387258 3379445  0 23:41 pts/3    00:00:00 ./set_lock

注意:lock_test函数的作用是检测是否存在已有的锁阻止调用进程设置自己的锁。因此该函数不能检测调用进程之前是否已经持有锁,因为F_SETLKF_SETLKW会替换调用进程现有的锁,而不是阻塞。

换言之,F_GETLK命令不会报告调用进程自己持有的锁,仅会报告其他进程持有的锁,该锁阻塞调用进程设置自己锁。

示例3:多进程死锁

如果两个进程相互等待对方持有锁且不释放的资源时,这两个进程都会陷入死锁状态。

在这个示例中,刚开始先创建了一个文件并写入了两个字节,然后子进程对第0个字节加锁,父进程对第1个字节加锁。然后父子进程相互对对方已经加锁的字节进行加锁,继而产生死锁。

在这个示例中,不会出现子进程已经对第0和第1个字节都加锁,而父进程还没来得及对第1个字节加锁的情况。这是因为子进程对第0个字节加锁之后,会给父进程发信号,然后等待父进程对第1个字节加锁之后,给子进程响应。父进程同理。

父子进程之间SIGUSR1SIGUSR2信号进行同步,以便每个进程能够等待另一个进程设置其第一把锁。

源码如下:

#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static volatile sig_atomic_t sigflag;
static sigset_t newmask, oldmask, zeromask;

/* SIGUSR1和SIGUSR2信号处理器 */
static void sig_usr(int signo)
{
    sigflag = 1;
}

void TELL_WAIT()
{
    if (signal(SIGUSR1, sig_usr) == SIG_ERR)
    {
        perror("signal");
        exit(-1);
    }
    if (signal(SIGUSR2, sig_usr) == SIG_ERR)
    {
        perror("signal");
        exit(-1);
    }

    sigemptyset(&zeromask);
    sigemptyset(&newmask);
    sigaddset(&newmask, SIGUSR1);
    sigaddset(&newmask, SIGUSR2);

    /* 阻塞SIGUSR1和SIGUSR2 */
    if (sigprocmask(SIG_BLOCK, &newmask, &oldmask) != 0)
    {
        perror("sigprocmask");
        exit(-1);
    }
}

/* 向父进程发信号 */
void TELL_PARENT(pid_t pid)
{
    /* 父进程接收SIGUSR2 */
    kill(pid, SIGUSR2);
}

/* 等待父进程 */
void WAIT_PARENT()
{
    while (sigflag == 0)
        /* 接收任何信号,并阻塞子进程 */
        sigsuspend(&zeromask);

    /* 收到信号会重置信号标志 */
    sigflag = 0;
    /* 恢复原来的信号屏蔽字 */
    if (sigprocmask(SIG_SETMASK, &oldmask, NULL) < 0)
    {
        perror("sigprocmask");
        exit(-1);
    }
}

/* 向子进程发信号 */
void TELL_CHILD(pid_t pid)
{
    kill(pid, SIGUSR1);
}

void WAIT_CHILD()
{
    while (sigflag == 0)
        /* 接收任何信号,并阻塞父进程 */
        sigsuspend(&zeromask);

    /* 收到信号会重置信号标志 */
    sigflag = 0;
    /* 恢复原来的信号屏蔽字 */
    if (sigprocmask(SIG_SETMASK, &oldmask, NULL) < 0)
    {
        perror("sigprocmask");
        exit(-1);
    }
}

int main()
{
    int fd;
    pid_t pid;

    /* 创建一个文件并写入两个字节 */
    if ((fd = creat("templock", S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP)) < 0)
    {
        perror("creat");
        exit(-1);
    }

    if (write(fd, "ab", 2) != 2)
    {
        perror("write");
        exit(-1);
    }

    TELL_WAIT();

    if ((pid = fork()) < 0)
    {
        perror("fork");
        exit(-1);
    }
    else if (pid == 0)
    {
        // 锁字节0
        if (writew_lock(fd, 0, SEEK_SET, 1) < 0)
            printf("child: writew_lock error, %s\n", strerror(errno));
        else
            printf("child: got the lock, byte: 0\n");
        TELL_PARENT(getppid());
        WAIT_PARENT();
        // 锁字节1
        if (writew_lock(fd, 1, SEEK_SET, 1) < 0)
            printf("child: writew_lock error, %s\n", strerror(errno));
        else
            printf("child: got the lock, byte: 1\n");
    }
    else
    {
        // 锁字节1
        if (writew_lock(fd, 1, SEEK_SET, 1) < 0)
            printf("parent: writew_lock error, %s\n", strerror(errno));
        else
            printf("parent: got the lock, byte: 1\n");
        TELL_CHILD(pid);
        WAIT_CHILD();
        // 锁字节0
        if (writew_lock(fd, 0, SEEK_SET, 1) < 0)
            printf("parent: writew_lock error, %s\n", strerror(errno));
        else
            printf("parent: got the lock, byte: 0\n");
    }
}

执行结果如下:

$ ./dead_lock
parent: got the lock, byte: 1
child: got the lock, byte: 0
parent: writew_lock error, Resource deadlock avoided
child: got the lock, byte: 1

内核检测到死锁时,会选择一个进程接收出错信息并返回。这里选择的是父进程。

锁的继承与释放

文件锁的继承与释放遵循下面三个规则:

  1. 锁与进程和文件相关联。表现在两个方面:1是进程终止时,该进程创建的锁会全部释放;2是无论一个描述符何时关闭,进程在该描述符引用的文件上设置的任何一把锁都会被释放
  2. fork产生的子进程不继承父进程设置的锁(锁与进程ID相关)。
  3. 执行exec后,新程序可以继承原执行程序的锁。除非对文件描述符设置执行时关闭标志

考虑如下示例:

// 示例1
fd1 = open(pathname, ...);
read_lock(fd1, ...);
fd2 = dup(fd1);
close(fd2);
// 示例2
fd1 = open(pathname, ...);
read_lock(fd1, ...);
fd2 = open(pathname, ...);
close(fd2);

通过第1条规则可以得知,在close(fd2)之后,在fd1上设置的锁会被释放。

为什么说锁与进程和文件相关(与文件描述符无关)呢,主要体现在锁的实现方式上。

  1. Linux在第一次打开某个文件时从磁盘中加载文件的i节点信息。
  2. 如果有进程在该文件上请求锁,那么内核会先检查是否与现有锁冲突,并根据需要创建新的锁条目。这些锁条目保存在内核数据结构中,并与已经加载到内存中的i节点信息相关联

i节点信息是从磁盘中加载,文件锁是在运行时由内核动态创建。

实现方式

参考以下示例

fd1 = open(pathname, ...);
write_lock(fd1, 0, SEEK_SET, 1); // 父进程锁定字节0
if ((pid = fork()) > 0){
    fd2 = dup(fd1);
    fd3 = open(pathname, ...);
} else if (pid == 0) {
    read_lock(fd1, 1, SEEK_SET, 1); // 子进程锁定字节1
}
pause();

当父子进程暂停之后,文件锁在内存中的情况如下图: 父子进程fcntl锁机制

具体参见源码文件linux-6.8.9中(fs/fcntl.c, include/fs.h include/filelock.h)。

图中显示了两个file_lock结构,一个是由父进程调用write_lock设置的锁,另一个是由子进程调用read_lock设置的锁,每个锁中都包含了fl_lowner_t指针,该指针指向进程ID。

在父进程中,关闭fd1, fd2, fd3中的任何一个都将释放由父进程设置的写锁。在关闭这3个描述符中的任意一个时,内核会从描述符关联的i节点开始,逐个检查flc_posix链表中的每项,释放由调用进程持有的所有锁。

文件加锁

下面的示例会对文件所有内容以及后续新增内容均加上写锁:

#include <unistd.h>
#include <fcntl.h>

int lockfile(int fd)
{
    struct flock fl;
    fl.l_type = F_WRLCK;
    fl.l_start = 0;
    fl.l_whence = SEEK_SET;
    fl.l_len = 0;
    return (fcntl(fd, F_SETLK, &fl));
}

向文件末尾加锁

当对文件的一部分进行加锁时,内核会将指定的偏移量变换为文件绝对偏移量

这是因为除了指定一个绝对文件偏移量SEEK_SET之外,fcntl函数还允许指定相对文件偏移量SEEK_CURSEEK_END,这两个偏移量可能会不断变化,但这种变化不应该影响锁的状态,所以内核必须独立于相对偏移量而记住锁。

在一些场景中,需要相对文件长度来设置一把锁,但是在设置锁之前又不知道文件长度(调用fstat函数和fcntl设置锁之间存在窗口,文件长度可能会发生变化),此时就用到了SEEK_END偏移量。

考虑这个例子:

writew_lock(fd, 0, SEEK_END, 0);
write(fd, buf, 1);
un_lock(fd, 0, SEEK_END, 0);
write(fd, buf, 1);

在这个例子中,首先对从文件末端开始的所有内容进行加锁,加完锁之后写入了1个字节。其次是解锁操作,对从文件末端开始追加的内容不在加锁,然后继续写入了1个字节。

此时,在加锁和解锁这两个调用中,文件偏移量SEEK_END的值是不相同的(延伸了1个字节),因此最终结果是第1次写入的字节是加锁状态,第2次写入的字节没有加锁。

具体效果如下图所示: 采用相对偏移量SEEK_END对文件末端加锁结果