【问题标题】:Atomicity of `write(2)` to a local filesystem`write(2)` 对本地文件系统的原子性
【发布时间】:2018-11-18 00:31:17
【问题描述】:

显然 POSIX 声明

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

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

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

但是运行下面的代码给了我意想不到的结果。

#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)。

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

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

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

我已经在 Stackoverflow 上阅读过类似的问题,但它们仍然存在 不要完全回答我的问题。

Atomic write on file from two process 没有具体说明 解决进程共享相同文件描述的情况 (而不是同一个文件)。

How does one programmatically determine if “write” system call is atomic on a particular file? 这么说

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

但作为cited above,它确实有一些。更重要的是, O_APPEND 似乎触发了这种原子性保证,尽管看起来 对我来说,即使没有O_APPEND,这个保证也应该存在。

您能否进一步解释这种行为?

【问题讨论】:

  • OSX 是否声称符合 POSIX08?我不这么认为。 (我相信他们只声称 '03 合规。)
  • 好点,根据images.apple.com/macosx/docs/OSX_for_UNIX_Users_TB_July2011.pdf,它是“Open Brand UNIX 03”。我得看看这是什么意思。
  • 很多人会根据 08 年前的规则进行回答,其中写入只是管道上的原子操作,即使在某些条件下也是如此。许多平台仍然不支持 '08 语义。许多声称拥有的平台仍然有一个或多个没有的文件系统。
  • OSX 声称的“POSIX 一致性”都是谎言。他们所拥有的是认证(这基本上是花很多钱并通过一些简单的测试,除了最明显的不合格案例之外什么都没有发现),这并不能保证,并且不可能保证符合规范;后者唯一能做的就是正式证明,对于如此庞大的系统来说,这基本上是不可能的。
  • 话虽如此,发布一致性认证的 Open Group 和其他标准机构确实应该采用撤销程序,即如果已通过认证的实现可以证明不符合规范,并拒绝延长一段时间(比如 6 个月或 1 年)补救这种情况,认证会自动被撤销。

标签: c file-io posix multiprocessing


【解决方案1】:

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

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

这是ext4 邮件列表中最近discussion 的引述:

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

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

【讨论】:

  • 虽然我很伤心,但我越深入研究,你就越觉得你是对的。
【解决方案2】:

编辑: 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() ... 如果两个线程各自调用这些函数之一,则每个调用 要么看到另一个呼叫的所有指定效果,要么 他们都没有。 [Source]

写入可以相对于其他读取和写入进行序列化。如果一个 文件数据的 read() 可以被证明(通过任何方式)发生在 数据的 write(),它必须反映 write(),即使调用 由不同的工艺制成。 [Source]

但反过来:

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

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

一个不太安全但仍然允许的解释可能是读取和写入仅在同一进程内的线程之间相互序列化,而进程之间的写入仅相对于读取进行序列化(即存在顺序一致的 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 字节扇区的部分更新是否会在读取-修改-写入周期内撕裂。

【讨论】:

  • 如果 FILE_FLAG_WRITE_THROUGH 为 on,您的意思是说 512 字节吗?如果不是,那这面旗帜为什么会让事情变得更糟?
  • write through 使得 update 的原子性更小的原因可能是因为微软已经为更新实现了一个快速的 DMA 路径和一个慢的非 DMA 路径,并且当 write through 时它使用慢速路径根本不使用 DMA,并且可能使用寄存器轮询来发出 i/o。考虑到 fsync 的速度非常慢,以及它在成本方面将如何支配所有其他代码,微软认为没有必要让通过代码路径的写入变得更快。最后只有微软肯定知道,我的经验测试器只是揭示原子性,而不是为什么或为什么不的原因。
  • 看起来在写完这个答案后,POSIX spec for write(2) 将措辞更改为“POSIX.1-2017 的这个卷没有指定从多个线程并发写入常规文件的行为,除了每次写入都是原子的(参见Thread Interactions with Regular File Operations)。应用程序应该使用某种形式的并发控制。”。但这并不会改变实证结果的结论。
【解决方案3】:

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

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

来自您上面引用的 POSIX 规范部分。对“使用 ]fork 创建 [ 句柄

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

这里的相关位是:

  • 子级拥有父级文件描述符的副本
  • 孩子的副本是指父母可以通过上述 fds 访问的同一“事物”
  • 文件 descriptors 和文件 descriptions相同的东西;特别是,文件描述符是上述意义上的句柄

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

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

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

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

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

使用O_APPEND 可以解决这个问题,因为再次使用那个 - 参见write(),会:

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

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

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

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

【讨论】:

    【解决方案4】:

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

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

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

    只有在写入大小适合PIPE_BUF 时,才能保证管道的原子性。

    顺便说一句,即使对普通文件的write 调用是原子调用,除非写入适合PIPE_BUF 的管道,write 总是可以返回部分写入(即已写入少于请求的字节数)。这种小于请求的写入将是原子的,但它对整个操作的原子性完全没有帮助(您的应用程序必须重新调用 write 才能完成)。

    【讨论】:

    • 在提供的示例中处理短写的情况。我现在正在重读这份文件,并考虑到你的解释……我会看看它给出了什么。
    • “应用程序影响第一个句柄上文件偏移量的所有活动都应暂停,直到它再次成为活动文件句柄”。如何将要求放在应用程序上?如果它暂停所有影响文件偏移量的活动,那么在句柄之一是流的情况下,它永远无法执行“再次成为活动文件句柄”所需的 [lf]seek()。
    • 所有的意思是你不能在非活动句柄上调用搜索函数。它是 活动句柄,您可能必须调用 seek 函数才能切换到新句柄,这是完全合法的。
    • “你不能在非活动句柄上调用查找函数”与“为了使句柄成为活动句柄 [...] 应用程序应执行 lseek() 或 fseek( ) [关于它]”
    • 我想我应该说“当另一个句柄处于活动状态时”。
    猜你喜欢
    • 1970-01-01
    • 2013-01-03
    • 1970-01-01
    • 2017-04-11
    • 1970-01-01
    • 2013-02-10
    • 1970-01-01
    • 2012-05-30
    相关资源
    最近更新 更多