Linux系统编程(八)--高级 IO-记录锁

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);
}

与读写锁的不同:

  1. 同一个进程重复加锁不会阻塞,已最后一次加的锁为准,不同进程会阻塞。

  2. 加锁范围

    • 如果 len > 0, [whence+start,whence+start+len)
    • 如果 len = 0, [whence+start,∞)
  3. 欲加锁的范围中,有任何一个字节被加锁,则等待

解锁

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);
}

请添加图片描述

内核中的数据结构间关系

从图中可以看到:

  1. 对于同一个文件,只有一份 vnode,无论是 dup 还是 open。

  2. 锁的信息是保存在 vnode 中

对任何一个文件描述符加锁和解锁,都会影响到所有最终指向 vnode 节点的描述符。如果在 fd1 上释放了锁,那么对于 fd2 和 fd3 来说,锁也是释放了的。关闭任何一个描述符,锁都会释放。

4 记录锁(继承与释放)

释放

  1. 如果进程关闭,所有的记录锁都被释放。

  2. 任何一个描述符关闭,该描述符引用的文件上的任何记录都被释放。

对于同一个文件,无论你是 open 还是 dup 多少次,vnode 节点始终只有一份,而锁的信息是在 vnode 节点中保存。

继承

  1. fork 产生的子进程不继承父进程所设置的锁。

    原因:记录锁的作用就是阻止多个进程同时写同一个文件。如果 fork 继承了父进程的锁,那么父进程和子进程相当于同时获得了锁,错误。

  2. 执行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 命令中查看的结果是一致的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值