22

显然 POSIX 声明

文件描述符或流在它所指的打开文件描述中被称为“句柄”;一个打开的文件描述可能有多个句柄。[…] 应用程序影响第一个句柄上文件偏移量的所有活动都应暂停,直到它再次成为活动文件句柄。[...] 句柄不需要在相同的进程中应用这些规则。-- POSIX.1-2008

如果两个线程各自调用 [write() 函数],则每个调用要么看到另一个调用的所有指定效果,要么一个都看不到。-- POSIX.1-2008

我对此的理解是,当第一个进程发出 a write(handle, data1, size1)和第二个进程发出 write(handle, data2, size2)时,写入可以以任何顺序发生,但data1anddata2 必须是原始且连续的。

但是运行以下代码会给我带来意想不到的结果。

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
die(char *s)
{
  perror(s);
  abort();
}

main()
{
  unsigned char buffer[3];
  char *filename = "/tmp/atomic-write.log";
  int fd, i, j;
  pid_t pid;
  unlink(filename);
  /* XXX Adding O_APPEND to the flags cures it. Why? */
  fd = open(filename, O_CREAT|O_WRONLY/*|O_APPEND*/, 0644);
  if (fd < 0)
    die("open failed");
  for (i = 0; i < 10; i++) {
    pid = fork();
    if (pid < 0)
      die("fork failed");
    else if (! pid) {
      j = 3 + i % (sizeof(buffer) - 2);
      memset(buffer, i % 26 + 'A', sizeof(buffer));
      buffer[0] = '-';
      buffer[j - 1] = '\n';
      for (i = 0; i < 1000; i++)
        if (write(fd, buffer, j) != j)
          die("write failed");
      exit(0);
    }
  }
  while (wait(NULL) != -1)
    /* NOOP */;
  exit(0);
}

我尝试在 Linux 和 Mac OS X 10.7.4 上运行它,并使用grep -a '^[^-]\|^..*-' /tmp/atomic-write.log显示某些写入不连续或重叠(Linux)或完全损坏(Mac OS X)。

O_APPEND在调用中添加标志open(2)可解决此问题。很好,但我不明白为什么。POSIX 说

O_APPEND 如果设置,文件偏移量应设置为每次写入之前的文件末尾。

但这不是这里的问题。我的示例程序从不 lseek(2)共享相同的文件描述,因此共享相同的文件偏移量。

我已经在 Stackoverflow 上阅读过类似的问题,但他们仍然没有完全回答我的问题。

从两个进程对文件进行原子写入并没有专门解决进程共享相同文件描述 (而不是相同文件)的情况。

如何以编程方式确定“写入”系统调用是否在特定文件上是原子的?

POSIX 中定义的write调用根本没有原子性保证。

但正如上面提到的,它确实有一些。更重要的是, O_APPEND似乎触发了这种原子性保证,尽管在我看来,即使没有O_APPEND.

你能进一步解释这种行为吗?

4

4 回答 4

15

man 2 write在我的系统上很好地总结了它:

请注意,并非所有文件系统都符合 POSIX。

这是邮件列表上最近讨论的引述:ext4

当前并发读/写仅在单个页面上是原子的,但不在系统调用中。这可能会导致read()返回来自多个不同写入的混合数据,我认为这不是一个好方法。我们可能会争辩说这样做的应用程序被破坏了,但实际上这是我们可以在文件系统级别轻松完成的事情,而不会出现重大性能问题,因此我们可以保持一致。POSIX 也提到了这一点,并且 XFS 文件系统已经具有此功能。

这清楚地表明ext4——仅举一个现代文件系统——在这方面不符合 POSIX.1-2008。

于 2012-05-18T10:40:30.157 回答
14

编辑: 2017 年 8 月更新了操作系统行为的最新变化。

首先,Windows 上的 O_APPEND 或等效的 FILE_APPEND_DATA 意味着最大文件范围(文件“长度”)的增量在并发写入者下是原子的。POSIX 保证了这一点,Linux、FreeBSD、OS X 和 Windows 都正确实现了它。Samba 也正确实现了它,v5 之前的 NFS 没有,因为它缺乏自动附加的有线格式功能。因此,如果您以仅附加方式打开文件,则并发写入不会在任何主要操作系统上相互撕裂,除非涉及 NFS。

