Внутреннее устройство Linux
Без понимания того, что происходит внутри системы, первый же зависший расчёт или необъяснимо медленный ввод-вывод превращается в гадание.
Слайды к главе. Материал главы изложен также в лекции «Внутренности Linux» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.
Значение внутреннего устройства
Знание внутреннего устройства необходимо в четырёх ситуациях. Когда расчёт завис или исчерпал всю память, требуется ставить диагноз, а не перезапускать наугад. Если программа работает медленнее ожидаемого, необходимо знать, куда смотреть. При написании кода полезно представлять, во что обходится каждый системный вызов, сделанный в цикле. Наконец, вся современная облачная инфраструктура выросла из механизмов ядра Linux, поэтому без них разобраться в ней невозможно.
Облаком называют чужие компьютеры, расположенные в другом месте. Те же процессы, та же память, та же сеть, только в удалённом дата-центре и с чужим счётом за электричество.
Практическая ценность глубоких знаний
Быстрая диагностика проблем
Когда программа ведёт себя странно, вопрос состоит не в том, «что делать», а в том, «что спросить у системы»: чем процесс сейчас занят, куда уходит его время и в каком состоянии он застрял.
# Вместо случайного тыкания
strace -p <pid> # что делает процесс?
perf record -g <command> # где тратится время?
cat /proc/<pid>/status # в каком состоянии?
Служба периодически «зависала» на несколько минут и восстанавливалась сама; в top при этом не наблюдалось ни загрузки процессора, ни повышенного расхода памяти. Разгадка заключалась в состоянии процесса: ps показывал D, непрерываемое ожидание ввода-вывода. Служба писала на сетевой диск NFS, тот время от времени переставал отвечать, и процесс, пробуждаемый лишь ответом, ждал. Проблема устранялась не в коде, а таймаутами монтирования и числом попыток, разрешённых на повтор.
Эффективное программирование
Пока нет представления о цене обращения к ядру, оптимизировать нечего. Порядки величин следующие:
- системный вызов стоит около тысячи тактов процессора;
- переключение контекста, происходящее между процессами, стоит от тысячи до десяти тысяч тактов;
- промах по странице памяти занимает доли микросекунды, если страница уже загружена в память, и от сотен микросекунд на NVMe до единиц миллисекунд на диске, если её приходится читать.
Обращаться к ядру в тесном цикле нецелесообразно: данные накапливаются в буфере и передаются разом. Разница между write на каждое число и write на мегабайтный буфер измеряется порядками.
Облачные технологии
Контейнеры, оркестраторы, бессерверные вычисления представляют собой надстройки над механизмами ядра, которым посвящена глава. Docker использует те же cgroups и пространства имён, только удобно упакованные. Kubernetes управляет ими же, но в масштабе кластера из сотен машин, работающего годами. AWS Lambda — та же изоляция, доведённая до очень быстрого запуска.
1. Процессы
Детальное понимание процессов
Процесс как учётная единица
Привычное определение процесса как запущенной программы верно и бесполезно. Полезнее считать процесс учётной единицей, на которую ядро записывает всё, чем программа владеет, и всё, что о ней знает.
Процесс владеет четырьмя вещами: собственным виртуальным адресным пространством, набором открытых файловых дескрипторов, учётными данными и привилегиями (от чьего имени он работает) и маской сигналов вместе с обработчиками, назначенными на них. Этот набор ядро освобождает при завершении процесса, поэтому дескриптор, оставленный незакрытым, существует столько же, сколько сам процесс.
Ядро знает о процессе следующее: идентификаторы самого процесса и его родителя (PID и PPID), от какого пользователя и группы он запущен (UID и GID), с каким приоритетом его планировать, в каком состоянии он сейчас находится и сколько ресурсов уже израсходовано. Всё это расположено в файловой системе /proc, открытой для чтения.
Структура процесса в ядре
// Упрощенная task_struct (include/linux/sched.h)
struct task_struct {
volatile long state; // состояние процесса
void *stack; // указатель на стек
struct mm_struct *mm; // память процесса
struct files_struct *files; // открытые файлы
struct signal_struct *signal; // сигналы
// ... сотни полей
};
Всё перечисленное ядро предоставляет наружу через файловую систему /proc, позволяющую исследовать любой работающий процесс:
# Анализ конкретного процесса
ls -la /proc/1234/
cat /proc/1234/maps # память процесса
cat /proc/1234/status # состояние и лимиты
ls /proc/1234/fd/ # открытые файлы
Создание процессов: fork() и exec()
Механизм Copy-on-Write (CoW)
Системный вызов fork(), создающий копию текущего процесса, в наивной реализации копировал всю память родителя. Для расчёта, занявшего десять гигабайт, это означало те же десять гигабайт, скопированных впустую, притом что сразу после fork() дочерний процесс обычно вызывает exec() и вся скопированная память немедленно уничтожается.
Отсюда copy-on-write, копирование при записи. При fork() память не копируется. Страницы помечаются как доступные только для чтения, и оба процесса читают их из одного и того же физического места. Настоящая копия создаётся лениво, в тот момент, когда один из двух процессов пытается в страницу записать, и только для затронутой страницы. В типичном случае «породить процесс и сразу запустить в нём другую программу» копировать почти ничего не приходится.
pid_t pid = fork();
if (pid == 0) {
// Дочерний процесс
// Страницы памяти разделяются до первой записи
execve("/bin/ls", args, env);
} else {
// Родительский процесс
waitpid(pid, &status, 0);
}
Потоки и процессы
Архитектурные различия
| Аспект | Процесс | Поток |
|---|---|---|
| Память | Изолированная | Разделяемая |
| Файлы | Отдельные таблицы | Общая таблица |
| Стоимость создания | Высокая | Низкая |
| Изоляция | Полная | Минимальная |
Практические сценарии использования
Выбор определяется тем, что для задачи важнее: изоляция, защищающая от чужих ошибок, или общая память.
Процессы применяются, когда падение одной части работы не должно приводить к падению остальных. Память у них раздельная, поэтому обработчик, завершившийся из-за собственной ошибки, уносит с собой только себя. По той же причине процессы необходимы, если части программы работают от разных пользователей и с правами, выданными по отдельности. Если расчёт предстоит распределить по нескольким машинам, выбора нет: потоки, существующие в общем адресном пространстве, за пределы одной машины не выходят.
Потоки применяются, когда частям работы требуется общее состояние, будь то разделяемый кеш, пул соединений или общий массив с данными. Обмен между ними не требует ни сериализации, ни копирования, задержка оказывается на порядки меньше, а кеш процессора, прогретый одним потоком, работает на всех сразу. Платой является необходимость самостоятельно следить за одновременным доступом к общим данным. В Python добавляется глобальная блокировка интерпретатора, которая рассматривается в главе про многопоточность.
Межпроцессное взаимодействие (IPC)
Сигналы как асинхронные уведомления
// Отправка сигнала
kill(pid, SIGTERM);
// Обработка сигнала
void handler(int sig) {
// Асинхронно! Осторожно с shared state
}
signal(SIGTERM, handler);
Обработчик прерывает выполнение программы в произвольной точке, поэтому вызывать из него разрешено далеко не всё. Если сигнал придёт в середине malloc, повторный malloc из обработчика застанет кучу в противоречивом состоянии и окончательно её повредит. Безопасным является только список функций, оговорённый стандартом. Их называют async-signal-safe, и printf в него не входит. Обычная практика состоит в том, чтобы выставить в обработчике флаг и сразу вернуться, а содержательную работу выполнить в основном цикле программы.
SIGHUP получают процессы, привязанные к терминалу, когда сессия закрывается, и по умолчанию он их завершает. Поэтому долгий расчёт, запущенный по ssh, завершается вместе с разорванным соединением. Пережить закрытие сессии процесс может, если его отвязать от терминала: для этого служат nohup, setsid и встроенная в оболочку команда disown, а также screen и tmux из главы про инструменты Linux.
Каналы как односторонняя связь
# Неименованные каналы
ls -la | grep ".txt" | wc -l
# Именованные каналы (FIFO)
mkfifo mypipe
echo "data" > mypipe &
cat mypipe
Канал представляет собой буфер, выделенный в памяти ядра, по умолчанию около 64 КБ. Пока в буфере остаётся место, пишущая сторона не задерживается; когда буфер заполнен, write блокируется, пока читающая сторона не заберёт накопленное. Так работает конвейер команда | команда. Быстрый производитель, ограниченный медленным потребителем, сам замедляется до его скорости, и никакой явной синхронизации писать не приходится.
Разделяемая память и максимальная производительность
// Создание shared memory
int shm_id = shmget(key, size, IPC_CREAT | 0666);
void *ptr = shmat(shm_id, NULL, 0);
// Использование
memcpy(ptr, data, data_size);
Разделяемая память остаётся самым быстрым способом обмена, поскольку обмена как такового не происходит. Оба процесса работают с одними и теми же физическими страницами, отображёнными к ним обоим, записанные данные никуда не копируются, задержка сводится к обычному обращению в память. Для передачи больших массивов наподобие кадров, снятых с детектора, альтернативы практически нет.
Платой является то, что ядро больше ничем не помогает. Канал упорядочивает данные, а разделяемая память не гарантирует ничего. Один процесс пишет, другой в это же время читает, и на выходе получается смесь из недописанных значений. Синхронизацию (семафоры, блокировки, барьеры) приходится строить самостоятельно, и гонки, проявляющиеся раз в неделю, являются здесь самой обычной ошибкой.
Семафоры и координация доступа
// Бинарный семафор (мьютекс)
sem_wait(&mutex);
// Критическая секция
sem_post(&mutex);
На практике достаточно двух видов семафоров. Двоичный принимает значения 0 и 1 и работает как мьютекс, защищающий критическую секцию. Считающий хранит целое число и ограничивает число пользователей, одновременно допущенных к ресурсу; например, разрешает читать общий файл не более чем четырём потокам сразу.
Состояния процессов: полный цикл жизни
Детали каждого состояния
Основные буквы состояния в выводе ps:
R (running / runnable) означает, что процесс либо выполняется в настоящий момент, либо стоит в очереди планировщика и готов выполняться. Ограничивает его только доступность процессорного времени. Если расчёт долго находится в R, он считает.
S (interruptible sleep) означает, что процесс чего-то ожидает: завершения ввода-вывода, семафора, сигнала. Такое ожидание прерывается сигналом, то есть процесс, находящийся в нём, нормально завершается по kill. Для программ, больше ожидающих, чем считающих, S является обычным рабочим состоянием.
D (uninterruptible sleep) описывает то же ожидание, но аппаратного ввода-вывода, диска или сети. Оно не прерывается сигналом, включая kill -9. С ним сталкиваются, когда процесс «завис намертво» и не завершается ничем; чаще всего причиной является отказавший сетевой диск, смонтированный по NFS. Разбираться в таком случае приходится с устройством.
T (stopped) показывает, что процесс приостановлен сигналом SIGSTOP или SIGTSTP и продолжится по SIGCONT. Так работает нажатие Ctrl+Z в оболочке, и так же останавливают процесс отладчики, устанавливающие точку останова.
Z (zombie) означает, что процесс уже завершился, но родитель не забрал его код возврата. Ресурсы освобождены, осталась одна запись в таблице процессов ради кода возврата. Завершить зомби нельзя, он и так завершён; запись исчезнет, когда родитель вызовет wait, а если родитель этого не делает, то после его завершения. Осиротевших зомби забирает процесс init.
Практический мониторинг
Сводку по состояниям всех процессов система выдаёт одной строкой; с неё удобно начинать разбор: она сразу показывает, есть ли в системе процессы, застрявшие в D, или накопившиеся зомби.
# Понимание состояния процессов
ps aux | awk '{print $8}' | sort | uniq -c
# Поиск проблемных процессов
# Процессы в D состоянии
ps aux | awk '$8=="D" {print $0}'
# Zombie процессы
ps aux | awk '$8=="Z" {print $0}'
2. Планировщик
Эволюция планировщиков Linux
Задача планировщика состоит в том, чтобы на каждом переключении решить, какому из готовых процессов отдать процессор. Способ решения за тридцать лет менялся трижды, и всякий раз причиной была практика, достигшая предела возможностей.
O(N) планировщик (по 2.4 включительно)
// Псевдокод старого планировщика
for (each task in system) {
calculate_goodness(task);
if (goodness > max_goodness) {
next_task = task;
max_goodness = goodness;
}
}
Первый планировщик при каждом переключении перебирал все процессы в системе и выбирал лучший по баллу. Пока процессов десятки, это незаметно, но на тысяче процессов сам выбор начинает занимать заметное время, а на многоядерной машине все ядра, кроме того, конкурируют за одну общую очередь.
O(1) планировщик (2.6.0–2.6.22)
Следующий планировщик устранил сплошной перебор. Процессы были разложены по очередям приоритетов, а сами очереди помечены битовой маской, благодаря которой поиск самого приоритетного непустого уровня стал делом одной инструкции. Отработавшие свой квант процессы складывались во вторую очередь, и когда первая пустела, очереди менялись местами. Время выбора перестало зависеть от числа процессов, а у каждого ядра появилась собственная очередь, и с этого момента Linux стал полноценно работать на многопроцессорных системах.
CFS (Completely Fair Scheduler) (2.6.23+)
// Основан на красно-чёрных деревьях
struct rb_root_cached {
struct rb_root rb_root;
struct rb_node *rb_leftmost;
};
Действующий сегодня планировщик (точнее, его прямой потомок) устроен иначе. Он не раздаёт кванты по приоритетам, а ведёт для каждого процесса счётчик, накапливающий уже полученное процессорное время, и всегда запускает тот процесс, которому досталось меньше всех. Все готовые процессы хранятся в красно-чёрном дереве, отсортированные по этому счётчику, и выбор следующего сводится к взятию самого левого узла.
Приоритеты и политики планирования
Политики реального времени
Для задач, где важна не общая пропускная способность, а гарантированное время реакции (управление установкой, сбор данных с жёстким тактом), в ядре предусмотрены две политики реального времени.
SCHED_FIFO не выделяет процессу квант времени вовсе. Получив процессор, процесс удерживает его, пока сам не отдаст или пока его не вытеснит процесс, обладающий более высоким приоритетом реального времени. Отсюда следует опасность: зациклившаяся программа с этой политикой занимает ядро навсегда, и обычные процессы, включая запущенную пользователем оболочку, до него уже не доберутся.
SCHED_RR отличается тем, что квант всё же существует, по умолчанию 100 мс. Процессы одинакового приоритета, стоящие в очереди, сменяют друг друга по кругу, поэтому один зациклившийся процесс не блокирует остальные. Эта политика безопаснее, и начинать лучше с неё.
Обычные политики
Всё остальное, а это практически все процессы, включая пользовательский расчёт, работает под политикой SCHED_NORMAL (она же SCHED_OTHER). Жёстких гарантий она не даёт, но и никого не лишает процессорного времени полностью. Приоритет плавающий, планировщик корректирует его сам. Процессу, который много спит в ожидании ввода, например реагирует на нажатия клавиш, достаётся небольшая прибавка, удерживающая систему отзывчивой. Процессу, который непрерывно считает, долю слегка урезают: своё он доберёт, пока остальные ждут.
Значения nice и приоритеты
# Установка nice значения
nice -n 10 ./long_running_task # низкий приоритет
nice -n -20 ./critical_task # высокий приоритет
# Изменение running процесса
renice -n 5 -p 1234
Шкала nice идёт от -20 до +19. Чем больше значение, тем ниже приоритет: процесс «вежливее» и охотнее уступает. Понижать себе приоритет может любой пользователь, а повышать, то есть уходить в отрицательные значения, разрешено только root.
CFS: внутреннее устройство
Ключевые концепции
Поведение планировщика определяется несколькими величинами.
Виртуальное время выполнения (vruntime) — счётчик, растущий у каждого процесса, пока тот занимает процессор. Скорость роста зависит от приоритета: у высокоприоритетного медленнее, у низкоприоритетного быстрее. Планировщик всегда выбирает процесс с наименьшим vruntime: «справедливым» считается тот, кому досталось меньше всех. Значение nice влияет на скорость накопления этого счётчика.
Целевой задержкой (target latency) называют срок, за который каждый готовый к выполнению процесс должен успеть получить процессор хотя бы раз. По умолчанию это 6 мс, умноженные на поправку по числу процессоров: единица плюс двоичный логарифм их числа, причём учитываются не более восьми. Получается от 6 мс на одном ядре до 24 мс на восьми и более. За отзывчивость интерфейса платят более частыми переключениями.
Минимальный квант (minimal granularity) задаёт нижнюю границу доли процесса. Готовых процессов бывает очень много, и честное деление целевой задержки на всех дало бы каждому доли микросекунды: система занималась бы одними переключениями. Поэтому квант не делают короче 0,75 мс с той же поправкой по числу процессоров, слегка нарушая справедливость.
Реализация на красно-чёрных деревьях
// Вставка процесса в дерево
struct sched_entity {
struct rb_node run_node;
u64 vruntime;
// ...
};
// Быстрый поиск процесса с минимальным vruntime
struct task_struct *pick_next_task(struct rq *rq) {
struct rb_node *left = rb_first_cached(&rq->tasks_timeline);
return rb_entry(left, struct task_struct, se.run_node);
}
Вставка и удаление обходятся в O(log N), а процесс с наименьшим vruntime всегда находится в самом левом узле, причём указатель на него ядро хранит отдельно и обновляет по ходу работы, поэтому самая частая операция планировщика, выбор следующего процесса, выполняется за постоянное время.
Управление планировщиком на практике
Привязка к ядрам
# Привязка процесса к конкретным ядрам
taskset -c 0,1 ./application
# Просмотр текущей маски
taskset -p 1234
# Запуск с распределением по ядрам
numactl --cpunodebind=0,1 --membind=0,1 ./app
Критичный процесс, например управляющий установкой, изолируют на выделенных ядрах, чтобы остальная система ему не мешала. Долгий расчёт привязывают к одному ядру ради прогретого кеша: мигрируя между ядрами, процесс каждый раз начинает с холодного кеша и теряет на этом больше, чем выигрывает от свободного ядра. На многосокетных серверах привязку используют вместе с NUMA, чтобы процесс работал с той памятью, которая физически подключена к его сокету.
Настройка планировщика
Параметры планировщика доступны для просмотра и изменения через файловую систему, но за последние годы они переезжали дважды, поэтому путь зависит от версии ядра. Без измерений изменять их не рекомендуется: значения по умолчанию разумны, и проблема почти никогда не заключается в них.
На ядрах до 5.13 параметры располагались в /proc/sys/kernel/:
cat /proc/sys/kernel/sched_min_granularity_ns
cat /proc/sys/kernel/sched_latency_ns
echo 10000000 > /proc/sys/kernel/sched_latency_ns
В 5.13 они были вынесены из sysctl в отладочную файловую систему с сохранением прежних
имён. Такой вариант ещё встречается на завершающих поддержку LTS наподобие Ubuntu 22.04
с исходным ядром 5.15:
cat /sys/kernel/debug/sched/min_granularity_ns
cat /sys/kernel/debug/sched/latency_ns
С 6.6 CFS заменён на EEVDF, и параметры, названные под старый планировщик,
исчезли вместе с ним. С этим случаем столкнётся большинство читателей: в Ubuntu 24.04
установлено ядро 6.8, в Debian 13 — 6.12, оба с EEVDF, и старых имён
в отладочной файловой системе там нет. Ближайший по смыслу параметр называется base_slice_ns:
cat /sys/kernel/debug/sched/base_slice_ns
Какой из трёх случаев имеет место, показывает uname -r.
Мониторинг планировщика
При подозрении, что процесс не получает процессорного времени, лучше обращаться не к top, а к статистике планировщика. Там видно, сколько раз процесс вытесняли и сколько он в сумме простоял в очереди.
# Статистика переключений
cat /proc/1234/sched
# Очереди выполнения
cat /proc/sched_debug
# Профилирование
perf sched record ./application
perf sched latency
3. Прерывания
Архитектура прерываний в x86/x64
Аппаратные прерывания
Прерыванием устройство сообщает процессору о готовности и требует обработки. Источников в обычной машине немного: системные таймеры, сетевые карты, дисковые контроллеры и устройства, подключённые к шине USB. В лаборатории к ним добавляется всё, что установлено в крейте: АЦП, счётчики, генераторы задержек.
Драйвер регистрирует обработчик на номер прерывания, и ядро вызывает его каждый раз, когда устройство подаёт сигнал:
// Регистрация обработчика
request_irq(IRQ_NUMBER, handler, flags, name, dev);
// Обработчик прерывания
static irqreturn_t my_handler(int irq, void *dev_id) {
// Быстрая обработка
return IRQ_HANDLED;
}
Исключения процессора
Процессор прерывает и сам себя, встретив ситуацию, с которой не справляется в одиночку. Такие события делятся на три вида по тому, чем они заканчиваются. Отказ (fault) исправим: типичный пример — обращение к странице, отсутствующей в физической памяти; ядро подгружает её и повторяет ту же инструкцию. Ловушка (trap) вызывается намеренно, так работают точки останова в отладчике, и выполнение продолжается со следующей инструкции. Авария (abort) не исправляется: состояние потеряно безвозвратно.
Обработка прерываний: верхняя и нижняя половины
Верхняя половина
Пока выполняется обработчик прерывания, на этом ядре запрещены все прерывания (так с версии 2.6.35, прежде — только того же типа), а планировщик, распределяющий процессорное время, не работает вовсе. Обработчик обязан уложиться в микросекунды: подтвердить устройству получение, забрать данные из регистров, поставить остальное в очередь, разбираемую позже, и вернуться. Ничего блокирующего внутри быть не может: засыпает процесс, а обработчик выполняется вне какого-либо процесса.
// Типичный upper half
irqreturn_t eth_interrupt(int irq, void *dev_id) {
struct net_device *dev = dev_id;
disable_irq_nosync(dev->irq);
schedule_work(&dev->tx_work);
return IRQ_HANDLED;
}
Нижняя половина
Содержательную работу обработчик откладывает в нижнюю половину. Способов отложить три, и различаются они тем, насколько поздно и в каком контексте выполнится отложенное.
SoftIRQ — самый быстрый механизм, но встроенный в ядро статически. Набор мягких прерываний фиксирован (сеть, блочные устройства), и добавить собственное невозможно. Tasklet создаётся динамически и потому подходит для драйверов, написанных под собственное оборудование, но один и тот же tasklet никогда не выполняется на двух ядрах одновременно, что упрощает разработку и ограничивает масштабирование. Очередь работ (work queue) выполняется в контексте обычного процесса ядра, поэтому ей разрешено засыпать и выполнять блокирующие вызовы. За это платят задержкой, и очереди применяют для длительной работы.
// Work queue пример
DECLARE_WORK(my_work, my_work_function);
void my_work_function(struct work_struct *work) {
// Медленная обработка
process_packets();
enable_irq(dev->irq);
}
Практическая работа с прерываниями
Мониторинг прерываний
# Статистика прерываний
cat /proc/interrupts
# Распределение прерываний по CPU
cat /proc/irq/*/smp_affinity
# Изменение привязки прерываний
echo 2 > /proc/irq/24/smp_affinity
Оптимизация обработки
Когда прерываний становится слишком много (на быстрой сетевой карте или скоростном АЦП их десятки тысяч в секунду), обработку настраивают.
Прерывания распределяют между ядрами процессора. По умолчанию все они могут поступать на нулевое ядро, которое перегружается, пока остальные простаивают. Вместо устаревшего механизма прерываний по выделенной линии используют MSI, где устройство сообщает о событии записью в память; это снимает ограничение на число линий и упрощает распределение. Для сетевых карт подбирают длину очередей: слишком короткая теряет пакеты на всплесках, слишком длинная увеличивает задержку. Включают объединение прерываний (coalescing): устройству разрешают накопить несколько событий и сообщить о накопленном разом, при этом число прерываний падает в разы ценой небольшой задержки.
4. Системные вызовы
Механизм системных вызовов
Переключение между пространствами
Программа не может обратиться к диску или сети напрямую. Всё, связанное с оборудованием, выполняет ядро, а программа запрашивает это системным вызовом. На уровне процессора запрос выглядит следующим образом: номер нужной операции и её аргументы раскладываются по регистрам, после чего выполняется одна специальная инструкция, переводящая процессор в режим ядра.
Пользовательское пространство → Пространство ядра:
; x86-64 системный вызов
mov rax, 1 ; номер syscall (write)
mov rdi, 1 ; fd (stdout)
mov rsi, buffer ; буфер
mov rdx, count ; размер
syscall ; переход в ядро
За инструкцией syscall стоят шесть шагов. Процессор сохраняет контекст пользовательского кода, то есть регистры и указатель, хранящий адрес инструкции, и переходит в режим ядра. Ядро проверяет переданные параметры, поскольку доверять пользовательской программе нельзя; адрес буфера может указывать куда угодно. Затем выполняется собственно операция, результат записывается туда, где его ожидает программа, и сохранённый контекст восстанавливается.
Отсюда следует стоимость системного вызова: сама операция может быть тривиальной, а сохранение, проверка и восстановление выполняются в любом случае.
Таблица системных вызовов
// Определение syscall (fs/read_write.c)
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
struct fd f = fdget_pos(fd);
// ... обработка
return ret;
}
Дескриптор, полученный от программы, напрямую не используется, а сначала преобразуется в проверенный объект ядра. Так устроен любой системный вызов: ни одному числу из пользовательского пространства ядро не доверяет.
Безопасность системных вызовов
Проверки доступа
Ядро проверяет две вещи: переданный указатель принадлежит адресному пространству вызывающего процесса, а не чужой или ядерной памяти, и у процесса есть право на запрошенную операцию.
// Проверка указателей из userspace
if (copy_from_user(kernel_buf, user_buf, size))
return -EFAULT;
// Проверка прав доступа
if (!file_permission(file, MAY_READ))
return -EPERM;
Права по возможностям (capabilities)
Исторически права проверялись просто: root (UID 0) может всё, остальные почти ничего. Программе, которой требуется одна привилегия, например открыть сетевой сокет на порту ниже 1024, приходилось выдавать полномочия root целиком. Современное ядро дробит права root на несколько десятков отдельных возможностей (capabilities) и проверяет конкретную из них.
// Вместо проверки UID == 0
if (!capable(CAP_SYS_ADMIN))
return -EPERM;
Производительность системных вызовов
Измерение стоимости
#include <sys/time.h>
struct timeval start, end;
gettimeofday(&start, NULL);
// системный вызов
gettimeofday(&end, NULL);
long microseconds = (end.tv_sec - start.tv_sec) * 1000000
+ (end.tv_usec - start.tv_usec);
Порядки величин необходимо помнить. Простой системный вызов наподобие getpid обходится в 0.1–1 микросекунду. Вызовы с вводом-выводом занимают от микросекунды до миллисекунды; разброс зависит от того, попал ли запрос в кеш или был направлен на диск. Переключение контекста между процессами стоит 1–10 микросекунд.
Обычное обращение в оперативную память занимает около сотни наносекунд. Отсюда следует правило: в тесном цикле системным вызовам не место; один write на каждое число превратит расчёт в сплошное ожидание.
Оптимизация
Оптимизация сводится к тому, чтобы выполнять системные вызовы реже.
Вызовы объединяют, заменяя десять write подряд одним writev, принимающим сразу список буферов. Файл отображают в память через mmap и далее работают с ним как с обычным массивом, без read и write; для больших файлов данных это является лучшим вариантом. Лишние вызовы устраняют: типичный случай — проверка существования файла перед открытием, хотя открытие и так вернёт ошибку. Для нескольких самых частых вызовов наподобие gettimeofday в Linux предусмотрен vDSO — фрагмент кода ядра, отображённый в память процесса, благодаря чему переключаться в режим ядра не приходится.
5. Память процесса
Виртуальная память: полная картина
Карта адресного пространства
Каждый процесс видит собственное адресное пространство и считает, что вся память принадлежит ему одному. Разложено оно всегда примерно одинаково: код в младших адресах, за ним инициализированные и неинициализированные данные, далее в сторону старших адресов растёт куча, навстречу ей — стек, между ними расположена область под библиотеки и разделяемую память, а старшие адреса отданы ядру. На схеме ниже адреса растут сверху вниз, поэтому стрелка кучи направлена вниз, а стрелка стека — вверх.
0x0000000000000000 ┌──────────────────────┐
│ Зарезервировано │
│ (NULL-ptr guard) │
0x0000000000400000 ├──────────────────────┤
│ Text │
│ (код программы) │
0x0000000000600000 ├──────────────────────┤
│ Data │
│ (init) │
0x0000000000601000 ├──────────────────────┤
│ BSS │
│ (uninit) │
0x0000000000800000 ├──────────────────────┤
│ Heap │
│ (динамическая) │
│ ↓ │
0x00007ffff0000000 ├──────────────────────┤
│ MMAP region │
│ (библиотеки, │
│ shared mem) │
0x00007ffff7a00000 ├──────────────────────┤
│ Stack │
│ (автоматические) │
│ ↑ │
0x00007fffffffffff ├──────────────────────┤
│ неканоническая │
│ дыра │
0xffff800000000000 ├──────────────────────┤
│ Kernel space │
│ (недоступно) │
0xffffffffffffffff └──────────────────────┘
Управление памятью на практике
Анализ памяти процесса
Разбираться, куда ушла память процесса, приходится регулярно, обычно после того, как расчёт исчерпал всю память и был завершён системой. Три команды закрывают почти все вопросы: что отображено в адресное пространство, сколько из этого занято в действительности и как часто процесс промахивается мимо памяти.
# Детальная информация о памяти
pmap -XX 1234
# Статистика памяти
cat /proc/1234/smaps
# Page faults
ps -o min_flt,maj_flt,cmd -p 1234
Виды промахов по странице
Промахи по страницам бывают двух видов, и различаются они на три порядка по стоимости.
Малый промах (minor fault) происходит, когда нужная страница уже находится в физической памяти, но пока не прописана в таблицах страниц процесса, например разделяется с соседним процессом, читающим то же самое, или только что выделена. Ядру достаточно обновить таблицы, и обходится это дёшево.
Большой промах (major fault) означает, что страницы в памяти нет и её необходимо читать с диска. Это миллисекунды вместо микросекунд. Если ps или top показывают, что расчёт набирает большие промахи тысячами, значит, ему не хватает выделенной памяти и система перешла в подкачку. Оптимизировать код в этот момент бессмысленно, необходимо разбираться с памятью.
Проблемы с памятью и решения
Нехватка памяти и OOM killer
Когда память в системе заканчивается и освободить её больше нечем, ядро не возвращает ошибку, а выбирает жертву и завершает её. OOM killer работает в три шага. Ядро фиксирует нехватку памяти, начисляет каждому процессу «балл вредности» (главным образом по объёму занятой памяти) и завершает процесс с наибольшим баллом. Обычно это пользовательский расчёт, самый требовательный к памяти в системе.
Процесс исчезает без сообщения об ошибке, а в выводе dmesg остаётся строка Out of memory: Killed process. Балл корректируют вручную, защищая критичную службу или назначая заведомую жертву.
Управление OOM:
# Настройка политики OOM
echo -1000 > /proc/1234/oom_score_adj # защитить процесс
echo 1000 > /proc/1234/oom_score_adj # первый кандидат
# Ручной вызов OOM killer: НЕ на рабочей машине —
# ядро прямо сейчас убьёт живой процесс, выбрав его само.
# kernel.sysrq ограничивает только клавиатуру: root вызывает
# через /proc/sysrq-trigger любые функции SysRq
echo f > /proc/sysrq-trigger
Управление подкачкой
Подкачка является не запасной памятью, а способом выгрузить на диск страницы, давно не использовавшиеся. Расчётному узлу она скорее вредит: если рабочий набор перестал помещаться в оперативную память, подкачка замедлит всё в сотни раз, и машина будет выглядеть зависшей. Насколько охотно ядро выгружает страницы, задаёт параметр swappiness, установленный по умолчанию в 60; на сервере его снижают, на настольной машине оставляют без изменений.
# Мониторинг swap
swapon --show
free -h
# Настройка swappiness
echo 10 > /proc/sys/vm/swappiness # меньше swap (сервер)
echo 60 > /proc/sys/vm/swappiness # больше swap (десктоп)
Настройка под NUMA
На машинах с несколькими процессорными сокетами память разделена между ними, и обращение к «чужой» памяти, подключённой к соседнему сокету, обходится заметно дороже, чем к своей. Такая архитектура называется NUMA. Если расчёт занимает весь узел, ядро обычно распределяет всё разумно само. Когда на узле выполняется несколько задач одновременно, каждую необходимо привязывать к своему сокету вместе с её памятью, иначе они будут обращаться к памяти через шину между сокетами.
# Информация о NUMA
numactl --hardware
# Запуск с учетом NUMA
numactl --membind=0 --cpunodebind=0 ./application
# Статистика NUMA
cat /proc/1234/numa_maps
6. Изоляция
На рассматриваемых ниже механизмах ядра основан Docker из следующей главы.
Эволюция изоляции в Linux
От chroot до контейнеров
Идея контейнеров старше почти всех, кто ими сегодня пользуется. Каждый шаг добавлял к изоляции что-то одно:
- 1979,
chrootв UNIX: процессу подменяют корень файловой системы; - 2000, FreeBSD Jails: к файловой системе добавляется изоляция процессов и сети;
- 2001, Linux-VServer и 2004, Solaris Zones: те же идеи в других системах;
- 2008, LXC: в ядре Linux появляются пространства имён и cgroups, то есть всё необходимое;
- 2013, Docker: удобная упаковка поверх готовых механизмов ядра;
- 2014, Kubernetes: управление контейнерами в масштабе кластера.
Механизмы ядра были готовы за пять лет до Docker; революция 2013 года произошла в удобстве: появился простой способ описать образ одним файлом и передать его другому человеку.
Cgroups: управление ресурсами
Иерархия cgroups v2
Cgroups отвечают за вторую половину изоляции, не за то, что процесс видит, а за то, сколько он может потребить. Устроены они как дерево каталогов в /sys/fs/cgroup, где каждый каталог представляет группу процессов, а файлы внутри неё задают лимиты и хранят накопленные счётчики. Ограничение, установленное на узел дерева, действует на всё поддерево целиком.
/sys/fs/cgroup/
├── system.slice/ # системные службы
│ ├── ssh.service
│ └── docker.service
├── user.slice/ # пользовательские процессы
│ ├── user-1000.slice
│ └── user-1001.slice
├── kubepods.slice/ # Kubernetes pods
│ ├── pod1/
│ └── pod2/
└── cgroup.controllers # доступные контроллеры
Контроллеры ресурсов
За каждый вид ресурса отвечает свой контроллер, а лимит устанавливается записью в файл.
Перед тем как устанавливать лимиты, группу необходимо создать и разрешить в ней нужные контроллеры, поскольку родитель передаёт их потомкам явно:
mkdir /sys/fs/cgroup/mygroup
echo "+cpu +memory +io" > /sys/fs/cgroup/cgroup.subtree_control
Без этих двух строк ни одна команда ниже не выполнится.
CPU:
# Ограничение CPU
echo "200000 1000000" > /sys/fs/cgroup/mygroup/cpu.max
# 200ms из каждых 1000ms
# CPU shares
echo 512 > /sys/fs/cgroup/mygroup/cpu.weight
Память:
# Лимит памяти
echo 1G > /sys/fs/cgroup/mygroup/memory.max
# SWAP лимит
echo 2G > /sys/fs/cgroup/mygroup/memory.swap.max
# убивать всю группу целиком, а не один процесс
echo 1 > /sys/fs/cgroup/mygroup/memory.oom.group
I/O:
# Ограничение дискового I/O
echo "8:0 rbps=1048576 wbps=1048576" > /sys/fs/cgroup/mygroup/io.max
Пространства имён: изоляция представлений
Виды пространств имён
Пространства имён отвечают за первую половину изоляции, подменяя процессу представление о системе. В своём PID-пространстве процесс видит себя первым номером и не подозревает о существовании остальных; в своём сетевом располагает собственными интерфейсами и портами. Видов пространств восемь, каждое подменяет что-то одно.
| Пространство | Что подменяет | Команда |
|---|---|---|
| PID | номера процессов | unshare --pid |
| Network | сетевые интерфейсы, порты, маршруты | unshare --net |
| Mount | смонтированные файловые системы | unshare --mount |
| UTS | имя узла и домена | unshare --uts |
| IPC | очереди сообщений и семафоры System V | unshare --ipc |
| User | идентификаторы пользователей и групп | unshare --user |
| Cgroup | видимый корень иерархии cgroups | unshare --cgroup |
| Time | системное время | unshare --time |
Пространства имён на практике
Команда unshare создаёт новые пространства имён и запускает в них процесс:
# Создание namespace и запуск процесса
sudo unshare --fork --pid --mount-proc bash
# В новом namespace:
ps aux # видит только свои процессы
mount # видит только свои mount points
До контейнера остаётся один шаг: предоставить процессу собственный корень файловой системы. Сверх этого Docker упаковывает корень в образ с метаданными и позволяет его скачивать и распространять.
# Создание root filesystem
mkdir /mycontainer
debootstrap stable /mycontainer
# Запуск в изоляции
unshare --fork --pid --mount-proc --net --uts --root=/mycontainer /bin/bash
# В контейнере:
hostname mycontainer
ip link set lo up
Заключение
При странном поведении программы правильнее не гадать, а запросить у системы, чем она занята. Если процесс завис, рассматривается его состояние; если расчёт замедлился, рассматриваются промахи по страницам и переключения контекста; если время уходит непонятно на что, рассматриваются системные вызовы, сделанные за секунду. Ядро сообщает о себе через /proc, /sys и perf.
Кроме того, необходимо помнить порядки величин. Обращение в память занимает наносекунды, системный вызов — микросекунды, чтение с диска — миллисекунды. Между соседними ступенями три порядка, и почти всякая оптимизация в конечном счёте сводится к тому, чтобы реже спускаться на ступень ниже.
Задания. Наблюдение за процессами и сигналами на практике: «Работа с долгоиграющими процессами».
straceиperf, здесь только названные, потребуются в задании «Настройка окружения и работа с утилитами».