读写锁扩展类型

读写锁不仅可以在单个进程内的线程之间共享,还可以通过设置PTHREAD_PROCESS_SHARED属性,在共享同一内存区域的不同进程间共享。而本章讨论的是另一种类型的读写锁,它允许有亲缘关系或无亲缘关系的进程之间共享文件的读与写。这种类型的锁是基于文件描述符并通过函数fcntl实现的,其维护是在内核层面完成的,并且是以进程ID来标识锁的所有者。因此,这种锁适用于不同进程间的同步,而不是同一进程内的线程间同步。

记录上锁 vs 文件上锁

1. 概念

(1)记录上锁:尽管Unix内核本身没有“记录”的概念,它允许应用程序通过指定文件中的字节范围来锁定或解锁文件的部分内容。这个字节范围可以对应于文件内的一个或多个逻辑记录,但这种关联是由应用程序自行定义的。

(2)文件上锁:这是记录上锁的一个特例,指的是对整个文件进行锁定。在Posix标准中,可以通过指定起始偏移为0、长度也为0的特殊字节范围来表示对整个文件的锁定。

2. 粒度与并发性

(1)粒度:指的是能被锁住的对象的大小。对于Posix记录上锁来说,最小粒度是单个字节,意味着可以非常精细地控制访问权限,允许更高级别的并发操作。

(2)并发性:粒度越小,允许多个用户同时使用的可能性越大。例如,在有五个进程几乎同时访问同一文件的不同部分的情况下:

如果使用的是文件级别锁定(最粗粒度),则所有读取者可以同时访问,但写入者必须等待所有读取者完成。总时间大约需要3秒。

如果使用的是记录级别锁定(最细粒度),因为每个进程访问的是不同的记录,所以理论上所有五个进程都 能同时处理,使得总时间只需1秒。

3. 影响

使用较小粒度的锁定(如记录级别)可以显著提高并发性能,因为它允许更多的进程在同一时间内安全地访问文件的不同部分。相反,使用较大粒度的锁定(如文件级别)虽然简化了管理,但在高并发场景下可能导致不必要的阻塞,降低效率。

POSIX fcntl 记录上锁

函数原型

#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* struct flock *arg */);

返回值:成功时取决于cmd,失败时返回-1。

锁定结构体 flock

fcntl使用flock结构来描述锁的信息:

struct flock {
    short l_type;   // 锁类型: F_RDLCK(读锁), F_WRLCK(写锁), F_UNLCK(解锁)
    short l_whence; // 起始偏移解释: SEEK_SET(文件开头), SEEK_CUR(当前位置), SEEK_END(文件末尾)
    off_t l_start;  // 相对起始字节偏移量
    off_t l_len;    // 锁定字节数;0表示直到文件末尾
    pid_t l_pid;    // 持有该锁的进程ID (仅F_GETLK返回时有效)
};

命令参数 cmd

F_SETLK:立即获取或释放由flock结构描述的锁。如果无法授予锁,则立即返回错误而不阻塞。

F_SETLKW:类似于F_SETLK,但如果无法立即获取锁,则调用线程将阻塞直到锁可以被授予(“w”代表等待wait)。

F_GETLK:检查是否有已存在的锁会妨碍新锁的授予。如果没有这样的锁存在,则将l_type设置为F_UNLCK。否则,返回有关现有锁的信息,包括持有锁的进程ID。

特殊情况与注意事项

原子性问题:发出F_GETLK命令后紧接着发出F_SETLK命令不是一个原子操作,在两次调用之间可能有其他进程获取了所需锁。

锁的清理:当进程关闭所有关联的文件描述符或终止时,所有相关的锁都会被自动删除。子进程不会继承父进程的锁。

文件范围锁定:对于一个给定的文件字节,最多只能有一种类型的锁(读锁或写锁)。多个读锁可以共存于同一字节,但每个字节只能有一个写锁。

文件打开模式:请求读锁的描述符必须是可读的,请求写锁的描述符必须是可写的。

标准I/O库限制:由于标准I/O库执行内部缓冲,因此在需要锁定的文件上应避免使用标准I/O函数(如fprintf),而应该使用read和write系统调用来确保正确的行为。

