Подтвердить что ты не робот

Атомность `write (2)` в локальную файловую систему

По-видимому, 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);
}

Я попытался запустить это на 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, но они все еще не полностью отвечайте на мой вопрос.

Атомная запись в файл из двух процессов обратитесь к случаю, когда процессы используют одно и то же описание файла (в отличие от того же файла).

Как программно определить, является ли системный вызов "write" атомарным в конкретном файле? говорит, что

Вызов write, определенный в POSIX, вообще не имеет гарантии на атомарность.

Но как приведенное выше, оно имеет некоторые. И что еще, O_APPEND, похоже, запускает эту гарантию атомарности, хотя кажется для меня эта гарантия должна присутствовать даже без O_APPEND.

Можете ли вы подробнее объяснить это поведение?

4b9b3361

Ответ 1

man 2 write в моей системе суммирует его:

Обратите внимание, что не все файловые системы соответствуют POSIX.

Вот цитата из недавнего обсуждения в списке рассылки ext4:

В настоящее время одновременные чтения/записи являются атомарными только по отдельным страницам,   однако не находятся в системном вызове. Это может привести к возврату данных read()  смешанный от нескольких разных записей, которые я не думаю, что это хорошо   подход. Мы можем утверждать, что приложение, выполняющее это, нарушено, но   на самом деле это то, что мы можем легко сделать на уровне файловой системы без   значительные проблемы с производительностью, поэтому мы можем быть последовательными. Также POSIX   это также упоминается, и файловая система XFS уже имеет эту функцию.

Это явное указание на то, что ext4 - для обозначения только одной современной файловой системы - не соответствует POSIX.1-2008 в этом отношении.

Ответ 2

Некоторое неверное истолкование того, что стандартные требования здесь вытекают из использования процессов против потоков, и что это означает для ситуации с "ручкой", о которой вы говорите. В частности, вы пропустили эту часть:

Ручки могут быть созданы или уничтожены явным действием пользователя, не затрагивая описание открытого файла. Некоторые из способов создать включают в себя fcntl(), dup(), fdopen(), fileno() и fork(). Они могут быть уничтожены как минимум fclose(), close() и функциями exec. [...] Обратите внимание, что после fork() существуют две ручки, где они существовали раньше.

из раздела спецификации POSIX, приведенного выше. Ссылка на "create [handles using] fork" далее не рассматривается в этом разделе, но спецификация для fork() добавляет немного деталей:

У дочернего процесса должна быть его собственная копия дескрипторов родительского файла. Каждый из дескрипторов дочерних файлов должен ссылаться на то же описание открытого файла с соответствующим файловым дескриптором родителя.

Соответствующие биты здесь:

  • у ребенка есть копии дескрипторов родительского файла
  • дочерние копии ссылаются на ту же "вещь", которую родитель может получить через указанные fds
  • описатель файла ors и файловый дескриптор ионы не одно и то же; в частности, дескриптор файла является дескриптором в указанном выше смысле.

Это первая цитата относится к тому, когда она говорит: "fork() создает [...] дескрипторы" - они создаются как копии и, следовательно, с этого момента, отделяются и больше не обновляются в lockstep.

В вашей примерной программе каждый дочерний процесс получает свою собственную копию, которая начинается в одном и том же состоянии, но после акта копирования эти filedescriptors/handle становятся независимыми экземплярами, и поэтому записи расходятся друг с другом. Это вполне приемлемо в отношении стандарта, потому что write() только сторонники:

В обычном файле или другом файле, способном к поиску, фактическая запись данных должна исходить из позиции в файле, указанной смещением файла, связанным с файлами. Перед успешным возвратом из write() смещение файла должно быть увеличено на количество фактически записанных байтов.

Это означает, что, хотя все они начинают писать с одинаковым смещением (потому что копия fd была инициализирована как таковая), они могут, даже если успешно, писать разные суммы (нет гарантии по стандарту, что запрос на запись N bytes будет записывать точно N bytes, он может преуспеть для чего-либо 0 <= actual <= N), и из-за того, что упорядочение неуказанных записей не выполняется, вся приведенная выше примерная программа имеет неуказанные результаты. Даже если записана общая запрашиваемая сумма, все вышеприведенное значение говорит о том, что смещение файла увеличивается - оно не говорит, что оно атомарно (только один раз) увеличивается, и не говорит, что фактическая запись данных произойдет атомарно.

Одна вещь гарантирована, хотя - вы никогда не увидите ничего в файле, который не был там ни до какой-либо из записей, или который не исходил ни из одной из данных, написанных ни одной из записей. Если вы это сделаете, это будет коррупция и ошибка в реализации файловой системы. То, что вы наблюдали выше, может быть так, что... если окончательные результаты не могут быть объяснены переупорядочением частей записей.

Использование O_APPEND исправляет это, потому что, используя это, снова - см. write(), делает:

Если флаг O_APPEND флагов состояния файла установлен, смещение файла должно быть установлено до конца файла перед каждой записью, и между изменением смещения файла и операции записи не должно происходить промежуточная операция изменения файла.

который является "до" / "без промежуточного" поведения сериализации, который вы ищете.

Использование потоков изменило бы поведение частично - потому что потоки при создании не получают копии filedescriptors/handle, а работают с фактическим (общим). Нити не будут (обязательно) начинать писать с одного и того же смещения. Но вариант для частичной записи-успеха по-прежнему будет означать, что вы можете видеть чередование, как вы могли бы не видеть. Тем не менее, возможно, он будет полностью соответствовать стандартам.

Мораль. Не считайте, что стандарт POSIX/UNIX является ограничивающим по умолчанию. Спецификации преднамеренно смягчены в общем случае и требуют от вас, чтобы программист был в явном виде о ваших намерениях.

