[Перевод] Linux Page Cache для SRE: основные файловые операции и syscall'ы (часть 1)

3110ecfdd3dcef3d3e161242277a38b1

В этой серии постов я хотел бы поговорить о Linux Page Cache. Я считаю, что данные знания теории и инструментов жизненно необходимы и важны для каждого SRE. Общее понимание как работает Page Cache помогает и в рутинных повседневных задачах, и в экстренной отладке на продакшене. При этом Page Cache часто оставляют без внимания, а ведь его лучшее понимание. как правило, приводит к:

  • более точному планированию емкости системы и лимитов сервисов и контейнеров;

  • улучшенным навыкам отладки приложений, интенсивно использующих память и диски (СУБД и хранилища данных);

  • созданию безопасных и предсказуемых сред выполнения специальных задач, связанных с памятью и/или вводом-выводом (например: сценарии резервного копирования и восстановления,  rsync однострочники и т.д.).

Я покажу, какие утилиты вы можете использовать, когда имеете дело с задачами и проблемами, связанными с Page Cache, как правильно к ним подходить,  и как понять реальное использование памяти.

Подготовка окружения

Начнём с подготовки окружения. Нам понадобиться файл для тестов:

$ dd if=/dev/random of=/var/tmp/file1.db count=128 bs=1M  

И утилита vmtouch. На арче (BTW, I use Arch Linux) её легко поставить из aur'a:

$ yay -Sy vmtouch 

И сбрасываем все кеши, чтобы получить чистую систему:

$ sync; echo 3 | sudo tee /proc/sys/vm/drop_caches

Теперь пришло время засучить рукава и приступить к практическим примерам.

ПРИМЕЧАНИЕ 

На данный момент мы игнорируем как работает утилита  vmtouch. Позже в этой статье я покажу, как написать его альтернативу с практически всеми фичами.

Чтение файлов и Page Cache

Чтение файлов используя read () syscall

Начнём с простой программы, которая считывает первые 2 байта из нашего тестового файла /var/tmp/file1.db.

with open("/var/tmp/file1.db", "br") as f:       
    print(f.read(2))

Обычно такого рода запросы транслируются в системный вызов read(). Давайте запустим скрипт под strace, для того чтобы убедиться что f.read() действительно вызывает системный вызов read() :

$ strace -s0 python3 ./read_2_bytes.py

Результат должен выглядеть как-то так:

...
openat(AT_FDCWD, "./file1.db", O_RDONLY|O_CLOEXEC) = 3
...
read(3, "%B\353\276\0053\356\346Nfy2\354[&\357\300\260%D6$b?'\31\237_fXD\234"..., 4096) = 4096  
...

ПРИМЕЧАНИЕ

Из вывода видно, что системный вызов read() возвращает 4096 байт (одна страница) не смотря на то, что наш скрипт запрашивал только 2 байта. Это пример того, как python оптимизирует работу буферизованного ввода-вывода. Хотя это и выходит за рамки данного поста, но в некоторых случаях важно иметь это в виду.

Теперь давайте проверим, сколько данных закэшировало ядро. Для получения этой информации мы используем vmtouch:

$ vmtouch /var/tmp/file1.db
           Files: 1       LOOK HERE
     Directories: 0          ⬇
  Resident Pages: 20/32768  80K/128M  0.061%  
         Elapsed: 0.001188 seconds

Из вывода мы видим, что вместо 2B данных, которые запрашивал python, ядро закэшировало 80 КБ или 20 страниц.

Ядро линукс в принципе не может загружать в Page Cache ничего меньше 4 КБ или одной страницы. Но почему их там оказалось на 19 страниц больше? Это отличный пример того, как ядро использует опережающее чтение (readahead) и предпочитает выполнять последовательные операции ввода-вывода, а не случайные. Основная идея состоит в том, чтобы предсказать последующие чтения и свести к минимуму количество запросов к диску. Этим поведением можно управлять с помощью системных вызовов:  
posix_fadvise()(man 2 posix_fadvise) и 
readahead() (man 2 readahead).

ПРИМЕЧАНИЕ

Обычно, в продакшене для систем управления базами данных и дисковых хранилищ, не имеет большого смысла в настройках параметров опережающего чтения. Если СУБД не нужны данные, которые были кэшированы при опережающем чтении, политика восстановления памяти ядра (memory reclaim) должна в конечном итоге удалить эти страницы из Page Cache. Так же, как правило, последовательный ввод-вывод не является дорогостоящим для ядра и аппаратного обеспечения. В свою очередь отключение опережающего чтения вообще — может даже навредить и привести к некоторому снижению производительности из-за увеличения числа операций ввода-вывода в дисковых очередях ядра, бÓльшего количества переключений контекста (context switches) и бÓльшего времени для подсистемы управления памятью ядра для распознавания рабочего набора данных (working set). Мы поговорим о политике восстановления памяти (memory reclaim), нагрузке на память (memory pressure) и обратной записи в кэш (writeback) позже в этой серии постов.

