Что стоит за open() и write(), где на самом деле лежат ваши данные после «успешной» записи, почему второе чтение в десять раз быстрее первого и чем NVMe отличается от HDD не только скоростью.
Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.
Файл, сокет, пайп, таймер — для процесса всё это число в таблице дескрипторов. Число ссылается на «открытый файл» в ядре: там хранятся текущее смещение и флаги. А тот — на inode, сам файл на диске.
dup() и fork() копируют только номер — смещение остаётся общим. Повторный open() создаёт новый открытый файл со своим смещением. Откройте файл, сделайте fork() и пишите из обоих процессов, затем откройте файл второй раз.
ulimit -n. Мягкий лимит по умолчанию часто 1024, в systemd его задаёт LimitNOFILE. Каждое HTTP-соединение — тоже дескриптор.EMFILE обычно значит утечку, а не маленький лимит: незакрытый файл, тело HTTP-ответа, сокет. Поднять лимит — отсрочить падение.with, try-with-resources, defer f.Close(), resp.Body.Close().
$ cat /proc/4242/limits | grep 'open files'
Max open files 1024 524288 files
$ ls /proc/4242/fd | wc -l # сколько открыто сейчас
1021
$ ls -l /proc/4242/fd | tail -3
lr-x------ 1018 -> /var/data/report-17.csv
lr-x------ 1019 -> /var/data/report-18.csv
lrwx------ 1020 -> socket:[884213]
$ lsof -p 4242 | awk '{print $5}' | sort | uniq -c
812 REG # обычные файлы — подозрительно
203 IPv4
6 FIFO
write() возвращает управление, как только данные скопированы в память ядра — в page cache. На диск они попадут позже. А до write() они могут ещё лежать в буфере вашей библиотеки, внутри процесса.
Пишите записи и сбрасывайте их на разные уровни, потом «убейте» процесс или выключите питание. Смотрите, что уцелеет. kill -9 и отключение питания — очень разные аварии.
Изменённые страницы page cache называются грязными. Когда их больше dirty_background_ratio (10% памяти), ядро начинает фоновую запись. Когда приближаются к dirty_ratio (20%), ядро тормозит сам процесс: write() начинает ждать диск.
Отсюда «запись 2 ГБ/с первые секунды, потом 150 МБ/с» и внезапные паузы в несколько секунд на обычном write() в логгере. Сравните быстрый и медленный диск.
flush() в Python и Java переносит данные из буфера библиотеки в ядро, и только. От kill -9 это спасает, от отключения питания — нет.fsync(fd) ждёт, пока данные и метаданные файла дойдут до носителя, включая сброс кэша самого диска. fdatasync пропускает несущественные метаданные вроде времени изменения — на одну запись меньше.O_SYNC / O_DSYNC — то же, но на каждый write(). O_DIRECT обходит page cache, но не заменяет fsync.f.flush()os.fsync(f.fileno())out.flush()channel.force(true)bw.Flush()f.Sync()fs.write(…)fh.sync()write(fd, …)fsync(fd)В Go os.File не буферизует: каждый Write — системный вызов, буфер даёт bufio.Writer. close() нигде не делает fsync. А ошибку записи на диск ядро может сообщить только в fsync — его результат надо проверять.
Сервис на Python записал строку в файл и вызвал f.flush(), но не fsync. Через секунду OOM killer убил процесс сигналом SIGKILL. Сервер продолжил работать. Строка в файле?
flush() данные принадлежат ядру, а не процессу. Смерть процесса им не грозит: грязные страницы запишутся фоном в течение ~30 секунд. Потерять их можно только вместе с ядром — при отключении питания или панике ядра. А вот без flush() строка осталась бы в буфере процесса и пропала.Переписать файл на месте нельзя: упадёте посередине — останется пустой или обрезанный файл. Правильно: записать во временный файл рядом, сделать fsync, атомарно переименовать rename() поверх старого и сделать fsync каталога.
Выберите способ, идите по шагам и нажимайте «Сбой питания» в разные моменты. Схема показывает, что видят программы и что окажется на диске после перезагрузки.
Создание файла меняет несколько мест: inode, битовую карту блоков, запись в каталоге. Журналируемая ФС сначала пишет описание изменений в журнал и только потом — на место. После сбоя она проигрывает журнал и остаётся целой — без многочасового fsck.
data=orderedПо умолчанию. В журнал идут только метаданные, но блоки данных пишутся раньше, чем фиксируются метаданные, которые на них ссылаются. В файле не окажется чужого мусора.
data=writebackПорядок не гарантирован: после сбоя в конце файла могут оказаться старые блоки, принадлежавшие другому файлу. Быстрее, но опаснее.
data=journalДанные тоже идут через журнал: всё пишется дважды. Редко нужно.
XFS журналирует метаданные. btrfs и ZFS не перезаписывают блоки на месте (copy-on-write). Но и им для надёжности нужен fsync.
Журнал ext4 фиксируется раз в 5 секунд (commit=5), грязные данные пишутся через ~30 секунд. Ни один режим не делает ваш write() надёжным или атомарным: целостность ФС — да, сохранность последних данных — только fsync.
Файл — это inode: размер, права, список блоков. Имени в нём нет. Каталог — таблица «имя → номер inode». Жёсткая ссылка — ещё одно имя того же inode. Символическая — отдельный маленький файл с путём внутри.
Место освобождается, когда у inode не осталось ни имён, ни открытых дескрипторов. Удалите файл, который держит процесс, и сравните df и du.
Всё прочитанное с диска остаётся в свободной памяти. Второе чтение того же файла идёт из RAM. Когда памяти просят программы, ядро без труда отдаёт чистые страницы кэша.
Поэтому «занято 15 из 16 ГБ» в free — не повод для паники: смотрите на колонку available. Прочитайте файл дважды, потом файл больше, чем помещается в кэш.
Каждый read() — системный вызов: переход в ядро и обратно плюс копирование из page cache в ваш буфер. Прочитать 1 ГБ по байту — миллиард вызовов, буфером в 64 КБ — шестнадцать тысяч.
FileInputStream.read() в Java и os.read(fd, 1) в Python читают по байту без буфера. mmap отображает page cache прямо в память процесса: копирования нет, но каждое первое обращение к странице — page fault, который блокирует поток. Подробнее — в презентации про виртуальную память.
На сервере с HDD, который пишет 200 МБ/с, вы запускаете:
$ dd if=/dev/zero of=test bs=1M count=1000
1048576000 bytes copied, 0.48 s, 2.2 GB/s
Откуда 2,2 ГБ/с?
write() вернулся, не дождавшись диска. Честный замер: dd … conv=fdatasync (fsync в конце) или oflag=direct (мимо кэша). А лучше — fio с --direct=1. Ту же ошибку делают бенчмарки «записи в файл» на любом языке.HDD тратит ~8 мс на каждое случайное чтение: подвести головку и дождаться нужного сектора. Подряд он читает 200 МБ/с, вразброс по 4 КБ — меньше 1 МБ/с. У SSD механики нет, и разница в сотни раз исчезает.
SSD внутри параллелен — десятки кристаллов памяти. Один поток с одним запросом за раз (глубина очереди 1) загружает его на доли процента. Двигайте глубину очереди.
none для NVMe — диску виднее; mq-deadline — для HDD и SATA; bfq — честное деление между процессами. Смотреть в /sys/block/*/queue/scheduler.%util — доля времени, когда в очереди был хоть один запрос. Для параллельного SSD 100% не значит «предел». Смотрите на r_await/w_await и aqu-sz.
$ iostat -x 1 nvme0n1
Device r/s w/s r_await w_await aqu-sz %util
nvme0n1 41250 812 0.21 0.05 8.7 99.6
# 41 тыс. чтений/с, задержка 0,21 мс,
# в среднем 8,7 запроса в очереди.
# %util ≈ 100, но NVMe выдержит в разы больше:
# он выполняет сотни запросов параллельно.
$ cat /sys/block/nvme0n1/queue/scheduler
[none] mq-deadline kyber bfq
База не делает fsync файлов данных на каждый коммит. Она дописывает изменения в журнал (WAL) — последовательно, — и коммит ждёт fsync только журнала. Страницы данных пишутся позже, в фоне.
Group commit: пока идёт один fsync, новые коммиты копятся и уходят следующим одним fsync. Так HDD со 120 fsync в секунду держит тысячи коммитов. Подробнее — в презентации о PostgreSQL. Добавляйте клиентов и включайте group commit.
logrotate переименовывает app.log в app.log.1. Но процесс пишет в дескриптор, а не в имя, — и продолжает писать в переименованный файл, пока его не попросят открыть лог заново.
Три способа: переименовать и послать сигнал (SIGHUP, USR1) на переоткрытие; переименовать без сигнала — ошибка; copytruncate — скопировать и обрезать, для программ, которые переоткрывать не умеют. Запустите ротацию в каждом режиме.
Мониторинг: диск заполнен на 100%, сервис падает с ENOSPC. Вы удалили старый лог на 40 ГБ — du показывает, что место есть, а df — что по-прежнему 100%. Что происходит?
rm удалил только имя. Inode жив, пока на него есть открытый дескриптор. Найти: lsof +L1 — файлы с нулём ссылок. Освободить: перезапустить процесс или попросить его переоткрыть лог, а в крайнем случае обрезать через : > /proc/PID/fd/N. (Резерв для root и нехватка inode тоже бывают, но du с df тогда не расходятся.)lsof -p PIDls -l /proc/PID/fdlsof +L1 — удалённые, но открытые файлы.strace -f -e trace=openat,write,fsync,rename -p PIDstrace -c — сводка: сколько read на самом деле.iostat -x 1IOPS, задержка, очередь. Кто именно пишет: iotop -o, pidstat -d 1.grep -E 'Dirty|Writeback' /proc/meminfoПороги — sysctl vm.dirty_ratio vm.dirty_background_ratio.free -hfincore fileСмотрите available, а не free.df -hdu -sh /var/*df -i — кончились inode (миллион мелких файлов).fio --name=r --rw=randread --bs=4k --iodepth=32 --direct=1 --size=2GСлучайное чтение с глубиной очереди 32.fio --name=w --rw=write --bs=4k --fsync=1 --size=256MГлавная цифра для баз данных и очередей.EMFILE.write() значит «в памяти ядра». flush() — «в ядре», надёжно — только после fsync и с проверкой его ошибки.rename → fsync каталога.--direct или fsync измеряют RAM.Данные записаны, когда вернулся fsync, а не write.