Внутренности Linux
Процессы, планировщик, прерывания, системные вызовы, память и изоляция
Вячеслав Федоров
Лекция 3
Разработка и применение ПО
в физических исследованиях
Изложение. Третья лекция раздела о Linux посвящена тому, что происходит внутри системы: как ядро учитывает процессы и распределяет между ними процессорное время, как устройства сообщают о событиях, как программа обращается к ядру, как устроена память процесса и на каких механизмах построены контейнеры. Надпись на титуле — PID, идентификатор процесса, с которого начинается любой разговор о работающей программе.
Демонстрации. Слайды с розовым выделением слова «Демонстрация» отмечают места, где изложение прерывается для работы в терминале виртуальной машины lab. На каждом таком слайде проигрывается запись тех же команд, поэтому показ воспроизводим и без живого терминала.
Содержание
От учёта процессов до контейнеров
Изложение. Лекция состоит из шести частей. Сначала рассматривается процесс: что ядро о нём знает, как процесс создаётся, чем отличается от потока, как процессы обмениваются данными и в каких состояниях бывают. Затем планировщик, распределяющий процессорное время, и прерывания, через которые о себе сообщают устройства. Четвёртая часть посвящена системным вызовам, через которые программа обращается к ядру, пятая — памяти процесса, шестая — изоляции, на которой построены контейнеры.
Цель лекции
Знание устройства системы как инструмент диагностики
Когда расчёт завис, замедлился или исчерпал память, вопрос состоит не в том, что делать, а в том, что спросить у системы: чем процесс занят, куда уходит его время и в каком состоянии он застрял. Облако — те же процессы, память и сеть, только на чужих компьютерах в другом месте.
Изложение. Знание внутреннего устройства необходимо в четырёх ситуациях. Когда расчёт завис или исчерпал всю память, требуется поставить диагноз, а не перезапускать программу наугад. Если программа работает медленнее ожидаемого, необходимо знать, куда смотреть. При написании кода полезно представлять, во что обходится каждое обращение к ядру, сделанное в цикле. Наконец, облачная инфраструктура построена на механизмах ядра: контейнеры, оркестраторы и бессерверные вычисления являются надстройками над cgroups и пространствами имён, которые рассматриваются в последней части.
Для физиков: на вычислительном кластере о причинах падения задачи узнают из тех же источников — состояния процесса, счётчиков промахов по страницам и журнала ядра.
Процессы
Учёт в ядре, создание, потоки, взаимодействие, состояния
01
Процессы
Планировщик
Прерывания
Системные вызовы
Память процесса
Изоляция
Изложение. Первая часть посвящена процессу: что о нём знает ядро, как процесс появляется, чем отличается от потока, какими способами процессы обмениваются данными и в каких состояниях находятся.
Процесс как
учётная единица
01
Адресное пространство
собственная виртуальная память: код, данные, куча, стек
02
Открытые файлы
таблица дескрипторов: файлы, каналы, сокеты
03
Учётные данные
от чьего имени работает: UID, GID, возможности
04
Сигналы
маска и назначенные обработчики
05
Потоки
одна или несколько задач планировщика
06
Прочие ресурсы
таймеры, разделяемая память, семафоры
// include/linux/sched.h, упрощённо struct task_struct { unsigned int __state; void *stack; struct mm_struct *mm; struct files_struct *files; struct signal_struct *signal; /* ... сотни полей */ };
Пояснение
Ресурсы процесса ядро освобождает при его завершении: незакрытый дескриптор существует столько же, сколько сам процесс.
Изложение. Привычное определение процесса как запущенной программы верно, но малополезно. Полезнее считать процесс учётной единицей, на которую ядро записывает всё, чем программа владеет: собственное виртуальное адресное пространство, таблицу открытых файловых дескрипторов (файлы, каналы, сокеты), учётные данные, от имени которых выполняются проверки прав, маску сигналов и назначенные на них обработчики, а также потоки, таймеры, сегменты разделяемой памяти и семафоры.
В ядре эта запись представлена структурой task_struct из include/linux/sched.h. На слайде она упрощена: в действительности полей несколько сотен, а память, открытые файлы и сигналы вынесены в отдельные структуры, на которые task_struct ссылается. Благодаря этому потоки одного процесса могут разделять адресное пространство и таблицу дескрипторов, ссылаясь на одни и те же структуры.
При завершении процесса ядро освобождает всё перечисленное; поэтому дескриптор, оставленный незакрытым, существует ровно столько же, сколько процесс.
/proc/<pid>:
процесс как каталог
Файл Содержимое
cmdline аргументы запуска, разделённые нулевым байтом
environ переменные окружения на момент запуска
exe, cwd, root исполняемый файл, текущий и корневой каталоги
fd/ открытые дескрипторы как символические ссылки
status имя, состояние, родитель, память, число потоков
maps, smaps карта виртуальной памяти и её расход
sched статистика планировщика
task/ каталоги потоков процесса
Пояснение
/proc — виртуальная файловая система: файлы не хранятся на диске, ядро формирует их содержимое в момент чтения.
Для физика
С /proc/<pid> начинается разбор странного поведения расчёта: какая программа запущена, с какими аргументами и какие файлы держит открытыми.
Изложение. Всё, что ядро знает о процессе, доступно через виртуальную файловую систему /proc. У каждого процесса есть каталог /proc/<pid>: файлы в нём не хранятся на диске, а формируются ядром в момент чтения. В cmdline записаны аргументы запуска, разделённые нулевыми байтами, в environ — переменные окружения на момент запуска; exe, cwd и root являются символическими ссылками на исполняемый файл, текущий и корневой каталоги. В каталоге fd каждый открытый дескриптор представлен ссылкой на то, что за ним стоит: файл, канал, сокет или терминал. Файл status содержит сводку в удобочитаемом виде, maps и smaps — карту виртуальной памяти, sched — статистику планировщика, а каталог task — по подкаталогу на каждый поток.
Для физиков: если расчёт ведёт себя странно, с /proc/<pid> удобно начинать: из него видно, какая именно программа запущена, с какими аргументами и в каком каталоге, какие файлы с данными она держит открытыми и сколько памяти занимает.
Демонстрация:
/proc
01
Фоновый sleep и его PID
03
Имя, состояние, родитель, память, потоки
04
Аргументы запуска из cmdline
05
Открытые дескрипторы: терминал
06
Исполняемый файл и текущий каталог
07
После kill: каталога больше нет
Что наблюдается
Дескрипторы 0, 1 и 2 процесса sleep указывают на терминал, из которого он запущен; каталог исчезает вместе с процессом.
root@lab — запись со стенда
Демонстрация. В фоне запускается sleep 600, его PID сохраняется в переменной P. Листинг /proc/$P показывает около полусотни файлов и каталогов. Из status выбираются строки с именем, состоянием (S, sleeping), PID родителя, резидентной памятью и числом потоков. cmdline печатается после замены нулевых байтов пробелами. В каталоге fd видны три стандартных дескриптора, указывающие на псевдотерминал /dev/pts/0. Ссылки exe и cwd показывают исполняемый файл /usr/bin/sleep и текущий каталог, в котором запущен процесс. После kill каталог /proc/$P исчезает.
Создание процесса:
fork и exec
fork()
bash · PID 16 · копия родителя
execve()
nginx · PID 16 · новая программа
родитель ждёт в wait()
страницы общие до первой записи
PID прежний, код и данные новые
Пояснение
Copy-on-write: после fork страницы общие и помечены только для чтения; копия страницы создаётся при первой записи в неё, поэтому fork процесса с гигабайтами данных стоит лишь копирования таблиц страниц.
Пояснение
В ядре fork, vfork, clone и clone3 сводятся к одной функции kernel_clone(): процесс и поток отличаются флагами, указывающими, какие ресурсы разделить.
Изложение. Новый процесс в Linux появляется в два шага. Системный вызов fork создаёт копию вызвавшего процесса с новым PID; копия продолжает выполнение с того же места, отличаясь только возвращаемым значением: родитель получает PID потомка, потомок — ноль. Затем потомок вызывает execve, который заменяет код и данные процесса новой программой, сохраняя PID и открытые дескрипторы, если они не помечены закрытием при exec. Так оболочка запускает любую команду; родитель тем временем ждёт завершения потомка в wait и забирает его код возврата.
Наивная реализация fork копировала бы всю память родителя, что для расчёта, занявшего десять гигабайт, означало бы десять гигабайт, скопированных впустую: сразу после fork потомок обычно вызывает execve и скопированное уничтожается. Поэтому используется копирование при записи (copy-on-write): страницы остаются общими и помечаются только для чтения, а копия конкретной страницы создаётся в тот момент, когда один из процессов пытается в неё записать.
В ядре fork, vfork, clone и clone3 сводятся к одной функции kernel_clone, которой флагами указывают, какие ресурсы потомок разделяет с родителем: память, таблицу дескрипторов, обработчики сигналов. Функция fork() библиотеки glibc вызывает clone, а потоки glibc начиная с версии 2.34 создаёт через clone3.
Демонстрация:
fork и exec
01
strace -f: системные вызовы bash и потомка
02
clone: появление дочернего процесса
03
execve: замена программы на ls
04
wait4: получение кода возврата
05
Дерево процессов в ps --forest
Что наблюдается
Сначала clone создаёт копию bash, затем копия вызывает execve и становится ls; родитель ждёт её в wait4.
root@lab — запись со стенда
Демонстрация. strace -f трассирует bash и всех его потомков, а фильтр -e trace=clone,execve,wait4 оставляет только вызовы, связанные с рождением процессов. Первым виден execve самой оболочки. Затем bash вызывает clone с флагом SIGCHLD и получает PID потомка; strace сообщает, что подключился к новому процессу. Пометка [pid …] показывает, какой процесс сделал вызов: строки с номером потомка — его execve("/usr/bin/ls") и завершение с кодом 0, строка с номером родителя — начало ожидания в wait4, по окончании которого родитель получает PID завершившегося потомка. Ключ -e signal=none убирает из вывода строку о сигнале SIGCHLD. Системного вызова fork в выводе нет: функция fork() библиотеки glibc реализована через clone, а на ARM64 отдельного вызова fork не существует вовсе.
Вторая команда запускает sleep в фоне и показывает дерево процессов: sleep и ps являются потомками оболочки.
Процессы и
потоки
Аспект Процесс Поток
Память своё адресное пространство общее адресное пространство
Файлы своя таблица дескрипторов общая таблица
Стек и регистры свои свои у каждого потока
Создание clone без разделения ресурсов clone с CLONE_VM, CLONE_SIGHAND, CLONE_THREAD
Аварийное завершение без влияния на другие процессы завершение всего процесса
Пояснение
Для ядра поток — такая же задача со своей task_struct, как процесс: планировщик распределяет время между потоками.
Для физика
Потоки удобны, когда нужны общие данные без копирования; процессы — когда аварийное завершение одной части не должно приводить к завершению остальных.
Изложение. Поток создаётся тем же вызовом clone, но с флагами, предписывающими разделять с родителем адресное пространство (CLONE_VM), таблицу дескрипторов (CLONE_FILES), обработчики сигналов (CLONE_SIGHAND) и принадлежность к одной группе потоков (CLONE_THREAD). Поэтому потоки видят одни и те же данные, а собственными у каждого остаются стек, регистры и состояние. Для ядра поток является такой же задачей со своей task_struct, как процесс, и планировщик распределяет процессорное время именно между потоками. PID, который показывают утилиты, у всех потоков общий — это идентификатор группы потоков (TGID), а собственный идентификатор потока называется TID или LWP.
Выбор определяется тем, что для задачи важнее. Потоки создаются немного быстрее и обмениваются данными без копирования, но ошибка в одном потоке, например обращение по неверному адресу, завершает весь процесс, и за одновременным доступом к общим данным приходится следить самостоятельно. Процессы изолированы друг от друга, поэтому падение одного обработчика не затрагивает остальные; кроме того, только процессы можно распределить по нескольким машинам.
Для физиков: в Python потоки дополнительно ограничены глобальной блокировкой интерпретатора, поэтому для счёта на нескольких ядрах используют процессы, а потоки — для ожидания ввода-вывода.
Демонстрация:
потоки
01
Python с тремя спящими потоками
02
ps -L: один PID, четыре LWP
03
/proc/PID/task: каталог на поток
Что наблюдается
У потоков общий PID процесса и собственные идентификаторы LWP: ядро учитывает каждый поток как отдельную задачу.
root@lab — запись со стенда
Демонстрация. Интерпретатор Python запускается в фоне и создаёт три потока, каждый из которых спит 600 секунд; главный поток тоже спит. ps -L выводит по строке на поток: PID у всех четырёх одинаковый, а LWP — идентификатор потока — у каждого свой; NLWP показывает общее число потоков. Состояние Sl означает сон в многопоточном процессе. В /proc/$P/task находятся четыре каталога с номерами потоков, а в status строка Threads сообщает о четырёх потоках.
Взаимодействие
процессов
01
Сигнал
уведомление о событии без данных; kill, Ctrl+C
02
Канал
поток байтов в одну сторону; | и mkfifo
03
Разделяемая память
общие страницы, данные не копируются
04
Семафор
счётчик для координации доступа
05
Сокет
двусторонний обмен, в том числе по сети
06
Файл
обмен через файловую систему, блокировки flock
Пояснение
Выбор механизма определяется объёмом данных и тем, кто отвечает за синхронизацию: канал упорядочивает данные сам, разделяемая память оставляет это программе.
Изложение. Адресные пространства процессов изолированы, поэтому для обмена данными ядро предоставляет отдельные механизмы межпроцессного взаимодействия (IPC). Сигнал сообщает о событии, но данных не переносит. Канал передаёт поток байтов в одну сторону: неименованный связывает команды конвейера, именованный (FIFO) виден в файловой системе и соединяет независимые процессы. Разделяемая память отображает одни и те же физические страницы в адресные пространства нескольких процессов. Семафор является счётчиком, с помощью которого процессы договариваются о доступе к общему ресурсу. Сокет обеспечивает двусторонний обмен как на одной машине, так и по сети, а файл — самый простой, хотя и медленный способ обмена, согласованный блокировками.
Выбор определяется объёмом данных и тем, кто отвечает за согласованность: канал и сокет упорядочивают данные сами, разделяемая память оставляет синхронизацию программе.
Сигналы
№ Сигнал Действие по умолчанию Источник
1 SIGHUP завершение закрытие терминала
2 SIGINT завершение Ctrl+C
3 SIGQUIT завершение с дампом памяти Ctrl+\
9 SIGKILL завершение без перехвата kill -9
15 SIGTERM завершение kill без ключей
17 SIGCHLD игнорирование завершение потомка
18 SIGCONT продолжение fg, bg, kill -CONT
19 SIGSTOP остановка без перехвата kill -STOP
20 SIGTSTP остановка Ctrl+Z
Важно
Перехватить или игнорировать нельзя только SIGKILL и SIGSTOP. Обработчик прерывает программу в произвольной точке: в нём допустимы лишь функции async-signal-safe.
Изложение. Сигнал является асинхронным уведомлением о событии; его может послать пользователь (Ctrl+C, команда kill), другой процесс или само ядро (обращение по неверному адресу, завершение потомка). Полный список печатает kill -l; номера на слайде соответствуют Linux на x86 и ARM. Для каждого сигнала определено действие по умолчанию: завершить процесс, завершить его с дампом памяти, остановить, продолжить или проигнорировать. Процесс может заменить действие собственным обработчиком или игнорировать сигнал; исключения — SIGKILL и SIGSTOP, которые нельзя ни перехватить, ни игнорировать, поэтому kill -9 является последним средством.
SIGTERM, отправляемый kill по умолчанию, позволяет программе завершиться аккуратно: сохранить состояние, закрыть файлы. SIGHUP получают процессы, привязанные к терминалу, при закрытии сессии, поэтому расчёт, запущенный по ssh, по умолчанию завершается вместе с соединением; домашнее задание посвящено именно этому.
Обработчик прерывает выполнение программы в произвольной точке. Если сигнал придёт во время malloc, повторный malloc из обработчика повредит кучу, поэтому в обработчике допустимы только функции из списка async-signal-safe, и printf в него не входит. Обычная практика — выставить в обработчике флаг и выполнить работу в основном цикле.
Демонстрация:
сигналы
01
kill -l: список сигналов
02
Обработчик SIGTERM в trap
03
SIGTERM: аккуратное завершение
04
SIGSTOP и SIGCONT: состояния T и S
05
Безрезультатный trap на SIGKILL
Что наблюдается
SIGTERM обработан сценарием, а SIGKILL завершил процесс с кодом 137 = 128 + 9, не дав выполнить обработчик.
root@lab — запись со стенда
Демонстрация. kill -l | head -4 показывает начало таблицы сигналов. Затем в фоне запускается сценарий bash с обработчиком: trap назначает на SIGTERM вывод сообщения и выход. Команда kill -TERM доставляет сигнал, сценарий печатает сообщение и завершается сам, с кодом 0. Для sleep 600 сигнал SIGSTOP переводит процесс в состояние T (оболочка сообщает о приостановленном задании), SIGCONT возвращает его в S.
Последний шаг: внутренний bash назначает trap на SIGKILL и посылает этот сигнал самому себе. Назначение принимается без ошибки, но обработчик не выполняется — процесс завершается, оболочка печатает Killed, а код завершения 137 равен 128 плюс номер сигнала 9.
Каналы, общая память,
семафоры
Канал
Процесс 1: write
Буфер ядра, 64 КиБ
Процесс 2: read
Одно направление; при полном буфере запись ждёт читателя. Неименованный — |, именованный — mkfifo.
Разделяемая память
Процесс 1
Общие физические страницы
Процесс 2
Данные не копируются — самый быстрый обмен. Согласованность — забота программы. ipcs, /dev/shm.
Семафор
Несколько процессов
Счётчик: 0 или 1, либо N
Критическая секция
Двоичный работает как мьютекс; считающий допускает не более N участников одновременно.
Изложение. Канал представляет собой буфер в памяти ядра, по умолчанию 64 КиБ. Пока в буфере есть место, пишущая сторона не задерживается; когда буфер заполнен, write блокируется, пока читатель не заберёт накопленное. Так работает конвейер «команда | команда»: быстрый производитель, ограниченный медленным потребителем, сам замедляется до его скорости без явной синхронизации. Неименованный канал существует, пока открыт хотя бы одним процессом, именованный создаётся командой mkfifo и виден в файловой системе как файл типа p.
Разделяемая память является самым быстрым способом обмена, поскольку обмена как такового не происходит: оба процесса работают с одними и теми же физическими страницами. Платой является то, что ядро больше ничем не помогает: один процесс пишет, другой в это же время читает, и без синхронизации на выходе получается смесь недописанных значений. Сегменты System V показывает ipcs, POSIX-сегменты видны как файлы в /dev/shm.
Для синхронизации служат семафоры. Двоичный принимает значения 0 и 1 и работает как мьютекс, защищающий критическую секцию; считающий хранит целое число и допускает к ресурсу не более заданного числа участников, например разрешает читать общий файл не более чем четырём процессам сразу.
Для физиков: для передачи больших массивов наподобие кадров, снятых с детектора, альтернативы разделяемой памяти практически нет, но гонки при доступе к ней являются самой обычной ошибкой.
Демонстрация:
каналы
02
Передача строки через FIFO
03
Дескриптор 1 команды ls: pipe:[…]
04
Размер буфера канала: 65536 байт
05
ipcs -m: сегменты System V
Что наблюдается
Канал — объект ядра: в /proc он виден как pipe:[номер], а его буфер по умолчанию вмещает 64 КиБ.
root@lab — запись со стенда
Демонстрация. mkfifo создаёт именованный канал; в выводе ls -l первая буква p обозначает тип файла. cat в фоне открывает канал на чтение и ждёт, echo открывает его на запись и передаёт строку, которую cat печатает. Дальше ls -l /proc/self/fd с выводом в конвейер показывает собственные дескрипторы ls: стандартный вывод (дескриптор 1) указывает на pipe:[номер], то есть на объект канала в ядре. Python запрашивает размер буфера нового канала через fcntl с командой F_GETPIPE_SZ (1032) и получает 65536 байт. ipcs -m выводит сегменты разделяемой памяти System V; на стенде их нет.
Состояния
процесса
выбран планировщиком
вытеснен
ввод-вывод, событие
событие произошло
Буква Смысл
R выполнение или готовность
S прерываемое ожидание
D непрерываемое ожидание, нечувствительное даже к SIGKILL
T, t остановка сигналом; t — остановка отладчиком
Z зомби: завершённый процесс с незабранным кодом
I простаивающий поток ядра
X уничтожение; в выводе отсутствует
Изложение. За время жизни процесс проходит несколько состояний. Созданный процесс становится готовым к выполнению и ждёт в очереди планировщика; выбранный планировщиком выполняется, пока не будет вытеснен или не начнёт ждать ввода-вывода или другого события; когда событие происходит, процесс снова становится готовым. В выводе ps состояние обозначается буквой.
R означает, что процесс выполняется или готов выполняться: если расчёт долго находится в R, он считает. S — прерываемое ожидание: процесс ждёт ввода, таймера или сигнала и нормально завершается по kill; для программ, больше ожидающих, чем считающих, это обычное рабочее состояние. D — непрерываемое ожидание, как правило ввода-вывода в драйвере; оно не прерывается сигналами, включая SIGKILL, а причиной обычно является отказавшее устройство или сетевая файловая система. T — процесс остановлен сигналом SIGSTOP или SIGTSTP (Ctrl+Z) и продолжится по SIGCONT; остановленный отладчиком процесс ps обозначает строчной t. Z — зомби: процесс завершился, ресурсы освобождены, но родитель ещё не забрал код возврата вызовом wait. Буквой I современные ядра обозначают простаивающие потоки ядра, X — состояние уничтоженного процесса, в выводе практически не встречается.
Часть ожиданий ядро помечает как прерываемые только смертельным сигналом (TASK_KILLABLE, с версии 2.6.25): в ps они тоже выглядят как D, но SIGKILL такой процесс будит; так устроено большинство ожиданий клиента NFS. Средняя загрузка (load average) учитывает процессы и в R, и в D, поэтому высокая загрузка при простаивающем процессоре указывает на ожидание ввода-вывода.
Сводку по всей системе даёт ps -eo stat= с подсчётом первых букв; с неё удобно начинать разбор зависания: сразу видно, есть ли процессы в D или накопившиеся зомби.
Демонстрация:
состояния
01
Сводка состояний: I, R, S
02
Родитель без вызова wait
04
Завершение родителя и исчезновение зомби
Что наблюдается
Зомби остаётся в таблице процессов, пока его код возврата не заберут; после завершения родителя это делает init, и запись исчезает.
root@lab — запись со стенда
Демонстрация. Первая команда подсчитывает процессы по первой букве состояния; ключ stat= убирает заголовок столбца. На стенде около полусотни простаивающих потоков ядра (I), примерно столько же спящих процессов (S) и один выполняющийся — сам ps. Затем bash запускает в фоне sleep 1 и заменяет себя через exec на sleep 600. Новый образ процесса ничего не знает о потомке и не вызывает wait, поэтому через секунду завершившийся sleep 1 остаётся в таблице процессов как зомби: ps показывает состояние Z и пометку <defunct>. Последняя команда запоминает PID зомби, завершает родителя и снова ищет этот PID: процесса больше нет — после смерти родителя зомби перешёл к init, который забрал код возврата.
Демонстрация:
состояние D
01
ФС в файле, смонтированная через loop
02
fsfreeze -f: запись заморожена
03
dd пишет в замороженную ФС
04
Состояние D, ожидание percpu_rwsem_wait
05
kill -9: процесс по-прежнему в D
06
fsfreeze -u: SIGKILL срабатывает, код 137
Что наблюдается
Процесс в D не реагирует даже на SIGKILL: сигнал срабатывает, только когда ожидание заканчивается.
root@lab — запись со стенда
Демонстрация. Во временном каталоге создаётся файл на 64 МиБ, в нём — файловая система ext4, которая монтируется через loop-устройство. Команда fsfreeze -f замораживает её: все попытки записи ждут разморозки, как ждали бы ответа отказавшего устройства. dd, пишущий в неё, сразу попадает в состояние D, а столбец WCHAN показывает функцию ядра, в которой процесс ждёт, — percpu_rwsem_wait. kill -9 ничего не меняет: сигнал доставлен, но процесс в непрерываемом ожидании его не обрабатывает. После fsfreeze -u ожидание заканчивается, отложенный SIGKILL срабатывает, оболочка сообщает Killed, а wait возвращает код 137. Так же выглядит процесс, пишущий на зависший сетевой диск: освобождает его не kill, а восстановление устройства.
Демонстрация безопасна для системы: заморожена только временная файловая система, после показа она размораживается и размонтируется.
Планировщик
Приоритеты, политики, CFS и EEVDF, привязка к ядрам
02
Процессы
Планировщик
Прерывания
Системные вызовы
Память процесса
Изоляция
Изложение. Вторая часть посвящена планировщику — части ядра, решающей, какой поток и на какое время получит процессор.
Задача
планировщика
На каждом переключении выбрать, какой из готовых потоков получит процессор и на какое время.
01
Ограниченные вводом-выводом
короткие вспышки счёта между ожиданиями: оболочка, редактор, веб-сервер; важна быстрая реакция
02
Ограниченные процессором
считают непрерывно: расчёт, компиляция, сжатие; важна пропускная способность
Пояснение
Отзывчивость и пропускная способность противоречат друг другу: частые переключения сокращают задержку, но тратят время на смену контекста и вытесняют данные из кеша.
Изложение. Готовых к выполнению потоков обычно больше, чем ядер процессора, и планировщик на каждом переключении решает, кому отдать процессор и на какое время. Задачи различаются по характеру. Ограниченные вводом-выводом (I/O bound) большую часть времени ждут — ввода с клавиатуры, данных из сети или с диска — и считают короткими вспышками; для них важна быстрая реакция. Ограниченные процессором (CPU bound) считают непрерывно; для них важна доля процессорного времени и прогретый кеш.
Цели противоречат друг другу: чем чаще переключения, тем быстрее реакция интерактивных задач, но тем больше времени уходит на смену контекста и тем чаще счётные задачи начинают с холодного кеша. История планировщика Linux является историей поиска компромисса между этими целями.
Приоритеты и
политики
Реального времени: 0–99 · SCHED_FIFO, SCHED_RR
0
99
100
139
в ядре меньшее число — более высокий приоритет
Политика Для чего Как задать
SCHED_OTHER (NORMAL) почти все процессы: справедливое деление времени nice -n 10, renice
SCHED_BATCH, SCHED_IDLE фоновый счёт, не мешающий интерактивным задачам chrt -b, chrt -i
SCHED_FIFO, SCHED_RR жёсткое время реакции: управление, сбор данных chrt -f 10, chrt -r 10
SCHED_DEADLINE периодические задачи с бюджетом и сроком chrt -d
Изложение. Внутри ядра приоритеты лежат на одной шкале от 0 до 139, и меньшее число означает более высокий приоритет. Диапазон 0–99 принадлежит задачам реального времени, 100–139 — обычным: приоритет обычной задачи равен 120 плюс её значение nice. Шкалы, которые видит пользователь, устроены иначе, и в этом легко запутаться. У задач реального времени sched_priority задаётся от 1 до 99, и большее число означает более высокий приоритет (в ядре ему соответствует 99 − sched_priority). Значение nice идёт от −20 до +19, и большее значение означает более низкий приоритет: процесс «вежливее» и охотнее уступает. Понижать себе приоритет может любой пользователь, повышать — только root.
Политика определяет, по каким правилам делится время. SCHED_OTHER (в ядре SCHED_NORMAL) — политика почти всех процессов с долями, зависящими от nice. SCHED_BATCH предназначена для фонового счёта, SCHED_IDLE — для задач, работающих только тогда, когда больше никому процессор не нужен. SCHED_FIFO и SCHED_RR — политики реального времени, SCHED_DEADLINE — для периодических задач с заданным бюджетом времени и сроком. Задача реального времени всегда вытесняет обычные.
FIFO
и
RR
01
SCHED_FIFO
кванта нет: задача работает, пока не заблокируется, не отдаст процессор сама (sched_yield) или её не вытеснит задача с более высоким приоритетом; вытесненная остаётся в начале своей очереди
02
SCHED_RR
то же, но с квантом: по его истечении задача уходит в конец очереди своего приоритета, и задачи равного приоритета сменяют друг друга по кругу; квант — /proc/sys/kernel/sched_rr_timeslice_ms, 100 мс
Важно
Зациклившаяся задача SCHED_FIFO занимает ядро целиком. От полного зависания систему страхует ограничение: задачам реального времени достаётся не больше 950 мс из каждой секунды (sched_rt_runtime_us).
Изложение. Политики реального времени применяются там, где важна не общая пропускная способность, а гарантированное время реакции: управление установкой, сбор данных с жёстким тактом, обработка звука. Задача SCHED_FIFO не получает кванта времени: получив процессор, она удерживает его, пока не заблокируется на вводе-выводе, не отдаст процессор сама вызовом sched_yield или пока её не вытеснит задача реального времени с более высоким приоритетом. Вытесненная задача остаётся в начале очереди своего приоритета и продолжит работу, как только более приоритетные освободят процессор; задача, отдавшая процессор сама, уходит в конец очереди.
SCHED_RR отличается наличием кванта, на стенде 100 мс: по его истечении задача перемещается в конец очереди своего приоритета, поэтому задачи равного приоритета сменяют друг друга по кругу, и одна зациклившаяся задача не блокирует остальные с тем же приоритетом. Эта политика безопаснее, и начинать лучше с неё.
Зациклившаяся задача SCHED_FIFO занимает ядро целиком, и обычные процессы, включая оболочку администратора, до него не добираются. От полного зависания систему страхует ограничение доли процессора для задач реального времени: по умолчанию задачам этих политик достаётся не более 950 мс из каждой секунды (sched_rt_runtime_us и sched_rt_period_us), а оставшиеся 5 % получают обычные процессы.
Обычное ядро не даёт гарантий худшего времени реакции; для этого существует вариант с полной вытесняемостью PREEMPT_RT, который в версии 6.12 вошёл в основную ветку ядра. Политика SCHED_DEADLINE, планирующая задачи по бюджету и сроку, доступна с версии 3.14.
Эволюция
планировщика
до 2.4
O(N)
при каждом переключении перебор всех готовых задач и одна общая очередь на все процессоры
2.6.0–2.6.22
O(1)
140 очередей по приоритетам и битовая маска непустых; очередь на каждый процессор
2.6.23–6.5
CFS
справедливое деление по виртуальному времени; красно-чёрное дерево: вставка за O(log N), выбор за O(1)
с 6.6
EEVDF
та же справедливость плюс виртуальные сроки: задачам с короткими квантами — быстрая реакция
Пояснение
Каждая замена была вызвана практикой: O(N) не справлялся с тысячами задач, эвристики интерактивности O(1) оказались хрупкими, а CFS плохо различал задачи, которым нужна быстрая реакция.
Изложение. Планировщик обычных задач в Linux менялся трижды. Первый, действовавший до ядра 2.4 включительно, при каждом переключении перебирал все готовые задачи и выбирал лучшую по баллу, поэтому время выбора росло с числом задач (отсюда обозначение O(N)); к тому же все процессоры конкурировали за одну общую очередь.
Планировщик O(1), появившийся в 2.6.0, разложил задачи по 140 очередям, по одной на уровень приоритета, и пометил непустые очереди битовой маской, благодаря чему самый приоритетный непустой уровень находится одной инструкцией. Отработавшие квант задачи складывались во вторую, «истёкшую» очередь, и когда первая пустела, очереди менялись местами. У каждого процессора появилась собственная очередь. Слабым местом стали эвристики, по которым планировщик угадывал интерактивные задачи и поднимал им приоритет: они были сложными и давали сбои.
В 2.6.23 его сменил CFS (Completely Fair Scheduler), который вместо квантов по приоритетам ведёт для каждой задачи счётчик полученного взвешенного времени и всегда запускает ту, которой досталось меньше всех. В 6.6 алгоритм выбора заменён на EEVDF, сохраняющий справедливость CFS, но учитывающий также, насколько срочно задаче нужен процессор.
CFS:
виртуальное время
самый левый — запускается следующим
01
vruntime
полученное процессорное время, умноженное на 1024 и делённое на вес
02
Вес по nice
1024 при nice 0, каждый шаг меняет вес примерно в 1,25 раза
03
Пример
nice 0 и nice 10 на одном ядре: 1024 : 110, то есть 90 % и 10 %
04
Выбор
задача с наименьшим vruntime — самый левый узел дерева
Изложение. CFS ведёт для каждой задачи виртуальное время выполнения vruntime — полученное процессорное время, пересчитанное по весу: умноженное на 1024 и делённое на вес задачи. Вес определяется значением nice: при nice 0 он равен 1024, а каждый шаг nice меняет его примерно в 1,25 раза, что соответствует изменению доли процессора примерно на 10 %. У задачи с большим весом vruntime растёт медленнее, и она дольше остаётся «обделённой», то есть получает больше времени. Планировщик всегда запускает задачу с наименьшим vruntime.
Пример на слайде подтверждается демонстрацией: две бесконечные задачи на одном ядре с nice 0 и nice 10 имеют веса 1024 и 110 и делят процессор примерно как 90 % к 10 %, при этом их vruntime растут одинаково.
Готовые задачи хранятся в красно-чёрном дереве, упорядоченном по vruntime, — самобалансирующемся двоичном дереве поиска, в котором вставка и удаление выполняются за O(log N). Задача с наименьшим vruntime всегда находится в самом левом узле, а указатель на него ядро хранит отдельно, поэтому самая частая операция — выбор следующей задачи — выполняется за постоянное время.
Длину отрезков определяли целевая задержка (за какой срок каждая готовая задача должна получить процессор хотя бы раз, в CFS — 6 мс, умноженные на поправку по числу процессоров) и минимальная гранулярность, ниже которой отрезок не дробится.
Новая задача получает не нулевой vruntime, а близкий к наименьшему в очереди: иначе она надолго захватила бы процессор. Задачи можно объединять в группы, которые делят время между собой как единое целое; на этом групповом планировании построены ограничения процессора для контейнеров и служб (cpu.weight и cpu.max в cgroups).
EEVDF:
виртуальные сроки
01
Отставание (lag)
разница между положенной задаче долей времени и полученной
02
Допуск
к выполнению допускаются задачи, получившие не больше положенного: lag ≥ 0
03
Виртуальный срок
момент допуска плюс квант, делённый на вес задачи
04
Выбор
из допущенных — задача с самым ранним виртуальным сроком
Пояснение
Задача с коротким квантом получает ранний срок и быстрее реагирует, не получая при этом больше своей доли. Квант по умолчанию — base_slice_ns, на стенде 1,4 мс.
Изложение. EEVDF (Earliest Eligible Virtual Deadline First) действует в Linux с версии 6.6 и заменил алгоритм выбора CFS. Справедливость сохраняется: для каждой задачи вычисляется отставание (lag) — разница между временем, которое ей положено по весу, и полученным. К выполнению допускаются только задачи с неотрицательным отставанием, то есть получившие не больше положенного. Для каждой задачи вычисляется виртуальный срок — момент допуска плюс квант, делённый на вес, и из допущенных выбирается задача с самым ранним сроком.
Благодаря этому задача, которой нужна быстрая реакция, может запросить короткий квант: её срок наступает раньше, и она получает процессор быстрее, но не больше своей доли. В CFS для этого пытались добавить отдельный параметр latency-nice, но в основную ветку ядра он так и не попал; EEVDF решает ту же задачу в рамках одного алгоритма.
Квант по умолчанию задаёт параметр base_slice_ns в отладочной файловой системе; на стенде он составляет 1,4 мс, что видно в демонстрации. Собственный квант задача может запросить вызовом sched_setattr начиная с версии 6.12.
Десятилетие
потерянных ядер
EuroSys, 2016
14–23 %
потеря пропускной способности СУБД в тесте TPC-H
Четыре ошибки балансировки нагрузки между ядрами: ядра простаивали секундами, пока готовые потоки ждали в чужих очередях.
Многократное замедление научных приложений с интенсивной синхронизацией; на 13 % дольше сборка ядра.
Для физика
Ускорение от добавленных ядер проверяют измерением: загрузку каждого ядра показывает mpstat -P ALL.
Изложение. В 2016 году группа исследователей опубликовала статью «The Linux Scheduler: a Decade of Wasted Cores». Проверяя простой инвариант — готовые потоки не должны ждать, пока ядра простаивают, — авторы нашли четыре ошибки в балансировке нагрузки между ядрами. Из-за них ядра могли простаивать секундами, в то время как готовые потоки стояли в очередях других ядер. Последствия, измеренные авторами: многократное замедление научных приложений с интенсивной синхронизацией, на 13 % большее время сборки ядра и на 14–23 % меньшая пропускная способность коммерческой СУБД в тесте TPC-H. Ошибки появились при оптимизациях планировщика для многоядерных и многосокетных машин и годами оставались незамеченными, поскольку внешне система выглядела загруженной.
Для физиков: многопоточный расчёт на многосокетном узле — типичный случай, где такие эффекты проявляются. Ожидаемое ускорение от ядер подтверждают измерением, а загрузку каждого ядра по отдельности показывает mpstat -P ALL.
Привязка к
ядрам
taskset -c 0,2 ./calc # запуск на ядрах 0 и 2 taskset -cp 1,3 12345 # смена ядер у процесса numactl --cpunodebind=0 --membind=0 ./calc nice -n 10 ./batch; renice -n 5 -p 12345 chrt -f 10 ./daq # SCHED_FIFO, приоритет 10
Пояснение
Привязка сохраняет прогретый кеш и держит процесс рядом с его памятью на многосокетной машине.
Пояснение
Частотой ядер управляет регулятор cpufreq (scaling_governor); на виртуальной машине стенда его нет.
Пояснение
Если готовых задач нет, выполняется поток простоя: он переводит ядро в энергосберегающее состояние (cpuidle).
Изложение. По умолчанию планировщик сам распределяет задачи по ядрам и переносит их при дисбалансе. Привязка (CPU affinity) ограничивает набор ядер, на которых задача может выполняться: taskset задаёт его при запуске или меняет у работающего процесса, numactl дополнительно привязывает выделение памяти к узлу NUMA. Долгий расчёт привязывают ради прогретого кеша: мигрируя между ядрами, процесс каждый раз начинает с холодного кеша. Критичный процесс, например управляющий установкой, изолируют на выделенных ядрах, чтобы остальная система ему не мешала.
Частотой и напряжением процессора управляет подсистема cpufreq: регулятор (governor), например performance или powersave, задаётся в /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor. Подсистема связана с планировщиком: регулятор schedutil выбирает частоту по оценке загрузки, которую ведёт планировщик. На виртуальной машине стенда этого каталога нет: частотой управляет хост.
Когда готовых задач нет, на ядре выполняется поток простоя (idle, PID 0; в /proc его нет, а в perf и ftrace он виден как swapper/N): он переводит ядро в одно из энергосберегающих состояний подсистемы cpuidle инструкцией WFI на ARM или HLT на x86, и выводит его оттуда прерывание.
Демонстрация:
nice и привязка
01
chrt -m: политики и диапазоны
02
Два бесконечных цикла на ядре 0
03
nice 0 и nice 10: 91 % и 9,6 %
05
base_slice_ns: квант EEVDF 1,4 мс
Что наблюдается
Доли процессора соответствуют весам 1024 и 110, а vruntime обеих задач растёт одинаково: справедливость измеряется во взвешенном времени.
root@lab — запись со стенда
Демонстрация. chrt -m перечисляет политики планирования и допустимые приоритеты: у SCHED_FIFO и SCHED_RR от 1 до 99, у остальных приоритет не задаётся. Затем два бесконечных цикла запускаются на ядре 0 через taskset -c 0, второй — с nice 10. Через пять секунд ps показывает, что оба процесса выполняются на ядре 0 (столбец PSR), но первый получил около 91 % процессорного времени, а второй — около 10 %, как и следует из весов 1024 и 110. Счётчики se.vruntime в /proc/PID/sched у обоих процессов почти равны: планировщик выравнивает именно взвешенное время. se.slice и base_slice_ns показывают квант EEVDF на стенде, 1,4 мс.
Прерывания
Как устройства сообщают о событиях и кто их обрабатывает
03
Процессы
Планировщик
Прерывания
Системные вызовы
Память процесса
Изоляция
Изложение. Третья часть посвящена прерываниям — механизму, которым устройства и сам процессор сообщают ядру о событиях, требующих немедленной реакции.
Прерывания и
исключения
Устройство
Контроллер прерываний
Процессор прерывает задачу
Обработчик по номеру
Возврат к задаче
Прерывание — асинхронно
Приходит от устройства в любой момент: таймер, сетевая карта, диск, USB. Номер линии (IRQ) указывает на обработчик драйвера. Контроллер — APIC на x86, GIC на ARM.
Исключение — синхронно
Вызвано текущей инструкцией. Отказ (fault): промах по странице, после обработки инструкция повторяется. Ловушка (trap): точка останова, продолжение со следующей. Авария (abort): неисправимая ошибка.
Изложение. Когда устройству требуется внимание — пришёл сетевой пакет, диск закончил чтение, сработал таймер, — оно подаёт сигнал контроллеру прерываний, а тот прерывает работу процессора. Процессор сохраняет состояние текущей задачи и по номеру прерывания находит обработчик, зарегистрированный драйвером; после обработки выполнение задачи продолжается. Контроллер на x86 называется APIC, на ARM — GIC; на стенде, работающем на ARM64, в /proc/interrupts видны именно линии GIC.
Прерывания асинхронны: они приходят независимо от того, какая инструкция выполняется. Исключения, напротив, синхронны: их вызывает сама выполняемая инструкция, а обрабатываются они тем же механизмом. Исключения делятся по тому, чем заканчиваются. Отказ (fault) исправим: типичный пример — промах по странице, после которого ядро подгружает страницу и повторяет ту же инструкцию. Ловушка (trap) вызывается намеренно, например точкой останова отладчика, и выполнение продолжается со следующей инструкции. Авария (abort) неисправима: состояние потеряно, и процесс или система аварийно завершаются.
Для физиков: в лаборатории к таймерам, сетевым картам и дискам добавляется всё, что установлено в крейте: АЦП, счётчики, генераторы задержек — каждое такое устройство сообщает о готовности данных прерыванием.
Верхняя и
нижняя половины
Прерывание
Верхняя половина: сразу, прерывания на ядре запрещены; подтверждение, чтение данных, планирование остального
Нижняя половина: позже, прерывания разрешены; основная работа
01
SoftIRQ
фиксированный набор: таймеры, сеть, блочные устройства; при нагрузке — поток ksoftirqd
02
Tasklet
создаётся драйвером на основе softirq; один tasklet не выполняется на двух ядрах сразу; устаревает
03
Очередь работ
выполняется потоком ядра kworker: может спать и блокироваться ценой задержки
Изложение. К обработке прерывания предъявляются два противоречивых требования: для устройства реакция должна быть немедленной, а для системы обработка — быстрой, потому что пока выполняется обработчик, на этом ядре запрещены все прерывания, а планировщик не работает. Поэтому обработку делят на две половины.
Верхняя половина (top half) запускается сразу и выполняет только критичное по времени: подтверждает устройству получение, забирает данные из регистров, планирует остальную работу и возвращается, укладываясь в микросекунды. Ничего блокирующего в ней быть не может: засыпает процесс, а обработчик прерывания выполняется вне какого-либо процесса.
Нижняя половина (bottom half) выполняет содержательную работу позже, с разрешёнными прерываниями. Механизмов три. SoftIRQ — самый быстрый, но набор мягких прерываний фиксирован при сборке ядра: таймеры, приём и передача сетевых пакетов, блочные устройства; при высокой нагрузке их обработку берёт на себя поток ksoftirqd, по одному на ядро. Tasklet создаётся динамически и подходит для драйверов, но один tasklet никогда не выполняется на двух ядрах одновременно; в новых ядрах tasklet постепенно заменяют очередями работ. Очередь работ (workqueue) выполняется потоком ядра kworker в контексте процесса, поэтому ей разрешено засыпать и выполнять блокирующие вызовы; платой является задержка.
Прерывания
на практике
cat /proc/interrupts # счётчики по линиям и ядрам cat /proc/softirqs # мягкие прерывания cat /proc/irq/24/smp_affinity_list # на каких ядрах обрабатывается echo 2 > /proc/irq/24/smp_affinity_list # перенос на ядро 2 ethtool -C eth0 rx-usecs 50 # объединение прерываний
Пояснение
MSI и MSI-X: устройство сообщает о событии записью в память; у каждой очереди сетевой карты своё прерывание и своё ядро.
Пояснение
Объединение прерываний (coalescing): устройство накапливает события и сообщает о них одним прерыванием — прерываний меньше, задержка больше.
Для физика
Быстрый АЦП или сетевая карта дают десятки тысяч прерываний в секунду: их распределяют по ядрам, иначе перегружается одно.
Изложение. Счётчики прерываний по линиям и ядрам находятся в /proc/interrupts, счётчики мягких прерываний — в /proc/softirqs. Для каждой линии в /proc/irq/<номер>/ указано, на каких ядрах её разрешено обрабатывать; записью в smp_affinity_list линию переносят на другое ядро. Распределением по умолчанию занимается служба irqbalance.
Когда прерываний становится слишком много, обработку настраивают. По умолчанию все они могут поступать на одно ядро, которое перегружается, пока остальные простаивают, поэтому прерывания распределяют между ядрами. Современные устройства вместо выделенных линий используют MSI и MSI-X: событие сообщается записью в память, что снимает ограничение на число линий и позволяет дать каждой очереди сетевой карты собственное прерывание и собственное ядро. Объединение прерываний (coalescing) разрешает устройству накопить несколько событий и сообщить о них разом: число прерываний падает в разы ценой небольшой задержки. Сетевая карта сама раскладывает пакеты в память по DMA, а под высокой нагрузкой драйвер переходит в режим NAPI: прерывания временно отключаются, и пакеты забираются опросом в мягком прерывании NET_RX. Это не отказ от прерываний, а защита от их лавины.
Для физиков: быстрый АЦП или сетевая карта с потоком данных с установки дают десятки тысяч прерываний в секунду, и одно ядро, на которое они все приходят, становится узким местом всей системы сбора.
Демонстрация:
прерывания
01
/proc/interrupts: таймер, UART, virtio
02
Счётчик таймера за секунду
03
/proc/softirqs: мягкие прерывания
04
Потоки ядра ksoftirqd и kworker
Что наблюдается
За секунду таймер прервал каждое ядро больше сотни раз; отложенную работу выполняют потоки ksoftirqd и kworker.
root@lab — запись со стенда
Демонстрация. Первые строки /proc/interrupts показывают линии контроллера GIC виртуальной машины ARM64: системный таймер arch_timer, последовательный порт uart-pl011 и прерывания MSI устройств virtio, по столбцу на каждое ядро. Два чтения строки таймера с паузой в секунду показывают прирост счётчиков примерно на сотню на каждом ядре. /proc/softirqs перечисляет мягкие прерывания: HI, TIMER, NET_TX, NET_RX и другие. В списке процессов видны потоки ядра ksoftirqd — по одному на ядро — и потоки kworker, выполняющие очереди работ.
Системные вызовы
Как программа обращается к ядру и во что это обходится
04
Процессы
Планировщик
Прерывания
Системные вызовы
Память процесса
Изоляция
Изложение. Четвёртая часть посвящена системным вызовам — единственному способу, которым программа может попросить ядро выполнить что-либо от её имени.
Системный
вызов
Программа: write()
libc: номер и аргументы в регистры
Инструкция перехода в ядро
Таблица вызовов: sys_write
Проверка и работа, результат
Архитектура Инструкция Номер вызова Аргументы
x86-64 syscall rax rdi, rsi, rdx, r10, r8, r9
x86, 32 бита int 0x80, sysenter eax ebx, ecx, edx, esi, edi, ebp
ARM64 svc #0 x8 x0–x5
Пояснение
Для номера за пределами таблицы вызов возвращает -ENOSYS. В исходниках вызовы определяются макросами SYSCALL_DEFINEn, а функции ядра называются sys_<имя>.
Изложение. Программа не может обратиться к диску, сети или памяти другого процесса напрямую: всё, связанное с оборудованием и чужими ресурсами, выполняет ядро, а программа запрашивает это системным вызовом. Код ядра нельзя вызвать как обычную функцию из пользовательского приложения, поэтому переход устроен так: номер нужного вызова и аргументы раскладываются по регистрам, после чего выполняется специальная инструкция, переводящая процессор в режим ядра. Ядро по номеру находит обработчик в таблице системных вызовов, проверяет аргументы, выполняет работу, записывает результат и возвращает управление.
Соглашения зависят от архитектуры. На x86-64 используется инструкция syscall, номер передаётся в rax, аргументы — в rdi, rsi, rdx, r10, r8 и r9. На 32-разрядном x86 исторически применялось программное прерывание int 0x80 (номер 128), позднее более быстрая инструкция sysenter. На ARM64, где работает стенд, это инструкция svc #0 с номером в регистре x8. Номера тоже различаются: write на x86-64 имеет номер 1, на ARM64 — 64.
Если номер выходит за пределы таблицы, вызов возвращает ошибку -ENOSYS. В исходниках ядра вызовы определяются макросами SYSCALL_DEFINE0…SYSCALL_DEFINE6 по числу аргументов, а соответствующие функции называются sys_<имя>. Новые вызовы добавляются редко: интерфейс ядра стабилен, и старые программы продолжают работать.
Ядро в
адресном пространстве
0x0000000000000000
Пользовательская часть: код, данные, куча, библиотеки, стек
ARM64: до 0x0000ffffffffffff
неканонические адреса
Ядро: в каждом процессе, доступно только в режиме ядра
0xffffffffffffffff
01
Старшие адреса — ядру
на x86-64 с 0xffff800000000000, на ARM64 с 0xffff000000000000
02
Контекст процесса
в режиме ядра код работает от имени процесса: может заснуть и быть вытеснен
03
Защита
из пользовательского режима страницы ядра недоступны; после Meltdown на уязвимых процессорах ядро их не отображает (KPTI)
Изложение. Переход в режим ядра не требует смены адресного пространства: ядро отображено в каждый процесс. Младшая часть виртуального адресного пространства принадлежит программе, старшая — ядру; на x86-64 она начинается с 0xffff800000000000, на ARM64 при 48-разрядных адресах — с 0xffff000000000000. Между ними лежит огромная область неканонических адресов, обращение к которой всегда является ошибкой. Страницы ядра помечены как недоступные из пользовательского режима, поэтому программа их не видит, а ядро, получив управление через системный вызов, сразу работает с памятью процесса и со своими структурами.
Выполняя системный вызов, ядро действует от имени процесса, в его контексте. Поэтому процесс в режиме ядра может заснуть, например в ожидании данных с диска, и может быть вытеснен другой задачей; именно в этом случае ps показывает состояние D или S при ожидании внутри системного вызова.
После обнаружения уязвимости Meltdown в 2018 году на уязвимых процессорах применяется изоляция таблиц страниц ядра (KPTI): в пользовательском режиме страницы ядра не отображаются вовсе, а переход в ядро сопровождается сменой таблиц, что сделало системные вызовы дороже.
Цена
системного вызова
Операция Порядок времени
Обращение к оперативной памяти около 100 нс
Простой системный вызов, например getpid 0,1–1 мкс
Переключение контекста между процессами 1–10 мкс
Чтение страницы с NVMe десятки–сотни мкс
Чтение с жёсткого диска единицы миллисекунд
01
Объединение
writev, буфер вместо write на каждое число
02
mmap
работа с файлом как с массивом, без read и write
03
vDSO
clock_gettime и gettimeofday без перехода в ядро
04
Подсчёт
strace -c, perf trace -s
Изложение. Сама операция в системном вызове может быть тривиальной, но сохранение контекста, переход в режим ядра, проверка аргументов и возврат выполняются в любом случае. Порядки величин необходимо помнить: обращение в оперативную память занимает около сотни наносекунд, простой системный вызов — от десятой доли до микросекунды, переключение контекста между процессами — от одной до десяти микросекунд, чтение с NVMe — десятки и сотни микросекунд, с жёсткого диска — миллисекунды. Грубо шкала состоит из трёх ступеней — наносекунды, микросекунды, миллисекунды, — и между ними по три порядка.
Отсюда правило: в тесном цикле системным вызовам не место. Оптимизация сводится к тому, чтобы выполнять их реже. Вызовы объединяют: вместо write на каждое число данные копят в буфере и отправляют разом, а несколько буферов передают одним writev. Файл отображают в память через mmap и работают с ним как с массивом. Для нескольких самых частых вызовов, таких как clock_gettime и gettimeofday, ядро предоставляет vDSO — фрагмент кода, отображённый в каждый процесс, благодаря чему перехода в режим ядра не происходит вовсе.
Сколько и каких вызовов делает программа, показывают strace -c и perf trace -s. strace сам сильно замедляет трассируемую программу, поэтому для измерения времени на рабочих системах предпочтительнее perf.
Демонстрация:
системные вызовы
01
strace -c: сводка вызовов ls
02
openat, read, write при cat /etc/hostname
03
Номера вызовов на ARM64
Что наблюдается
Даже ls делает больше сотни системных вызовов; номера на ARM64 свои (write — 64, на x86-64 — 1), а vDSO есть в каждом процессе.
root@lab — запись со стенда
Демонстрация. strace -c считает системные вызовы команды ls /, упорядочив их по числу: больше всего openat, mmap, close и fstat — это загрузка библиотек и локали, а сама работа — несколько getdents64, читающих каталог. Столбец errors показывает неудачные вызовы, например попытки открыть отсутствующие файлы. Трассировка cat /etc/hostname с фильтром openat, read и write показывает весь путь: открытие библиотек, открытие файла, read, вернувший 4 байта, и write, выведший их на терминал; последний read возвращает 0 — конец файла. Переменная LC_ALL=C убирает из вывода поиск файлов локали.
Заголовок asm-generic/unistd.h содержит номера вызовов для ARM64: read — 63, write — 64, getpid — 172. Наконец, в карте памяти любого процесса есть область [vdso].
Память процесса
Адресное пространство, промахи по страницам, OOM и NUMA
05
Процессы
Планировщик
Прерывания
Системные вызовы
Память процесса
Изоляция
Изложение. Пятая часть посвящена памяти процесса: как устроено его адресное пространство, как понимать показатели расхода памяти, что происходит при промахе по странице и при нехватке памяти.
Адресное
пространство
младшие адреса
BSS: неинициализированные
свободно
Отображения: библиотеки, mmap, общая память
свободно
старшие адреса, далее — ядро
Карта памяти awk на стенде, /proc/self/maps:
Адрес и права Что это
00400000 r-xp gawk код
004d0000 rw-p gawk данные
004d2000 rw-p BSS
1efe4000 rw-p [heap] куча
e528… rw-p анонимные отображения, библиотеки
[vvar], [vdso] страницы ядра для vDSO
ffffea94… rw-p [stack] стек
Пояснение
C и C++: инициализированные глобальные — data, неинициализированные — BSS, malloc — куча или mmap для крупных блоков, локальные переменные — стек.
Изложение. Каждый процесс видит собственное виртуальное адресное пространство и считает, что вся память принадлежит ему. Разложено оно всегда примерно одинаково. В младших адресах лежит код программы (text), за ним инициализированные данные (data) и неинициализированные (BSS), которые при запуске заполняются нулями. Выше растёт куча, из которой malloc выделяет небольшие блоки. В средней части находится область отображений: разделяемые библиотеки, файлы, отображённые через mmap, разделяемая память и крупные блоки, которые malloc получает сразу через mmap. У верхней границы пользовательской части расположен стек с локальными переменными функций, растущий вниз, навстречу куче; ещё выше начинается ядро. На схеме адреса растут сверху вниз, поэтому стрелка кучи направлена вниз, а стрелка стека — вверх.
Таблица справа взята из демонстрации: программа awk на стенде собрана без позиционно-независимого кода, поэтому её код загружен по классическому адресу 0x400000. Права r-xp означают чтение и выполнение без записи — это код; rw-p — данные; [vvar] и [vdso] — страницы, которые ядро отображает в каждый процесс для vDSO.
Адреса меняются от запуска к запуску: ядро размещает стек, кучу и отображения случайно (ASLR), а у позиционно-независимых программ — и код; отключить рандомизацию для проверки можно командой setarch -R. Порядок байтов в слове (little-endian) с раскладкой памяти не связан, раскладка одинакова на x86-64 и ARM64.
Сколько памяти
у процесса
Показатель Что учитывает
VIRT, VSZ всё отображённое, включая ни разу не тронутые страницы
RES, RSS страницы, находящиеся в физической памяти
SHR разделяемые: библиотеки, общая память, файлы
Анонимная куча и стек, без файла на диске
Страничный кеш прочитанные файлы; освобождается при нехватке
pmap -XX 12345 cat /proc/12345/smaps_rollup free -h
Пояснение
Выделение — ещё не использование: malloc гигабайта увеличивает VIRT, а RES растёт только по мере записи в страницы.
Изложение. Утилиты показывают несколько чисел, и их легко перепутать. VIRT (в ps — VSZ) — весь объём отображённого адресного пространства, включая страницы, к которым процесс ни разу не обращался; виртуальной памяти может быть во много раз больше физической. RES (RSS) — страницы, которые в текущий момент находятся в физической памяти. SHR — та часть RES, которая может быть общей с другими процессами: библиотеки, разделяемая память, отображённые файлы.
По происхождению память делится на анонимную (куча, стек — у неё нет файла на диске, и выгрузить её можно только в подкачку) и файловую. Файлы, прочитанные с диска, остаются в страничном кеше: free показывает его в столбце buff/cache. Кеш не является потерянной памятью — при нехватке ядро освобождает его первым, поэтому ориентиром служит столбец available.
RSS складывается из анонимной памяти, отображённых файлов (кода, библиотек) и разделяемой памяти; страничный кеш, заполненный обычным read(), в RSS процесса не входит, но засчитывается в лимит его cgroup, поэтому в контейнере упираются и в него. Подробную картину по каждому отображению дают pmap -XX и /proc/<pid>/smaps, сводку — smaps_rollup. Выделение памяти ещё не означает её использования: malloc — функция библиотеки, получающая память у ядра вызовами brk и mmap, — на гигабайт сразу увеличивает VIRT, а RES растёт только по мере записи в страницы. Разрешено ли просить больше памяти, чем есть в системе, определяет параметр vm.overcommit_memory.
Промахи
по странице
Обращение к странице
Нет записи в таблице страниц
Исключение: обработчик ядра
Повтор инструкции
01
Малый промах (minor)
страница уже в памяти или выделяется сразу, без чтения с диска: ядро лишь дописывает таблицу — доли микросекунды
02
Большой промах (major)
страницы в памяти нет: чтение с диска или из подкачки — от сотен микросекунд до миллисекунд
ps -o min_flt,maj_flt,cmd -p 12345 /usr/bin/time -v ./calc
Изложение. Виртуальные адреса отображаются на физические через таблицы страниц, которые просматривает блок управления памятью процессора (MMU). Если при обращении к странице записи в таблице нет, возникает исключение — промах по странице, — и управление получает обработчик ядра. Разобравшись, ядро заполняет запись и повторяет ту же инструкцию.
Промахи бывают двух видов, и различаются они по стоимости на три порядка. Малый промах (minor fault) происходит, когда нужная страница уже находится в физической памяти или её можно выделить сразу: страница только что выделена и ещё ни разу не тронута, разделяется с соседним процессом или лежит в страничном кеше. Ядру достаточно дописать таблицу; самый частый случай — первое касание только что выделенной памяти. Обычный read() промахов по странице не вызывает: ядро само копирует данные из страничного кеша. Большой промах (major fault) означает, что страницы в памяти нет и её необходимо прочитать с диска или из подкачки — это сотни микросекунд на NVMe и миллисекунды на жёстком диске.
Если ps или top показывают, что расчёт набирает большие промахи тысячами, то, как правило, ему не хватает памяти и система перешла в подкачку; большие промахи дают также файлы, отображённые через mmap, и запуск программ. Оптимизировать код в этот момент бессмысленно, сначала необходимо разобраться с памятью.
Демонстрация:
память
01
Карта памяти awk: код, куча, стек, vDSO
03
200 МиБ: 52 тысячи малых промахов
Что наблюдается
Строка в 200 МиБ дала около 52 тысяч малых промахов — по одному на страницу в 4 КиБ; с диска ничего не читалось, поэтому больших нет.
root@lab — запись со стенда
Демонстрация. awk печатает собственную карту памяти из /proc/self/maps без библиотек: код и данные программы около адреса 0x400000, BSS, кучу [heap], анонимные отображения, страницы [vvar] и [vdso] и стек у верхней границы пользовательской части. ps показывает для оболочки VSZ 7712 КиБ (около 7,5 МиБ) и RSS 3896 КиБ (около 3,8 МиБ). Затем /usr/bin/time -v запускает Python, создающий строку в 200 МиБ: пиковая резидентная память 214016 КиБ, около 209 МиБ, больших промахов нет, малых — около 52 тысяч, что соответствует 200 МиБ, делённым на страницы по 4 КиБ, плюс запуск интерпретатора. free -h показывает общий расход памяти, страничный кеш в столбце buff/cache и отсутствие подкачки на стенде.
Нехватка
памяти
01
OOM killer
памяти не осталось: ядро выбирает процесс с наибольшим oom_score и завершает его сигналом SIGKILL
02
oom_score_adj
от −1000 (исключён из выбора) до 1000 (первый кандидат); /proc/<pid>/oom_score_adj
03
Лимит cgroup
memory.max: OOM срабатывает внутри группы, остальная система его не замечает
04
Подкачка
swappiness 60 по умолчанию; для расчётного узла обычно вредна: расчёт в подкачке замедляется на порядки
Пояснение
NUMA: у каждого сокета своя память, обращение к чужой дороже — numactl --hardware, numactl --membind. На стенде один узел.
Изложение. Когда память в системе заканчивается и освободить её больше нечем (кеш сброшен, подкачки нет или она заполнена), ядро не возвращает ошибку, а выбирает жертву. OOM killer начисляет каждому процессу балл oom_score, главным образом по объёму занятой памяти, и завершает процесс с наибольшим баллом сигналом SIGKILL. Обычно это самый требовательный к памяти пользовательский расчёт. Процесс исчезает без сообщения об ошибке, а в журнале ядра (dmesg) остаётся запись Out of memory: Killed process. Балл корректируют через /proc/<pid>/oom_score_adj: значение −1000 защищает критичную службу, 1000 делает процесс первым кандидатом.
Если процесс работает в cgroup с ограничением memory.max, нехватка наступает внутри группы, и OOM выбирает жертву только среди её процессов, не затрагивая остальную систему; это и показывает следующая демонстрация.
Подкачка является не запасной памятью, а способом выгрузить на диск давно не использовавшиеся анонимные страницы. Насколько охотно ядро это делает, задаёт параметр swappiness, по умолчанию 60. Расчётному узлу подкачка скорее вредит: если рабочий набор перестал помещаться в оперативную память, всё замедлится в сотни раз, и машина будет выглядеть зависшей. Отключение подкачки тоже компромисс: без неё ядро может вытеснять только файловые страницы, в том числе код программ, и при нехватке памяти система начинает перечитывать собственный код с диска ещё до срабатывания OOM. Сам OOM killer — не отдельный процесс, а функция ядра, вызываемая, когда освободить память не удалось.
На машинах с несколькими процессорными сокетами память разделена между ними (NUMA), и обращение к памяти соседнего сокета обходится дороже. Топологию показывает numactl --hardware, привязку задачи и её памяти к узлу задают ключи --cpunodebind и --membind. На стенде один узел NUMA.
Демонстрация:
OOM в cgroup
01
Scope systemd с MemoryMax=100M
02
Строка в 300 МиБ: Killed
03
Код 137 = 128 + SIGKILL
04
Запись OOM в журнале ядра
Что наблюдается
OOM сработал внутри cgroup: завершён только процесс группы, остальная система лимита не заметила.
root@lab — запись со стенда
Демонстрация. systemd-run --scope запускает Python во временной группе (scope) с ограничением памяти MemoryMax=100M; systemd сообщает имя созданной группы. Программа пытается создать строку в 300 МиБ, упирается в лимит группы, и ядро завершает её: оболочка печатает Killed, код завершения 137 равен 128 плюс номер сигнала SIGKILL. В журнале ядра остаётся запись Memory cgroup out of memory: Killed process с PID и именем процесса. Остальные процессы стенда лимита не заметили: OOM выбирал жертву только внутри группы.
Изоляция
Cgroups ограничивают потребление, пространства имён — видимость
06
Процессы
Планировщик
Прерывания
Системные вызовы
Память процесса
Изоляция
Изложение. Шестая часть посвящена изоляции: механизмам, которые не дают процессам мешать друг другу и на которых построены контейнеры.
Уровни
изоляции
chroot: свой корень ФС
ulimit: лимиты процесса
systemd и cgroups: лимиты служб
Контейнеры: LXC, Docker, Porto
Виртуальные машины: KVM, Xen
слева направо: изоляция сильнее, накладные расходы больше; контейнеры делят одно ядро, виртуальные машины — нет
Пояснение
Изоляция защищает соседей: ошибка или чрезмерное потребление ресурсов одним сервисом не должны задевать остальные на той же машине.
Пояснение
Контейнер собирается из двух механизмов ядра: пространства имён ограничивают то, что процесс видит, cgroups — то, сколько он потребляет.
Изложение. На одной машине обычно работает много сервисов, и изоляция нужна, чтобы ошибка или чрезмерное потребление ресурсов одним сервисом не задевали остальные. Механизмы изоляции образуют спектр. chroot (1979) подменяет процессу корень файловой системы и ничего больше; границей безопасности он не является, процесс с правами root из него выходит. ulimit ограничивает ресурсы отдельного процесса: число открытых файлов, размер стека, процессорное время. systemd помещает каждую службу в собственную cgroup и позволяет ограничить её память и процессорное время. Контейнеры (LXC, OpenVZ, внутренняя система Яндекса Porto, Docker) дополнительно дают процессам собственное представление о системе через пространства имён, но все контейнеры работают на одном общем ядре. Виртуальные машины (Xen, KVM с QEMU) запускают отдельное ядро и потому изолированы сильнее всего, но и обходятся дороже.
Контейнер собирается из двух механизмов ядра: пространства имён ограничивают то, что процесс видит, а cgroups — то, сколько он может потребить. Механизмы были готовы к 2008 году, за пять лет до Docker; революция 2013 года состояла в удобстве упаковки образа.
Cgroups:
сколько можно потребить
/sys/fs/cgroup/ ├── system.slice/ # службы │ ├── cron.service │ └── ssh.service ├── user.slice/ # пользователи └── cgroup.controllers
Пояснение
Потомки процесса наследуют его группу; лимит на узле дерева действует на всё поддерево. Контроллеры для потомков включаются записью в cgroup.subtree_control.
Ресурс Файлы
процессор (cpu) cpu.max, cpu.weight
память (memory) memory.max, memory.high
ввод-вывод (io) io.max
процессы (pids) pids.max
ядра, узлы (cpuset) cpuset.cpus, cpuset.mems
заморозка группы cgroup.freeze
Изложение. Cgroups (control groups) отвечают за то, сколько ресурсов может потребить группа процессов. В версии 2, используемой на стенде и во всех современных дистрибутивах, группы образуют одно дерево каталогов в /sys/fs/cgroup: каждый каталог является группой, а файлы внутри неё задают лимиты и хранят накопленные счётчики. systemd раскладывает процессы по этому дереву: службы — в system.slice, сеансы пользователей — в user.slice.
Каждый процесс принадлежит ровно одной группе, а порождённые процессы наследуют группу родителя; лимит, установленный на узле дерева, действует на всё поддерево. За каждый вид ресурса отвечает свой контроллер: cpu (cpu.max задаёт квоту в виде «время на период», cpu.weight — долю при конкуренции), memory (memory.max — жёсткий предел, memory.high — порог, после которого начинается принудительное освобождение), io, pids (ограничение числа процессов), cpuset (ядра и узлы памяти); файл cgroup.freeze приостанавливает всю группу. Кроме ограничения, cgroups служат для учёта: сколько памяти и процессорного времени израсходовала служба.
Контроллеры передаются потомкам явно: перед установкой лимитов их включают записью в cgroup.subtree_control родительской группы. Версия 2 отличается от первой не только интерфейсом: у неё единое дерево для всех контроллеров, порог memory.high, учёт памяти ядра и сокетов, ограничение отложенной записи и метрики давления PSI; поддержку версии 1 systemd убрал в 2025 году. Лимит cpu.max не меняет число процессоров, которое видит программа, — его меняет только cpuset.
Демонстрация:
cgroups
01
Служба cron в своей cgroup
02
Контроллеры и счётчики группы
05
cpu.max: 20000 из 100000
Что наблюдается
Лимит задаётся записью в файл группы: cpu.max «20000 100000» означает 20 мс процессорного времени на каждые 100 мс.
root@lab — запись со стенда
Демонстрация. /proc/<pid>/cgroup службы cron показывает её группу: /system.slice/cron.service. В каталоге группы cgroup.controllers перечисляет включённые контроллеры (memory и pids), memory.current — израсходованную память, pids.current — число процессов. Файла cpu.max у этой группы нет, поскольку контроллер cpu для неё не включён. Затем systemd-run --scope запускает бесконечный цикл на Python с ограничением CPUQuota=20%: ps показывает, что процесс получает около 20 % процессора, хотя готов считать непрерывно. В cpu.max созданной группы записано «20000 100000» — 20 мс на каждые 100 мс. Через 5 секунд timeout завершает цикл с кодом 124.
Пространства
имён
Пространство Что подменяет unshare
PID номера процессов --pid
Network интерфейсы, адреса, маршруты, порты --net
Mount смонтированные файловые системы --mount
UTS имя узла и домена --uts
IPC объекты System V и очереди сообщений POSIX --ipc
User идентификаторы пользователей и групп --user
Cgroup видимый корень дерева cgroups --cgroup
Time смещения часов MONOTONIC и BOOTTIME --time
Пояснение
Процесс попадает в пространства имён при создании (clone), может перейти в новые (unshare) или присоединиться к существующим (setns); их список — /proc/<pid>/ns.
Изложение. Пространства имён отвечают за то, что процесс видит. Каждое подменяет одну часть представления о системе, и изменения внутри пространства не видны снаружи. Первый процесс нового PID-пространства получает номер 1, а процессы пространства не видят процессов вне его; в сетевом располагает собственными интерфейсами, адресами, маршрутами и портами; в пространстве монтирования видит собственный набор файловых систем; в UTS — собственное имя узла. Пространство IPC изолирует объекты System V (разделяемую память, семафоры, очереди сообщений) и очереди сообщений POSIX, но не сигналы и не каналы, пользовательское — идентификаторы пользователей, позволяя быть root внутри пространства, не будучи им снаружи. Пространство cgroup подменяет видимый корень дерева cgroups, пространство времени — смещения часов CLOCK_MONOTONIC и CLOCK_BOOTTIME; календарное время (CLOCK_REALTIME) у всех общее.
Процесс получает пространства имён при создании через clone с соответствующими флагами, может перейти в новые вызовом unshare или присоединиться к существующим вызовом setns, которым пользуются docker exec и nsenter. Пространства, в которых находится процесс, перечислены в /proc/<pid>/ns. Процесс с PID 1 внутри пространства получает только сигналы, для которых установил обработчик, поэтому изнутри его нельзя завершить даже kill -9, а из родительского пространства SIGKILL доставляется принудительно — так контейнер останавливают снаружи.
Контейнер и
под
Контейнер
свои: mnt, pid, uts, ipc, net, cgroup
cgroup с лимитами, корень ФС из образа
Под Kubernetes
общие: net, ipc, uts — один адрес, localhost между контейнерами
общая cgroup пода и лимиты каждого контейнера
Пояснение
Контейнер — не отдельная машина, а обычный процесс хоста в своём наборе пространств имён и cgroup; ps на хосте его видит.
Изложение. Контейнер является обычным процессом хоста, помещённым в собственный набор пространств имён и в cgroup с лимитами; корень его файловой системы берётся из образа. Поэтому ps на хосте видит процессы всех контейнеров, а ядро у них общее: уязвимость ядра затрагивает все контейнеры сразу, в отличие от виртуальных машин.
Разные комбинации пространств имён позволяют делить ресурсы выборочно. Под Kubernetes объединяет несколько контейнеров, которые разделяют сетевое пространство (один IP-адрес, связь через localhost), пространство IPC и имя узла, но каждый имеет собственную файловую систему и, по умолчанию, собственное PID-пространство. Так вспомогательный контейнер, например собирающий журналы, работает рядом с основным приложением, как если бы они были на одной машине. Пользовательское пространство имён Docker и Kubernetes по умолчанию не создают: root в контейнере совпадает с root хоста, если не включён режим без root или userns-remap. В новом сетевом пространстве есть только интерфейс lo; связь с внешним миром Docker создаёт парой виртуальных интерфейсов veth и мостом.
Демонстрация:
пространства имён
01
Пространства имён оболочки в /proc/$$/ns
02
unshare --pid: оболочка с PID 1
03
unshare --uts: своё имя узла
04
lsns: пространства имён системы
Что наблюдается
В новом PID-пространстве оболочка получает номер 1 и видит только себя и своих потомков; имя узла, изменённое в UTS-пространстве, снаружи прежнее.
root@lab — запись со стенда
Демонстрация. Ссылки в /proc/$$/ns перечисляют пространства имён оболочки: cgroup, ipc, mnt, net, pid, time, user и uts; число в квадратных скобках идентифицирует пространство. unshare --fork --pid --mount-proc запускает bash в новом PID-пространстве с собственной /proc: оболочка сообщает, что её PID равен 1, а ps видит только её и себя. unshare --uts запускает bash в новом UTS-пространстве, где hostname меняется на container, а после выхода команда hostname снаружи по-прежнему печатает lab. lsns показывает пространства имён системы: у большинства процессов они общие с init, а некоторые службы, например systemd-udevd и systemd-timesyncd, systemd запускает в собственных UTS-пространствах.
Заключение
01
Процесс — учётная единица ядра; всё, что о нём известно, видно в /proc/<pid>.
02
Состояние процесса — первый вопрос при зависании: R, S, D, T и Z требуют разных действий.
03
Планировщик делит время по весам; nice, chrt и taskset меняют долю, политику и набор ядер.
04
Память — наносекунды, системный вызов — микросекунды, диск — миллисекунды: оптимизация сводится к тому, чтобы реже спускаться ниже.
05
Контейнер — процесс в своих пространствах имён с лимитами cgroups, а не отдельная машина.
При странном поведении программы не гадают, а спрашивают систему: /proc, strace, perf.
Изложение. При странном поведении программы правильнее не гадать, а запросить у системы, чем она занята. Если процесс завис, рассматривается его состояние: процесс в D ждёт устройства, и сигналом его не освободить; зомби уже завершён, и разбираются с его родителем; остановленный процесс ждёт SIGCONT. Если расчёт замедлился, рассматриваются доля процессора, промахи по страницам и переключения контекста; если время уходит непонятно на что, — системные вызовы, сделанные за секунду. Ядро сообщает о себе через /proc, /sys и perf.
Порядки величин необходимо помнить: обращение в память занимает наносекунды, системный вызов — микросекунды, чтение с диска — миллисекунды. Между соседними ступенями три порядка, и почти всякая оптимизация сводится к тому, чтобы реже спускаться на ступень ниже.
Следующая лекция посвящена сетевому стеку Linux.
Домашнее задание
Работа с долгоиграющими процессами
01
Скрипт с шебангом #!/usr/bin/bash и бесконечным циклом: echo «I am still alive», sleep 1
02
Запуск на сервере по ssh
03
Продолжение работы после отключения от сервера
05
(*) Чтение stdout и stderr
06
(*) Перенаправление вывода в файлы
Требования к сдаче
Способ, благодаря которому процесс переживает отключение, и объяснение: какой сигнал приходит при закрытии сессии и что с ним происходит
Вывод ps после повторного входа, доказывающий, что процесс жив
(*) Файлы с выводом и способ читать их у работающего процесса, не останавливая его
Репозиторий: github.com/phys-dev/linux-structure-task · главы «Внутреннее устройство Linux» и «Инструменты Linux»
Домашнее задание. Тестовая программа — скрипт с шебангом #!/usr/bin/bash и бесконечным циклом, который раз в секунду печатает «I am still alive»; развёрнутый листинг размещён в репозитории задания. Программу требуется запустить на сервере по ssh и обеспечить продолжение её работы после отключения от сервера. Задание со звёздочкой: программа работает в фоне, её stdout и stderr перенаправлены в соответствующие файлы, и их можно читать у работающего процесса, не останавливая его.
При сдаче указывается команда или способ, благодаря которому процесс переживает отключение, с объяснением, почему он работает: какой сигнал приходит процессу при закрытии сессии и что с ним происходит. Прилагается вывод ps, снятый после повторного входа и доказывающий, что процесс жив; для задания со звёздочкой — файлы с выводом и способ их чтения у работающего процесса.
Для выполнения достаточно материала о сигналах из этой лекции и о tmux из предыдущей.
Типичные
ошибки
01
Запуск через & без отвязки
при обрыве соединения оболочка получает SIGHUP и пересылает его заданиям
02
Проверка в той же сессии
живучесть доказывает только ps после повторного входа
03
nohup без перенаправления
вывод уходит в nohup.out текущего каталога, а не туда, где его ищут
04
Объяснение без сигнала
требуется назвать SIGHUP и объяснить, что с ним делает выбранный способ
05
Остановка процесса ради чтения
tail -f по файлу вывода не мешает работающему процессу
06
Сразу kill -9
процесс лишается возможности завершиться аккуратно; сначала SIGTERM
Изложение. На слайде приведены ошибки, наиболее часто допускаемые при выполнении задания. Процесс, запущенный в фоне символом &, остаётся заданием оболочки: при обрыве соединения оболочка получает SIGHUP и пересылает его своим заданиям, поэтому без nohup, setsid, disown или мультиплексора процесс завершится. Проверка ps в той же сессии ничего не доказывает — живучесть подтверждает только вывод после повторного входа. nohup, если вывод не перенаправлен, дописывает его в файл nohup.out в текущем каталоге. В объяснении требуется назвать сигнал и то, что с ним делает выбранный способ: nohup игнорирует SIGHUP, setsid отвязывает процесс от терминала, disown убирает задание из списка заданий, которым оболочка пересылает сигнал. Читать вывод работающего процесса можно командой tail -f по файлу, не останавливая процесс. Программу завершают сигналом SIGTERM, а kill -9 оставляют на случай, когда она не реагирует.
Источники
3
Love R. Linux Kernel Development. – 3rd ed. – Addison-Wesley, 2010
4
Kerrisk M. The Linux Programming Interface. – No Starch Press, 2010
man7.org/tlpi
8
Страницы руководства: proc(5), signal(7), sched(7), syscall(2), namespaces(7), cgroups(7)
Изложение. Основой лекции служат глава книги курса «Внутреннее устройство Linux» и лекция «Внутренности Linux» курса КИТ 2024 (Young&&Yandex), которая, по словам её автора, является кратким изложением книги Р. Лава о разработке ядра Linux. Для самостоятельного изучения интерфейса ядра со стороны программ рекомендуется книга М. Керриска, для планировщика — статья о потерянных ядрах и документация EEVDF, для изоляции — документация cgroup v2 и страницы руководства namespaces(7) и cgroups(7).
Спасибо за внимание
Вопросы
phys-dev.github.io/soft-dev-book
Изложение. Надпись на последнем слайде — exit, системный вызов, которым процесс завершает работу: после завершения остаётся только код возврата, ожидающий, пока его заберёт родитель. Лекция завершается ответами на вопросы по её содержанию и по домашнему заданию.