Теперь давайте посмотрим как использование posix_fadvise() может уведомить ядро о том, что мы читаем файл случайным образом, и поэтому не хотим иметь никакого опережающего чтения (readahead):

import os

with open("/var/tmp/file1.db", "br") as f:  
    fd = f.fileno()  
    os.posix_fadvise(fd, 0, os.fstat(fd).st_size, os.POSIX_FADV_RANDOM)  
    print(f.read(2))

Перед запуском скрипта нам нужно сбросить все кэши:

$ echo 3 | sudo tee /proc/sys/vm/drop_caches && python3 ./read_2_random.py

Теперь, если вы проверите выдачу vmtouch — вы можете увидеть, что, как и ожидалось, там находится лишь одна страница:

$ vmtouch /var/tmp/file1.db
           Files: 1     LOOK HERE
     Directories: 0        ⬇
  Resident Pages: 1/32768  4K/128M  0.00305%
         Elapsed: 0.001034 seconds

Чтение файлов с помощью системного вызова mmap ()

Для чтение данных из файла мы также можем использовать  системный вызов mmap()(man 2 mmap). mmap()  является «волшебным» инструментом и может быть использован для решения широкого круга задач. Однако для наших тестов нам понадобиться только одна его особенность, а именно, возможность отображать файл на адресное пространство процесса. Это позволяет получить доступ к файлу в виде плоского массива. Я расскажу детально о mmap() далее в этом цикле статей. Но сейчас, если вы совсем не знакомы с mmap() , его API должен быть понятным из следующего примера:

import mmap
with open("/var/tmp/file1.db", "r") as f:
    with mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ) as mm:
        print(mm[:2])

Данный код делает то же самое, что и системный вызов read() . Он читает первые 2 байта из файла.

Также в целях тестирования нам необходимо очистить кэш перед выполнением скрипта:

$ echo 3 | sudo tee /proc/sys/vm/drop_caches && python3 ./read_2_mmap.py

Теперь давайте посмотрим на содержимое Page Cache:

$ vmtouch /var/tmp/file1.db
           Files: 1.       LOOK HERE
     Directories: 0           ⬇
  Resident Pages: 1024/32768  4M/128M  3.12%
         Elapsed: 0.000627 seconds

Как вы видите mmap() выполнил еще более агрессивный readahead, чем read().

Давайте теперь изменим readahead при помощи системного вызва madvise() как это было сделано с fadvise().

import mmap
with open("/var/tmp/file1.db", "r") as f:
    with mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ) as mm:
        mm.madvise(mmap.MADV_RANDOM)
        print(mm[:2])

Запускаем:

$ echo 3 | sudo tee /proc/sys/vm/drop_caches && python3 ./read_2_mmap_random.py

и содержимое Page Cache:

$ vmtouch /var/tmp/file1.db
           Files: 1     LOOK HERE
     Directories: 0        ⬇
  Resident Pages: 1/32768  4K/128M  0.00305% 
         Elapsed: 0.001077 seconds

Как вы можете видеть с MADV_RANDOM нам удалось загрузить ровно одну страницу в Page Cache.

Запись в файл и Page Cache

Теперь давайте поэкспериментируем с записью.

Запись в файлы с помощью системного вызова write ()

Давайте продолжим работу с нашим экспериментальным файлом и попробуем записать первые 2 байта:

with open("/var/tmp/file1.db", "br+") as f:
    print(f.write(b"ab"))

ПРИМЕЧАНИЕ

Будьте осторожны и не открывайте файл в режиме w. Он перезапишет ваш файл и сделает его размером в 2 байта. Нам нужен режим r+.

Удалите все из Page Cache и запустите приведенный выше скрипт:

sync; echo 3 | sudo tee /proc/sys/vm/drop_caches && python3 ./write_2_bytes.py

Теперь давайте проверим содержимое Page Cache.

$ vmtouch /var/tmp/file1.db
           Files: 1     LOOK HERE
     Directories: 0        ⬇
  Resident Pages: 1/32768  4K/128M  0.00305%
         Elapsed: 0.000674 seconds