这并没有说明读取是否会看到撕裂的写入,并且在该 POSIX 上说以下关于 read() 和 write() 对常规文件的原子性:

以下所有函数在对常规文件或符号链接进行操作时,在 POSIX.1-2008 中指定的效果中相互之间应是原子的... [许多函数] ... read() ... write( ) ... 如果两个线程各自调用这些函数之一,则每个调用要么看到另一个调用的所有指定效果,要么一个都看不到。[资源]

写入可以相对于其他读取和写入进行序列化。如果可以(通过任何方式)证明文件数据的 read() 发生在数据的 write() 之后,则它必须反映 write(),即使调用是由不同的进程进行的。[资源]

但反过来:

本卷 POSIX.1-2008 未指定从多个进程并发写入文件的行为。应用程序应该使用某种形式的并发控制。[资源]

对所有这三个要求的安全解释表明,在同一文件中与范围重叠的所有写入必须相对于彼此进行序列化,并且对于读取,使得撕裂的写入永远不会出现在读者面前。

一个不太安全但仍然允许的解释可能是读取和写入仅在同一进程内的线程之间相互序列化,并且进程之间的写入仅相对于读取进行序列化(即在线程之间存在顺序一致的 i/o 顺序)一个进程,但进程之间的 i/o 只是获取-释放)。

那么流行的操作系统和文件系统是如何在这方面发挥作用的呢?作为提出Boost.AFIO异步文件系统和文件 i/o C++ 库的作者,我决定编写一个经验测试器。单个进程中的多个线程的结果如下。


没有 O_DIRECT/FILE_FLAG_NO_BUFFERING:

带有 NTFS 的 Microsoft Windows 10:更新原子性 = 1 字节,直到并包括 10.0.10240,从 10.0.14393 至少 1Mb,根据 POSIX 规范可能是无限的。

带有 ext4 的 Linux 4.2.6:更新原子性 = 1 字节

带有 ZFS 的 FreeBSD 10.2:更新原子性 = 至少 1Mb,根据 POSIX 规范可能是无限的。

O_DIRECT/FILE_FLAG_NO_BUFFERING:

带有 NTFS 的 Microsoft Windows 10:更新原子性 = 直到并包括 10.0.10240,仅当页面对齐时最多 4096 字节,否则如果 FILE_FLAG_WRITE_THROUGH 关闭,则为 512 字节,否则为 64 字节。请注意,这种原子性可能是 PCIe DMA 的一个特性,而不是设计的。从 10.0.14393 开始,至少 1Mb,根据 POSIX 规范可能是无限的。

带有 ext4 的 Linux 4.2.6:更新原子性 = 至少 1Mb,根据 POSIX 规范可能是无限的。请注意,带有 ext4 的早期 Linux 肯定不会超过 4096 字节,XFS 肯定曾经有自定义锁定,但看起来最近的 Linux 终于在 ext4 中解决了这个问题。

带有 ZFS 的 FreeBSD 10.2:更新原子性 = 至少 1Mb,根据 POSIX 规范可能是无限的。


所以总而言之,带有 ZFS 的 FreeBSD 和带有 NTFS 的最近的 Windows 是符合 POSIX 的。带有 ext4 的最新 Linux 是仅符合 O_DIRECT 的 POSIX。

您可以在https://github.com/ned14/afio/tree/master/programs/fs-probe看到原始的经验测试结果。请注意,我们仅在 512 字节倍数上测试撕裂的偏移量,因此我不能说 512 字节扇区的部分更新是否会在读取-修改-写入周期内撕裂。

于 2016-02-07T20:20:51.587 回答
8

对这里标准要求的一些误解来自于进程与线程的使用,以及这对您正在谈论的“处理”情况意味着什么。特别是,您错过了这部分:

