文件锁之fcntl
文件锁的作用:当第一个进程正在读或修改文件的某个部分时,使用文件锁可以阻止其他进程修改文件的相同部分。
因此文件锁可用于多个进程之间进行同步,防止进程间的竞争状态。
Linux系统支持两组给文件加锁的不同API,分别是fcntl
与flock
。本节主要记录fcntl
的实现原理以及使用方式。
任意多个进程在同一个给定字节上都可以有一把共享读锁,但一个给定字节上仅能有一个进程持有一把独占写锁。
锁类型
文件锁分为两种,分别是建议性锁和强制性锁。
欲使建议性锁模型能够正常工作,所有访问文件的进程都需要进行配合,采用一致的方法来进行文件I/O,即在进行文件I/O之前先设置一把锁。这些相互配合的进程称为合作进程。
建议性锁并不能阻止其他进程在文件已加锁的情况下,不获得锁而强制执行与锁类型冲突的文件I/O。
强制性锁会让内核检查每一个open
、read
和write
调用,验证调用进程是否违背了正在访问的文件上的某一把锁。
启用强制性锁需要满足两个条件:
- 文件系统支持强制性锁,挂载文件系统时启用强制性锁(
mount -o mand
)。 - 为锁定的文件设置标志位,即打开其设置组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_GETLK
、F_SETLK
或F_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
(解锁)
注意:
- 锁可以在当前文件尾端处开始或者越过尾端处开始,但不能在文件起始位置之前开始。
- 如果
l_len
为0,则表示锁的范围可以扩展到最大可能偏移量。这意味着不管向文件中追加多少数据,都处于锁的范围内,而且起始位置可以是文件中的任意一个位置。 - 为了对整个文件加锁,可以设置
l_start
和l_whence
指向文件的起始位置,并且指定长度l_len
为0。
锁的基本规则是:任意多个进程在同一个给定字节上都可以有一把共享读锁,但一个给定字节上仅能有一个进程持有一把独占写锁。
话句话说,如果一个字节上已经有一把或多把读锁,则不能在该字节上再加写锁(可继续加读锁)。如果一个字节上已经有一把独占性写锁,则不能再对其加任何锁。这个规则适用于不同进程之间的锁请求。
如果一个进程对一个文件区间已经有了一把锁,后来该进程有企图再同一文件区间再加一把写锁,那么新锁将替换已有锁。
加读锁时描述符必须是读打开,加写锁时描述符必须是写打开。
cmd参数作用
cmd | 作用 |
---|---|
F_GETLK | 判断是否存在一把锁,阻塞创建flockptr 描述的锁。如果存在这样一把锁,那么该现有锁的信息将重写flockptr 指向的信息;如果不存在这样一把锁,则除了将flockptr 指向锁的l_type 设置为F_UNLCK ,其余信息不变。 |
F_SETLK | 设置flockptr 所描述的锁。如果加锁规则不允许,则fcntl 立即返回,并设置errno 为EACCES 或EAGAIN 。此命令也用来清除锁F_UNLCK 。 |
F_SETLKW | 设置flockptr 所描述的锁。如果加锁规则不允许,则调用进程阻塞休眠。直到加锁规则允许,该进程会被唤醒。 |
F_GETLK
测试是否能够建立一把锁,F_SETLK
和F_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_SETLK
和F_SETLKW
会替换调用进程现有的锁,而不是阻塞。
换言之,F_GETLK
命令不会报告调用进程自己持有的锁,仅会报告其他进程持有的锁,该锁阻塞调用进程设置自己锁。
示例3:多进程死锁
如果两个进程相互等待对方持有锁且不释放的资源时,这两个进程都会陷入死锁状态。
在这个示例中,刚开始先创建了一个文件并写入了两个字节,然后子进程对第0个字节加锁,父进程对第1个字节加锁。然后父子进程相互对对方已经加锁的字节进行加锁,继而产生死锁。
在这个示例中,不会出现子进程已经对第0和第1个字节都加锁,而父进程还没来得及对第1个字节加锁的情况。这是因为子进程对第0个字节加锁之后,会给父进程发信号,然后等待父进程对第1个字节加锁之后,给子进程响应。父进程同理。
父子进程之间SIGUSR1
和SIGUSR2
信号进行同步,以便每个进程能够等待另一个进程设置其第一把锁。
源码如下:
#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是进程终止时,该进程创建的锁会全部释放;2是无论一个描述符何时关闭,进程在该描述符引用的文件上设置的任何一把锁都会被释放。
fork
产生的子进程不继承父进程设置的锁(锁与进程ID相关)。- 执行
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
上设置的锁会被释放。
为什么说锁与进程和文件相关(与文件描述符无关)呢,主要体现在锁的实现方式上。
- Linux在第一次打开某个文件时从磁盘中加载文件的
i
节点信息。 - 如果有进程在该文件上请求锁,那么内核会先检查是否与现有锁冲突,并根据需要创建新的锁条目。这些锁条目保存在内核数据结构中,并与已经加载到内存中的
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();
具体参见源码文件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_CUR
与SEEK_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次写入的字节没有加锁。
- 原文作者:生如夏花
- 原文链接:https://blduan.top/post/%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0/apue/%E6%96%87%E4%BB%B6%E9%94%81%E4%B9%8Bfcntl/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。