`write(2)`对本地文件系统的原子性

kmk*_*lan 17 c file-io posix multiprocessing

显然POSIX说明了这一点

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

如果两个线程分别调用[write()函数],则每个调用应该看到另一个调用的所有指定效果,或者没有看到它们.- POSIX.1-2008

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

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

#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);
}
Run Code Online (Sandbox Code Playgroud)

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

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

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

但这不是问题所在.我的示例程序永远不会 lseek(2)共享相同的文件描述,因此共享相同的文件偏移量.

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

来自两个进程的文件上的原子写入并不专门解决进程共享相同文件描述 (而不是相同文件)的情况.

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

writePOSIX中定义的调用根本没有原子性保证.

如上所述它确实有一些.更重要的是, O_APPEND似乎触发了这种原子性保证,尽管在我看来这种保证应该存在,即使没有O_APPEND.

你能解释一下这种行为吗?

NPE*_*NPE 12

man 2 write 在我的系统上总结得很好:

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

以下是对邮件列表最近讨论的引用ext4:

目前,并发读/写只是单个页面的原子,但不在系统调用上.这可能导致read()返回来自几个不同写入的数据混合,我认为这不是好方法.我们可能会争辩说,执行此操作的应用程序已被破坏,但实际上这是我们可以轻松地在文件系统级别上执行而不会出现明显的性能问题,因此我们可以保持一致 此外,POSIX也提到了这一点,XFS文件系统已经具备此功能.

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

  • 虽然让我感到很难过,但是我越是深入研究这个问题,你看起来就越正确. (3认同)

Nia*_*las 9

编辑: 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( )...如果两个线程分别调用其中一个函数,则每个调用应该看到另一个调用的所有指定效果,或者没有一个.[资源]

写入可以相对于其他读取和写入进行序列化.如果在数据的write()之后可以证明(通过任何方式)文件数据的read(),它必须反映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规范可能是无限的.

Linux 4.2.6 with ext4:update atomicity = 1 byte

带有ZFS的FreeBSD 10.2:update atomicity =至少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规范可能是无限的.

Linux 4.2.6 with ext4:update atomicity =至少1Mb,根据POSIX规范,可能是无限的.请注意,早期使用ext4的Linux肯定没有超过4096字节,XFS肯定用于自定义锁定,但看起来最近的Linux终于在ext4中解决了这个问题.

带有ZFS的FreeBSD 10.2:update atomicity =至少1Mb,根据POSIX规范可能是无限的.


总而言之,带有ZFS的FreeBSD和最近使用NTFS的Windows符合POSIX标准.最近使用ext4的Linux是POSIX,只符合O_DIRECT.

您可以在https://github.com/ned14/afio/tree/master/programs/fs-probe上查看原始实证测试结果.注意我们只测试512字节倍数的撕裂偏移,所以我不能说在读 - 修改 - 写周期中是否会撕裂512字节扇区的部分更新.


Fra*_*kH. 8

对标准强制要求的误解来自于流程与线程的使用,以及这对于您所谈论的"处理"情况意味着什么.特别是,你错过了这部分:

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

从上面引用的POSIX规范部分."创建[句柄使用] fork" 的引用在本节中没有详细说明,但规范fork()增加了一些细节:

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

这里的相关部分是:

  • 子项具有父项文件描述符的副本
  • 孩子的副本指的是父母可以通过所述fds访问的相同"事物"
  • 文件DESCRIPT ORS和文件DESCRIPT 离子一样的东西; 特别是,文件描述符是上述意义上的句柄.

这是第一个引用所指的" fork()创建[...]句柄" - 它们被创建为副本,因此,从那时起,分离,不再以锁步方式更新.

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

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

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

但有一件事是有保证的 - 你永远不应该在文件中看到任何写入之前没有任何内容,或者任何写入中没有写入任何数据的内容.如果你这样做,那就是腐败,以及文件系统实现中的一个错误.您在上面观察到的可能是......如果最终结果无法通过重新排序部分写入来解释.

使用O_APPEND修复这个,因为使用它,再次 - 看write(),做:

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

这是您寻求的"先行"/"无介入"序列化行为.

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

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


R..*_*R.. 6

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

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

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

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

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