Как вы можете видеть, в Page Cache находится 1 страница данных. Это достаточно важное наблюдение, так как, если происходят записи размером меньше размера страницы, то им будут предшествовать 4 Кб чтения, для того, чтобы загрузить данные в Page Cache.

Также мы можем проверить состояние грязных (dirty) страниц, заглянув в файл статистики памяти cgroup.

Получаем текущую cgroup:

$ cat /proc/self/cgroup
0::/user.slice/user-1000.slice/session-4.scope
$ grep dirty /sys/fs/cgroup/user.slice/user-1000.slice/session-3.scope/memory.stat
file_dirty 4096

Запись в файл с помощью mmap () syscall

Давайте теперь повторим наш опыт с записью, но будем использовать в этот раз mmap():

import mmap
with open("/var/tmp/file1.db", "r+b") as f:
    with mmap.mmap(f.fileno(), 0) as mm:
        mm[:2] = b"ab"

Вы можете самостоятельно повторить вышеописанные команды vmtouch, cgroup и grep. В итоге вы должны получить тот же результат. Единственным исключением является опережающее чтение. По умолчанию mmap() загружает гораздо больше данных в кэш страниц даже при записи.

Грязные страницы кеша (dirty pages)

Как мы видели ранее, процесс генерирует грязные страницы путем записи в файлы через кэш.

Linux предоставляет несколько вариантов получения количества грязных (dirty) страниц. Первый и самый старый из них — это прочитать системный файл /proc/memstat:

$ cat /proc/meminfo | grep Dirty
Dirty:                 4 kB

Часто такую системную информацию трудно интерпретировать и использовать, поскольку мы не можем точно определить, какой процесс их сгенерировал и к какому файлу они относятся.

Поэтому, как было показано выше, лучшим вариантом для получения данной информации лучше всего использовать cgroup:

$ cat /sys/fs/cgroup/user.slice/user-1000.slice/session-3.scope/memory.stat  | grep dirt
file_dirty 4096

Если же ваша программа использует mmap() для записи в файлы, у вас есть еще один вариант получения статистики с детализацией по каждому процессу. В procfs есть специальный файл для каждого процесса /proc/PID/smap, где отображаются счетчики памяти с разбивкой по областям виртуальной памяти (VMA). Как мы помним, с помощью mmap() процесс отображает файл на свою память, что следовательно создает VMA с файлом и соответствующей информацией. Мы можем получить грязные страницы, найдя там:

  • Private_Dirty — объем грязных данных, сгенерированных этим процессом;

  • Shared_Dirty — грязные страницы других процессов. Эта метрика будет отображать данные только для страниц, на которые есть ссылки (referenced memory). Это означает, что процесс должен был обратиться к этим страницам раньше и сохранить их в своей таблице страниц (page table) (подробнее позже).

$ cat /proc/578097/smaps | grep file1.db -A 12 | grep Dirty
Shared_Dirty:          0 kB
Private_Dirty:       736 kB

