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

24

显然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没有特别涉及进程共享文件描述符(而不是相同的文件)的情况。

如何通过编程确定特定文件的“写入”系统调用是否是原子性的?提到:

根据POSIX定义,write调用没有任何原子性保证。

但正如上面引用的内容所述,它确实具有一些原子性保证。而且更重要的是,O_APPEND似乎触发了这种原子性保证,尽管我认为即使没有O_APPEND,这种保证也应该存在。

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


1
OSX声称符合POSIX08标准吗?我不这么认为。(我相信他们只声称符合'03标准。) - David Schwartz
1
不错,根据http://images.apple.com/macosx/docs/OSX_for_UNIX_Users_TB_July2011.pdf的说法,它是“Open Brand UNIX 03”。我得去查一下这是什么意思。 - kmkaplan
3
很多人会按照'08年之前的规则回答,即write命令只在某些条件下对管道进行原子操作。许多平台仍不支持'08年的语义。而一些声称支持的平台,仍存在一个或多个不支持的文件系统。 - David Schwartz
6
OSX声称符合“POSIX标准”的说法都是谎言。他们所拥有的是认证(基本上只需支付大量费用并通过一些简单的测试,这些测试只能捕捉到最显然的不符合情况),这并不能保证也不可能保证符合规范;唯一能够做到后者的是正式的证明,但针对这样一个庞大的系统来说,这几乎是不可能的。 - R.. GitHub STOP HELPING ICE
3
话虽如此,Open Group和其他颁发符合性认证的标准机构确实应该采用撤销程序。如果已经获得认证的实现被证明不符合规范,并且拒绝在一段时间内(比如6个月或1年)解决这种情况,那么认证将自动被撤销。 - R.. GitHub STOP HELPING ICE
显示剩余3条评论
4个回答

16

编辑:更新于2017年8月,包括操作系统行为的最新更改。

首先,O_APPEND或Windows上的等效FILE_APPEND_DATA表示在并发写入者下,文件“长度”的最大文件范围的增量是原子性的。这由POSIX保证,Linux、FreeBSD、OS X和Windows都正确实现了它。Samba也正确实现了它,在v5之前的NFS没有这个线路格式能力,因此如果您使用append-only打开文件,任何主要操作系统上的并发写入都不会相互撕裂,除非涉及NFS。

但这并不意味着读取是否会看到撕裂的写入。关于常规文件的read()和write()的原子性,POSIX如下所述:

当它们作用于常规文件或符号链接时,所有以下函数在POSIX.1-2008中指定的效果方面彼此是原子性的...... [许多函数] ... read() ... write() ... 如果两个线程分别调用其中一个函数,则每个调用将要么看到另一个调用的所有指定效果,要么不看到它们中的任何一个。 [来源]

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

但相反地:

POSIX.1-2008的这个卷没有规定从多个进程并发写入文件的行为。应用程序应使用一些形式的文件锁定来同步对文件的访问。

并发控制的一种形式。 [来源]

对这三个要求的安全解释应该是:所有与同一文件中的范围重叠的写入必须相互序列化,并与读取相互序列化,以使撕裂的写入永远不会出现在读取器中。

较不安全但仍允许的一种解释是:在同一进程内的线程之间,读取和写入只序列化到彼此之间,在进程之间,写入只与读取序列化(即进程内有时序一致性的i/o排序,在进程之间,i/o仅具有获取-释放)。

那么流行的操作系统和文件系统如何执行此操作?作为提议的Boost.AFIO异步文件系统和C++文件i/o库的作者,我决定编写经验测试程序。以下是单个进程中的许多线程的结果。


无O_DIRECT/FILE_FLAG_NO_BUFFERING:

Microsoft Windows 10 with NTFS: update atomicity = 1 byte until and including 10.0.10240, from 10.0.14393 at least 1Mb, probably infinite as per the POSIX spec.

Linux 4.2.6 with ext4: update atomicity = 1 byte

FreeBSD 10.2 with ZFS: update atomicity = at least 1Mb, probably infinite as per the POSIX spec.

O_DIRECT/FILE_FLAG_NO_BUFFERING:

Microsoft Windows 10 with NTFS: update atomicity = until and including 10.0.10240 up to 4096 bytes only if page aligned, otherwise 512 bytes if FILE_FLAG_WRITE_THROUGH off, else 64 bytes. Note that this atomicity is probably a feature of PCIe DMA rather than designed in. Since 10.0.14393, at least 1Mb, probably infinite as per the POSIX spec。

Linux 4.2.6搭配ext4:更新原子性为至少1MB,根据POSIX规范可能无限大。请注意,早期的ext4 Linux绝对没有超过4096字节,XFS确实曾经使用自定义锁定,但看起来最近的Linux已经在ext4中解决了这个问题。

FreeBSD 10.2搭配ZFS:更新原子性为至少1MB,根据POSIX规范可能无限大。


因此,总结一下,搭配ZFS的FreeBSD和非常近期的搭配NTFS的Windows符合POSIX标准。只有使用O_DIRECT的最新的ext4 Linux才符合POSIX标准。

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


您是不是想说如果 FILE_FLAG_WRITE_THROUGH 打开的话,应该是 512 字节呢?如果不是,为什么打开这个标志会让事情变得更糟呢? - user541686
1
写透的原因使更新的原子性变小可能是因为Microsoft已经为更新实现了快速DMA路径和慢速非DMA路径,当写透打开时,它使用慢速路径,该路径根本不使用DMA并且发出i/o可能使用寄存器轮询。鉴于fsync有多么慢,以及它将在成本方面支配所有其他代码,Microsoft感觉没有必要使写透代码路径更快。最终只有Microsoft确切知道,我的经验测试仅揭示了原子性,而不是原因或原因。 - Niall Douglas
看起来在回答这个问题之后,POSIX规范中的write(2)的措辞已经改变为“此版本的POSIX.1-2017未指定从多个线程对常规文件进行并发写入的行为,除了每个写操作都是原子性的(请参见线程与常规文件操作的交互)。应用程序应该使用某种形式的并发控制。”。但这并不改变实证结果的结论。 - Anon

16

在我的系统上,man 2 write 很好地概括了:

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

以下是最近 ext4 邮件列表中的一句引用:

目前,针对单个页面并发读写操作是原子的,但并非针对系统调用。这可能导致read()返回来自多个不同写入的混合数据,我认为这不是一个好方法。我们可以认为执行此操作的应用程序是有问题的,但事实上,在文件系统层面上我们很容易做到这一点而不会有显著的性能问题,因此我们可以保持一致。此外,POSIX 也提到了这一点,XFS 文件系统已经具有此功能。

这清楚地表明,现代文件系统中的ext4(只是其中之一)在这方面不符合 POSIX.1-2008 标准。


4
尽管这让我感到非常悲伤,但我越深入探究,就越发现你是正确的。 - kmkaplan

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

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

从您引用的 POSIX 规范中可以看出。关于“使用 fork 创建 [句柄]”的引用在本节中没有进一步阐述,但 fork() 的规范增加了一些细节:

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

这里的相关部分是:
  • 子进程有父进程文件描述符的副本
  • 子进程的副本指向与父进程通过上述文件描述符可以访问的相同“东西”
  • 文件描述符和文件描述不是同一回事;特别地,文件描述符是上述意义下的一个句柄
当第一个引用说“fork()创建[...]句柄”时,就是指它们被创建为副本,因此从那时起就是分离的,不再同步更新。
在您的示例程序中,每个子进程都获得了自己的副本,它们从相同的状态开始,但在复制后,这些文件描述符/句柄已成为独立的实例,因此写入彼此之间存在竞争。这在标准方面是完全可以接受的,因为 write() 仅保证:

对于常规文件或其他可寻址文件,数据的实际写入应从与 fildes 关联的文件偏移量所指示的位置开始。在从 write() 成功返回之前,文件偏移量将增加实际写入的字节数。

这意味着虽然它们都从相同的偏移量开始写入(因为fd副本是这样初始化的),但即使成功,它们可能会写入不同数量的数据(标准不能保证写请求的N个字节将完全写入),实际上可以成功写入任何0 <= 实际字节数 <= N),并且由于写入顺序未指定,因此以上整个示例程序的结果是未指定的。即使写入了总请求量,所有标准上都说文件偏移量已经增加 - 它没有说过它是原子性(仅一次)增加的,也没有说数据的实际写入会以原子方式发生。
但有一件事是肯定的 - 您永远不应该在文件中看到任何在任何写入之前不存在的内容,或者来自于任何写入的数据之外的内容。如果出现这种情况,那就是损坏,是文件系统实现中的错误。您观察到的可能就是那种情况...如果最终结果无法通过重新排序写入的部分来解释。
使用O_APPEND可以解决此问题,因为使用它,就像write()一样,会执行以下操作:

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

