Друзья мои, программисты и операторы, я бы хотел поговорить о том, как в Linux работает запись файлов.
Раньше я думал, что она устроена определённым образом, и как Джон Леннон, «I’m not the only one». Оказалось, операции записи работают совершенно иначе. То, как они работают, интересно и важно знать.
Позвольте начать с того, как я раньше думал о записи файлов.
вы вводите команду
echo "foo" > bar.txt
(спустя несколько микросекунд) бум, всё готово, строка «foo» записана на диск.
Я думал так, потому что считал, что файлы находятся на диске, поэтому если выполняется запись в файл, то она выполняется на диск.
Но это так не работает, и описанная выше модель о нахождении файлов на диске ошибочна.
Давайте начнём с демонстрации того, почему ошибочна эта модель.
Файлы не находятся на диске
Логично думать, что файлы находятся на диске, потому что файлы — это то, с чем мы взаимодействуем при записи данных на диск.
Но дело в том, что на самом деле файл — это интерфейс. Операционная система использует этот интерфейс, чтобы мы могли сказать ей, чего хотим.
Пока всё это немного теория, поэтому позвольте повторить: файлы — это интерфейс, во многом похожий на экземпляр ООП с методами и атрибутами. Файлы — это не единицы на диске. Это всего лишь абстрактный интерфейс для них.
Но что же тогда находится на диске? Байты.
На диске находятся байты
Диск — это просто мешок с байтами. Разумеется, эти байты имеют структуру. В противном случае они были бы произвольными, и мы бы не смогли извлечь из них пользу. Конкретный способ упорядочивания байтов на диске называется inode. inode — это то, что в конечном итоге описывается при помощи файла.
Можно сравнить inode с jpeg: это способ упорядочивания байтов определённым образом. Например, inode указывает, что в определённом месте вашего мешка с байтами записывается размер файла. В другое место записывается время создания файла.
Но почему бы тогда просто не взаимодействовать с inode?
Зачем использовать интерфейсы
Напрямую выполнять запись на диск можно. Нужно точно знать, куда выполнять запись, и необходимо записывать эти байты напрямую. Вероятность ошибок крайне высока.
Поэтому гораздо удобнее просто попросить об этом операционную систему. Благодаря этому мы можем сосредоточиться на собственных приложениях.
И это одна из причин использования интерфейсов: мы абстрагируем задачи, которыми не хотим заниматься.
Ещё одна важная причина — это эффективность. Делегируя доступ к диску операционной системе, мы даём программистам операционной системы разрешение принимать множество эффективных решений.
Диски медленные
Давайте вернёмся к нашей гипотезе:
вы вводите команду
echo "foo" > bar.txt
(спустя несколько микросекунд) бум, всё готово, строка «foo» записана на диск.
Она абсолютно ложна. Если бы она была верна, компьютеры бы казались ужасно медленными. Диски о-о-очень медленные.
Насколько медленные? Получение небольшой порции данных с SSD в тысячу раз медленнее, чем получение её из памяти. Получение тех же данных с жёсткого диска в миллион раз медленнее. Дисковый доступ на много порядков медленнее, чем доступ к памяти!
Вспомним, что до SSD диски были последними механическими элементами компьютеров (если не считать вентиляторов). В мире скоростного перемещения электронов мы перемещали атомы. Это различие в скорости между электрическим и механическим миром огромно.
Поэтому операционные системы делают всё возможное, чтобы защитить приложения от этой медленности.
Как они это делают?
Как на самом деле работает запись?
Вот, как работает запись.
вы вводите команду
echo "foo" > bar.txt
операционная система копирует «foo» в особое место памяти под названием страничный кэш
(несколько микросекунд спустя) операционная система сообщает, что запись успешно выполнена
(асинхронно, спустя до тридцати секунд) операционная система на самом деле записывает «foo» на диск
Если вы привыкли к мысленной модели «файлы находятся на диске», то это вас потрясёт. Лично я привык считать, что запись на диск происходит мгновенно, но на самом деле она выполняется спустя тридцать секунд!
Зачем нужна такая асинхронность?
Допустим, вы сделали приложение для публикации фотографий с кнопками Like. Лайки под фото сохраняются в базе данных. Однако если бы вы лично проектировали это приложение, то предпочли бы…
Чтобы сердечко лайка появлялось как только пользователь нажмёт на кнопку Like?
Чтобы сердечко появлялось только после того, как лайк был сохранён на диск?
Разумеется, вы выбрали бы вариант 1, потому что отзывчивость UI очень важна.
Это пример защиты пользователя от медленной работы при помощи асинхронности: мы сообщаете пользователю, что сделали это, но на самом деле, делаете это позже, когда он не смотрит.
С Linux ситуация почти такая же. Он защищает приложение от медленности диска, просто выполняя запись на диск позже. Это называется неблокирующим вводом-выводом: мы не заставляем приложение ждать медленные диски.
Однако это не единственная причина асинхронности записи.
Буферизация также ускоряет запись на диск
Важно уяснить следующее: асинхронность позволяет использовать буферизацию.
Позже я исследую эту тему глубже, а пока скажу, что меня потрясло то, насколько часто встречаются буферы и очереди в computer science. Не сказать, что это какой-то секрет, но они важнее для эффективных вычислений, чем я думал.
Очереди и буферы также повышают эффективность записи на диск.
Например, каждая запись на диск связана с тратой дополнительных ресурсов. Учитывая эту трату, мы лучше выполним одну большую операцию записи на 1 мегабайт, чем сто небольших записей по 10 килобайтов каждая. Буферизация записей на диск позволяет операционной системе объединять эти небольшие записи в более крупные.
Так как операционная система отделяет запись в файл от настоящей записи на диск, то если вы быстро выполняете серию записей файлов, то они объединятся в небольшую очередь. Затем операционная система может их объединить.
На самом деле, существует гораздо больше трюков, позволяющих повысить скорость записей, и все они становятся возможными благодаря этой асинхронности. Возможно, я расскажу о них в другой статье, но если вам не терпится, крайне рекомендую прочитать главы «File System» и «Disk» книги Брендана Грегга «Systems Performance».
Как бы то ни было, мы теперь понимаем, что асинхронные записи на диск дают нам огромный рост скорости, но здесь есть ещё один аспект: какой же ценой?
Компромисс между эффективностью и надёжностью
Что произойдёт, если вы отключите компьютер прежде, чем операционная система запишет данные на диск? Всё очень просто: данные будут потеряны.
Мне бы хотелось найти обсуждение, в котором намеренно было принято это решение, но у такого варианта как будто и не было альтернатив: до недавнего времени запись на диск была в миллион раз медленнее, чем доступ к памяти. Разумеется, по умолчанию запись выполнялась асинхронно.
Но что, если мы хотим сделать наши операции записи надёжными?
O_SYNC и sync: делаем записи надёжными
Хотя лично меня удивил факт асинхронности записей, многие программисты, и, в частности, программисты баз данных, хорошо о нём знают.
Существует множество способов сделать записи надёжными. Я расскажу о двух из них.
Системный вызов sync
Существует системный вызов под названием sync
, который можно совершить в любой момент. Он означает «операционная система, немедленно сбрось всё из страничного кэша на диск!» И операционная система сделает это. Вы даже можете прямо сейчас ввести эту команду в шелл, чтобы проверить.
В программах часто встречаются последовательности записей, перемежаемые вызовами sync
. Например, через каждые n
записей выполняется вызов sync
. Это позволяет приложениям тонко настраивать баланс между надёжностью и скоростью записи.
Файловый режим O_SYNC
Также программисты могут открывать файл в режиме O_SYNC
. Это действие гораздо ближе к модели, изложенной в начале статьи: запись завершается только тогда, когда данные были сохранены на диск.
Демонстрация асинхронных записей
Настало время для научной демонстрации. Я буду выполнять запись в файл и чтение из файла. При этом я покажу, что обе операции выполнятся за секунды до того, как будет выполнен доступ к диску.
В шелл я вставлю следующие команды:
echo "foo" > example.txt
dd if=example.txt
Ниже показан результат выполнения bpftrace
, позволяющей отслеживать события ядра.
В частности, мы будем отслеживать моменты завершения vfs_read
и vfs_write
(вам достаточно знать, что это функции, вызываемые при чтении и записи в файл). Также мы будем отслеживать block_rq_issue
, которая вызывается, когда драйвер диска на самом деле выполняет запись на диск.
# запись начинается
11:32:05 kfunc:vmlinux:vfs_write
запись заканчивается
11:32:05 kretfunc:vmlinux:vfs_write
считывание начинается
11:32:05 kfunc:vmlinux:vfs_read
считывание заканчивается
11:32:05 kretfunc:vmlinux:vfs_read
5 секунд спустя действительная запись "foo" на диск!
11:32:10 tracepoint:block:block_rq_issue
Обратите внимание, что мы даже никогда не считываем с диска, хотя и считываем файл. Так происходит, потому что чтение выполняется из страничного кэша!
Особенность: это поведение не универсально
Использование или неиспользование страничного кэша для считывания и записи на самом деле зависит от файловой системы. Однако самые распространённые файловые системы Linux (ext4, btrfs, XFS) используют страничный кэш. С ZFS ситуация чуть сложнее. Хотя в ней используется страничный кэш, у неё также есть собственный относительно сложный механизм кэширования.
Заключение
Надеюсь, это помогло вам привести свою мысленную модель в соответствие с реальностью. На диске находятся не файлы, а байты. Файлы просто описывают эти байты. Это расхождение упрощает программирование и позволяет операционной системе защитить наши быстрые приложения от медленных дисков.
Эта тема гораздо шире — разница между файловым и дисковым вводом-выводом намного глубже. Например, какой объём будет записан на диск при записи 1 байта в файл? Удивитесь ли вы, если я скажу, что запись 1 байта вызывает запись на диск 65 тысяч байтов? И существует ещё множество таких тонкостей.