Когда речь идёт об организации надёжного хранения данных на диске, возникает
проблема: надо как-то узнавать о том, что данные на диск не смогли записаться,
и принимать по этому поводу какие-то меры.
Есть, конечно, и другая проблема: даже если данные на самом деле записались,
они всё равно могут позже потеряться из-за аппаратного сбоя. Но это другая тема
и тут мы не будем её рассматривать.
Для начала простые факты по теме, начиная с самых очевидных.
-
Надо проверять значения, возвращаемые функциями записи, ведь там может вернуться ошибка или неполная запись.
-
Даже если fwrite() сообщил об успешном выполнении и записал столько же байт, сколько мы ему запросили — это ещё ничего не гарантирует, ведь stdio кеширует записи и откладывает их по возможности на потом — то есть если после успешного fwrite() программа тупо упадёт (сегфолт, kill -9 или просто вызов _exit()), то запись молча потеряется. А ещё fwrite() по той же причине не сообщит нам даже от такой очевидной проблеме как disk full. Для того, чтобы принудительно отослать этот отложенный кеш ядро (и узнать ядерную диагностику ошибок, если она будет), есть функция fflush(), но, в целом, сражаться с надёжностью stdio-функций выходит себе дороже, поэтому в ответственных местах лучше ими не пользоваться, а пользоваться нативным вводом-выводом ОС. Про него следующий пункт.
-
Даже если системный вызов write() сообщил об успехе — это всё ещё мало что гарантирует. Единственное, что гарантируется — это то, что ядро наши данные приняло к сведению и по крайней мере краш процесса уже не станет причиной их потери. Но данные, скорее всего, всё так же где-то закешированы для отложенной записи, на этот раз в ядре. Так что если упадёт вся система (kernel panic или аварийное выключение железа без участия ОС) — данные скорее всего всё так же потеряются. Хотя, по сравнению с writeback-кешированием stdio тут есть и ещё одно улучшение: ядро, даже без нашей явной команды, данные рано или поздно всё-таки запишет. Так что опасения про падение системы тут относятся только к некоторому недолгому времени после write(). Ещё одной причиной незаписи данных может быть неисправность накопителя или связи с ним. Некоторые программисты ожидают, что в случае такой неисправности write() вернёт им EIO или что-то подобное, но этого не случится из-за того, что физическая запись откладывается. Хотя, иногда может и случиться: если операционная система на момент write() уже обнаружила проблему связи с диском, то может вернуть и EIO, но рассчитывать на такую удачу конечно же не стоит. Своевременная (прямо в write()) диагностика disk full тут уже более вероятна, но всё ещё не гарантирована.
Решение указанной проблемы с write() уже не такое тривиальное, как вышеописанные баяны. И, поскольку главная ожидаемая опасность это всё-таки краш приложения, а проблемы с ОС и железом считаются чем-то выходящим за рамки ответственности прикладного программиста, на это часто забивают. Во многих случаях оправданно, потому как после такого сбоя программу, скорее всего, просто запустят заново с нуля. Да что уж говорить, даже на проблемы, указанные в п.2, часто тоже забивают по той же причине. Однако если мы всё-таки хотим минимизировать риск оставить после себя некий файл (или файлы) в неконсистентном состоянии, надо что-то предпринимать.
Сразу скажу, что 100% идеального решения нет, writeback-кеши могут обнаружиться много где в совершенно неподконтрольных приложению местах: в самом накопителе, в рейд-контроллере, в инфраструктуре сетевого хранилища, в гипервизоре виртуалки. Возможность их «потерять» зависит от качества изделия, содержащего кеш. У накопителя, в целом, есть возможность всё записать при признаках пропадания питания, у хороших контроллеров вообще есть своё автономное питание, позволяющее сохранить незаписанные данные на длительный срок. Но закончим о внешних проблемах.
Ошибки, которые были неизвестны на момент возврата из write(), могут быть сообщены ядром при close(), о чём есть например намёки в man 2 close как в Linux так и в FreeBSD (и, вероятно, других ОС). С другой стороны, в том же man 2 close Linux-а прямо заявлено, что close не гарантирует сброс кешей записи (если что, POSIX ничего на этот счёт не требует) и считать его точкой, где можно забрать все pending ошибки, не следует. Есть и другая проблема: даже если close вернёт ошибку, не очень понятно что с ней делать — дескриптор всё равно уже закрыт (кроме EINTR, который ужасно некроссплатформенно может оставить дескриптор открытым, например в HP-UX, и этим сделать чрезвычайно проблемным корректное кроссплатформенное использование close() в мультитреде).
Стоит упомянуть флаг O_DIRECT для open(), который вроде бы должен запрещать отложенную запись и заставлять ядро все ошибки сразу сообщать приложению, но он не стандартный и не кроссплатформенный, хотя и есть и в Linux и в FreeBSD и ещё в некоторых системах, но единство семантики никем не гарантируется, в частности например в Linux до 2.4.10 он молча игнорировался. При этом он точно снижает скорость работы (если не игнорируется) и имеет ряд платформенно-зависимых и даже файлосистемно-зависимых оговорок по его использованию (см. man 2 open).
Системные вызовы fsync(), fdatasync(), флаги O_SYNC, O_DSYNC, O_FSYNC для open(). Самое поддерживаемое тут fsync(). fdatasync() отсутствует в FreeBSD до версии 11.1 и, хоть и присутствует, но делает то же самое что и fsync() в Linux версий 2.2 и раньше. O_SYNC по идее означает что после каждого write() делается неявный fsync(), O_DSYNC - после каждого write() делается неявный fdatasync(). O_FSYNC это BSD синоним к POSIX стандартному O_SYNC. O_DSYNC не поддерживается в FreeBSD. fsync() означает: записать на физический носитель (точнее, отправить железу или сетевому хранилищу) все ещё не отправленные данные и метаданные, связанные с файлом. fdatasync() отправляет только данные, но не метаданные. При этом эти функции вернут все ошибки, которые могут возникнуть в ходе выполнения отложенных записей. Если делались переименования файлов и подобное, обязателен ещё fsync() на директорию, их содержащую.
Вроде бы, вот оно решение проблемы (в рамках софта), но не тут то было. Во-первых, на старых Linux-ах и на необычных файловых системах эти функции могут вообще саботировать свою работу. Во-вторых, fsync по факту тоже может молча прятать ошибки.
Начнём с того, что POSIX не особо строго что-то требует от fsync(), поэтому проблемное поведение вполне в него укладывается. А именно, его требования таковы:
-
передать железу всё связанное с файлом, что пока что стоит в очереди, и не возвращаться из функции пока это не будет сделано;
-
вернуть -1 и errno если в ходе записей произошла ошибка;
-
собственные errno: EBADF, EINVAL, EINTR, EIO, кроме того могут быть любые от read()/write(); этот разрешённый EINTR дополнительно усугубляет проблему; EBADF/EINVAL тут не важны т.к. это по сути ошибки не самого fsync() а некорректного аргумента ему от приложения.
Пункты 1 и 2 в целом аналогичны в манах от Linux-а и FreeBSD.
Пункт 3 (список ошибок) отличается. Что в Linux-е: EINTR в списке нет — это радует (см. ниже почему), добавилось EROFS для попыток fsync для сокета/пайпа (аналог EINVAL), добавилось ENOSPC/EDQUOT (а это значит, что write() и правда может не знать про закончивийся диск). FreeBSD: EINTR тоже нет, зато добавилось EINTEGRITY (то ли в 11.х то ли в 12.х) — «обнаружена битая файловая система».
В чём проблема: fsync() действительно отправляет те блоки, что стоят в очереди, и действительно возвращает -1 если с их отправкой возникли проблемы. Но вот те блоки, которые ядро ОС уже отправило раньше, и про которые уже сообщило приложению ошибку, он заново слать не обязан, и эту старую ошибку он помнить тоже не обязан. В частности, Linux именно так и делал как минимум в районе 2018 года (и, по косвенным признакам, исправлять это не планировалось, так что, вероятно, и сейчас так) — старую ошибку забывал, на что наткнулись в 2018 году разработчики PostgreSQL [1] и пришли к выводу, что единственный вариант безопасно обработать ошибку fsync() - это паника (не ядра, базы).
Поясню подробнее. Допустим, программа сделала write(), затем сделала fsync(), из которого было получено -1 как сигнал ошибки (хорошо, что хотя бы не EINTR, который есть только в POSIX, но допустим ENOSPC — ошибка явно не фатальная, впрочем, ошибок-то может быть несколько, но fsync() вернёт только одну из них), что ей делать дальше? [2] Можно наивно попытаться сделать fsync() ещё раз, но, как уже выяснено, второй fsync может тупо забыть про недописанные первым блоки и сказать что всё хорошо, ничего не сделав. Можно усложнить обработчик: сделать ещё раз write() (для чего придётся дублировать весь writeback-кеш в памяти приложения до тех пор, пока не убедимся что он записан), и ещё раз fsync(). Тут шансов на успех больше, однако всё равно без гарантий: у ядра в памяти уже лежат обновлённые блоки, на диске этих блоков нет, но этот факт должным образом нигде не зафиксирован. Хорошо, если это блоки данных (хотя кто мешает файловой системе сравнить новое записываемое с содержимым кеша и «передумать» записывать его на диск т.к. вроде уже записано?), а если это ещё и метаданные — то способов напрямую повлиять на ситуацию с неудавшейся и забытой записью практически нет. В Linux до 4.13 было ещё хуже — даже первый fsync() мог вернуть «всё хорошо», если какая-то другая программа его успела перед этим сделать на тот же файл. В версиях 4.13-4.16 хотя бы это исправили.
Даже если конкретно в Linux-е это исправят (или исправили), это не решает общую проблему: POSIX действительно не требует от fsync() консистентного поведения, и на разных немейнстримных системах может быть то же самое. Радует, что аналогичное поведение в FreeBSD таки было посчитано багом ещё в 1999 году и тогда же исправлено. Поведение других систем можно посмотреть в [4].
Ссылки:
[1] Тема в мейллисте PostgreSQL: https://postgrespro.com/list/thread-id/2379543
[2] Обширное исследование на тему проблем fsync в Linux с ext4/xfs/btrfs (англ): https://ramalagappan.github.io/pdfs/papers/cuttlefs.pdf https://www.usenix.org/system/files/atc20-rebello.pdf
[3] Тема на stackoverflow: https://stackoverflow.com/questions/42434872/writing-programs-to-cope-with-i-o-errors-causing-lost-writes-on-linux
[4] Статья в вики PostgreSQL https://wiki.postgresql.org/wiki/Fsync_Errors