flock(): 是否可以仅检查文件是否已被锁定,而不实际获取锁定?

11

我的用例如下:我有一个程序,强制只能同时运行一个实例,因此在启动时它总是尝试在标准位置获取锁定文件,并在文件已被锁定时终止。这一切都运作得很好,但现在我想使用新的命令行选项来增强程序,当指定该选项时,程序将仅打印出程序的状态报告,然后终止(在上述主锁守护进程之前),其中包括锁定文件是否已被锁定、运行进程的pid(如果存在)、以及从数据库查询到的一些程序状态。

因此,可以看出,在此“状态报告”模式下调用我的程序时,如果可用,程序不应实际获取锁定。我只想知道文件是否已被锁定,以便可以作为状态报告的一部分通知用户。

从我的搜索中,似乎没有任何方法可以做到这一点。而唯一可能的解决方案似乎是使用非阻塞标志调用flock(),如果您实际获取了锁,则可以立即释放它。类似于这样:

if (flock(fileno(lockFile), LOCK_EX|LOCK_NB ) == -1) {
    if (errno == EWOULDBLOCK) {
        printf("lock file is locked\n");
    } else {
        // error
    } // end if
} else {
    flock(fileno(lockFile), LOCK_UN );
    printf("lock file is unlocked\n");
} // end if

我想获取锁并立即释放它可能并不是什么大问题,但我想知道是否有更好的解决方案,可以避免短暂且不必要的获取锁?

注意:已经有一些类似的问题,其标题可能使它们看起来与此问题相同,但从这些问题的内容清楚地表明,OP实际上是在获取锁后写入文件,因此这是一个不同的问题:

3个回答

11

您无法可靠地这样做。进程是异步的:当您未能获取锁时,不能保证在打印locked状态时文件仍然被锁定。同样,如果您成功获取锁定,则立即释放它,因此在打印unlocked状态时,文件可能已被另一个进程锁定。如果有很多竞争者试图锁定此文件,状态消息不同步的可能性很高。攻击者可以利用这种近似来渗透系统。

如果您要在脚本中依赖此检查执行任何并发工作,那么一切都不确定。如果只是生成信息性状态,应在状态消息中使用过去式:

if (flock(fileno(lockFile), LOCK_EX|LOCK_NB) == -1) {
    if (errno == EWOULDBLOCK) {
        printf("lock file was locked\n");
    } else {
        // error
    }
} else {
    flock(fileno(lockFile), LOCK_UN);
    printf("lock file was unlocked\n");
}

1
@bgoldst 这个回答说得很到位。简单来说,任何API在不尝试获取锁的情况下轮询锁状态都会返回一个过时的结果,因为它一旦计算出来就已经过期了。这是一个固有的设计问题;它无法修复。事实上,甚至不可能原子地完成你所要求的其余部分:拥有锁文件的进程可以在你看到文件仍然存在且被锁定之后解锁并退出,但在你探测进程之前。或者,在你使用printf报告文件已解锁之前,它可能会在你看到它解锁之后锁定该文件。 - Iwillnotexist Idonotexist

