文章目录
1 记录锁
1.1 概念
对文件的部分字节或所有字节加锁,在多进程之间对指定的字节进行互斥访问。是一种字节范围锁。
记录锁通过 fcntl 函数来获取和释放锁,fcntl 的执行命令(cmd 参数)是 F_SETLK (非阻塞)和 F_SETLKW (阻塞)。 F_GETLK 测试是否可以加锁解锁。
int fcntl(int fd, int cmd, ...); // 设置文件标志位
fcntl 的第三个参数是一个结构体类型指针,定义如下:
struct flock {
short l_type;
short l_whence;
off_t l_start;
off_t l_len;
pid_t l_pid;
}
- l_type:是要获取锁呢,还是释放锁?它的值为 F_RDLCK,F_WRLCK,F_UNLCK
- l_whence : SEEK_SET, SEEK_CUR, SEEK_END
- l_start:相对 l_whence 的偏移
- l_len:要加锁或解释的字节数
- l_pid:这个先不讲
1.2 加锁解锁
加锁
void wlock(int fd, int start, int len) {
struct flock flk;
// 加写锁,如果是加读锁,这里就写 F_RDLCK
flk.l_type = F_WRLCK;
// 设置参考点,参考 lseek;这里表示参考点为第 0 字节处。
flk.l_whence = SEEK_SET;
// 相对于参考点什么位置;
flk.l_start = start;
// 要加锁的字节数。
flk.l_len = len;
// 获得锁。如果该区域有任何一个字节被其他进程加锁,则阻塞。如果设置 cmd 参数为 F_SETLK,获得锁失败会返回 -1,同时 errno 置为 EAGAIN,成功返回 0.
fcntl(fd, F_SETLKW, &flk);
}
与读写锁的不同:
-
同一个进程重复加锁不会阻塞,已最后一次加的锁为准,不同进程会阻塞。
-
加锁范围
- 如果 len > 0, [whence+start,whence+start+len)
- 如果 len = 0, [whence+start,∞)
-
欲加锁的范围中,有任何一个字节被加锁,则等待
解锁
void wlock(int fd, int start, int len) {
struct flock flk;
// 解锁
flk.l_type = F_UNLCK;
flk.l_whence = SEEK_SET;
flk.l_start = start;
// 要解锁的字节数。
flk.l_len = len;
fcntl(fd, F_SETLKW, &flk);
}
解锁的程序与加锁区别就在于 flock 的 type 字段。
实验:
程序 lockfile 用来对文件进行加写锁,需要从命令行传参数,格式如下:
./lockfile <filename> <start> <len>
- filename 表示想对哪个文件加锁
- start 表示从第几个字节开始
- len 表示加锁多少字节
// lockfile.c
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#define PERR(err, msg) do { errno = err; perror(msg); exit(-1); } while(0)
int lock(int fd, int start, int len) {
puts("locking...");
struct flock flk;
int err;
flk.l_type = F_WRLCK;
flk.l_whence = SEEK_SET;
flk.l_start = start;
flk.l_len = len;
err = fcntl(fd, F_SETLKW, &flk);
if (err < 0) PERR(errno, "lock");
puts("locked...");
return err;
}
int unlock(int fd, int start, int len) {
puts("unlocking...");
struct flock flk;
int err;
flk.l_type = F_UNLCK;
flk.l_whence = SEEK_SET;
flk.l_start = start;
flk.l_len = len;
err = fcntl(fd, F_SETLKW, &flk);
if (err < 0) PERR(errno, "unlock");
puts("unlocked...");
return err;
}
int main(int argc, char* argv[]) {
if (argc < 4) {
printf("Usage: %s <filename> <start> <len>\n", argv[0]);
return -1;
}
char* filename = argv[1];
int start = atoi(argv[2]);
int len = atoi(argv[3]);
printf("pid: %d\n", getpid());
int fd = open(filename, O_WRONLY);
lock(fd, start, len);
sleep(10);
unlock(fd, start, len);
sleep(10);
return 0;
}
编译
$ gcc lockfile.c -o lockfile
测试文件 a.txt 内容(11 字节):
2.3 运行
测试文件 a.txt 内容(11 字节):
hello world
可以通过命令 echo -n 'hello world' > a.txt
获得 a.txt
- 测试一:进程 A 和 B 对 [2, 5) 范围加锁。
- 测试二:进程 A 对 [2, 3) 范围加锁,进程 B 对 [2, 5) 范围加锁。
- 测试三:进程 A 对 [2, 3) 范围加锁,进程 B 对 [3, 5) 范围加锁。
- 测试四:进程 A 对整个文件加锁,进程 B 对 [3, 5) 范围加锁。
2 记录锁测试命令
有时候,并不想直接对某个文件的部分内容先请求锁,只想知道能不能对其加锁。
测试锁的方法
将 fcntl 的 cmd 命令设置为 F_GETLK。返回的结果保存在 l_type 字段和 l_pid 字段。
int testlock(int fd, int start, int len) {
struct flock flk;
// 测试能否加写锁,这个字段既是输入字段,也是输出结果字段。
flk.l_type = F_WRLCK;
flk.l_start = start;
flk.l_whence = SEEK_SET;
flk.l_len = len;
// 开始测试,这不会阻塞
fcntl(fd, F_GETLK, &flk);
// 如果返回的 l_type 值为 F_UNLCK,表示这部分内容未加锁,你可以正常加锁。否则,这部分内容是不参加写锁的(注意一开始上面的 l_type 字段是 F_WRLCK),同时 l_pid 字段会保存当前的锁被哪个进程持有。
if (flk.l_type == F_UNLCK) return 0;
return flk.l_pid;
}
F_GETLK 命令会影响 flock 中的两个字段:l_type和l_pid
l_type 输入参数,同时还接收结果。fcntl 执行完成后,如果 l_type == F_UNLCK,表示没加锁,如果不相等,有一个叫 l_pid 进程持有锁,不能再加写锁了。
实验:
程序 testlock 用来测试能否对文件指定内容加写锁。该程序需要从命令行传入参数,格式如下:
./testlock <filename> <start> <len>
表示对文件 filename 的 start 位置开始的 len 个字节进行测试能否加写锁。
// testlock.c
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#define PERR(err, msg) do { errno = err; perror(msg); exit(-1); } while(0)
// 如果能加锁,返回 0;否则返回持有锁的进程 pid
int testlock(int fd, int start, int len) {
struct flock flk;
int err;
flk.l_type = F_WRLCK;
flk.l_start = start;
flk.l_whence = SEEK_SET;
flk.l_len = len;
err = fcntl(fd, F_GETLK, &flk);
if (err < 0) PERR(errno, "getlock");
if (flk.l_type == F_UNLCK) return 0;
return flk.l_pid;
}
int main(int argc, char* argv[]) {
if (argc < 4) {
printf("Usage: %s <filename> <start> <len>\n", argv[0]);
return -1;
}
int fd = open(argv[1], O_WRONLY);
printf("testlock = %d\n", testlock(fd, atoi(argv[2]), atoi(argv[3])));
close(fd);
return 0;
}
编译
$ gcc testlock.c -o testlock
运行
测试文件 a.txt 内容(11 字节):
hello world
可以通过命令 echo -n 'hello world' > a.txt
获得 a.txt.
可以通过命令 echo -n ‘hello world’ > a.txt 获得 a.txt.
- 测试一
进程 A 对 [2, 5) 范围加锁。进程 B 对 [2, 5) 进行测试。
-
测试二
进程 A 对 [2, 3) 范围加锁。进程 B 对 [2, 5) 进行测试。
-
测试三
进程 A 对 [2, 3) 范围加锁。进程 B 对 [3, 5) 进行测试。
-
测试四
进程 A 对整个文件加锁,进程 B 对 [3, 5) 范围加锁。
3 记录锁(底层实现)
// 下面的程序中,假设 fd1 = 3, fd2 = 4, fd3 = 5
fd1 = open(pathname, ...);
// 对第一个字节加写锁
wlock(fd1, 0, SEEK_SET, 1);
pid = fork();
if (pid > 0) {
// 父进程,假设 pid = 1000
fd2 = dup(fd1);
fd3 = open(pathname, ...);
}
else if (pid == 0) {
// 子进程,假设 pid = 1001
// 对第二个字节加读锁。
rlock(fd1, 1, SEEK_SET, 1);
}
从图中可以看到:
-
对于同一个文件,只有一份 vnode,无论是 dup 还是 open。
-
锁的信息是保存在 vnode 中
对任何一个文件描述符加锁和解锁,都会影响到所有最终指向 vnode 节点的描述符。如果在 fd1 上释放了锁,那么对于 fd2 和 fd3 来说,锁也是释放了的。关闭任何一个描述符,锁都会释放。
4 记录锁(继承与释放)
释放
-
如果进程关闭,所有的记录锁都被释放。
-
任何一个描述符关闭,该描述符引用的文件上的任何记录都被释放。
对于同一个文件,无论你是 open 还是 dup 多少次,vnode 节点始终只有一份,而锁的信息是在 vnode 节点中保存。
继承
-
fork 产生的子进程不继承父进程所设置的锁。
原因:记录锁的作用就是阻止多个进程同时写同一个文件。如果 fork 继承了父进程的锁,那么父进程和子进程相当于同时获得了锁,错误。
-
执行exec系列函数后,如果描述符没有设置 O_CLOEXEC(执行时关闭),新程序仍然可以使用原来的锁。
实验:
程序 closelock 对文件 test.txt 的 [2,4) 字节进行加写锁,然后 dup 了一份新的描述符,然后立即 close,此时对文件锁进行测试,看是否能够获得写锁。
// closelock.c
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#define PERR(err, msg) do { errno = err; perror(msg); exit(-1); } while(0)
int lock(int fd) {
puts("locking...");
struct flock flk;
int err;
flk.l_type = F_WRLCK;
flk.l_start = SEEK_SET;
flk.l_whence = 2;
flk.l_len = 2;
err = fcntl(fd, F_SETLKW, &flk);
if (err < 0) PERR(errno, "lock");
puts("locked...");
return err;
}
int unlock(int fd) {
puts("unlocking...");
struct flock flk;
int err;
flk.l_type = F_UNLCK;
flk.l_start = SEEK_SET;
flk.l_whence = 2;
flk.l_len = 2;
err = fcntl(fd, F_SETLKW, &flk);
if (err < 0) PERR(errno, "unlock");
puts("unlocked...");
return err;
}
int main() {
printf("pid: %d\n", getpid());
int fd = open("test.txt", O_WRONLY);
lock(fd);
sleep(10);
puts("close fd2");
int fd2 = dup(fd);
close(fd2);
sleep(10);
unlock(fd);
close(fd);
return 0;
}
编译运行
$ gcc closelock.c -o closelock
5 记录锁(尾部加锁)
在记录锁尾部加锁解锁容易产生大坑。
wlock(fd, 0, SEEK_END, 0);
// 追加一字节
write(fd, buf, 1);
unlock(fd, 0, SEEK_END, 0);
// 再次追加一字节
write(fd, buf, 1);
运行结果如下:
结果不是我们预期的,对于第一次追加完成后,解锁时SEEK_END位置已经变了。
实验:
程序 lockendfile 对文件 end.txt 加锁后,追加一字节数据,然后解锁。同样这里使用 testlock 程序进行测试。
// lockendfile.c
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#define PERR(err, msg) do { errno = err; perror(msg); exit(-1); } while(0)
int lockend(int fd) {
puts("locking...");
struct flock flk;
int err;
flk.l_type = F_WRLCK;
flk.l_start = 0;
flk.l_whence = SEEK_END;
flk.l_len = 0;
err = fcntl(fd, F_SETLKW, &flk);
if (err < 0) PERR(errno, "lock");
puts("locked...");
return err;
}
int unlockend(int fd) {
puts("unlocking...");
struct flock flk;
int err;
flk.l_type = F_UNLCK;
flk.l_start = 0;
flk.l_whence = SEEK_END;
flk.l_len = 0;
err = fcntl(fd, F_SETLKW, &flk);
if (err < 0) PERR(errno, "unlock");
puts("unlocked...");
return err;
}
int main() {
printf("pid: %d\n", getpid());
int fd = open("end.txt", O_WRONLY | O_APPEND);
lockend(fd);
write(fd, "l", 1);
sleep(5);
unlockend(fd);
sleep(30);
close(fd);
return 0;
}
编译运行
$ gcc lockendfile.c -o lockendfile
6 建议性锁和强制性锁
6.1 提出问题
假设有一个文件 b.txt,进程 A 对其进行加锁。另一个进程 B,如果它打开了文件 b.txt,一上来就直接读写 b.txt,会不会出问题?
6.2 建议性锁和强制性锁
建议性锁(Advisory Locking)
建议性锁不是一把真正的锁,而是文件系统本身具备的性质,一种属性。即使再没有获得锁的情况下进程 B 也可以自由读写文件 b.txt。
强制性锁(Mandatory Locking)
如果文件系统开启了强制性锁属性,同时文件也开启了强制性锁机制,那么进程 B 试图在不获得锁的情况读写 b.txt,要么阻塞,要么返回 EAGAIN(看描述符是阻塞的还是非阻塞的)。
6.3 建议性锁和强制性锁
文件系统开启了强制性锁属性,同时文件也开启了强制性锁机制。
打开文件强制性锁属性
默认情况文件系统的 Mandatory Locking 属性是关闭的,需要使用 mount -o mand 命令打开。
比如:
mount -o remount,mand /dev/sda1 /
表示重新将硬盘 /dev/sda1 以 Mandatory Locking 方式挂载到根文件系统。不知道你当前的文件系统上挂载的是哪个设备时,通过 df -h 查看。
打开文件本身的强制性锁属性
有两种方法可以打开文件的 Mandatory Locking 属性:
1、打开文件前,给文件加上Mandatory Locking 属性。
chmod g+s b.txt
chmod g-x b.txt
就是去掉组执行属性,加上设置组 ID 属性。
2、在程序里去做
打开文件后,使用 fchmod 函数修改 x 和 s 属性:
struct stat statbuf;
fstat(fd, &statbuf);
fchmod(fd, (statbuf.st_mode & ~S_IXGRP) | S_ISGID);
实验:
程序 readfile 尝试在对文件加锁的情况下读取数据,该程序需要从命令行传入数,表示读取哪个文件。
./readfile <filename>
readfile 会在读取文件内容前对锁进行测试。
// readfile.c
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#define PERR(err, msg) do { errno = err; perror(msg); exit(-1); } while(0);
int testlock(int fd, int start, int len) {
struct flock flk;
int err;
flk.l_type = F_WRLCK;
flk.l_start = start;
flk.l_whence = SEEK_SET;
flk.l_len = len;
err = fcntl(fd, F_GETLK, &flk);
if (err < 0) PERR(errno, "getlock");
if (flk.l_type == F_UNLCK) return 0;
return flk.l_pid;
}
int main(int argc, char* argv[]) {
char* filename = argv[1];
char buf[64] = { 0 };
int err;
int fd = open(filename, O_RDONLY | O_NONBLOCK);
if (testlock(fd, 0, 0) != 0)
printf("%s is locked\n", filename);
err = read(fd, buf, 2);
if (err < 0)
printf("errno = %d, msg = %s\n", errno, strerror(errno));
else
printf("content: %s\n", buf);
close(fd);
return 0;
}
编译运行
$ gcc readfile.c -o readfile
- 测试一:不开启 Mandatory Locking 属性。
- 测试二:开启 Mandatory Locking 属性。
可以看到,在 Mandatory Locking 开启的情况下, readfile 进程尝试以 NONBLOCK 方式读取数据时产生错误。errno = 11 在这里实际上就是 EAGAIN。
7 单例守护进程
问题提出
之前写的守护进程,如果多次启动,就会产生多个相同的进程
单例守护进程
如果一个程序被启动多次,系统中仍然只存在一份实例,这种进程就是单例进程。放到守护进程上来说,就是单例守护进程
实现单例守护进程
使用记录锁。启动进程的时候,对约定的文件进行加锁,如果加锁失败,说明已经有进程对其加过锁了,直接退出。
实验:singleprocessd 演示了如何实现一个单例守护进程。
// singleprocessd.c
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
// 对文件加锁
int lockfile(int fd) {
struct flock flk;
flk.l_type = F_WRLCK;
flk.l_start = 0;
flk.l_whence = SEEK_SET;
flk.l_len = 0;
// 非阻塞方式加锁
return fcntl(fd, F_SETLK, &flk);
}
int already_running(void) {
int fd;
char buf[16];
// 输出日志文件
FILE* fp = fopen("/tmp/singleprocess.log", "a");
// 锁文件
fd = open("/var/run/singleprocess.pid", O_RDWR | O_CREAT, 0644);
if (fd < 0) {
fprintf(fp, "can't open /var/run/singleprocess.pid: %s\n", strerror(errno));
fclose(fp);
exit(1);
}
if (lockfile(fd) < 0) {
if (errno == EACCES || errno == EAGAIN) {
fprintf(fp, "singleprocess already running: %s\n", strerror(errno));
close(fd);
fclose(fp);
return 1;// 表示已被加锁
}
fprintf(fp, "can't lock /var/run/singleprocess.pid: %s\n", strerror(errno));
fclose(fp);
exit(1);
}
ftruncate(fd, 0);// 截断成 0
sprintf(buf, "%d", getpid());
write(fd, buf, strlen(buf) + 1);
return 0; // 加锁成功
}
int main() {
if (daemon(0, 0) < 0) {
perror("daemon");
return 1;
}
if (already_running()) {
return 1;
}
while (1) {
sleep(10);
// do something
}
return 0;
}
编译运行:
gcc singleprocessd.c -o singleprocessd
运行的时候用 root 权限,因为它要向 /var/run 下面写文件
如图所示,singleprocessd 程序启动了 4 次,但是最终只有一个实例在运行。日志 /tmp/singprocess.log 中记录了后三次运行历史。
锁文件 /var/run/singprocess.pid 中保存的是实例的 pid,和 ps 命令中查看的结果是一致的。