Ответ 3

Изменить: Обновлено Aug 2017 с последними изменениями в поведении ОС.

Во-первых, O_APPEND или эквивалентный FILE_APPEND_DATA в Windows означает, что приращения максимального размера файла (длина файла) являются atomic под параллельными авторами. Это гарантируется POSIX, и Linux, FreeBSD, OS X и Windows реализуют его правильно. Samba также реализует его правильно, NFS до v5 не делает, поскольку ему не хватает возможности форматирования каналов для атомарного добавления. Поэтому, если вы откроете свой файл только с помощью append-only, одновременная запись не будет разорваться по отношению друг к другу на какой-либо основной ОС, если не задействована NFS.

Это ничего не говорит о том, будут ли читатели когда-либо видеть разрывную запись, и на этом POSIX говорит следующее об атомарности read() и write() для обычных файлов:

Все следующие функции должны быть атомарными по каждому другие в эффектах, указанных в POSIX.1-2008, когда они работают регулярные файлы или символические ссылки... [много функций]... read()... write()... Если каждый из двух потоков вызывает одну из этих функций, каждый вызов должен либо увидеть все указанные эффекты другого вызова, либо ни один из них. [Источник]

и

Писания могут быть сериализованы по отношению к другим чтениям и записи. Если read() данных файла может быть доказано (каким-либо образом), чтобы произойти после write() данных, он должен отражать, что write(), даже если вызовы производятся различными процессами. [Источник]

но наоборот:

В этом томе POSIX.1-2008 не указано поведение одновременных записывает в файл из нескольких процессов. Приложения должны использовать некоторые форма управления concurrency. [Источник]

Безопасная интерпретация всех трех этих требований предполагает, что все записи, перекрывающиеся в одном и том же файле, должны быть сериализованы друг относительно друга и читать так, чтобы разрывающие записи никогда не появлялись для читателей.

Менее безопасным, но все же допускаемым интерпретацией может быть то, что чтение и запись только сериализации друг с другом между потоками внутри одного и того же процесса, а между процессами записи сериализуются только для чтения (т.е. последовательный последовательный порядок ввода/вывода между потоками в процессе, но между процессами i/o - только получение-релиз).

Итак, как работают популярные ОС и файловые системы? Как автор предлагаемого Boost.AFIO асинхронной файловой системы и библиотеки ввода/вывода С++, я решил написать эмпирический тестер. Результаты приведены для многих потоков в одном процессе.


Нет O_DIRECT/FILE_FLAG_NO_BUFFERING:

Microsoft Windows 10 с NTFS: update atomicity = 1 байт до 10.0.10240 включительно, от 10.0.14393 не менее 1 Мб, возможно, бесконечно в соответствии с спецификацией POSIX.

Linux 4.2.6 с ext4: update atomicity = 1 байт

FreeBSD 10.2 с ZFS: update atomicity = не менее 1 Мб, возможно, бесконечно по спецификации POSIX.

O_DIRECT/FILE_FLAG_NO_BUFFERING:

Microsoft Windows 10 с NTFS: обновить atomicity = до 10.0.10240 включительно до 4096 байт, только если выровнено по странице, иначе 512 байт, если FILE_FLAG_WRITE_THROUGH выключен, а еще 64 байта. Обратите внимание, что эта атомарность, вероятно, является отличительной чертой PCIe DMA, а не ее конструкцией. Поскольку 10.0.14393, по крайней мере, 1 Мб, вероятно, бесконечно по спецификации POSIX.

Linux 4.2.6 с ext4: update atomicity = не менее 1 Мб, вероятно, бесконечно в соответствии с спецификацией POSIX. Обратите внимание, что ранее Linuxes с ext4 определенно не превышал 4096 байт, XFS, безусловно, использовала пользовательскую блокировку, но похоже, что последняя Linux наконец-то исправила эту проблему в ext4.

FreeBSD 10.2 с ZFS: update atomicity = не менее 1 Мб, возможно, бесконечно по спецификации POSIX.


Таким образом, FreeBSD с ZFS и очень свежими Windows с NTFS соответствует POSIX. Очень недавний Linux с ext4 - это POSIX, соответствующий только O_DIRECT.

Вы можете увидеть исходные результаты эмпирического теста https://github.com/ned14/afio/tree/master/programs/fs-probe. Обратите внимание, что мы тестируем разрывы смещений только на 512 байт, поэтому я не могу сказать, будет ли частичное обновление 512-байтового сектора разрываться во время цикла чтения-изменения-записи.

Ответ 4

Вы неправильно истолковываете первую часть спецификации, которую вы указали:

Либо дескриптор файла, либо поток называются "дескриптором" в описании открытого файла, к которому он относится; описание открытого файла может иметь несколько дескрипторов. [...] Вся активность приложения, влияющая на смещение файла на первом дескрипторе, приостанавливается до тех пор, пока он снова не станет активным дескриптором файла. [...] Ручки не обязательно должны быть в одном процессе для применения этих правил.

Это не устанавливает никаких требований к реализации для обработки параллельного доступа. Вместо этого он ставит требования к приложению не делать одновременный доступ даже из разных процессов, если вы хотите четко определенный порядок вывода и побочных эффектов.

Единственная временная атомарность гарантируется для труб, когда размер записи соответствует PIPE_BUF.

Кстати, даже если вызов write был атомарным для обычных файлов, за исключением случаев записи в каналы, которые вписываются в PIPE_BUF, write всегда может возвращаться с частичной записью (т.е. записав меньше запрошенного количества байтов). Тогда эта меньшая, чем запрошенная запись, будет атомарной, но это не поможет ситуации в отношении атомарности всей операции (ваше приложение должно будет повторно вызвать write для завершения).