句柄可以通过显式用户操作来创建或销毁,而不会影响底层打开文件的描述。创建它们的一些方法包括 fcntl()、dup()、fdopen()、fileno() 和fork(). 它们至少可以被 fclose()、close() 和 exec 函数销毁。[ ... ] 请注意,在 fork() 之后,存在两个句柄,而之前存在一个句柄。

来自您在上面引用的 POSIX 规范部分。本节没有详细说明对“create [handles using ] fork”的引用,但规范fork()添加了一些细节:

子进程应拥有自己的父文件描述符副本。每个子文件描述符都应与父文件描述符对应的打开文件描述相同。

这里的相关位是:

  • 孩子有父母的文件描述符的副本
  • 孩子的副本是指父母可以通过所述 fds 访问的同一“事物”
  • 文件描述ors文件描述不是一回事;特别是,文件描述符是上述意义上的句柄。

这就是第一个引用当它说“fork()创建 [...] 句柄”时所指的内容 - 它们被创建为副本,因此,从那时起,分离,并且不再同步更新。

在您的示例程序中,每个子进程都有自己的副本,该副本以相同的状态开始,但在复制行为之后,这些文件描述符/句柄已成为独立的实例,因此写入相互竞争。这对于标准是完全可以接受的,因为write()只有保证:

在常规文件或其他能够查找的文件上,数据的实际写入应从与 fildes 关联的文件偏移量指示的文件中的位置开始。在 write() 成功返回之前,文件偏移量应增加实际写入的字节数。

这意味着虽然它们都以相同的偏移量开始写入(因为 fd副本是这样初始化的),但即使成功,它们也可能都写入不同的数量(标准不能保证N字节的写入请求会准确 N写入字节;对于任何0 <=实际的事情都可以成功<= N),并且由于未指定写入的顺序,因此上面的整个示例程序具有未指定的结果。即使写入了请求的总数量,上述所有标准都表示文件偏移量是递增的——它没有说它是原子的(仅一次)递增,也没有说实际的数据写入将以原子的方式发生。

但有一件事是有保证的——您永远不会在文件中看到任何在任何写入之前都不存在的内容,或者不是来自任何写入写入的任何数据的任何内容。如果这样做,那将是损坏,并且是文件系统实现中的错误。您在上面观察到的情况很可能是……如果最终结果无法通过对部分写入的重新排序来解释。

使用O_APPEND解决了这个问题,因为再次使用它 - 参见write(),确实:

如果设置了文件状态标志的 O_APPEND 标志,则文件偏移量应在每次写入之前设置为文件末尾,并且在更改文件偏移量和写入操作之间不应发生中间文件修改操作。

这是您寻求的“之前”/“无干预”序列化行为。

线程的使用会部分改变行为 - 因为线程在创建时不会接收文件描述符/句柄的副本,而是在实际(共享)的副本上操作。线程不会(必然)都以相同的偏移量开始写入。但是部分写入成功的选项仍然意味着您可能会以您不想看到的方式看到交错。然而,它可能仍然完全符合标准。

道德:不要指望 POSIX/UNIX 标准默认具有限制性。在常见情况下,规范是故意放宽的,并且要求您作为程序员明确说明您的意图。

于 2012-05-21T11:01:42.100 回答
6

您误解了您引用的规范的第一部分:

文件描述符或流在它所指的打开文件描述中被称为“句柄”;一个打开的文件描述可能有多个句柄。[…] 应用程序影响第一个句柄上的文件偏移量的所有活动都应暂停,直到它再次成为活动文件句柄。[...] 句柄不需要在相同的进程中应用这些规则。

这对处理并发访问的实现没有任何要求。相反,它要求应用程序不要进行并发访问,即使来自不同的进程,如果您想要明确定义的输出顺序和副作用。

唯一保证原子性的时间是在写入大小适合的管道PIPE_BUF

顺便说一句,即使write对普通文件的调用是原子的,除了写入适合 的管道的情况外PIPE_BUFwrite总是可以返回部分写入(即写入的字节数少于请求的字节数)。这种小于请求的写入将是原子的,但就整个操作的原子性而言,它根本无助于这种情况(您的应用程序必须重新调用write才能完成)。

于 2012-05-18T13:02:51.680 回答