В отличие от таких операционных систем как HPUX (dbc_min_pct, dbc_max_pct) или AIX (minperm%, maxperm%), в Linux нет возможности настраивать размер кэша страниц, читаемых с диска (страничный кэш, page cache). Под страничный кэш Linux использует всю доступную память. Размер страничного кэша можно увидеть в /proc/meminfo
в параметре "Cached". В /proc/meminfo
есть также значение "Buffers", которое часто путают с размером страничного кэша. "Buffers" — это память, содержащая сырые дисковые данные (raw disk data) и выступающая в роли промежуточного буфера между процессами, ядром и диском.
В этой статье рассмотрим, как Linux работает с памятью, и, в частности, со страничным кэшем, а также исследуем, как доступный объем памяти влияет на производительность буферизованного ввода-вывода (buffered IO).
Буферизованный синхронный ввод-вывод
Любая обычная синхронная операция ввода-вывода без особых флагов открытия файла (дескриптора) будет выполнять следующие действия:
Чтение: процесс попытается найти запрашиваемые страницы в кэше, и если не найдет, то обратится к диску и поместит страницы в кэш.
Запись: процесс просто запишет в кэш.
Это отлично работает, если запросы на ввод-вывод небольшие и у операционной системы достаточное количество памяти под кэш. В большинстве случаев это так и есть.
Выделение памяти
Linux, как и большинство современных операционных систем, для выделения памяти использует "подкачку страниц по требованию" (demand paging). При таком подходе непосредственное выделение памяти откладывается до момента, пока процесс действительно не обратится к странице. Если запрошенная страница отсутствует в памяти, то возникает "ошибка страницы" (page fault), и ядро загружает ее с диска. Это происходит для каждой отдельной страницы памяти.
Некоторые базы данных могут выполнять "предзагрузку страниц" (pre-paging) при выделении памяти, что вынуждает операционную систему выполнять фактическую загрузку данных с диска, используя описанный ранее механизм ошибок страниц (page fault).
Свободная память
Linux не "хранит" свободную память, за исключением объема, указанного в параметре ядра vm.min_free_kbytes. Любая свободная страница, за пределами этой памяти (см. ниже pages_high), будет использоваться в качестве страничного кэша или выделена для чего-то другого. Это не означает, что у вас никогда не бывает много свободной памяти. Большой объем свободной памяти можно наблюдать сразу же после запуска операционной системы, прежде чем процессы начнут выделение памяти, а также после явного освобождения ранее выделенной и использованной памяти.
В целом, небольшое количество свободной (free) памяти — это вполне нормальная ситуация. Система Linux считается здоровой, если объем свободной памяти примерно равен vm.min_free_kbytes.
Обычно в Linux для управления свободной памятью используется метод трех пороговых значений, основанный на vm.min_free_bytes:
pages_high (pages_min + (pages_min/2))
pages_low (pages_min + (pages_min/4))
-
pages_min (vm.min_free_bytes / 2^2)
в исходном коде: vm.min_free_bytes >> (PAGE_SHIFT-10)
здесь вычисляется размер страницы, то есть pages_min — это vm.min_free_bytes в четырехкилобайтных страницах.
См. функцию __setup_per_zone_wmarks в файле mm/page_alloc.c.
В действительности пороговые значения настраиваются для каждой зоны памяти (memory zone). Однако для общего представления можно рассматривать их как общесистемный порог.
Немного упрощая, суть заключается в следующем:
vm.min_free_kbytes задает pages_min.
pages_low и pages_high вычисляются на основе pages_min.
Если объем свободной памяти в системе становится меньше pages_low, то kernel page daemon (он же "swapper" или swapd) начинает очистку памяти. Это происходит до тех пор, пока свободная память не достигнет значения pages_high. Количество страниц, просканированных kernel page daemon, видно в статистике pgscan_kswapd, а количество освобожденных страниц — в статистике pgsteal_kswapd (таким образом, можно вычислить эффективность через отношение steal/scan).
Если общесистемное выделение памяти пересекает порог pages_min, то нагрузка на память считается высокой. Процессы, запрашивающие память, вынуждены выполнять сканирование в поисках доступных страниц и приостанавливать работу. Количество страниц, просканированных процессами, видно из статистики pgscan_direct, а количество освобожденных страниц — из статистики pgsteal_direct. Количество случаев освобождения страниц для каждого типа памяти — "allocstall_*".
Если количество свободных страниц становится меньше pages_low, то swapper начинает сканировать память в поисках свободных страниц до тех пор, пока не достигнет pages_high. При уменьшении свободных страниц до pages_min, для пользовательских процессов приходится искать память, одновременно с работой swapper.
Неперемещаемая память
Как уже упоминалось выше в разделе "Выделение памяти", Linux и другие современные операционные системы выполняют множество умных оптимизаций для максимально эффективного использования системы.
Значительная часть памяти может быть легко использована повторно, в том числе с изменением ее назначения. В кэше находятся страницы, которые являются копией блока на диске и поэтому могут быть легко переиспользованы. Если какая-то страница понадобится позже, то она может быть прочитана с диска. То же самое справедливо и для файлов, отображаемых в память (memory-mapped file), таких как исполняемые файлы и библиотеки: если страница не используется активно, ее можно смело выгрузить. Единственным недостатком является то, что в будущем для использования ее нужно будет загрузить с диска.
Однако есть два трудноперемещаемых типа памяти:
Грязные страницы (данные в страничном кэше, еще не записанные на диск) не могут быть удалены из памяти, иначе файловая система будет повреждена. Также нет смысла перемещать их в своп (swap), чтобы позже прочитать для последующей записи по месту назначения. Единственным способом удаления грязных страниц является запись в реальные файлы, информация из которых и хранится в них.
Анонимная память — это память, содержащая используемые данные (они должны использоваться из-за подкачки по требованию) и не связанная ни с каким файлом на диске (do not have a backing file). Эти страницы нельзя отбросить, иначе при попытке использовать такую страницу, процесс, выделивший их, получит ошибку нарушения защиты памяти (protection failure), что приведет к повреждению памяти. Но при использовании свопа анонимные страницы могут быть перемещены в своп.
Память, используемая PostgreSQL, частично выделяется при создании соединения/backend-процесса (каждому backend-процессу требуется определенный объем памяти для себя и для выполнения кода PostgreSQL), и частично во время выполнения запросов при чтении данных с диска для обработки данных и буферизации. Потребление памяти во время работы может быть очень скачкообразным и случайным, в зависимости от запросов и объемов считываемых данных.
Производительность буферизованной записи
Производительность записи на диск зависит от доступной памяти для кэша и ряда настроек. В принципе, запись выполняется в кэш и, следовательно, зависит от скорости памяти, равной, в системе без существенной нагрузки на процессор, примерно 9000 наносекундам (на виртуальной машине Amazon c5.large, используя данные по задержке, выдаваемые fio).
В случае буферизованной записи пользовательский процесс выполняет обычный системный вызов write. Индикатором того, что запись выполняется в кэш, является время выполнения этого вызова. С точки зрения пользовательского процесса, после записи в кэш сохранение данных завершено успешно, но с точки зрения ядра и в реальности — операция еще не завершена, поскольку данные еще только в памяти. Стоит отметить, что для процессов, ожидающих сохранения данных из кэша, ошибка записи на диск не может быть обнаружена, поскольку исходный вызов write больше не имеет к этому никакого отношения.
Ядро добавляет измененные страницы в список "грязных страниц" и, очевидно, должно записать эти страницы на диск, чтобы их действительно сохранить и по-настоящему завершить запись. Сохранение на диск производится независимым от пользовательского процесса потоком ядра "kworker". Если в результате записи объем свободной памяти уменьшится до pages_low (поскольку грязные страницы, как ни странно, тоже используют память), то активизируется swapper и будет сканировать память в поисках свободных страниц в пределах пороговых значений, упомянутых ранее.
Параметры ядра для настройки сброса грязных страниц на диск
Мы не можем писать в кэш/память бесконечно. Количество грязных страниц в памяти регулируется параметрами ядра vm.dirty_background_ratio и vm.dirty_ratio, а также vm.dirty_background_bytes и vm.dirty_bytes. Первые два задаются в процентах, последние — в байтах. Значения в байтах по умолчанию равны нулю.
Данные параметры являются источником большой путаницы. Вопреки распространенному мнению, эти значения — это доля от доступной памяти, а не от всей памяти. Это очень важно, поскольку доступная память уменьшается при выделении памяти процессам. В итоге доступной памяти может оказаться очень мало, и эти значения также станут небольшими.
Это может стать проблемой, если производительность записи тестируется на системе с большим объемом памяти. Например, когда база данных только стартанула и большая часть памяти для нее еще не выделена. В этом случае dirty_ratio будет большим, и, следовательно, запись будет идти в основном в память. Однако, как только база данных получит необходимую память, этому проценту будут соответствовать куда меньшие значения порога.
Текущие абсолютные значения порогов выраженное в страницах, можно увидеть в /proc/vmstat в параметрах nr_dirty_threshold и nr_dirty_background_threshold.
Порог срабатывания фоновой записи грязных страниц будет 79 230 страницы = 3 169 220 (доступно) / 4 (размер страницы) / 100 * 10.
Порог срабатывания синхронной блокирующей записи 237 690 страниц = 3 169 220 (доступно) / 4 (размер страницы) / 100 * 30.
Текущие значения:
Поскольку данные динамические, значения могут немного изменяться, но очевидно, что незначительно:
dirty_threshold = 236 996 страниц по 4 КБ = 926 МБ
dirty_background_threshold = 78 998 страниц по 4 КБ = 306 МБ.
Для дальнейших экспериментов воспользуемся утилитой eatmemory: https://github.com/fritshoogland-yugabyte/eatmemory-rust
Смоделируем потребление 85% доступной памяти:
До "поедания" памяти было доступно 3166 МБ, а после — 469 МБ.
Посмотрим пороговые значения сброса грязных страниц:
Видим, что значения изменились — доступной памяти стало меньше. Теперь dirty_threshold составляет 32 692 страницы по 4 КБ = 128 MБ, а background_threshold = 10 897 страницы по 4 КБ = 42 MБ.
Производительность записи при разных значениях dirty ratio
Мы увидели, что абсолютные пороговые значения снижаются при выделении памяти. Но влияет ли это на производительность буферизованной записи, и если да, то насколько сильно?
Ситуация 1 — нет повышенного потребления памяти
Первый тест выполним с объемом данных, заведомо меньшим dirty_background_threshold.
Скорость записи составила 1802 МиБ/с, средняя задержка 3.6 мкс, физическая запись не выполнялась (ios=0/0). Все страницы поместились в памяти, запись не приостанавливалась и выполнялась со скоростью работы памяти.
Во втором тесте используем файл размером примерно в 50% от общего объема памяти.
Теперь скорость составила 277 МиБ/с, средняя задержка 27.9 мкс, и здесь уже выполняется физическая запись на диск (50248). Также был запущен фоновый процесс записи и fio был приостановлен, чтобы предотвратить создание слишком большого количества грязных страниц, что и привело к увеличению задержки записи.
Третий тест — 150% от общего объема памяти.
Скорость снизилась до 39,5 МиБ/с, средняя задержка увеличилась до 197.6 мкс, и, очевидно, что на диск было записано много страниц. Поскольку в этом тесте создается больше грязных страниц, чем в предыдущих, этому пакету данных пришлось ждать дольше, когда память станет доступной для записи, после того как фоновые потоки сбросят страницы на диск.
Ситуация 2 — занято 50% памяти
При "поедании" 50% памяти из 3219 МБ, доступная память уменьшилась до 1567 МБ.
Посмотрим dirty threshold:
Получается 150 МБ для порога фоновой записи и 453 МБ для блокирующей записи.
Задержка записи 200 МБ также равна 3.7 мкс, поскольку реальной записи на диск не было. Это вполне объяснимо, поскольку размер пакета данных в 200 МБ меньше порога сброса грязных страниц.
Теперь попробуем увеличить размер пакета до 50% от общего объема памяти, то есть до 2 ГБ:
Скорость записи снизилась с 277 МиБ/с до 48.1 МиБ/с без нехватки памяти (no memory pressure). Это произошло из-за уменьшения объема памяти, которую можно использовать для кэширования. Также увеличилась средняя задержка записи с 27.9 мкс до 162 мкс.
И третий тест — 150% памяти (6 ГБ):
Здесь мы видим, что при записи объема, значительно превышающего доступную память и пороговые значения сброса грязных страниц, скорость снижается еще сильнее. Это происходит потому, что пишущий процесс вынужден ждать, пока грязные страницы будут сброшены на диск.
Ситуация 3 — занято 75% памяти
При потреблении 75% памяти объем доступной памяти сокращается до 734 МБ.
Пороговые значения следующие:
Фоновая запись запускается при 72 МБ, а блокирующая — 217 МБ.
Теперь, когда занято 85% памяти, dirty threshold, казалось бы, говорит, что 200 МБ записываемых данных поместятся в памяти. Однако запись все же остановилась, в результате средняя задержка записи увеличилась с чуть менее 4 мкс до 16.3 мкс, что оказало огромное влияние на скорость, которая снизилась до 458 МиБ/с с первоначальных более чем 1 ГиБ/с.
Второй тест с размером пакета записи 2 ГБ:
Неудивительно, что при записи 2 ГБ данных мы пересекаем пороговые значения сброса грязных страниц и должны ждать, пока грязные блоки будут записаны на диск. Поскольку доступная память для грязных страниц значительно уменьшилась, запись существенно ограничивается. В результате чего скорость снижается до 28.5 МиБ, а средняя задержка увеличивается до 274 мкс.
Третий тест выполняется с размером пакета записи 6 ГБ:
Тест с 6 ГБ показывает дальнейшее снижение скорости до 24.7 МиБ/с с 28.5 МиБ/с. Поскольку размер записываемых данных значительно превышает объем памяти, в которой могут храниться грязные страницы, задержка буферизованной записи постепенно приближается к задержке физической записи.
Выводы по производительности записи
Какие выводы мы можем сделать из наших экспериментов?
Важно отметить, что мы измеряли производительность буферизированной записи для изолированного одиночного процесса на отдельной виртуальной машине. В реальной жизни, скорее всего, чтение и запись на диск будут выполнять несколько процессов одновременно, и операции ввода-вывода могут оказаться в очереди. Поэтому полученные цифры, скорее всего, будут более оптимистичными, чем в реальной жизни.
Для получения стабильной, предсказуемой производительности буферизованной записи должен быть "доступен" значительный объем памяти для страничного кэша. Наличие значительного объема доступной памяти делает dirty threshold достаточно высоким, чтобы не останавливать процесс записи для балансировки количества грязных страниц и, таким образом, ожидать потоков записи ядра.
Если вдуматься в общую идею использования кэша для записи, то нетрудно догадаться, что его конструкция рассчитана на множество мелких операций ввода-вывода с небольшими объемами данных. То есть скорость чтения и записи ограничивается доступной памятью. Большие объемы ввода-вывода не очень хорошо работают с этим механизмом. Запросы на запись останавливаются, и производительность "внезапно" падает со скорости, близкой к скорости памяти, до скорости, близкой к скорости дискового ввода-вывода.
Отсутствие достаточного объема памяти приведет к тому, что количество грязных страниц превышает порог, в результате чего любая буферизованная запись останавливается из-за ограничения ядром количества грязных страниц в памяти. Это может оказать серьезное влияние на производительность систем с интенсивным вводом-выводом.
Полученные результаты вполне ожидаемые. При записи в кэш объема данных, превышающего его емкость, производительность снижается.
Троттлинг операций записи
Причиной приостановки выполнения операций записи может быть не только ожидание устройства ввода-вывода, но и троттлинг (замедление) операций записи ядром. Этот механизм пытается поддерживать количество грязных страниц ниже значения dirty threshold.
Троттлинг реализуется в ядре Linux в функции balance_dirty_pages() .
Функция balance_dirty_pages() вызывается для каждой буферизованной записи и сообщает потокам отложенной записи ядра (writeback) о превышении порога грязных страниц. Она адаптивно ограничивает запись, если количество грязных страниц превышает (dirty_background_threshold + dirty_threshold)/2 (см. комментарии к функции balance_dirty_pages()).
Троттлинг не отображается ни в обычной статистике Linux, ни в логе ядра (/var/log/messages
), ни в выводе dmesg
. Однако его можно отследить с помощью профилировщика perf:
perf record -e writeback:balance_dirty_pages -filter 'pause > 0'
После записи событий можно использовать perf script
для просмотра собранных данных.
Производительность буферизованного чтения
Производительность буферизованного чтения зависит от доступной памяти для кэша и страниц, уже находящихся в кэше, запрошенных ранее. Очевидно, что страницы в кэше не нужно повторно читать с диска, и это избавляет от накладных расходов и задержек, связанных с прямым чтением с диска. Кэш полностью динамический и использует память, которая не выделена ядру и процессам. Из-за этого сложно прогнозировать точный размер страничного кэша. При нехватке памяти, как правило, страничный кэш является первым кандидатом на переиспользование. То есть чрезмерное выделение памяти сведет к минимуму объем страничного кэша.
Сброс грязных страниц на диск регулируется параметрами ядра, рассмотренными выше. Но для операций чтения подобных прямых настроек нет. Некоторое отношение к этому имеет параметр vm.vfs_cache_pressure, контролирующий баланс между страничным кэшем (page cache) и кэшем объектов каталогов и индексных дескрипторов (dentry и inode кэши).
Linux имеет сложную архитектуру страничного кэша, который находится на уровне vfs (virtual filesystem — виртуальная файловая система). Страницы файлов, отображаемых в память (mmap()
) также являются частью кэша как и прочитанные данные, которые не связаны напрямую с процессом после вызова. Общий размер страничного кэша можно увидеть в /proc/meminfo
в параметре "Cached", но это не чистый размер страничного кэша — он также включает в себя отображаемые в память страницы исполняемых файлов и библиотек.
"Чистые" страницы помещаются в кэш читающим процессом или потоком и могут быть освобождены без каких-либо проблем, поскольку они представляют собой равноценную страницу на диске. Освобождение страниц выполняет kernel page daemon (swapper), когда объем свободной памяти падает ниже pages_low, или сам пользовательский процесс. Стоит отметить файл /proc/sys/vm/drop_caches
, который можно использовать для очистки кэшей вручную.
Ситуация 1 — нет повышенного потребления памяти
Для первого запуска укажем размер в 2 ГБ. Обратите внимание, что при первом запуске кэш пустой и большая часть данных должна быть получена с диска. Параметр --norandommap делает ввод-вывод по-настоящему случайным, поэтому вполне возможно, что некоторые страницы будут прочитаны несколько раз.
Утилита fio читает 2 ГБ фрагментами по 8 КБ. Параметр --invalidate, установленный в 0, означает, что fio не очищает кэш для запрашиваемого файла и, таким образом, может воспользоваться уже кэшированными страницами. Это более важно при втором запуске, целью которого является показать задержку и производительность чтения кэшированных данных.
Второй запуск может использовать заполненный кэш и достичь значительного увеличения скорости (+4 ГиБ/с). Это происходит потому, что фактические запросы ввода-вывода к диску не выполняются (0 в последней строке), а средняя задержка снижается до 1.3 мкс.
Во втором тесте используется 6 ГБ, что превышает доступную память, и все данные не поместятся в кэш.
Первый запуск вынужден снова прочитать данные с диска, но теперь невозможно сохранить все в кэше. Это означает, что уже при первом запуске будет меньше обращений к кэшу и, следовательно, средняя задержка будет немного выше, чем при первом запуске с данными в 2 ГБ (320 мкс <> 327 мкс).
Второй запуск должен выполнить много операций ввода-вывода для получения данных с диска, хотя и стартовал с прогретым кэшем. Средняя задержка возросла до 182 мкс с 1,3 мкс у полностью кэшированного прогона, что также повлияло на скорость.
Ситуация 2 — занято 50% памяти
Для "поедания" памяти снова воспользуемся утилитой eatmemory.
При использовании 50% памяти 2 ГБ страниц не могут быть полностью кэшированы, и поэтому при втором запуске теперь приходится выполнять чтение с диска. Это значительно увеличивает задержку и изменяет среднюю задержку чтения при втором прогоне с 1.3 мкс до 219 мкс, а следовательно, и скорость.
В тесте 6 ГБ с кэшированием все еще хуже, поскольку данных для записи больше. Это отражается на средней задержке, которая достигает 410 мкс для первого прогона и незначительно улучшается для второго.
Ситуация 3 — занято 75% памяти
Для сценария, в котором занято 75% памяти, опять воспользуемся eatmemory. Памяти, которую можно использовать для кэширования, становится еще меньше.
Задержка чтения увеличивается в обоих случаях. Первый запуск мог бы воспользоваться некоторым преимуществом кэширования при наличии достаточного объема памяти. Однако при малом объеме доступной памяти это вряд ли возможно, поэтому задержка увеличивается по сравнению с 50%-ным случаем: 436 мкс против 313 мкс, что снижает скорость.
В ситуации, когда занято 75% памяти и читается много данных кэш, никак не помогает. В обоих случаях получилась одинаковая медленная скорость.
Копаем дальше… упреждающее чтение
В Linux есть еще одно явление — упреждающее чтение (read ahead). Но статистику по нему нигде не увидеть, кроме возможности использовать Kernel Probe (зонды ядра).
Взгляните повнимательнее на следующий запуск fio. Мы изменили значение параметра --rw
на "read
", что означает последовательное сканирование файла. В результате все страницы двухгигабайтного файла читаются только один раз, и мы не можем воспользоваться кэшированием:
Здесь мы сначала очищаем кэш и все страницы должны читаться напрямую с диска.
Отметим здесь три момента.
Первый — в предыдущем тесте случайного чтения мы получили скорость около 24 МиБ/с, которая уже включала некоторое кэширование, а здесь — 133 МиБ/с. Но такого же не должно быть!
Второй — это задержка: средняя задержка последовательного чтения 58 мкс, в то время как случайное чтение из холодного кэша — около 320 мкс.
И последнее — количество операций ввода-вывода (ios): fio выполняет чтение файла в 2 ГБ партиями по 8 КБ, следовательно, в теории понадобится 262144 операций. Однако мы видим всего 8087!
Оказывается, в ядре Linux есть механизм, который обнаруживает последовательное чтение файлов, и динамически увеличивает порцию читаемых данных, заполняя страничный кэш. Поэтому при запросе очередного диапазона страниц они уже могут быть в кэше.
Упреждающее чтение реализовано в функции ядра ondemand_readahead. Эта функция не ведет ни статистики, ни логов в /var/log/messages
или dmesg
. У нее также нет точки трассировки ядра. Однако этот механизм можно отследить через perf probe (perf test ondemand_readahead
), а затем использовать perf record -e probe:ondemand_readahead
и perf script
для просмотра записанных событий. Механизм упреждающего чтения ядра динамически масштабирует размер читаемых данных при последовательном сканировании. Именно поэтому этот механизм более эффективен при сканировании больших объемов данных. Размер запросов увеличивается до установленного максимального размера ввода-вывода для устройства (/sys/block/<DEVICE>/queue/max_sectors_kb
).
Выводы по производительности чтения
Вывод о производительности буферизованного чтения практически такой же, как и для записи. В ядре очень умная система кэширования, которая значительно повышает производительность. Однако улучшение происходит только в том случае, если достаточно памяти для хранения кэшированных страниц, и нет запросов на чтение данных объемом больше кэша.
Заключение
При использовании приложений, для которых производительность ввода-вывода является критичной, необходимо, чтобы объем памяти сервера и использование памяти приложением обеспечивали "активный набор данных" — это общий объем запросов ввода-вывода, который умещается в памяти, доступной для страничного кэша.
Linux не позволяет фиксировать размеры страничного кэша. Единственная возможность — обеспечить, чтобы приложения и процессы выделяли объем памяти, позволяющий Linux поддерживать страничный кэш желаемого размера.
Мы подготовили перевод данной статьи в рамках запуска курса Administrator Linux. Professional. Также всех желающих хотим пригласить на бесплатный урок, где поговорим про память в Linux: cache, swap, dirty pages.
temnikov_vasiliy
оно конечно интересно, но хотелось бы конкретные значения всех этих параметров, при которых всё будет работать зашибись )
lorc
Тогда просто не трогайте настройки по умолчанию. Если бы был какой-то универсальный набор параметров, при которых "все будет работать зашибись", то он как раз и стоял бы по умолчанию.
arheops
В большинстве случаев "зашибись" будет когда памяти столько, что своп и все эти параметры не используются.
Ставьте NVME диски и память, и вам это не нужно будет.
Буферизированная запись на всех продакшн системах должна быть отключена. Без вариантов.
ptr128
Ой не знал и включил отложенную запись для PostgreSQL на temp_tablespace, вынесенной на отдельный диск )))