6
不要使用 flock()。如果锁文件目录恰好是网络文件系统(例如NFS),并且您正在使用的操作系统没有使用fcntl()建议性记录锁来实现flock(),则它不会可靠地工作。
(例如,在当前Linux系统中,flock()fcntl()锁是分开的,在本地文件上不互动,但在驻留在NFS文件系统上的文件上互动。在服务器集群中,特别是故障转移和Web服务器系统中,将/var/lock放在NFS文件系统上并不奇怪,因此我认为这是一个您应该考虑的真正问题。)
编辑以添加:如果由于某些外部原因而限制您使用flock(),则可以使用flock(fd,LOCK_EX|LOCK_NB)尝试获取独占锁定。此调用永远不会阻塞(等待锁被释放),但如果文件已被锁定,则会失败并显示-1和errno == EWOULDBLOCK。与下面详细解释的fcntl()锁定方案类似,您尝试获取独占锁定(不阻塞);如果成功,则保持锁定文件描述符打开,并让操作系统在进程退出时自动释放锁定。如果非阻塞锁定失败,则必须选择是否中止或继续执行。
您可以使用POSIX.1函数和fcntl()建议性记录锁(覆盖整个文件)来实现您的目标。语义在所有POSIXy系统上都是标准的,因此这种方法适用于所有POSIXy和类Unix系统。 fcntl()锁的特点很简单,但不直观。当引用锁文件的任何描述符关闭时,该文件上的建议锁将被释放。进程退出时,所有打开文件上的建议锁会自动释放。锁通过exec*()维护。锁不通过fork()继承,也不在父级中释放(即使标记为close-on-exec)。 (如果描述符是close-on-exec,则它们将在子进程中自动关闭。否则,子进程将有一个对文件的打开描述符,但没有任何fcntl()锁定。在子进程中关闭描述符不会影响父进程对文件的锁定。)
因此,正确的策略非常简单:仅打开锁定文件一次,并使用fcntl(fd,F_SETLK,&lock)来放置一个排他的全文件咨询锁定而不会阻塞:如果存在冲突锁定,则会立即失败,而不是阻塞直到可以获取锁定。保持描述符打开,并让操作系统在进程退出时自动释放锁定。
例如:
#define _POSIX_C_SOURCE 200809L
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>

/* Open and exclusive-lock file, creating it (-rw-------)
 * if necessary. If fdptr is not NULL, the descriptor is
 * saved there. The descriptor is never one of the standard
 * descriptors STDIN_FILENO, STDOUT_FILENO, or STDERR_FILENO.
 * If successful, the function returns 0.
 * Otherwise, the function returns nonzero errno:
 *     EINVAL: Invalid lock file path
 *     EMFILE: Too many open files
 *     EALREADY: Already locked
 * or one of the open(2)/creat(2) errors.
*/
static int lockfile(const char *const filepath, int *const fdptr)
{
    struct flock lock;
    int used = 0; /* Bits 0 to 2: stdin, stdout, stderr */
    int fd;

    /* In case the caller is interested in the descriptor,
     * initialize it to -1 (invalid). */
    if (fdptr)
        *fdptr = -1;

    /* Invalid path? */
    if (filepath == NULL || *filepath == '\0')
        return errno = EINVAL;

    /* Open the file. */
    do {
        fd = open(filepath, O_RDWR | O_CREAT, 0600);
    } while (fd == -1 && errno == EINTR);
    if (fd == -1) {
        if (errno == EALREADY)
            errno = EIO;
        return errno;
    }

    /* Move fd away from the standard descriptors. */
    while (1)
        if (fd == STDIN_FILENO) {
            used |= 1;
            fd = dup(fd);
        } else
        if (fd == STDOUT_FILENO) {
            used |= 2;
            fd = dup(fd);
        } else
        if (fd == STDERR_FILENO) {
            used |= 4;
            fd = dup(fd);
        } else
            break;

    /* Close the standard descriptors we temporarily used. */
    if (used & 1)
        close(STDIN_FILENO);
    if (used & 2)
        close(STDOUT_FILENO);
    if (used & 4)
        close(STDERR_FILENO);

    /* Did we run out of descriptors? */
    if (fd == -1)
        return errno = EMFILE;    

    /* Exclusive lock, cover the entire file (regardless of size). */
    lock.l_type = F_WRLCK;
    lock.l_whence = SEEK_SET;
    lock.l_start = 0;
    lock.l_len = 0;
    if (fcntl(fd, F_SETLK, &lock) == -1) {
        /* Lock failed. Close file and report locking failure. */
        close(fd);
        return errno = EALREADY;
    }

    /* Save descriptor, if the caller wants it. */
    if (fdptr)
        *fdptr = fd;

    return 0;
}

以上代码中确保不会意外重用标准描述符的原因是,我曾经在一个非常罕见的情况下受到了它的影响。(我想要在持有锁的同时执行用户指定的进程,但将标准输入和输出重定向到当前控制终端。)

