Недавно я читал материал Эвана Джонса «Устойчивое хранение данных и файловые API Linux». Я полагаю, что автор этой довольно хорошей статьи ошибается, говоря о том, чего можно ожидать от команды write()
(и в том виде, в каком она описана в стандарте POSIX, и на практике). Начну с цитаты из статьи:
Системный вызов write() определён в стандарте IEEE POSIX как попытка записи данных в файловый дескриптор. После успешного завершения работы write() операции чтения данных должны возвращать именно те байты, которые были до этого записаны, делая это даже в том случае, если к данным обращаются из других процессов или потоков (вот соответствующий раздел стандарта POSIX). Здесь, в разделе, посвящённом взаимодействию потоков с обычными файловыми операциями, имеется примечание, в котором говорится, что если каждый из двух потоков вызывает эти функции, то каждый вызов должен видеть либо все обозначенные последствия, к которым приводит выполнение другого вызова, либо не видеть вообще никаких последствий. Это позволяет сделать вывод о том, что все файловые операции ввода/вывода должны удерживать блокировку ресурса, с которым работают.
Означает ли это, что операция write() является атомарной? С технической точки зрения — да. Операции чтения данных должны возвращать либо всё, либо ничего из того, что было записано с помощью write(). […].
К сожалению, то, что операции записи, в целом, атомарны — это не то, что говорится в стандарте POSIX, и даже если именно это и попытались выразить авторы стандарта — весьма вероятно то, что ни одна версия Unix этому стандарту не соответствует, и то, что ни одна из них не даёт нам полностью атомарных команд записи данных. Прежде всего, то, что в стандарте POSIX чётко сказано об атомарности, применяется лишь в двух ситуациях: когда что-то пишется в конвейер или в FIFO, или когда речь идёт о нескольких потоках одного и того же процесса, выполняющих некие действия. А то, что стандарт POSIX говорит об операциях записи, смешанных с операциями чтения, имеет гораздо более ограниченную область применения. Позвольте мне процитировать стандарт (выделение — моё):
После того как произошёл успешный возврат из операции записи (write()) в обычный файл:
- Любая успешная операция чтения (read()) данных из каждой байтовой позиции файла, которая была модифицирована этой операцией записи, должна возвращать данные, установленные для этой позиции командой write() до тех пор, пока данные в таких позициях не будут снова модифицированы.
Это не требует какого-то особого поведения от команд чтения данных из файла, запущенных другим процессом до возврата из команды записи (включая те, которые начались до начала работы write()
). Если выполнить подобную команду read()
, POSIX позволяет этой команде вовсе не прочитать данные, записываемые write()
, прочитать лишь некоторую часть этих данных, или прочитать их все. Подобная команда read()
(теоретически) является атомарной в том случае, если её вызывают из другого потока того же самого процесса. При таком подходе, определённо, не реализуется обычная, привычная всем, атомарная схема работы, когда либо видны все результаты работы некоей команды, либо результаты её работы не видны вовсе. Так как разрешено кросс-процессное выполнение команды read()
во время выполнения команды write()
, выполнение команды чтения может привести к возврату частичного результата работы команды записи. Мы не назвали бы «атомарной» SQL-базу данных, которая позволяет прочитать результаты незавершённой транзакции. Но именно это стандарт POSIX позволяет команде write()
, с помощью которой выполняется запись в файлы.
(Это, кроме того, именно то, что, почти гарантированно, дают нам реальные Unix-системы, хотя тут возможно много ситуаций, и Unix-системы я на предмет этого не тестировал. Например, меня бы не удивило, если бы оказалось, что выровненные операции записи фрагментов данных, размеры которых соответствуют размерам страниц (или блоков файловой системы), на практике, оказались бы атомарными во множестве Unix-систем.)
Если подумать о том, что потребуется для реализации атомарных операций записи в файлы в многопроцессной среде, то сложившаяся ситуация не должна выглядеть удивительной. Так как Unix-программы не ожидают коротких сеансов записи в файлы, мы не можем упростить проблему, установив лимит размера данных, запись которых необходимо выполнять атомарно, а потом ограничив write()
этим размером. Пользователь может запросить у системы запись мегабайтов или даже гигабайтов данных в одном вызове write()
и эта операция должна быть атомарной. Во внутренний буфер ядра придётся собирать слишком много данных, а затем придётся менять видимое состояние соответствующего раздела файла, укладывая всё это в одно действие. Вместо этого подобное, вероятно, потребует блокировки некоего диапазона байтов, где write()
и read()
блокируют друг друга при перекрытии захваченных ими диапазонов. А это означает, что придётся выполнять очень много блокировок, так как в этом процессе придётся принимать участие каждой операции write()
и каждой операции read()
.
(Операцию чтения данных из файлов, которые никто не открывал для записи, можно оптимизировать, но при таком подходе, всё равно, придётся блокировать файл, делая так, чтобы его нельзя было бы открыть для записи до тех пор, пока не будет завершена операция read()
.)
Но блокировать файлы лишь при выполнении команды read()
в современных Unix-системах недостаточно, так как многие программы, на самом деле, читают данные, используя отображение файлов на память с помощью системного вызова mmap()
. Если действительно требуется, чтобы операция write()
была бы атомарной, нужно блокировать и операции чтения данных из памяти при выполнении команды записи. А это требует довольно-таки ресурсозатратных манипуляций с таблицей страниц. Если заботиться ещё и о mmap()
, то возникнет одна проблема, которая заключается в том, что когда чтение осуществляется через отображение файла на память, команда write()
не обязательно будет атомарной даже на уровне отдельных страниц памяти. Тот, кто читает данные из памяти, на которую отображается файл, может столкнуться со страницей, находящейся в процессе копирования в неё байтов, записанных с помощью write()
.
(Это может произойти даже с read()
и write()
, так как и та и другая команды могут получить доступ к одной и той же странице данных из файла в буферном кеше ядра. Но тут, вероятно, легче применить механизм блокировки.)
Помимо проблем с производительностью тут наблюдаются и проблемы со справедливостью распределения системных ресурсов. Если команда write()
является, по отношению к команде read()
, атомарной, это значит, что длительная операция write()
может остановить другую команду на значительное время. Пользователям не нравятся медленные и откладываемые на какое-то время операции read()
и write()
. Подобное, кроме того, даст удобный инструмент для DoS-атак путём записи данных в файлы, открываемые для чтения. Для этого достаточно снова и снова запрашивать у системы выполнение операции над всем файлом за один заход (или над таким его фрагментом, над которым можно выполнить нужную операцию).
Правда, большая часть затрат системных ресурсов происходит из-за того, что мы рассуждаем о кросс-процессных атомарных операциях записи, так как это значит, что действия по выполнению блокировок должно выполнять ядро. Кросс-поточная атомарная операция write()
может быть реализована полностью на уровне пользователя, в пределах отдельного процесса (при условии, что библиотека C перехватывает операции read()
и write()
при работе в многопоточном режиме). В большинстве случаев можно обойтись какой-нибудь простой системой блокировки всего файла, хотя тем, кто работает с базами данных, это, вероятно, не понравится. Вопросы справедливости распределения системных ресурсов и задержки операций из-за блокировок при работе в пределах одного процесса теряют остроту, так как единственным, кому это повредит, будет тот, кто запустил соответствующий процесс.
(Большинство программ не выполняют операции read()
и write()
над одним и тем же файлом в одно и то же время из двух потоков.)
P.S. Обратите внимание на то, что даже операции записи в конвейеры и в FIFO являются атомарными только если объём записываемых данных достаточно мал. Запись больших объёмов данных, очевидно, не должна быть атомарной (и в реальных Unix-системах она таковой обычно и не является). Если бы стандарт POSIX требовал бы атомарности при записи ограниченных объёмов данных в конвейеры, и при этом указывал бы на то, что запись любых объёмов данных в файлы так же должна быть атомарной, это выглядело бы довольно-таки необычно.
P.P.S. Я с осторожностью относился бы к принятию как данности того, что в некоем варианте Unix, в многопоточной среде, полностью реализованы атомарные операции read()
и write()
. Возможно, я настроен скептически, но я бы сначала это как следует проверил. Всё это похоже на излишне прихотливые требования POSIX, на которые разработчики систем закрывают глаза во имя простоты и высокой производительности своих решений.
Приходилось ли вам сталкиваться с проблемами, вызванными одновременным выполнением записи в файл и чтения из него?
Автор: ru_vds