fcntl()函数在前面系列内容中已经多次用到了,它是一个多功能文件描述符管理工具箱,通过配合不同的 cmd 操作命令来实现不同的功能。为了方便述说,这里再重申一次:
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* struct flock *flockptr */ );
与锁相关的 cmd 为 F_SETLK、F_SETLKW、F_GETLK,第三个参数 flockptr 是一个 struct flock 结构体指针。使用 fcntl()实现文件锁功能与 flock()有两个比较大的区别:
- flock()仅支持对整个文件进行加锁/解锁;而 fcntl()可以对文件的某个区域(某部分内容)进行加锁 /解锁,可以精确到某一个字节数据。
- flock()仅支持建议性锁类型;而 fcntl()可支持建议性锁和强制性锁两种类型。
我们先来看看 struct flock 结构体,如下所示:
struct flock {
...
short l_type; /* Type of lock: F_RDLCK,F_WRLCK, F_UNLCK */
short l_whence; /* How to interpret l_start: SEEK_SET, SEEK_CUR, SEEK_END */
off_t l_start; /* Starting offset for lock */
off_t l_len; /* Number of bytes to lock */
pid_t l_pid; /* PID of process blocking our lock(set by F_GETLK and F_OFD_GETLK) */
...
};
对 struct flock 结构体说明如下:
- l_type:所希望的锁类型,可以设置为 F_RDLCK、F_WRLCK 和 F_UNLCK 三种类型之一,F_RDLCK 表示共享性质的读锁,F_WRLCK 表示独占性质的写锁,F_UNLCK 表示解锁一个区域。
- l_whence 和 l_start:这两个变量用于指定要加锁或解锁区域的起始字节偏移量,与lseek()函数中的 offset 和 whence 参数相同,这里不再重述。
- l_len:需要加锁或解锁区域的字节长度。
- l_pid:一个 pid,指向一个进程,表示该进程持有的锁能阻塞当前进程,当 cmd=F_GETLK 时有效。
以上便是对 struct flock 结构体各成员变量的简单介绍,对于加锁和解锁区域的说明,还需要注意以下几项规则:
- 锁区域可以在当前文件末尾处开始或者越过末尾处开始,但是不能在文件起始位置之前开始。
- 若参数 l_len 设置为 0,表示将锁区域扩大到最大范围,也就是说从锁区域的起始位置开始,到文 件的最大偏移量处(也就是文件末尾)都处于锁区域范围内。而且是动态的,这意味着不管向该文件追加写了多少数据,它们都处于锁区域范围,起始位置可以是文件的任意位置。
- 如果我们需要对整个文件加锁,可以将 l_whence 和 l_start 设置为指向文件的起始位置,并且指定参数 l_len 等于 0。
两种类型的锁:F_RDLCK 和 F_WRLCK
上面我们提到了两种类型的锁,分别为共享性读锁(F_RDLCK)和独占性写锁(F_WRLCK)。基本的规则与 Linux线程同步(6)——更高并行性的读写锁中所介绍的线程同步读写锁很相似,任意多个进程在一个给定的字节上可以有一把共享的读 锁,但是在一个给定的字节上只能有一个进程有一把独占写锁,进一步而言,如果在一个给定的字节上已经 有一把或多把读锁,则不能在该字节上加写锁;如果在一个字节上已经有一把独占性写锁,则不能再对它加任何锁(包括读锁和写锁),下图显示了这些兼容性规则:
如果一个进程对文件的某个区域已经上了一把锁,后来该进程又试图在该区域再加一把锁,那么通常新加的锁将替换旧的锁。譬如,若某一进程在文件的 100~200 字节区间有一把写锁,然后又试图在 100~200 字 节区间再加一把读锁,那么该请求将会成功执行,原来的写锁会替换为读锁。 还需要注意另外一个问题,当对文件的某一区域加读锁时,调用进程必须对该文件有读权限,譬如 open() 时 flags 参数指定了 O_RDONLY 或 O_RDWR;当对文件的某一区域加写锁时,调用进程必须对该文件有写 权限,譬如 open()时 flags 参数指定了 O_WRONLY 或 O_RDWR。 F_SETLK、F_SETLKW 和 F_GETLK
我们来看看与文件锁相关的三个 cmd 它们的作用:
⚫ F_GETLK:这种用法一般用于测试,测试调用进程对文件加一把由参数 flockptr 指向的 struct flock 对象所描述的锁是否会加锁成功。如果加锁不成功,意味着该文件的这部分区域已经存在一把锁, 并且由另一进程所持有,并且调用进程加的锁与现有锁之间存在排斥关系,现有锁会阻止调用进程想要加的锁,并且现有锁的信息将会重写参数 flockptr 指向的对象信息。如果不存在这种情况,也就是说 flockptr 指向的 struct flock 对象所描述的锁会加锁成功,则除了将 struct flock 对象的 l_type 修改为 F_UNLCK 之外,结构体中的其它信息保持不变。
⚫ F_SETLK:对文件添加由 flockptr 指向的 struct flock 对象所描述的锁。譬如试图对文件的某一区域加读锁(l_type 等于 F_RDLCK)或写锁(l_type 等于 F_WRLCK),如果加锁失败,那么 fcntl() 将立即出错返回,此时将 errno 设置为 EACCES 或 EAGAIN。也可用于清除由 flockptr 指向的 struct flock 对象所描述的锁(l_type 等于 F_UNLCK)。
⚫ F_SETLKW:此命令是F_SETLK 的阻塞版本(命令名中的 W 表示等待 wait),如果所请求的读锁或写锁因另一个进程当前已经对所请求区域的某部分进行了加锁,而导致请求失败,那么调用进程将会进入阻塞状态。只有当请求的锁可用时,进程才会被唤醒。
F_GETLK 命令一般很少用,事先用 F_GETLK 命令测试是否能够对文件加锁,然后再用 F_SETLK或F_SETLKW 命令对文件加锁,但这两者并不是原子操作,所以即使测试结果表明可以加锁成功,但是在使 用 F_SETLK 或 F_SETLKW 命令对文件加锁之前也有可能被其它进程锁住。
使用示例与测试
示例代码演示了使用 fcntl()对文件加锁和解锁的操作。需要加锁的文件通过外部传参传入,先 调用 open()函数以只写方式打开文件;接着对 struct flock 类型对象 lock 进行填充,l_type 设置为 F_WRLCK 表示加一个写锁,通过 l_whence 和 l_start 两个变量将加锁区域的起始位置设置为文件头部,接着将 l_len 设置为 0 表示对整个文件加锁。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char *argv[])
{
struct flock lock = {0};
int fd = -1;
char buf[] = "Hello World!";
/* 校验传参 */
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_WRONLY);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 对文件加锁 */
lock.l_type = F_WRLCK; //独占性写锁
lock.l_whence = SEEK_SET; //文件头部
lock.l_start = 0; //偏移量为 0
lock.l_len = 0;
if (-1 == fcntl(fd, F_SETLK, &lock)) {
perror("加锁失败");
exit(-1);
}
printf("对文件加锁成功!\n");
/* 对文件进行写操作 */
if (0 > write(fd, buf, strlen(buf))) {
perror("write error");
exit(-1);
}
/* 解锁 */
lock.l_type = F_UNLCK; //解锁
fcntl(fd, F_SETLK, &lock);
/* 退出 */
close(fd);
exit(0);
}
整个代码很简单,比较容易理解,具体执行的结果就不再给大家演示了。
一个进程可以对同一个文件的不同区域进行加锁,当然这两个区域不能有重叠的情况。下示例代码演示了一个进程对同一文件的两个不同区域分别加读锁和写锁,对文件的 100~200 字节区间加了一个写锁, 对文件的 400~500 字节区间加了一个读锁。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
struct flock wr_lock = {0};
struct flock rd_lock = {0};
int fd = -1;
/* 校验传参 */
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_RDWR);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将文件大小截断为 1024 字节 */
ftruncate(fd, 1024);
/* 对 100~200 字节区间加写锁 */
wr_lock.l_type = F_WRLCK;
wr_lock.l_whence = SEEK_SET;
wr_lock.l_start = 100;
wr_lock.l_len = 100;
if (-1 == fcntl(fd, F_SETLK, &wr_lock)) {
perror("加写锁失败");
exit(-1);
}
printf("加写锁成功!\n");
/* 对 400~500 字节区间加读锁 */
rd_lock.l_type = F_RDLCK;
rd_lock.l_whence = SEEK_SET;
rd_lock.l_start = 400;
rd_lock.l_len = 100;
if (-1 == fcntl(fd, F_SETLK, &rd_lock)) {
perror("加读锁失败");
exit(-1);
}
printf("加读锁成功!\n");
/* 对文件进行 I/O 操作 */
// ......
// ......
/* 解锁 */
wr_lock.l_type = F_UNLCK; //写锁解锁
fcntl(fd, F_SETLK, &wr_lock);
rd_lock.l_type = F_UNLCK; //读锁解锁
fcntl(fd, F_SETLK, &rd_lock);
/* 退出 */
close(fd);
exit(0);
}
如果两个区域出现了重叠,譬如 100~200 字节区间和 150~250 字节区间,150~200 就是它们的重叠部分,一个进程对同一文件的相同区域不可能同时加两把锁,新加的锁会把旧的锁替换掉,譬如先对 100~200 字节区间加写锁、再对 150~250 字节区间加读锁,那么 150~200 字节区间最终是读锁控制的,关于这个问题,大家可以自己去验证、测试。
接下来对读锁和写锁彼此之间的兼容性进行测试,使用下示例代码测试读锁的共享性。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
struct flock lock = {0};
int fd = -1;
/* 校验传参 */
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_RDWR);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将文件大小截断为 1024 字节 */
ftruncate(fd, 1024);
/* 对 400~500 字节区间加读锁 */
lock.l_type = F_RDLCK;
lock.l_whence = SEEK_SET;
lock.l_start = 400;
lock.l_len = 100;
if (-1 == fcntl(fd, F_SETLK, &lock)) {
perror("加读锁失败");
exit(-1);
}
printf("加读锁成功!\n");
for ( ; ; )
sleep(1);
}
首先运行上述示例代码,程序加读锁之后会进入死循环,进程一直在运行着、持有读锁。接着多次运行上述示例代码,启动多个进程加读锁,测试结果如下所示:
从打印信息可以发现,多个进程对同一文件的相同区域都可以加读锁,说明读锁是共享性的。由于程序是放置在后台运行的,测试完毕之后,可以使用 kill 命令将这些进程杀死,或者直接关闭当前终端,重新启动新的终端。
几条规则
关于使用 fcntl()创建锁的几条规则与 flock()相似,如下所示:
⚫ 文件关闭的时候,会自动解锁。
⚫ 一个进程不可以对另一个进程持有的文件锁进行解锁。
⚫ 由 fork()创建的子进程不会继承父进程所创建的锁
除此之外,当一个文件描述符被复制时(譬如使用 dup()、dup2()或 fcntl()F_DUPFD 操作),这些通过复制得到的文件描述符和源文件描述符都会引用同一个文件锁,使用这些文件描述符中的任何一个进行解锁都可以,这点与 flock()是一样的。
lock.l_type = F_RDLCK;
fcntl(fd, F_SETLK, &lock);//加锁
new_fd = dup(fd);
lock.l_type = F_UNLCK;
fcntl(new_fd, F_SETLK, &lock);//解锁
这段代码先在 fd 上设置一个读锁,然后使用 dup()对 fd 进行复制得到新文件描述符 new_fd,最后通过 new_fd 来解锁,这样可以解锁成功。如果不显示的调用一个解锁操作,任何一个文件描述符被关闭之后锁都会自动释放,那么这点与 flock()是不同的。譬如上面的例子中,如果不调用 flock(new_fd, LOCK_UN)进行解锁,当 fd 或 new_fd 两个文件描述符中的任何一个被关闭之后锁都会自动释放。
Linux高级 I/O总结
本系列向大家介绍了几种高级 I/O 功能,非阻塞 I/O、I/O 多路复用、异步 I/O、存储映射 I/O、以及文件锁,其中有许多的功能,我们将会在后面的提高篇和进阶篇章节实例中使用到。
- 非阻塞 I/O:进程向文件发起 I/O 操作,使其不会被阻塞。
- I/O 多路复用:select()和 poll()函数。
- 异步 I/O:当文件描述符上可以执行 I/O 操作时,内核会向进程发送信号通知它。
- 存储映射 I/O:mmap()函数。
- 文件锁:flock()、fcntl()以及 lockf()函数。