PHP 和 C 边界情况之间的 flock()

flock() between PHP and C edge case

我有一个 PHP 脚本,它接收发票并将其保存为 Linux 中的文件。稍后,基于 C++ 无限循环的程序读取每个并进行一些处理。我希望后者能够安全地读取每个文件(仅在完全写入之后)。

PHP 边码简化:

file_put_contents("sampleDir/invoice.xml", "contents", LOCK_EX)

在 C++ 方面(使用 C 文件系统 API),我必须首先注意,我想保留一个代码,该代码删除指定发票文件夹中的空文件,作为一种正确的方法处理从其他来源(不是 PHP 脚本)创建的空文件的边缘情况。

现在,这里还有一个 C++ 端代码简化:

FILE* pInvoiceFile = fopen("sampleDir/invoice.xml", "r");

if (pInvoiceFile != NULL)
{
    if (flock(pInvoiceFile->_fileno, LOCK_SH) == 0)
    {
        struct stat fileStat;
        fstat(pInvoiceFile->_fileno, &fileStat);
        string invoice;
        invoice.resize(fileStat.st_size);

        if (fread((char*)invoice.data(), 1, fileStat.st_size, pInvoiceFile) < 1)
        {
            remove("sampleDir/invoice.xml"); // Edge case resolution
        }

        flock(pInvoiceFile->_fileno, LOCK_UN);
    }
}

fclose(pInvoiceFile);

如您所见,总结关键概念是 LOCK_EXLOCK_SH 标志的配合。

我的问题是,虽然此集成工作正常,但昨天我注意到为不应为空的发票执行的边缘情况,因此它被 C++ 程序删除。

PHP file_put_contents 手册提到了 LOCK_EX 标志的以下内容:

Acquire an exclusive lock on the file while proceeding to the writing. In other words, a flock() call happens between the fopen() call and the fwrite() call. This is not identical to an fopen() call with mode "x".

您的代码假设 file_put_contents() 操作是原子操作,并且使用 FLOCK_EXFLOCK_SH 足以确保两个程序之间不会发生竞争条件。 This is not the case.

正如您从 PHP 文档中看到的那样,FLOCK_EX 打开文件后应用。这很重要,因为它为 C++ 程序成功打开文件并使用 FLOCK_SH 锁定它留下了很短的 window 时间。那时文件已经被 PHP 完成的 fopen() 截断,并且它是空的。

最有可能发生的情况是:

  1. PHP 代码打开文件进行写入,将其截断并有效地清除其内容。
  2. C++ 代码打开文件进行读取。
  3. C++ 代码请求文件的共享锁:已授予锁。
  4. PHP 代码请求文件的独占锁:调用块,等待锁可用。
  5. C++ 代码读取文件内容:无,文件为空。
  6. C++ 代码删除文件。
  7. C++ 代码释放共享锁。
  8. PHP 代码获取独占锁。
  9. PHP 代码写入文件:数据未到达磁盘,因为与打开的文件描述符关联的 inode 不再存在。
  10. 实际上您没有文件,数据也丢失了。

您的代码的问题在于,您从两个不同的程序对文件执行的操作不是原子操作,并且您获取锁的方式无助于确保这些操作不会重叠。

在 POSIX 兼容系统上保证此类操作的原子性的唯一合理方法,甚至不用担心文件锁定,就是利用 rename(2) 的原子性:

If newpath already exists, it will be atomically replaced, so that there is no point at which another process attempting to access newpath will find it missing.

If newpath exists but the operation fails for some reason, rename() guarantees to leave an instance of newpath in place.

等效的 rename() PHP 函数是您在这种情况下应该使用的函数。这是保证对文件进行原子更新的最简单方法。

我的建议如下:

  • PHP代码:

    $tmpfname = tempnam("/tmp", "myprefix");     // Create a temporary file.
    file_put_contents($tmpfname, "contents");    // Write to the temporary file.
    rename($tmpfname, "sampleDir/invoice.xml");  // Atomically replace the contents of invoice.xml by renaming the file.
    
    // TODO: check for errors in all the above calls, most importantly tempnam().
    
  • C++代码:

    FILE* pInvoiceFile = fopen("sampleDir/invoice.xml", "r");
    
    if (pInvoiceFile != NULL)
    {
        struct stat fileStat;
        fstat(fileno(pInvoiceFile), &fileStat);
    
        string invoice;
        invoice.resize(fileStat.st_size);
    
        size_t n = fread(&invoice[0], 1, fileStat.st_size, pInvoiceFile);
        fclose(pInvoiceFile);
    
        if (n == 0)
            remove("sampleDir/invoice.xml");
    }
    

这样,C++ 程序将始终看到文件的旧版本(如果 fopen() 发生在 PHP 的 rename() 之前)或文件的新版本(如果 fopen() 之后发生),但它永远不会看到文件的不一致版本。