使用方法非常简单:

    int result;

    result = lockfile(YOUR_LOCKFILE_PATH, NULL);
    if (result == 0) {
        /* Have an exclusive lock on YOUR_LOCKFILE_PATH */
    } else
    if (result == EALREADY) {
        /* YOUR_LOCKFILE_PATH is already locked by another process */
    } else {
        /* Cannot lock YOUR_LOCKFILE_PATH, see strerror(result). */
    }

编辑补充:上述函数中我使用了内部链接(static)只是出于习惯。如果锁文件是特定于用户的,则应使用~/.yourapplication/lockfile;如果它是系统范围内的,则应使用例如/var/lock/yourapplication/lockfile。我有一个习惯,即将与此类初始化相关的函数(包括定义/构建锁文件路径等以及自动插件注册函数(使用opendir()/readdir()/dlopen()/dlsym()/closedir())放在同一个文件中;锁文件函数往往是由内部调用的(由构建锁文件路径的函数),因此最终具有内部链接。

请随意使用、重复使用或修改该函数;我认为它属于公共领域,或者在不可能进行公共领域捐赠的情况下根据CC0许可证授权。

描述符被“故意泄漏”,以便进程退出时操作系统将其关闭(并释放其上的锁),但在此之前不会关闭。

如果您的进程有很多后续清理工作,在此期间您确实希望允许另一个副本该进程,您可以保留描述符,并在希望释放锁的点上只需close(thatfd)即可。


厉害的函数!我很欣赏这个知识。有几个问题想问你:(1)为什么在定义函数时使用了内部链接?这个函数不是适合作为可重用库的函数吗?因此应该具有外部链接。(2)您能否回答我的原始问题,关于锁定的 fcntl 方法?也就是说,是否可能使用 fcntl 风格的锁定来检查文件是否已被锁定,而不实际获取锁定? - bgoldst
@bgoldst:(1)出于习惯,我倾向于在同一源文件中的初始化函数中调用此类函数。 - Nominal Animal
@bgoldst:(2)不,你不能仅仅检查文件是否被锁定(尽管如果你使用fcntl()的咨询记录锁,你可以这样做)。然而,你不想这样做。你可以尝试获取锁,而不会阻塞(flock(fd,LOCK_EX|LOCK_UN))。如果可以放置锁,它将成功。如果文件上已经有冲突的锁,则会失败,并且errno==EWOULDBLOCK,你需要决定是否要继续没有锁。当使用fcntl()锁时,锁定逻辑与上述完全相同:在所有情况下都尝试获取锁。 - Nominal Animal
@NominalAnimal,flock()在文件共享上无法工作的惊喜/干扰可能比锁被释放更少令人惊讶,因为进程下的其他线程(或您自己的线程)打开并关闭了文件,导致锁被释放,就像在lockf下一样。以不要使用flock开头可能会产生误导。 - Luke

6

我认为在文件上放置锁并立即释放是可行的做法,我不认为有什么问题。就我个人而言,我会这样做。

但是,在Unix中还有另一个锁定API:fcntl锁。在Linux上查看man fcntl。它具有F_SETLK以获取或释放锁定,以及F_GETLK以测试是否可以放置锁定。 fcntl锁与flock锁略有不同:它们是放置在文件区域上的咨询记录锁,而不是整个文件。

还有第三个api:lockf(3)。您可以使用F_LOCK来锁定文件,并使用F_TEST测试文件区域是否可以锁定。在Linux上,lockf(3) API已作为对fcntl(2)锁定的包装器实现,但在其他操作系统上可能不是这样。


我不认为在文件上放置锁并立即释放的方法有什么问题 - 问题是,这样一来,如果另一个程序试图使用LOCK_EX|LOCK_NB进行锁定,它会在瞬间失败。如果program1以if(!flock(fp,LOCK_EX|LOCK_NB)){die("already running! this program can only run a single instance at a time!");}开始,则在program2想要检查program1是否正在运行时,程序将在短暂的瞬间无法运行。 - hanshenrik

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接