锁定整个文件的方法

可以通过以下两种方式之一锁定整个文件:

设置l_whence为SEEK_SET,l_start为0,l_len为0。

使用lseek将读写指针定位到文件头,然后设置l_whence为SEEK_CUR,l_start为0,l_len为0。

第一种方法最常用,因为它只需要一次fcntl调用。

劝告性上锁 (Advisory Locking)

定义与特性

定义:POSIX记录上锁被称为劝告性上锁。内核会维护所有由各个进程上锁的文件的信息,但它并不强制执行这些锁。内核不会阻止一个进程写入已被另一个进程读锁定的文件,也不会阻止一个进程读取已被另一个进程写锁定的文件。只要进程有足够权限,它可以无视劝告性锁进行读或写操作。

适用场景

(1)协作进程:劝告性锁对于协作进程(cooperating processes)是足够的。在这种情况下,所有涉及的进程都遵循约定,尊重其他进程设置的锁。例如,在网络编程中,守护程序通常都是协作的,它们访问共享资源如序列号文件,并且都在系统管理员的控制之下。

(2)安全性依赖于权限设置:为了确保劝告性锁的有效性,真正包含关键数据的文件(如序列号文件)不应被所有进程可写。只有这样,即使有进程不理会劝告性锁,也无法在文件被锁住期间对其进行修改。

实际应用

在网络服务中,多个守护程序可能需要安全地更新和读取共享配置文件或日志文件。通过使用劝告性锁,这些程序可以协调对文件的访问,避免冲突和数据损坏。同时,由于这些程序都是在系统管理员的监督下运行,因此可以信任它们会遵守锁定协议。

强制性上锁 (Mandatory Locking)

定义:强制性上锁是一种文件锁定机制,它要求内核检查每个read和write请求,以确保这些操作不会干扰由某个进程持有的锁。

强制执行:对于阻塞式描述符,当有冲突时,读或写操作将使调用进程进入等待状态,直到锁被释放;对于非阻塞式描述符,冲突的读或写操作将立即返回一个EAGAIN错误。

与劝告性上锁的区别

劝告性上锁:依赖于所有进程的合作,即所有进程都必须遵守锁定协议。如果一个进程无视锁,它可以进行读写操作。

强制性上锁:由内核强制执行,即使进程不合作,也无法违反锁定规则。这提供了更强的安全性和数据一致性保障。

启用条件

为了对某个特定文件施行强制性上锁,需要满足以下两个条件:

(1)组成员执行位(x)必须关闭:即权限字符串中的第三个字符不能是x或s。

(2)SGID位必须打开:即权限字符串中的第二个字符应为s或S(表示设置了SGID但没有执行权限)。

例如,权限设置rw-r-s---符合上述条件,而rw-r-x---则不符合,因为组成员执行位未关闭。

工具支持

(1)ls命令:在支持强制性记录上锁的系统上,ls命令可以查找权限位的这种特殊组合,并通过输出1或L来指示相应文件是否启用了强制性上锁。

(2)chmod命令:chmod命令接受l这个指示符来给某个文件启用强制性上锁。

一个守护进程(daemon)启动时确保其唯一性的代码片段,通过记录上锁机制来防止多个副本同时运行。

确保守护进程的唯一性

#include "unpipc.h"
#define PATH_PIDFILE "pidfile"
int main(int argc, char **argv) {
    int pidfd;
    char line[MAXLINE];
    /* 打开或创建PID文件 */
    pidfd = Open(PATH_PIDFILE, O_RDWR | O_CREAT, FILE_MODE);
    /* 尝试对整个文件进行写入锁定 */
    if (write_lock(pidfd, 0, SEEK_SET, 0) < 0) {
        if (errno == EACCES || errno == EAGAIN)
            err_quit("unable to lock %s, is %s already running?", PATH_PIDFILE, argv[0]);
        else
            err_sys("unable to lock %s", PATH_PIDFILE);
    }
    /* 写入当前进程的PID,并保持文件打开以维持写入锁 */
    snprintf(line, sizeof(line), "%ld\n", (long)getpid());
    Ftruncate(pidfd, 0); // 截断文件以避免残留内容
    write(pidfd, line, strlen(line));
    /* 守护进程的其他操作... */
    pause(); // 模拟守护进程持续运行
}