这是您所寻求的“prior to” / “no intervening”序列化行为。
使用线程会部分改变行为-因为线程在创建时不会接收文件描述符/句柄的副本,而是操作实际(共享)文件描述符/句柄。线程不会(必须)从相同的偏移量开始写入。但是,部分写入成功的选项仍然意味着您可能会看到以您不想看到的方式交错。但它可能仍然完全符合标准。
道德:不要指望POSIX / UNIX标准默认情况下会有限制。规范在常见情况下故意放松,并要求您作为程序员明确您的意图。

6
您误解了您引用的规范的第一部分:
要么是文件描述符,要么是流,都被称为指向其所属的打开文件描述符的“句柄”;一个打开文件描述符可以有多个句柄。[...] 应用程序通过第一句柄影响文件偏移量的所有活动都将被挂起,直到它再次成为活动文件句柄。 [...] 这些规则适用时,这些句柄不必在同一个进程中。
这并没有要求实现处理并发访问。相反,它对应用程序提出了要求,如果您想要输出和副作用的明确定义的排序,则不要进行并发访问,即使来自不同的进程。
仅在管道的写入大小适合PIPE_BUF时才保证原子性。
顺便说一下,即使对于普通文件,调用write也是原子的,除了适用于适合PIPE_BUF的管道的写入之外,write始终可以返回部分写入(即写入少于请求的字节数)。然后,这个比请求的写操作更小的写操作将是原子的,但它对于整个操作的原子性没有任何帮助(您的应用程序必须重新调用write以完成)。

提供的示例已经处理了短写入的情况。我现在会以您的解释为依据重新阅读文档...看看它会给出什么结果。 - kmkaplan
“应用程序对第一个句柄的文件偏移所产生的所有活动都应该被暂停,直到它再次成为活动文件句柄。” 如何将此要求放置在应用程序上?如果它暂停了所有影响文件偏移的活动,那么在其中一个句柄是流的情况下,它永远无法执行所需的[lf]seek()以“再次成为活动文件句柄”。 - kmkaplan
这句话的意思是,你不能在非活动的句柄上调用寻找函数。你可能需要在活动的句柄上调用寻找函数以切换到新的句柄,这是完全合法的。 - R.. GitHub STOP HELPING ICE
@R.. 那整个部分不是关于文件描述符和标准I/O流的交互吗?而原始问题严格来说不是关于文件描述符吗? - David Schwartz
据推测,它们应该也适用于引用同一打开文件描述符的多个文件描述符,尽管这种情况的规则似乎非常丑陋和荒谬。实际上,我认为您是正确的,然而它们不/不能应用于不同线程对同一文件描述符的并发访问。 - R.. GitHub STOP HELPING ICE
显示剩余3条评论

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