Но что, если мы хотим получить статистику наличия грязных страниц (dirty pages) для конкретного файла. Чтобы ответить на этот вопрос, ядро Linux предоставляет 2 файла в procfs: /proc/PID/pagemap и /proc/kpageflags. Я покажу, как используя эти файлы написать наш собственный инструмент позже в этом цикле статей, а сейчас мы можем использовать инструмент отладки памяти из репозитория ядра Linux чтобы получить информацию о страницах файла:  page-types (https://github.com/torvalds/linux/blob/master/tools/vm/page-types.c).

$ sudo page-types -f /var/tmp/file1.db -b dirty
             flags      page-count       MB  symbolic-flags                     long-symbolic-flags
0x0000000000000838             267        1  UDl_____M_____________________________       uptodate,dirty,lru,mmap
0x000000000000083c              20        0  RUDl_____M______________________________       referenced,uptodate,dirty,lru,mmap
             total             287        1

Я отфильтровал все страницы нашего файла /var/tmp/file1.db по наличию грязного (dirty) флага. В выводе вы можете видеть, что файл содержит 287 грязных страниц или 1 МБ грязных данных, которые в конечном итоге будут записаны обратно в хранилище. page-type объединяет страницы по флагам, поэтому в выводе вы можете увидеть 2 набора страниц. У обоих есть грязный флаг D, и разница между ними заключается в наличии флага R.

Синхронизация данных файла:  fsync (),  fdatasync () и msync ()

Мы уже использовали команду для синхронизации (man 1 sync) всех грязных страниц системы на диски перед каждым тестом, для того чтобы получить свежий Page Cache. Но что делать, если мы хотим написать систему управления базами данных, и нам нужно быть уверенными, что все записи попадут на диски до того, как произойдет отключение питания или другие аппаратные ошибки. Для таких случаев linux предоставляет несколько способов заставить ядро совершить сброс грязных страницы для конкретного файла из Page Cache на диски:

  • fsync() — блокируется до тех пор, пока не будут синхронизированы все грязные страницы конкретного файла и его метаданные;

  • fdatasync() — то же самое, но без метаданных;

  • msync() — то же самое, что делает fsync(), но для файла, отображенного на память процесса;

  • флагами открытия файла: O_SYNC или O_DSYNC сделают все записи в файл синхронными по умолчанию.

ПРИМЕЧАНИЕ

Вам все еще нужно заботиться о барьерах записи (write barriers) и понимать, как работает ваша файловая система. Обычно операции добавления в конец файла безопасны и не могут повредить данные которые были записаны до этого Другие же типы операций записи могут повредить ваши файлы (например, даже с настройками журнала по умолчанию в ext4). Поэтому все системы управления базами данных, такие как MongoDB, PostgreSQL, Etcd, Dgraph и т. д., используют журналы предварительной записи (WAL). Если вам интересно узнать более подробнее об этой теме, — можно пожалуй начать с поста в блоге Dgraph.

А вот и пример синхронизации файлов:

import os
with open("/var/tmp/file1.db", "br+") as f:
    fd = f.fileno()
    os.fsync(fd)

Проверяем наличие данных файла в Page Cache с помощью mincore ()

Настало время выяснить, каким же таким способом vmtouch удается показать нам, сколько страниц того или иного файла содержит Page Cache.

Секрет заключается в системном вызове mincore() (man 2 mincore). mincore() буквально означает «память в ядре» (memory in core). Его параметрами являются начальный адрес виртуальной памяти, длина адресного пространства и результирующий вектор. mincore() работает с памятью (а не с файлами), поэтому его можно использовать и для проверки, была ли вытеснена анонимная память в своп (swap).

man 2 mincore

mincore() returns a vector that indicates whether pages of the calling process«s virtual memory are resident in core (RAM), and so will not cause a disk access (pagefault) if referenced. The kernel returns residency information about the pages starting at the address addr, and continuing for length bytes.

Поэтому для повторения фичи vmtouch нам нужно сначала отобразить файл в виртуальную память процесса, даже если мы не собираемся выполнять ни чтение, ни запись.

Теперь у нас есть все необходимое для написания нашего собственного простого vmtouch, чтобы вывести информацию из Page Cache о файле. Я использую Go, потому что, к сожалению, в Python нет простого способа вызвать mincore() syscall:

package main

import (
	"fmt"
	"log"
	"os"
	"syscall"
	"unsafe"
)

var (
	pageSize = int64(syscall.Getpagesize())
	mode     = os.FileMode(0600)
)

func main() {
	path := "/var/tmp/file1.db"

	file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NOATIME, mode)
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()

	stat, err := os.Lstat(path)
	if err != nil {
		log.Fatal(err)
	}
	size := stat.Size()
	pages := size / pageSize

	mm, err := syscall.Mmap(int(file.Fd()), 0, int(size), syscall.PROT_READ, syscall.MAP_SHARED)
	defer syscall.Munmap(mm)

	mmPtr := uintptr(unsafe.Pointer(&mm[0]))
	cached := make([]byte, pages)

	sizePtr := uintptr(size)
	cachedPtr := uintptr(unsafe.Pointer(&cached[0]))

	ret, _, err := syscall.Syscall(syscall.SYS_MINCORE, mmPtr, sizePtr, cachedPtr)
	if ret != 0 {
		log.Fatal("syscall SYS_MINCORE failed: %v", err)
	}

	n := 0
	for _, p := range cached {
		// the least significant bit of each byte will be set if the corresponding page 
		// is currently resident in memory, and be clear otherwise.
		if p%2 == 1 {
			n++
		}
	}

	fmt.Printf("Resident Pages: %d/%d  %d/%d\n", n, pages, n*int(pageSize), size)
}

Запускаем:

$ go run ./main.go
Resident Pages: 1024/32768  4194304/134217728

И сверяем вывод с vmtouch:

$ vmtouch /var/tmp/file1.db
           Files: 1         LOOK HERE
     Directories: 0            ⬇
  Resident Pages: 1024/32768  4M/128M  3.12%
         Elapsed: 0.000804 seconds

Вывод

Как видно из статьи ядро Linux предоставляет широкий набор возможностей для взаимодействия и управления Page Cache, которые на мой взгляд должен знать каждый SRE.

Habrahabr.ru прочитано 34519 раз