打开或创建PID文件

使用Open函数打开指定路径的PID文件,如果文件不存在则创建它。

O_RDWR | O_CREAT:以读写模式打开文件,若文件不存在则创建文件。

FILE_MODE:设置文件权限。

尝试对整个文件进行写入锁定

调用write_lock函数对整个文件请求写入锁。

如果无法取得锁(例如因为另一个进程已经持有该锁),检查错误码:

如果是EACCES或EAGAIN,说明已经有另一个守护进程在运行,输出错误信息并终止。

其他错误则调用err_sys输出系统错误信息。

写入当前进程的PID

使用snprintf格式化字符串,将当前进程ID写入缓冲区。使用Ftruncate截断文件,确保文件内容仅包含新的PID。将格式化的PID字符串写入文件。

保持文件打开以维持写入锁

文件保持打开状态,这样即使守护进程继续执行其他任务,写入锁也不会被释放,从而确保只有一个守护进程实例运行。模拟守护进程持续运行.使用pause()函数使进程暂停,模拟守护进程的长期运行。

使用文件作为锁

原子创建:当以O_CREAT(如果文件不存在则创建)和O_EXCL(独占打开)标志调用open函数时,如果该文件已经存在,open将返回错误。这一操作是原子的,确保了任何时刻只有一个进程能够成功创建这样的文件。

(1)上锁与解锁:

上锁:通过成功创建一个文件来获取锁。一旦文件被创建,其他尝试创建同一文件的进程将失败并返回EEXIST错误。

解锁:通过删除(unlink)该文件来释放锁。

#include "unpipc.h"
#define LOCKFILE "/tmp/segmo.lock"
void my_lock(void) {
    int tempfd;
    while ((tempfd = open(LOCKFILE, O_RDWR | O_CREAT | O_EXCL, FILE_MODE)) < 0) {
        if (errno != EEXIST)
            err_sys("open error for lock file");
        // 文件已存在,等待一段时间后重试
        sleep(1);
    }
    close(tempfd); // 成功创建文件,持有锁,关闭文件描述符
}
void my_unlock(void) {
    unlink(LOCKFILE); // 通过删除文件来释放锁
}

存在的问题及解决方案

未释放的锁:

如果持有锁的进程在没有释放锁的情况下终止,文件名不会被删除,导致其他进程无法获取锁。

解决方案:检查文件的最近访问时间,若长时间未访问,则假设它已被遗忘。将持有锁的进程ID写入锁文件中,其他进程可以读取并检查该进程是否仍在运行(但要注意进程ID可能被重用)。使用fcntl记录上锁,因为进程终止时锁会自动释放。

(1)无限循环轮询

如果另一个进程已打开锁文件,当前进程会在无限循环中不断尝试open,浪费CPU资源。引入短暂的休眠(如sleep(1)),然后再次尝试open。对于fcntl记录上锁,使用F_SETLKW命令可以让内核阻塞进程直到锁可用。

(2)性能问题

创建和删除文件涉及文件系统的访问,通常比调用fcntl两次(一次获取锁,一次释放锁)慢得多。测量表明,fcntl记录上锁比调用open和unlink快75倍,因此推荐使用fcntl记录上锁。

(3)替代技巧

硬链接(link)技巧:创建一个唯一的临时文件(路径名包含进程ID或进程ID和线程ID组合)。使用link函数创建这个临时文件到锁文件的硬链接。如果link成功,unlink掉临时文件;否则,重新尝试。解锁时,unlink掉锁文件。

要求:临时文件和锁文件必须位于同一文件系统中。O_TRUNC 技巧:使用O_CREAT | O_WRONLY | O_TRUNC标志调用open,并将mode参数设置为0。如果调用成功,表示获取了锁;如果返回EACCES错误,则重新尝试。限制:此方法对具备超级用户特权的进程不起作用。