Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Процессы: fork, execve, waitpid
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Процессы: fork, execve, waitpid
В прошлом уроке управление впервые ушло из программы не по её воле: исключение увело процессор в ядро и вернуло обратно. Сегодня смотрим, что ядро строит на этом механизме. Главная постройка называется процесс. Это та самая иллюзия, благодаря которой твоя программа пишется так, будто процессор и память принадлежат ей одной, хотя рядом работает ещё тысяча таких же. Мы разберём иллюзию на части, потом научимся процессы создавать, подменять в них программу и хоронить их по правилам. Вызовов всего три:
fork,execve,waitpid. На них стоит каждая оболочка, каждый веб-сервер с рабочими процессами и раннер, который проверяет твои задачи в этом курсе. К концу урока у тебя будетzbox run, первая команда своей песочницы, а на машине Y86 заработают сразу две программы с ядром, которое переключает их по просьбе.
Цели урока
- Объяснять процесс как две иллюзии: свой процессор (логический поток управления) и своя память (изолированное адресное пространство), и знать, чем каждая обеспечена.
- Понимать переключение контекста: что сохраняется, когда оно случается, сколько стоит на железе 2026 года.
- Писать
forkна Zig 0.16, где вstd.posixего нет, и читать программу сforkкак граф процессов: какие выводы возможны, какие нет. - Не попадаться в две ловушки вывода: непустой буфер, который
forkудваивает, и позиционная запись, при которой родитель затирает строку ребёнка. - Утилизировать детей через
waitpid, разбирать слово состояния по битам и знать, откуда берутся зомби и куда деваются сироты. - Заменять образ процесса через
execve, понимать, что переживает замену, и почемуforkиexecveразделены. - Знать, что делает за тебя
std.process.spawnи когда его мало. - Написать
zbox run: запуск чужой программы с отчётом о коде возврата, сигнале, времени процессора и пике памяти. - Сохранить контекст процесса на Y86 руками: PCB, два образа в памяти и ядро на ассемблере, которое переключает их по
yield.
Идея: две иллюзии
Запусти на своей машине ps ax | wc -l. У меня выходит 1020 строк на ноутбуке с шестнадцатью ядрами. Тысяча программ, каждая написана так, будто она одна: никто не проверяет, свободен ли процессор, никто не договаривается с соседями, по каким адресам класть свои данные. Это работает, потому что операционная система выдаёт каждой программе процесс: экземпляр программы в исполнении вместе с его контекстом. Контекст это всё состояние, без которого выполнение не продолжить: код и данные в памяти, стек, содержимое регистров, счётчик команд, переменные окружения, таблица открытых файлов.
Процесс даёт программе две иллюзии.
Свой процессор. Кажется, что инструкции программы идут одна за другой без перерывов. Если подключить отладчик и записывать значения счётчика команд, получится последовательность адресов, которая целиком принадлежит твоему исполняемому файлу и библиотекам, которые он подгрузил. Эта последовательность называется логическим потоком управления. На самом деле физический поток процессора нарезан на куски: кусок твоего процесса, кусок чужого, кусок ядра, снова твой. Процесс, у которого отобрали процессор, называют вытесненным. Сам он этого не замечает: инструкции не знают, сколько времени прошло между ними. Заметить можно только по часам. Если измерять время выполнения цикла с точностью до микросекунды, то иногда один проход окажется в сто раз длиннее соседних. Это не кэш и не предсказатель переходов, с которыми ты воевал в уроке про измерение производительности. Это ядро на время отдало процессор кому-то ещё.
Своя память. Кажется, что все адреса от нуля до верхней границы принадлежат одной программе. У каждого процесса своё адресное пространство, и байт по адресу 0x7ffc1000 в твоём процессе не имеет никакого отношения к байту по тому же адресу у соседа. Прочитать или испортить чужую память обычной инструкцией нельзя: до неё просто нет адреса. Раскладка у всех одинаковая, ты видел её в уроке про загрузку: внизу код и данные из исполняемого файла, выше куча, посередине разделяемые библиотеки, наверху стек, а самая верхняя часть отдана ядру и процессу недоступна. Как именно железо превращает одинаковые адреса разных процессов в разные физические байты, разберём через пять уроков, в блоке про виртуальную память. Сегодня достаточно факта: пространства изолированы.
Конкурентные потоки
Два логических потока называют конкурентными, если они перекрываются во времени: поток X начался после начала Y, но до того, как Y закончился, или наоборот. Важно, что определение ничего не говорит о числе процессоров. На одном ядре процессы A и B выполняются по очереди, кусками, и всё равно конкурентны: у A ещё остались инструкции, когда B уже начал свои. Если же потоки в самом деле идут одновременно на разных ядрах, их называют параллельными. Параллельные потоки это частный случай конкурентных.
Возьми три процесса с такими временами жизни:
| Процесс | Начало | Конец |
|---|---|---|
| A | 0 | 2 |
| B | 1 | 4 |
| C | 3 | 5 |
A и B конкурентны: B начался в момент 1, когда A ещё работал. B и C тоже. А вот A и C нет: A закончился в момент 2, C начался в момент 3, общего времени у них не было. Зачем программисту это различие? Затем, что для любой пары конкурентных потоков ты обязан считать, что их инструкции могут перемешаться в любом порядке. Вся вторая половина урока про графы процессов стоит на этом допущении, а в уроке про сигналы оно же станет источником настоящих гонок.
Отрезок времени, который процесс работает без перерыва, называют квантом. В Linux с версии 6.6 планировщик называется EEVDF, и базовый квант у него 3 мс на машине с восемью ядрами и больше (файл /sys/kernel/debug/sched/base_slice_ns, число 3000000; на одном ядре 0,75 мс). Это верхняя оценка: процесс, который ушёл ждать диск или сеть, отдаёт процессор раньше.
Переключение контекста
Ядро держит для каждого процесса его контекст и умеет делать одну операцию, на которой стоит вся многозадачность: переключение контекста. Шагов три: сохранить контекст текущего процесса, восстановить контекст того, кого решили запустить, передать ему управление. Решение принимает планировщик, часть ядра.
Переключение происходит только в режиме ядра, поэтому ему всегда предшествует вход в ядро одним из способов прошлого урока:
- Системный вызов, который не может завершиться сразу. Процесс попросил
readс диска. Ждать данные миллисекунды, за это время можно выполнить десять миллионов чужих инструкций. Ядро усыпляет просителя и запускает другого. - Системный вызов, который прямо просит об этом.
sleep,sched_yield,waitpidна живом ребёнке. - Прерывание таймера. Процесс ни о чём не просил, но его квант истёк. Это вытесняющая многозадачность: процессор отбирают, не спрашивая.
Вот как выглядит read с диска, если смотреть на физический процессор:
время
| процесс A, режим пользователя
| A зовёт read: trap, вход в ядро
| ядро от имени A: запрос ушёл на диск, A засыпает
| ---- переключение контекста A -> B ----
| процесс B, режим пользователя
| ...
| прерывание от диска: данные приехали
| ядро от имени B: помечает A готовым, решает вернуть ему процессор
| ---- переключение контекста B -> A ----
| ядро от имени A: копирует данные в буфер, возврат из read
v процесс A, режим пользователя
Обрати внимание на фразу “ядро от имени A”. Ядро это не отдельный процесс, который где-то работает сам по себе. Это код, который выполняется в контексте того процесса, который в него вошёл. У каждого процесса в Linux есть свой стек ядра (16 КБ на x86-64), и когда A засыпает посреди read, его недовыполненный системный вызов остаётся лежать на этом стеке до пробуждения.
Сколько это стоит? Само переключение на x86-64 это сохранение полутора десятков регистров общего назначения, около 2,5 КБ векторного состояния через XSAVE (если включён AVX-512), смена регистра CR3 с корнем таблицы страниц и работа планировщика. Прямая цена на современном ядре это единицы микросекунд: столько показывает perf bench sched pipe, который гоняет байт между двумя процессами через пайп и на каждый обмен платит двумя переключениями и двумя системными вызовами. Косвенная цена больше: новый процесс приходит на холодные кэши и холодный TLB, и первые тысячи его обращений к памяти идут по медленному пути из блока про иерархию памяти. Поэтому квант измеряется миллисекундами, а не микросекундами: переключаться чаще значит тратить заметную долю времени на прогрев.
Запомни три слова, которыми описывают состояние процесса с точки зрения программиста. Выполняется: либо прямо сейчас на процессоре, либо ждёт своей очереди и будет запущен. Остановлен: приостановлен сигналом SIGSTOP или SIGTSTP и не будет запущен, пока не придёт SIGCONT. Завершён: закончился навсегда. Завершиться можно тремя способами: вернуться из main, позвать exit, получить сигнал, действие которого по умолчанию это смерть.
Номер процесса и fork
У каждого процесса есть положительный номер, pid. getpid возвращает свой номер, getppid номер родителя. Родитель есть у всех, кроме самого первого процесса с номером 1, которого ядро создаёт само при загрузке.
Новый процесс создаёт системный вызов fork. Он не принимает аргументов и не запускает никакой программы. Он делает копию того процесса, который его позвал. У копии тот же код, те же значения всех переменных, тот же стек, те же открытые файлы, тот же счётчик команд: оба процесса продолжают с одной и той же точки, с возврата из fork. Отличаются они двумя вещами: номером процесса и тем, что fork им вернул. Родителю он возвращает pid ребёнка, ребёнку ноль. fork это функция, которую зовут один раз, а возвращается она дважды, в двух разных процессах.
Где fork в Zig 0.16
Тут первая неожиданность. В std.posix версии 0.16 нет ни fork, ни execve, ни waitpid: при переезде на std.Io их оттуда убрали, оставив порождение процессов за std.process.spawn. Голые вызовы живут в двух местах.
| Откуда | Что даёт | Цена |
|---|---|---|
std.c | fork, execve, waitpid, wait4, _exit, getpid из libc, с привычными сигнатурами и errno | программа собирается с -lc; работает на Linux и на macOS |
std.os.linux | сырые системные вызовы: fork() возвращает usize, ошибка закодирована как минус errno | только Linux; зато без libc, статический бинарник без зависимостей |
В уроке и в zbox мы берём std.c: одна и та же программа работает и на Linux, и на macOS. В задаче из раздела “Практика” наоборот, std.os.linux: там libc нет, и ты увидишь сырой интерфейс ядра из прошлого урока. Чего нет даже в std.c, например execvp, объявляется одной строкой extern "c" fn.
Первая программа
const std = @import("std");
const c = std.c;
fn say(comptime fmt: []const u8, args: anytype) void {
var buf: [128]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
_ = c.write(1, text.ptr, text.len);
}
pub fn main() void {
var x: i32 = 1;
const pid = c.fork();
if (pid < 0) {
say("fork не удался\n", .{});
return;
}
if (pid == 0) {
// Ребёнок: fork вернул ноль.
x += 1;
say("ребёнок: pid={d} ppid={d} x={d} по адресу {*}\n", .{ c.getpid(), c.getppid(), x, &x });
c._exit(0);
}
// Родитель: fork вернул pid ребёнка.
x -= 1;
say("родитель: pid={d} ребёнок={d} x={d} по адресу {*}\n", .{ c.getpid(), pid, x, &x });
_ = c.waitpid(pid, null, 0);
}
Функция say пишет прямо в дескриптор 1 через write, без буфера. Почему так, а не через привычный std.Io.Writer, станет ясно через два раздела. Собери и запусти:
$ zig build-exe fork_x.zig -lc
$ ./fork_x
родитель: pid=257 ребёнок=258 x=0 по адресу i32@7ffffffca8a8
ребёнок: pid=258 ppid=257 x=2 по адресу i32@7ffffffca8a8
Этот и остальные выводы урока сняты на Linux 7.0 (x86-64 под эмулятором Rosetta в Docker на машине с Apple Silicon), если рядом не сказано другое.
В этих двух строках весь fork.
- Позвали раз, вернулся дважды. Одна строка от родителя, одна от ребёнка. В родителе
pidравен 258, в ребёнке нулю. - Процессы конкурентны. Сегодня первым успел родитель. Завтра может успеть ребёнок: ядро вправе запустить любого. Программа, которая полагается на порядок, сломана, даже если тысячу раз подряд ей везло.
- Адресные пространства одинаковые, но раздельные. Обе строки показывают один и тот же адрес переменной
x, до последней цифры. Но родитель видит там 0, а ребёнок 2. Один виртуальный адрес, два разных байта в физической памяти. Каждый менял свою копию, и сосед об этом не узнал. Копировать всю память приforkбыло бы дорого, и ядро этого не делает: страницы копируются по одной, в момент первой записи. Механизм называется copy-on-write, он ждёт тебя в уроке про отображение памяти. - Открытые файлы общие. Ребёнок не открывал stdout, он его унаследовал. Обе строки попали в один терминал. У этого наследования есть тонкость с позицией в файле, до неё тоже дойдём.
Ребёнок выходит через _exit, а не возвращается из main. Это привычка, которую стоит завести сразу: код после if (pid == 0) принадлежит родителю, и ребёнок, который туда провалился, начинает выполнять чужую работу. В этой программе он позвал бы waitpid и получил ошибку, в программе посложнее он запустил бы второй веб-сервер на том же порту.
Графы процессов
Программу с одним fork легко удержать в голове. С двумя уже труднее, а с fork в цикле и waitpid вперемешку голова отказывает. Помогает картинка: граф процессов. Рисуется он так.
- Каждому процессу отведи горизонтальную линию, время идёт слева направо.
- Вершины на линии это события:
fork, печать строки,waitpid, выход. forkпорождает вторую линию: от вершины вниз отходит ребро к началу ребёнка.waitpidэто вершина, в которую приходит ребро от выхода ребёнка: родитель не пойдёт дальше, пока ребёнок не завершится.
Рёбра задают частичный порядок: что обязано случиться раньше чего. Вдоль одной линии порядок жёсткий. Между линиями порядка нет, кроме рёбер fork и waitpid. Допустимый вывод программы это любая топологическая сортировка графа: выпиши все вершины в ряд так, чтобы каждое ребро шло слева направо. Если для какого-то вывода это удаётся, он возможен. Если хоть одно ребро приходится развернуть, такого вывода не бывает.
Возьмём программу, где есть всё сразу.
const std = @import("std");
const c = std.c;
fn say(text: []const u8) void {
_ = c.write(1, text.ptr, text.len);
}
pub fn main() void {
say("Hello\n");
const pid = c.fork();
if (pid == 0) {
say("1\n");
} else {
say("0\n");
_ = c.waitpid(pid, null, 0);
say("2\n");
}
say("Bye\n");
}
Сначала Hello, потом fork. Ребёнок печатает 1 и Bye. Родитель печатает 0, ждёт ребёнка, печатает 2 и Bye. Ограничений три: Hello раньше всех; в ребёнке 1 раньше его Bye; в родителе 0, потом 2, потом Bye, причём 2 только после Bye ребёнка. Открой этот граф в виджете, он уже выбран. Сверху линии процессов, под ними все различимые выводы. В поле внизу можно ввести свой вариант через пробел и узнать, возможен ли он.
Проверь себя: вывод Hello 1 0 Bye 2 Bye допустим, а Hello 0 2 1 Bye Bye нет, потому что 2 обогнала Bye ребёнка, а между ними ребро waitpid.
Теперь сверим модель с машиной. Я прогнал программу 300 раз и сосчитал разные выводы:
$ for i in $(seq 1 300); do ./fork_status | tr '\n' ' '; echo; done | sort | uniq -c
294 Hello 0 1 Bye 2 Bye
6 Hello 1 Bye 0 2 Bye
Оба вывода есть в списке виджета. Но виджет показывает три допустимых вывода, а выпали два, причём один в 98 процентах случаев. Третий, Hello 1 0 Bye 2 Bye, требует, чтобы ребёнка прервали ровно между двумя его write, а это окно в несколько микросекунд. Отсюда главный практический вывод раздела: редкий порядок не значит невозможный. Тесты его не поймают, нагрузка в продакшене поймает. Рассуждать о конкурентных программах надо по графу, а не по прогонам.
Переключи в виджете программу на “два fork подряд” и на “fork в цикле”. Каждый fork удваивает число процессов, потому что его выполняют все, кто до него дожил: после двух вызовов процессов четыре, после трёх в цикле восемь. Запусти fork в цикле без условия выхода, и получится форк-бомба: число процессов растёт как степень двойки, пока не упрётся в лимит RLIMIT_NPROC или в pids.max группы cgroups. Раннер этого курса запускает твои решения с --pids-limit, и теперь ты знаешь зачем.
Ловушка: fork и буфер вывода
До сих пор мы печатали через голый write. Вернём привычный буферизованный Writer из бойлерплейта раздела и сделаем одну ошибку.
const std = @import("std");
const c = std.c;
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &w.interface;
try out.writeAll("привет до fork\n");
// Ошибка: строка ещё лежит в buf, а buf это обычная память процесса.
const pid = c.fork();
if (pid == 0) {
try out.writeAll("ребёнок\n");
try out.flush();
c._exit(0);
}
_ = c.waitpid(pid, null, 0);
try out.writeAll("родитель\n");
try out.flush();
}
$ ./fork_buffer
привет до fork
ребёнок
привет до fork
родитель
Строка, которую программа печатает один раз и до fork, вышла дважды. Причина в том, что writeAll ничего не вывел: он положил байты в массив buf. Массив это обычная память процесса. fork скопировал память, и у ребёнка оказался свой buf с той же непечатанной строкой. Потом каждый сделал flush.
В C эта ошибка коварнее, потому что буфер спрятан внутри printf, а его режим зависит от того, куда смотрит stdout. На терминале stdio сбрасывает буфер на каждом переводе строки, и программа ведёт себя правильно. Стоит перенаправить вывод в файл или в пайп, и буфер начинает копиться до 4 КБ: ./prog печатает строку один раз, а ./prog | cat дважды. Люди находят это через месяц, когда программу впервые запускают из cron. В Zig буфер твой и на виду, поэтому удвоение воспроизводится всегда, и это хорошо.
Правил два.
- Перед
forkбуферы пусты. Либоflushнепосредственно перед вызовом, либо доforkв буфер вообще ничего не пишется.zboxпойдёт вторым путём. - Ребёнок после неудачи выходит через
_exit. Обычныйexitиз libc сбрасывает буферы stdio и выполняет обработчикиatexit, которые ребёнок унаследовал от родителя: временные файлы удалятся дважды, в лог допишется чужой хвост.std.process.exitв программе, собранной с libc, зовёт именно такойexit._exitэто голый системный вызов, он просто завершает процесс.
Вторая ловушка: общая позиция в файле
В демо выше стоит writerStreaming, а не writer, и это не случайность. Замени одно на другое и перенаправь вывод в файл:
const std = @import("std");
const c = std.c;
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writer(init.io, &buf);
const out = &w.interface;
const pid = c.fork();
if (pid == 0) {
try out.writeAll("ребёнок написал эту строку\n");
try out.flush();
c._exit(0);
}
_ = c.waitpid(pid, null, 0);
try out.writeAll("родитель\n");
try out.flush();
}
$ ./fork_offset | cat
ребёнок написал эту строку
родитель
$ ./fork_offset > out.txt
$ cat out.txt
родитель
аписал эту строку
В пайп всё пришло целиком, а в файле родитель затёр начало строки ребёнка. Разберёмся, что тут общее, а что нет. После fork дескриптор 1 в обоих процессах указывает на одну и ту же запись в таблице открытых файлов ядра, а позиция в файле хранится именно в этой записи. Поэтому обычный write у родителя и ребёнка дописывает по очереди, ничего не теряя: ребёнок сдвинул общую позицию, родитель пишет с неё. Но File.writer в Zig 0.16 для обычного файла работает в позиционном режиме: он зовёт pwrite и сам ведёт счёт смещению, начиная с нуля, в своей памяти. Память после fork у каждого своя. Ребёнок записал строку по смещению 0 и сдвинул свой счётчик. Счётчик родителя остался нулём, и его pwrite лёг поверх. Пайп позиционную запись не поддерживает, Writer это замечает и переходит на обычный write, поэтому через | cat ошибки не видно.
Вывод: всё, что пишет в унаследованный дескриптор из нескольких процессов, должно писать потоково, через writerStreaming или голый write. Подробно таблицу открытых файлов и общие позиции разберём в уроке про разделение файлов.
Утилизация: зомби, сироты и waitpid
Когда процесс завершается, ядро не стирает его сразу. Память и файлы освобождаются, но строка в таблице процессов остаётся: в ней лежит код возврата и счётчики ресурсов, и кто-то должен их забрать. Забрать может только родитель, вызовом waitpid. Это называется утилизацией (reaping). Процесс, который уже завершился, но ещё не утилизирован, называют зомби.
const std = @import("std");
const c = std.c;
pub fn main() void {
const zombie = c.fork();
if (zombie == 0) c._exit(0); // ребёнок выходит сразу
// Родитель не зовёт waitpid. Дадим ребёнку секунду, чтобы умереть.
const second: c.timespec = .{ .sec = 1, .nsec = 0 };
_ = c.nanosleep(&second, null);
// Строку с pid готовим до fork: после fork аллокатор не трогаем.
var buf: [16]u8 = undefined;
const pid_text = std.fmt.bufPrintZ(&buf, "{d}", .{zombie}) catch return;
const argv = [_:null]?[*:0]const u8{ "ps", "-o", "pid,ppid,stat,comm", "-p", pid_text };
const ps = c.fork();
if (ps == 0) {
_ = c.execve("/bin/ps", &argv, c.environ);
c._exit(127);
}
_ = c.waitpid(ps, null, 0);
// Утилизация: после waitpid запись о ребёнке исчезает из таблицы процессов.
_ = c.waitpid(zombie, null, 0);
}
$ ./zombie
PID PPID STAT COMMAND
192 191 Z zombie
Буква Z в столбце STAT и есть зомби. Убить его нельзя, он уже мёртв: kill -9 на зомби ничего не меняет. Вреда от одного зомби нет, он не ест ни процессор, ни память. Вред в накоплении: сервер, который порождает ребёнка на каждый запрос и не утилизирует, за сутки займёт все pid, и следующий fork вернёт EAGAIN. Предел на Linux лежит в /proc/sys/kernel/pid_max, в современных дистрибутивах там 4194304.
А если родитель умер раньше ребёнка? Ребёнок становится сиротой, и ядро назначает ему нового родителя: процесс с номером 1, то есть init или systemd, либо ближайшего предка, который объявил себя усыновителем через prctl(PR_SET_CHILD_SUBREAPER). Первый процесс обязан крутить цикл waitpid и утилизировать всех, кто ему достался. Поэтому зомби, чей родитель завершился, исчезает сам. Отсюда же известная беда контейнеров: процесс с номером 1 внутри контейнера это твоя программа, усыновителем становится она, а цикла waitpid в ней нет. Для этого у Docker есть флаг --init, который ставит первым процессом крошечный усыновитель.
waitpid в деталях
pid_t waitpid(pid_t pid, int *status, int options);
Кого ждать задаёт первый аргумент. Положительный pid это один конкретный ребёнок. Минус единица это любой из детей. Ноль и отрицательные числа меньше минус единицы относятся к группам процессов, они понадобятся в уроке про оболочку.
Как ждать задаёт options. Ноль это поведение по умолчанию: уснуть, пока кто-нибудь из ожидаемых детей не завершится; если такой уже есть, вернуться сразу. Флаги меняют это так:
| Флаг | Что меняет |
|---|---|
WNOHANG | не спать: если никто ещё не завершился, сразу вернуть 0 |
WUNTRACED | вернуться и тогда, когда ребёнок остановлен, а не только завершён |
WCONTINUED | вернуться и тогда, когда остановленного ребёнка продолжили сигналом SIGCONT |
Что случилось лежит в status. Это не код возврата, а упакованное слово, и на Linux оно устроено так:
бит 15 ............ 8 7 6 ............ 0
+------------------+----+------------------+
| код выхода |дамп| номер сигнала |
+------------------+----+------------------+
- Младшие семь битов равны нулю: процесс вышел сам, код выхода в битах с 8 по 15. Отсюда правило, что код возврата это число от 0 до 255:
exit(256)превращается в ноль. - Младшие семь битов не ноль и не
0x7f: процесс убит сигналом с этим номером. Бит 7 говорит, что ядро записало дамп памяти. - Младший байт равен
0x7f: процесс остановлен, номер сигнала в битах с 8 по 15. Такое слово приходит только сWUNTRACED.
Руками сдвигать не принято: раскладка формально зависит от системы. В C есть макросы WIFEXITED, WEXITSTATUS, WIFSIGNALED, WTERMSIG, WIFSTOPPED, WSTOPSIG, в Zig они живут в std.c.W и std.os.linux.W.
Ошибки. Минус единица и ECHILD в errno: детей нет, ждать некого. Минус единица и EINTR: ожидание прервал сигнал, вызов надо повторить. Вторую ошибку забывают чаще всего, и программа с обработчиком сигнала начинает терять детей.
Соберём всё в одной программе: пять детей, один из них погибает от сигнала, родитель утилизирует всех.
const std = @import("std");
const c = std.c;
const children = 5;
fn say(comptime fmt: []const u8, args: anytype) void {
var buf: [128]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
_ = c.write(1, text.ptr, text.len);
}
pub fn main() void {
var pids: [children]c.pid_t = undefined;
for (&pids, 0..) |*pid, i| {
pid.* = c.fork();
if (pid.* == 0) {
if (i == 3) c.abort(); // этот ребёнок погибает от SIGABRT
c._exit(@intCast(100 + i));
}
}
// pid = -1: любой ребёнок. Порядок выбирает ядро, а не мы.
while (true) {
var status: c_int = 0;
const pid = c.waitpid(-1, &status, 0);
if (pid < 0) break;
const word: u32 = @bitCast(status);
if (c.W.IFEXITED(word)) {
say("ребёнок {d} вышел сам, код {d}\n", .{ pid, c.W.EXITSTATUS(word) });
} else if (c.W.IFSIGNALED(word)) {
say("ребёнок {d} убит сигналом {d}\n", .{ pid, @intFromEnum(c.W.TERMSIG(word)) });
}
}
// Детей не осталось: waitpid вернул -1, в errno лежит ECHILD.
say("waitpid: {t}\n", .{std.posix.errno(@as(c_int, -1))});
}
$ ./reap
ребёнок 264 вышел сам, код 100
ребёнок 265 вышел сам, код 101
ребёнок 266 вышел сам, код 102
ребёнок 267 убит сигналом 6
ребёнок 268 вышел сам, код 104
waitpid: CHILD
Здесь дети пришли по порядку, но это совпадение. Вот та же программа на macOS минутой раньше:
ребёнок 63077 вышел сам, код 104
ребёнок 63076 убит сигналом 6
ребёнок 63072 вышел сам, код 102
ребёнок 63070 вышел сам, код 101
ребёнок 63068 вышел сам, код 100
waitpid: CHILD
waitpid(-1, ...) отдаёт детей в том порядке, в каком ядру удобно. Нужен определённый порядок, жди по номерам: сохрани pid в массив и зови waitpid(pids[i], ...) по очереди. Программа при этом не станет медленнее, она лишь утилизирует ранних детей чуть позже.
У waitpid есть старший брат wait4: те же аргументы плюс указатель на struct rusage, куда ядро кладёт счётчики ресурсов ребёнка: время процессора в режиме пользователя и ядра, пик резидентной памяти, число сбоев страниц и переключений контекста. На нём мы построим zbox run.
execve: другая программа в том же процессе
fork размножает процессы, но все они выполняют одну и ту же программу. Запустить другую умеет execve:
int execve(const char *path, char *const argv[], char *const envp[]);
Он загружает исполняемый файл path в текущий процесс. Нового процесса не появляется, pid тот же. Старая программа исчезает целиком: её код, данные, куча и стек выброшены, на их месте образ нового файла. Поэтому execve зовут один раз, а возвращается он ноль раз. Вернуться он может только с ошибкой, когда заменить образ не вышло: файла нет, нет права на исполнение, формат не распознан.
Два массива это всё, что новая программа получает от старой. argv это аргументы командной строки, по соглашению argv[0] это имя программы. envp это окружение, строки вида ИМЯ=значение. Оба массива кончаются нулевым указателем. Ядро копирует их на вершину нового стека, и раскладка там такая, сверху вниз: сами строки, потом массив указателей envp с нулём в конце, массив argv с нулём в конце, и в самом низу число argc. На это слово и смотрит %rsp в первой инструкции _start. Оттуда startup-код libc или Zig достаёт аргументы и зовёт твой main. Как ядро раскладывает по адресному пространству сегменты ELF, ты видел в уроке про загрузку; что при этом происходит с таблицей страниц, увидишь в уроке 56.
Напишем подопытную программу, которая печатает всё, что получила.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &w.interface;
const args = try init.minimal.args.toSlice(init.arena.allocator());
try out.writeAll("Аргументы командной строки:\n");
for (args, 0..) |arg, i| try out.print(" argv[{d:>2}]: {s}\n", .{ i, arg });
try out.writeAll("Переменные окружения:\n");
for (init.minimal.environ.block.view().slice, 0..) |entry, i| {
try out.print(" envp[{d:>2}]: {s}\n", .{ i, entry });
}
try out.flush();
}
И программу, которая превращается в неё.
const std = @import("std");
const c = std.c;
pub fn main() void {
// Оба массива кончаются нулевым указателем: так их ждёт ядро.
const argv = [_:null]?[*:0]const u8{ "какое-угодно-имя", "раз", "два три" };
const envp = [_:null]?[*:0]const u8{ "LANG=ru_RU.UTF-8", "COURSE=batschool" };
const before = "pid до execve тот же, что у myecho\n";
_ = c.write(1, before.ptr, before.len);
_ = c.execve("./myecho", &argv, &envp);
// Сюда попадаем, только если execve не удался.
const failed = "execve вернулся, значит не сработал\n";
_ = c.write(2, failed.ptr, failed.len);
c._exit(127);
}
$ zig build-exe myecho.zig
$ zig build-exe exec_demo.zig -lc
$ ./exec_demo
pid до execve тот же, что у myecho
Аргументы командной строки:
argv[ 0]: какое-угодно-имя
argv[ 1]: раз
argv[ 2]: два три
Переменные окружения:
envp[ 0]: LANG=ru_RU.UTF-8
envp[ 1]: COURSE=batschool
Три наблюдения. argv[0] это просто строка, которую передал вызывающий, ядро её не проверяет. На этом держится BusyBox: один бинарник, сотня символических ссылок, и программа решает, кто она сегодня, по argv[0]. Окружение не наследуется само собой: мы передали две переменные, и myecho увидела ровно две, без PATH и HOME. Оболочка передаёт своё окружение целиком, поэтому кажется, что оно “глобальное”. И “два три” приехало одним аргументом с пробелом внутри: на слова строку режет оболочка, а не ядро. Ядру всё равно, что лежит в строках.
Что переживает execve
Память не переживает. Но процесс это не только память, и остальное остаётся.
| Остаётся | Сбрасывается |
|---|---|
| pid, родитель, группа процессов | код, данные, куча, стек, отображения mmap |
открытые дескрипторы без флага O_CLOEXEC | дескрипторы с флагом O_CLOEXEC закрываются |
текущий каталог, umask, лимиты setrlimit | обработчики сигналов: их функции исчезли вместе с кодом, действие возвращается к умолчанию |
| маска заблокированных сигналов и сигналы, которые игнорируются | потоки, кроме вызвавшего |
счётчики rusage, которые накопил процесс |
Левый столбец это и есть ответ на вопрос, зачем fork и execve разделены. Между ними есть промежуток, в котором ребёнок уже отдельный процесс, но в нём ещё работает твой код. В этом промежутке ребёнка настраивают: перенаправляют дескрипторы 0, 1 и 2 в файл или пайп, меняют каталог, ставят лимиты, переводят в другую группу процессов, сбрасывают привилегии. Потом execve, и новая программа просыпается в уже подготовленном мире, ничего о подготовке не зная. ls > out.txt работает именно так: ls не умеет писать в файлы, она пишет в дескриптор 1, а файл туда подложила оболочка между fork и execve. Весь zbox будет расти в этом промежутке: лимит памяти, захват вывода, изоляция.
У семейства exec в libc шесть обёрток над одним системным вызовом. Нам пригодится execvp: буква p значит, что имя без косой черты ищется по каталогам из PATH, как это делает оболочка. С ним zbox run sleep 1 работает без полного пути.
std.process: обёртка, которой обычно хватает
В прикладной программе голый fork нужен редко. Стандартная библиотека 0.16 даёт три функции в std.process: spawn порождает ребёнка и возвращает Child, Child.wait дожидается его и отдаёт Term, а run делает всё сразу и заодно собирает stdout и stderr ребёнка в память.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &w.interface;
// spawn это fork плюс execve, wait это wait4 плюс разбор слова состояния.
var child = try std.process.spawn(init.io, .{
.argv = &.{ "sh", "-c", "echo из ребёнка; exit 3" },
.request_resource_usage_statistics = true,
});
const term = try child.wait(init.io);
switch (term) {
.exited => |code| try out.print("вышел сам, код {d}\n", .{code}),
.signal => |sig| try out.print("убит сигналом {t}\n", .{sig}),
.stopped, .unknown => try out.writeAll("остановлен или непонятно что\n"),
}
if (child.resource_usage_statistics.getMaxRss()) |bytes| {
try out.print("пик памяти {d} КБ\n", .{bytes / 1024});
}
// run дополнительно забирает stdout и stderr через пайпы.
const result = try std.process.run(init.gpa, init.io, .{ .argv = &.{ "uname", "-s" } });
defer init.gpa.free(result.stdout);
defer init.gpa.free(result.stderr);
try out.print("uname сказал: {s}", .{result.stdout});
try out.flush();
}
$ ./child_demo
из ребёнка
вышел сам, код 3
пик памяти 4332 КБ
uname сказал: Linux
Term это тот же разбор слова состояния, только типом: .exited с кодом, .signal и .stopped с сигналом. switch по нему компилятор проверяет на полноту, и забыть про смерть от сигнала не получится. С флагом request_resource_usage_statistics внутри работает wait4, и getMaxRss уже приводит единицы: в байты на любой системе.
Загляни в реализацию, processSpawnPosix в std/Io/Threaded.zig. Там ровно те три вызова, что в этом уроке, и три приёма, которые стоит запомнить.
-
Вся память выделяется до
fork. Массивыargvиenvpс нулями на конце собираются заранее. Причина та же, что с буферами: в многопоточной программеforkкопирует только вызвавший поток, и если другой поток в этот миг держал замок аллокатора, в ребёнке замок останется запертым навсегда. Междуforkиexecveбезопасны только функции из списка async-signal-safe, подробно о нём в уроке про сигналы. -
Пайп для ошибок с флагом
CLOEXEC. Как родителю узнать, чтоexecveв ребёнке не удался? По коду 127, как делает оболочка, не отличить “программы нет” от “программа вернула 127”.spawnпередforkоткрывает пайп сO_CLOEXEC. Еслиexecveудался, ядро само закрывает пишущий конец, и родитель читает конец файла. Если нет, ребёнок пишет в пайп код ошибки и выходит. Поэтомуspawnнесуществующей программы честно возвращает ошибку:spawn: FileNotFound -
waitповторяет вызов приEINTR. Та самая ошибка, которую забывают.
Когда обёртки мало? Когда нужен тот самый промежуток между fork и execve. SpawnOptions покрывает частые случаи: каталог, окружение, перенаправление трёх стандартных дескрипторов, uid, gid, pgid. Но поставить setrlimit, вписать ребёнка в группу cgroups или сменить пространство имён через него нельзя, хука для своего кода в ребёнке нет. Песочница вся состоит из таких действий, поэтому zbox пишем на голых вызовах.
На macOS. Всё в этом уроке, кроме задачи в “Практике”, собирается и работает на macOS напрямую:
std.cтам это libSystem, флаг-lcможно не писать, он подразумевается. Отличия мелкие, но на них наступают. Номера процессов у соседнихforkидут не подряд, поэтому в выводеreapчисла с дырами.psпоказывает зомби как<defunct>в столбцеCOMM, букваZта же.ru_maxrssвrusageприходит в байтах, а не в килобайтах, как на Linux: это единственная системная разница, которуюzboxобязан знать уже сегодня. Раскладка слова состояния совпадает с линуксовой, но пользуйся макросамиW. Усыновителем сирот служитlaunchdс номером 1,prctlиpids.maxнет. Сырые вызовыstd.os.linuxна macOS не работают, поэтому задачуmysystemрешай прямо в редакторе на сайте или в Docker: локально на macOS тесты с процессами пропустят себя сами, проверится только разбор слова состояния. И ещё одно: Apple не любитforkбезexecveв программах с графическим интерфейсом и потоками. Многие системные библиотеки послеforkв ребёнке намеренно падают с сообщением проfork()безexec(). Наши консольные демо этого не задевают.
Проект zbox: команда run
С этого урока в разделе появляется третий сквозной проект. zbox это своя песочница: программа, которая запускает чужую программу под присмотром и отчитывается, чем всё кончилось. Ровно этим занимается bs-runner, который проверяет твои задачи: он зовёт docker run с десятком флагов, и каждый флаг это один или два системных вызова. Мы будем добавлять их по одному, по мере того как курс до них доходит: лимит времени в уроке про сигналы, лимит памяти в уроке 54, захват вывода в уроке 61. Сегодня фундамент:
$ zbox run sh -c 'echo привет; exit 7'
привет
{"exit_code":7,"signal":null,"cpu_user_ms":16,"cpu_sys_ms":1,"max_rss_kb":5088,"wall_ms":19}
zbox run запускает команду, ждёт её и печатает одну строку JSON: код возврата или номер сигнала, время процессора в двух режимах, пик резидентной памяти, время по настенным часам. Вывод самой программы пока идёт напрямую в тот же терминал: ребёнок унаследовал дескрипторы. Сам zbox возвращает 0, если до запуска дошло, и 2, если его позвали неправильно. Код программы лежит в JSON, а не в коде возврата zbox: так вызывающий отличает “программа упала” от “песочница упала”.
Раскладка проекта:
our-runner/
build.zig граф сборки: модуль zbox, программа zbox, шаг test по шагам проекта
src/root.zig корень модуля zbox, отсюда тесты берут части песочницы
src/main.zig командная строка и печать ответа
src/box/args.zig разбор `run <cmd> [args...]`, чистая функция
src/box/report.zig итог запуска, единицы `ru_maxrss`, печать JSON
src/box/run.zig `fork`, `execvp`, `wait4`, разбор слова состояния
tests/support.zig запуск настоящего zbox из теста и разбор его ответа
tests/step_48.zig тесты этого урока
Деление то же, что в zt и в симуляторе Y86: всё, что можно проверить без операционной системы, лежит в чистых функциях, а системного кода один файл.
Сборка
const std = @import("std");
/// Номера уроков курса, на которых проект вырос. Каждому шагу
/// соответствует файл `tests/step_NN.zig`, и все они остаются зелёными.
const project_steps = [_]u8{48};
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Библиотечный код: подключается и программой, и тестами шагов.
// fork, execvp и wait4 берём из libc: в std.posix обёрток для них нет.
const zbox = b.addModule("zbox", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
});
const exe = b.addExecutable(.{
.name = "zbox",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{.{ .name = "zbox", .module = zbox }},
}),
});
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| run_cmd.addArgs(args);
const run_step = b.step("run", "Собрать и запустить zbox");
run_step.dependOn(&run_cmd.step);
// Интеграционные тесты запускают настоящий zbox. Путь к собранной
// программе попадает в них константой, и тесты зависят от её сборки.
const options = b.addOptions();
options.addOptionPath("zbox_exe", exe.getEmittedBin());
// zig build test прогоняет все шаги, zig build test -Dstep=48 только один.
const only_step = b.option(u8, "step", "Прогнать тесты одного шага, например -Dstep=48");
const test_step = b.step("test", "Прогнать тесты всех шагов проекта");
inline for (project_steps) |number| {
if (only_step == null or only_step.? == number) {
const path = std.fmt.comptimePrint("tests/step_{d:0>2}.zig", .{number});
const step_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path(path),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zbox", .module = zbox },
.{ .name = "build_options", .module = options.createModule() },
},
}),
});
test_step.dependOn(&b.addRunArtifact(step_tests).step);
}
}
}
link_libc = true у модуля и есть наш -lc. Интересное место это addOptions: тестам нужен путь к собранному zbox, и граф сборки передаёт его константой build_options.zbox_exe. Заодно тесты начинают зависеть от сборки программы, и zig build test всегда гоняет свежий бинарник.
//! Корень модуля zbox. Программа и тесты берут части песочницы отсюда.
pub const args = @import("box/args.zig");
pub const report = @import("box/report.zig");
pub const run = @import("box/run.zig");
Командная строка
//! Разбор командной строки `zbox run`. Чистая функция: ни процессов,
//! ни вывода, поэтому проверяется обычными тестами.
const std = @import("std");
pub const Command = struct {
/// Программа и её аргументы: то, что уйдёт в `execvp`.
argv: []const []const u8,
};
pub const Error = error{BadUsage};
/// `args` это командная строка без имени самого zbox: `run <cmd> [args...]`.
pub fn parse(args: []const []const u8) Error!Command {
if (args.len < 2) return error.BadUsage;
if (!std.mem.eql(u8, args[0], "run")) return error.BadUsage;
// Всё после `run` принадлежит программе: `zbox run ls -l` передаст `-l` в ls.
return .{ .argv = args[1..] };
}
Флагов у run пока нет, поэтому разбор умещается в три строки. Отдельный файл он получил авансом: уже в следующем уроке здесь появится --time-ms.
Отчёт
//! Итог запуска и его печать одной строкой JSON. Тоже чистый код:
//! от операционной системы здесь только знание о единицах `ru_maxrss`.
const std = @import("std");
const builtin = @import("builtin");
pub const Outcome = struct {
/// Код возврата, если программа завершилась сама (`exit` или `return` из `main`).
exit_code: ?u8 = null,
/// Номер сигнала, если программу убил сигнал. Ровно одно из двух полей не null.
signal: ?u32 = null,
/// Время процессора в режиме пользователя и в режиме ядра.
cpu_user_ms: u64 = 0,
cpu_sys_ms: u64 = 0,
/// Пиковый размер резидентной памяти, килобайты.
max_rss_kb: u64 = 0,
/// Время по настенным часам от `fork` до конца `wait4`.
wall_ms: u64 = 0,
};
/// `ru_maxrss` на Linux приходит в килобайтах, а на macOS в байтах.
/// man-страница `getrusage(2)` у каждой системы своя, и они расходятся.
pub fn maxRssKb(ru_maxrss: u64, os: std.Target.Os.Tag) u64 {
return switch (os) {
.macos => ru_maxrss / 1024,
else => ru_maxrss,
};
}
pub fn maxRssKbNative(ru_maxrss: u64) u64 {
return maxRssKb(ru_maxrss, builtin.os.tag);
}
/// Секунды и микросекунды из `timeval` в целые миллисекунды.
pub fn timevalMs(sec: i64, usec: i64) u64 {
return @intCast(sec * 1000 + @divTrunc(usec, 1000));
}
/// Поля только числовые, экранировать нечего,
/// поэтому JSON собираем обычным `print`.
pub fn writeJson(out: *std.Io.Writer, outcome: Outcome) std.Io.Writer.Error!void {
try out.writeAll("{\"exit_code\":");
try writeOptional(out, outcome.exit_code);
try out.writeAll(",\"signal\":");
try writeOptional(out, outcome.signal);
try out.print(",\"cpu_user_ms\":{d},\"cpu_sys_ms\":{d},\"max_rss_kb\":{d},\"wall_ms\":{d}}}\n", .{
outcome.cpu_user_ms,
outcome.cpu_sys_ms,
outcome.max_rss_kb,
outcome.wall_ms,
});
}
fn writeOptional(out: *std.Io.Writer, value: anytype) std.Io.Writer.Error!void {
if (value) |number| {
try out.print("{d}", .{number});
} else {
try out.writeAll("null");
}
}
exit_code и signal сделаны необязательными полями, и ровно одно из них не null. Это честнее, чем принятое в оболочке 128 + номер сигнала: код 137 может значить и SIGKILL, и программу, которая сама вернула 137. В задаче “Практики” ты напишешь оболочечный вариант и почувствуешь разницу. maxRssKb принимает систему аргументом, а не читает builtin.os.tag внутри, чтобы обе ветки проверялись тестом на любой машине.
Запуск
//! Запуск чужой программы: `fork`, `execvp`, `wait4`.
const std = @import("std");
const c = std.c;
const report = @import("report.zig");
// В std.c есть execve, но нет execvp. Нам нужен именно он: пусть libc
// сама найдёт `sleep` или `sh` по PATH, как это делает оболочка.
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
pub const Error = error{ ForkFailed, WaitFailed, OutOfMemory };
/// Код возврата ребёнка, если `execvp` не удался. Так же поступает оболочка:
/// 127 значит, что команда не найдена.
pub const exec_failed_code = 127;
pub fn run(arena: std.mem.Allocator, argv: []const []const u8) Error!report.Outcome {
// Массив для execvp собираем до fork. После fork в ребёнке безопасны
// только async-signal-safe функции, а аллокатор к ним не относится.
const c_argv = try arena.allocSentinel(?[*:0]const u8, argv.len, null);
for (argv, c_argv) |arg, *slot| slot.* = try arena.dupeZ(u8, arg);
const started_ms = nowMs();
const pid = c.fork();
if (pid < 0) return error.ForkFailed;
if (pid == 0) {
// Мы в ребёнке. Успешный execvp не возвращается: образ процесса
// заменён, этого кода в памяти больше нет.
_ = execvp(c_argv[0].?, c_argv.ptr);
childFail();
}
// Мы в родителе. wait4 это waitpid, который заодно отдаёт rusage
// ребёнка: время процессора и пик памяти ядро считало всё это время.
var status: c_int = 0;
var usage: c.rusage = undefined;
while (true) {
const reaped = c.wait4(pid, &status, 0, &usage);
if (reaped == pid) break;
// Сигнал мог прервать ожидание, тогда просто ждём дальше.
if (std.posix.errno(reaped) == .INTR) continue;
return error.WaitFailed;
}
var outcome = decodeStatus(@bitCast(status));
outcome.cpu_user_ms = report.timevalMs(usage.utime.sec, usage.utime.usec);
outcome.cpu_sys_ms = report.timevalMs(usage.stime.sec, usage.stime.usec);
outcome.max_rss_kb = report.maxRssKbNative(@intCast(usage.maxrss));
outcome.wall_ms = nowMs() - started_ms;
return outcome;
}
/// Слово состояния из wait4 упаковано по-разному на разных системах,
/// поэтому разбираем его макросами W*, а не сдвигами руками.
fn decodeStatus(status: u32) report.Outcome {
if (c.W.IFEXITED(status)) return .{ .exit_code = c.W.EXITSTATUS(status) };
if (c.W.IFSIGNALED(status)) return .{ .signal = @intFromEnum(c.W.TERMSIG(status)) };
// Без WUNTRACED остановленного ребёнка wait4 не вернёт.
unreachable;
}
/// Выход из ребёнка с сообщением. Только async-signal-safe вызовы.
fn childFail() noreturn {
const message = "zbox: не удалось запустить программу\n";
_ = c.write(2, message.ptr, message.len);
// Именно _exit, а не exit: обычный exit сбросил бы буферы stdio
// и выполнил atexit-обработчики, унаследованные от родителя.
c._exit(exec_failed_code);
}
fn nowMs() u64 {
var now: c.timespec = undefined;
_ = c.clock_gettime(.MONOTONIC, &now);
return @intCast(now.sec * 1000 + @divTrunc(now.nsec, std.time.ns_per_ms));
}
Пройдись по функции run и найди в ней весь урок.
- Массив
c_argvс нулём на конце собирается доfork, из арены. Послеforkребёнок не зовёт ничего, кромеexecvp,writeи_exit. - Ребёнок не возвращается из функции ни при каком исходе: либо
execvpудался и этого кода больше нет, либоchildFailсnoreturn. Компилятор это проверяет: убериchildFail(), и веткаif (pid == 0)провалится в родительский код, а с типомnoreturnтакая ошибка не соберётся незамеченной. wait4стоит в цикле с проверкойEINTR.- Слово состояния разбирается макросами
W. Веткаunreachableзаконна: мы не передавалиWUNTRACED, значит остановленного ребёнкаwait4не вернёт. - Код 127 при неудачном
execvpэто соглашение оболочки. Приём с пайпом иCLOEXECизstd.processбыл бы точнее, но у песочницы другой приоритет: её ответ должен выглядеть так же, как ответsh.
Время процессора ядро считало само, всё время жизни ребёнка: на каждом входе в ядро и на каждом тике таймера оно знает, в каком режиме был процесс, и прибавляет к нужному счётчику. wait4 просто отдаёт накопленное.
Точка входа
//! Точка входа `zbox`. Здесь только командная строка и печать ответа:
//! вся работа живёт в `src/box`, чтобы её можно было проверить тестами.
const std = @import("std");
const zbox = @import("zbox");
const usage =
\\zbox, своя песочница
\\
\\Использование:
\\ zbox run <программа> [аргументы...]
\\
\\Ответ: одна строка JSON на stdout. Код программы лежит в поле exit_code,
\\сам zbox при этом возвращает 0.
\\
;
pub fn main(init: std.process.Init) !void {
const arena = init.arena.allocator();
const args = try init.minimal.args.toSlice(arena);
var buf: [4096]u8 = undefined;
var stdout = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &stdout.interface;
const command = zbox.args.parse(args[1..]) catch {
try out.writeAll(usage);
try out.flush();
std.process.exit(2);
};
// До fork в буфер stdout ничего не пишем: непустой буфер достался бы
// и ребёнку, и один и тот же текст мог бы выйти дважды.
const outcome = try zbox.run.run(arena, command.argv);
try zbox.report.writeJson(out, outcome);
try out.flush();
}
Здесь обе ловушки вывода из первой половины урока. writerStreaming, потому что zbox пишет в тот же дескриптор, что и его ребёнок, и позицию должен вести один счётчик, в ядре. И комментарий про пустой буфер до fork: пока run не вернётся, в out не попадает ни байта.
Тесты шага
Тесты двух сортов. Чистые функции проверяются напрямую. А run проверяется по-настоящему: тест запускает собранный zbox через std.process.run (вот где обёртка на своём месте), забирает его stdout и разбирает последнюю строку как JSON.
//! Общее для интеграционных тестов: запустить настоящий zbox и разобрать ответ.
const std = @import("std");
const build_options = @import("build_options");
/// Поля ответа, которые проверяют тесты. Лишние поля JSON пропускаем:
/// поздние шаги добавят свои, а ранние тесты обязаны остаться зелёными.
pub const Reply = struct {
exit_code: ?u8,
signal: ?u32,
cpu_user_ms: u64,
cpu_sys_ms: u64,
max_rss_kb: u64,
wall_ms: u64,
};
pub const Run = struct {
reply: Reply,
/// Всё, что попало на stdout до строки с JSON: это вывод самой программы.
program_stdout: []const u8,
};
/// `zbox_args` это командная строка после имени zbox.
pub fn zbox(arena: std.mem.Allocator, zbox_args: []const []const u8) !Run {
const argv = try std.mem.concat(arena, []const u8, &.{ &.{build_options.zbox_exe}, zbox_args });
const result = try std.process.run(arena, std.testing.io, .{ .argv = argv });
try std.testing.expectEqual(std.process.Child.Term{ .exited = 0 }, result.term);
// Ответ zbox это последняя строка stdout.
const trimmed = std.mem.trimEnd(u8, result.stdout, "\n");
const json_start = if (std.mem.lastIndexOfScalar(u8, trimmed, '\n')) |newline| newline + 1 else 0;
const reply = try std.json.parseFromSliceLeaky(Reply, arena, trimmed[json_start..], .{
.ignore_unknown_fields = true,
});
return .{ .reply = reply, .program_stdout = trimmed[0..json_start] };
}
ignore_unknown_fields стоит с прицелом на будущее: поздние шаги добавят в ответ свои поля, а тесты сегодняшнего шага обязаны остаться зелёными.
//! Шаг 48: `zbox run <cmd>`, код возврата, сигнал, rusage, ответ в JSON.
const std = @import("std");
const zbox = @import("zbox");
const support = @import("support.zig");
const testing = std.testing;
test "parse: run и программа с аргументами" {
const command = try zbox.args.parse(&.{ "run", "sh", "-c", "exit 7" });
try testing.expectEqual(3, command.argv.len);
try testing.expectEqualStrings("sh", command.argv[0]);
try testing.expectEqualStrings("exit 7", command.argv[2]);
}
test "parse: без подкоманды или без программы это ошибка" {
try testing.expectError(error.BadUsage, zbox.args.parse(&.{}));
try testing.expectError(error.BadUsage, zbox.args.parse(&.{"run"}));
try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "exec", "true" }));
}
test "maxRssKb: на macOS байты, на Linux килобайты" {
try testing.expectEqual(2048, zbox.report.maxRssKb(2048 * 1024, .macos));
try testing.expectEqual(2048, zbox.report.maxRssKb(2048, .linux));
}
test "timevalMs складывает секунды и микросекунды" {
try testing.expectEqual(1250, zbox.report.timevalMs(1, 250_999));
try testing.expectEqual(0, zbox.report.timevalMs(0, 999));
}
test "writeJson: завершилась сама" {
var buf: [1024]u8 = undefined;
var out: std.Io.Writer = .fixed(&buf);
try zbox.report.writeJson(&out, .{
.exit_code = 7,
.cpu_user_ms = 12,
.cpu_sys_ms = 3,
.max_rss_kb = 2048,
.wall_ms = 40,
});
const line = out.buffered();
try testing.expect(std.mem.startsWith(u8, line, "{\"exit_code\":7,\"signal\":null,"));
try testing.expect(std.mem.indexOf(u8, line, "\"cpu_user_ms\":12,\"cpu_sys_ms\":3,\"max_rss_kb\":2048,\"wall_ms\":40") != null);
try testing.expect(std.mem.endsWith(u8, line, "}\n"));
}
test "writeJson: убита сигналом" {
var buf: [1024]u8 = undefined;
var out: std.Io.Writer = .fixed(&buf);
try zbox.report.writeJson(&out, .{ .signal = 9 });
try testing.expect(std.mem.startsWith(u8, out.buffered(), "{\"exit_code\":null,\"signal\":9,"));
}
test "true возвращает 0, false возвращает 1" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const ok = try support.zbox(arena, &.{ "run", "true" });
try testing.expectEqual(0, ok.reply.exit_code);
try testing.expectEqual(null, ok.reply.signal);
const failed = try support.zbox(arena, &.{ "run", "false" });
try testing.expectEqual(1, failed.reply.exit_code);
}
test "код возврата доезжает как есть, вывод программы идёт перед JSON" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const run = try support.zbox(arena_state.allocator(), &.{ "run", "sh", "-c", "echo привет; exit 7" });
try testing.expectEqual(7, run.reply.exit_code);
try testing.expectEqualStrings("привет\n", run.program_stdout);
}
test "сигнал: exit_code пуст, в signal номер" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const run = try support.zbox(arena_state.allocator(), &.{ "run", "sh", "-c", "kill -TERM $$" });
try testing.expectEqual(null, run.reply.exit_code);
try testing.expectEqual(15, run.reply.signal);
}
test "несуществующая программа даёт 127" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const run = try support.zbox(arena_state.allocator(), &.{ "run", "zbox-no-such-program" });
try testing.expectEqual(zbox.run.exec_failed_code, run.reply.exit_code);
}
test "rusage: память и время процессора не нулевые у программы, которая считает" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
// Цикл в sh жжёт процессор в режиме пользователя. Порог щедрый:
// важно, что время вообще дошло до отчёта, а не сколько его.
const script = "i=0; while [ $i -lt 200000 ]; do i=$((i+1)); done";
const run = try support.zbox(arena_state.allocator(), &.{ "run", "sh", "-c", script });
try testing.expectEqual(0, run.reply.exit_code);
try testing.expect(run.reply.cpu_user_ms + run.reply.cpu_sys_ms > 0);
try testing.expect(run.reply.max_rss_kb > 100);
}
$ zig build test --summary all
Build Summary: 5/5 steps succeeded; 11/11 tests passed
Тест про сигнал стоит разобрать. sh -c 'kill -TERM $$': оболочка подставляет вместо $$ свой pid и убивает сама себя. Для zbox это выглядит как ребёнок, погибший от сигнала 15:
$ zbox run sh -c 'kill -TERM $$'
{"exit_code":null,"signal":15,"cpu_user_ms":18,"cpu_sys_ms":2,"max_rss_kb":5088,"wall_ms":20}
$ zbox run zbox-no-such-program
zbox: не удалось запустить программу
{"exit_code":127,"signal":null,"cpu_user_ms":0,"cpu_sys_ms":1,"max_rss_kb":5216,"wall_ms":2}
$ zbox run sleep 1
{"exit_code":0,"signal":null,"cpu_user_ms":14,"cpu_sys_ms":2,"max_rss_kb":5088,"wall_ms":1017}
Последняя строка хорошо показывает разницу между двумя временами. sleep 1 прожил секунду по настенным часам и потратил 16 мс процессора: остальное время он был вытеснен и ждал таймера. Те же команды на macOS (Apple Silicon, без эмулятора) дают cpu_user_ms от 0 до 2 и max_rss_kb около 2000: 14 мс на Linux это цена эмуляции x86-64 через Rosetta, а не цена sleep.
У сегодняшнего zbox есть дыра размером с ворота: zbox run sleep 1000000 будет ждать миллион секунд. Закрыть её нечем, пока мы не умеем будить родителя по часам и убивать ребёнка. Это сигналы, следующий урок.
Проект Y86: контекст процесса и yield
В начале урока было сказано: ядро сохраняет контекст одного процесса и восстанавливает контекст другого. В Linux за этой фразой стоят struct task_struct, макрос switch_to и тысячи строк. На своей машине ту же фразу можно прочитать целиком, в полутора сотнях строк ассемблера. В прошлом уроке Y86 получил всё нужное железо: бит привилегий, инструкции trap и iret, таблицу исключений по адресу 0xfc0, слово ksp по адресу 0xfe0 и кадр исключения из трёх слов. Симулятор сегодня не меняется вообще. Весь шаг это программы: новое ядро yield.ys и три процесса для него.
Вспомни, что делает железо при входе в ядро из пользовательского режима: берёт вершину стека ядра из слова ksp, кладёт под неё три слова (прежний %rsp, слово состояния с флагами и режимом, адрес возврата), переключает режим и прыгает по адресу из таблицы. iret делает обратное. Получается, что три части контекста, которые программа сама сохранить не может (счётчик команд, флаги с режимом и указатель стека), железо уже сохранило. Ядру остаются четырнадцать регистров общего назначения.
PCB
Контекст процесса, пока тот не на процессоре, лежит в PCB, блоке управления процессом. У нас это 19 слов:
смещение содержимое
0x00 %r14 \
0x08 %r13 |
... | четырнадцать регистров,
0x60 %rcx | в порядке, обратном pushq
0x68 %rax /
0x70 PC \
0x78 слово состояния | кадр исключения, его кладёт железо
0x80 %rsp /
0x88 жив ли процесс
0x90 адрес следующего PCB по кругу
Главная хитрость в том, куда смотрит ksp. Он всегда указывает на смещение 0x88 в PCB текущего процесса. Тогда при входе в ядро железо кладёт кадр из трёх слов прямо в поля 0x70, 0x78, 0x80, а %rsp оказывается на 0x70. Ядру остаётся сделать четырнадцать pushq, и регистры лягут в поля с 0x68 по 0x00. Контекст сохранён без единого копирования. Восстановление зеркально: поставить %rsp на начало PCB, четырнадцать popq, iret. Переключить процесс значит всего лишь поставить %rsp на начало другого PCB перед этими popq.
Ядро
# Ядро с двумя процессами и кооперативным переключением.
#
# Контекст процесса лежит в PCB: четырнадцать регистров, потом кадр
# исключения (PC, слово состояния, %rsp), потом два поля ядра.
#
# 0x00 %r14 ... 0x60 %rcx, 0x68 %rax регистры, в порядке обратном pushq
# 0x70 PC 0x78 состояние 0x80 %rsp кадр, его кладёт железо
# 0x88 жив ли процесс 0x90 следующий PCB по кругу
#
# Слово ksp в таблице исключений всегда смотрит на смещение 0x88 текущего
# PCB. Поэтому кадр железо кладёт прямо в PCB, а ядро дописывает под него
# регистры обычными pushq. Копировать ничего не надо.
.pos 0
boot: jmp resume # PCB заполнены статически, остаётся запустить первый
# Входы из таблицы. Причину кладём в %rax, прежний %rax уже в PCB.
on_trap:
pushq %rax
irmovq $0, %rax
jmp save
on_fault:
pushq %rax
irmovq $1, %rax
save: pushq %rcx
pushq %rdx
pushq %rbx
pushq %rbp
pushq %rsi
pushq %rdi
pushq %r8
pushq %r9
pushq %r10
pushq %r11
pushq %r12
pushq %r13
pushq %r14
irmovq kstack, %rsp # контекст сохранён, дальше свой стек
irmovq current, %rbx
mrmovq (%rbx), %rbx # %rbx это PCB текущего процесса
andq %rax, %rax
jne kill # сбой: процесс снимаем
# Системный вызов: номер и аргументы читаем из сохранённого контекста.
mrmovq 0x68(%rbx), %rax
iaddq $-1, %rax
je sys_write # 1
iaddq $-23, %rax
je schedule # 24, yield
iaddq $-36, %rax
je kill # 60, exit
irmovq $-1, %rax
rmmovq %rax, 0x68(%rbx) # такого вызова нет
jmp resume
# write(%rdi = адрес, %rsi = длина)
sys_write:
mrmovq 0x38(%rbx), %rdi
mrmovq 0x40(%rbx), %rsi
wloop: andq %rsi, %rsi
je wdone
mrmovq (%rdi), %rcx
out %rcx
iaddq $1, %rdi
iaddq $-1, %rsi
jmp wloop
wdone: mrmovq 0x40(%rbx), %rax
rmmovq %rax, 0x68(%rbx) # процесс увидит длину в %rax
jmp resume
# Процесс закончился или упал. Если живых не осталось, машина встаёт.
kill: irmovq $0, %rax
rmmovq %rax, 0x88(%rbx)
irmovq alive, %rcx
mrmovq (%rcx), %rax
iaddq $-1, %rax
rmmovq %rax, (%rcx)
jne schedule
halt
# Следующий живой процесс по кругу становится текущим.
schedule:
mrmovq 0x90(%rbx), %rbx
mrmovq 0x88(%rbx), %rax
andq %rax, %rax
je schedule
irmovq current, %rcx
rmmovq %rbx, (%rcx)
# Вернуть текущий процесс на процессор.
resume: irmovq current, %rbx
mrmovq (%rbx), %rsp # %rsp на начало PCB
rrmovq %rsp, %rax
iaddq $0x88, %rax
irmovq ksp, %rcx
rmmovq %rax, (%rcx) # следующий кадр ляжет в этот же PCB
popq %r14
popq %r13
popq %r12
popq %r11
popq %r10
popq %r9
popq %r8
popq %rdi
popq %rsi
popq %rbp
popq %rbx
popq %rdx
popq %rcx
popq %rax
iret
# Данные ядра. Код обязан кончиться раньше: ассемблер наложение не ловит.
.pos 0x280
current:
.quad pcb_a
alive: .quad 2
# Процесс A: образ с 0x400, стек под 0x800.
.pos 0x2a0
pcb_a:
.pos 0x310
.quad 0x400 # PC
.quad 0x19 # пользовательский режим, прерывания разрешены, ZF = 1
.quad 0x800 # %rsp
.quad 1 # жив
.quad pcb_b
# Процесс B: образ с 0x800, стек под 0xc00.
.pos 0x340
pcb_b:
.pos 0x3b0
.quad 0x800
.quad 0x19
.quad 0xc00
.quad 1
.quad pcb_a
# Стек ядра растёт вниз от таблицы исключений, в свободную память.
.pos 0xfc0
kstack:
.quad on_trap
.quad on_fault
.quad on_fault
.quad 0
ksp: .quad 0
Прочитай его как три части.
Вход (on_trap, on_fault, save). Два входа из таблицы различаются только числом в %rax: 0 для системного вызова, 1 для сбоя. Чтобы положить туда причину, %rax сначала сохраняется, и это как раз первый pushq из четырнадцати. После save ядро пересаживается на свой стек kstack: PCB заполнен, дальше писать в него через pushq нельзя.
Решение (системные вызовы, kill, schedule). Номер вызова ядро читает не из регистра, а из сохранённого контекста, из поля 0x68: живой %rax уже занят причиной. Номера как в Linux: write 1, sched_yield 24, exit 60. Сравнение сделано цепочкой вычитаний, потому что у Y86 нет cmp с константой: минус 1 дал ноль, значит write; ещё минус 23, значит 24; ещё минус 36, значит 60. yield это просто переход на schedule: текущим становится следующий живой по кругу. kill помечает процесс мёртвым, уменьшает счётчик живых и либо идёт в планировщик, либо останавливает машину. Результат write ядро тоже кладёт не в регистр, а в поле 0x68 PCB: в %rax он попадёт при восстановлении.
Выход (resume). %rsp на начало PCB текущего процесса, ksp на его поле 0x88, четырнадцать popq, iret. После popq %rax указатель стека стоит на поле 0x70, и iret снимает оттуда кадр.
Загрузка машины это тот же resume. PCB обоих процессов заполнены статически, прямо директивами .quad: счётчик команд 0x400 или 0x800, слово состояния 0x19, стек под концом своего куска памяти. Регистры нулевые. С точки зрения ядра процесс, который ещё ни разу не запускался, ничем не отличается от процесса, которого когда-то прервали на его первой инструкции. 0x19 это три бита слова состояния: режим пользователя (16), разрешение прерываний (8, пригодится в уроке 50) и флаг ZF (1), как после сброса.
Процессы
# Процесс ping: три раза печатает своё слово и каждый раз уступает процессор.
.pos 0x400
irmovq $3, %rbx
loop: irmovq $1, %rax # write(word, 5)
irmovq word, %rdi
irmovq $5, %rsi
trap
irmovq $24, %rax # yield
trap
iaddq $-1, %rbx
jne loop
irmovq $60, %rax # exit
trap
.align 8
word: .quad 0x0a676e6970 # "ping\n", младший байт первым
# Процесс pong: три раза печатает своё слово и каждый раз уступает процессор.
.pos 0x800
irmovq $3, %rbx
loop: irmovq $1, %rax # write(word, 5)
irmovq word, %rdi
irmovq $5, %rsi
trap
irmovq $24, %rax # yield
trap
iaddq $-1, %rbx
jne loop
irmovq $60, %rax # exit
trap
.align 8
word: .quad 0x0a676e6f70 # "pong\n", младший байт первым
Программы одинаковые, отличаются адресом загрузки и словом. Адрес зашит при сборке директивой .pos: виртуальной памяти у машины пока нет, и два процесса обязаны договориться, кто где живёт. Каждый исходник собирается твоим ассемблером отдельно, а образы накладываются в одну память: load из прошлого урока копирует только ненулевые байты. Счётчик цикла лежит в %rbx, и между итерациями процесс дважды уходит в ядро и один раз теряет процессор. Если ядро перепутает хоть один регистр при сохранении, слов будет не три.
Третий процесс проверяет ветку сбоя.
# Процесс, который лезет за границу памяти. Ядро снимает его, сосед живёт дальше.
.pos 0x800
irmovq $0xfff, %rax
mrmovq (%rax), %rbx # слово не помещается в память: сбой ADR
jmp 0x800 # сюда управление не вернётся
Программы регистрируются в двух местах. В build.zig список файлов ядра, которые попадают в модуль как анонимные импорты:
const kernel_programs = [_][]const u8{ "hello", "yield", "ping", "pong", "crash" };
И в src/programs.zig имена для тестов:
pub const kernel = struct {
/// Один процесс, сисколл `write` и сбой INS.
pub const hello = @embedFile("hello.ys");
/// Два процесса, переключение по `yield`.
pub const yield = @embedFile("yield.ys");
pub const ping = @embedFile("ping.ys");
pub const pong = @embedFile("pong.ys");
pub const crash = @embedFile("crash.ys");
};
Прогон
$ zig build run -- kernel programs/kernel/yield.ys programs/kernel/ping.ys programs/kernel/pong.ys --console
ping
pong
ping
pong
ping
pong
Два логических потока на одном процессоре. Ключ --events оставляет от трассы только входы в ядро и байты консоли:
exc trap 0x429 -> 0x009 ping: write
out 70
out 69
out 6e
out 67
out 0a
exc trap 0x434 -> 0x009 ping: yield
exc trap 0x829 -> 0x009 pong: write
out 70
out 6f
...
exc trap 0x452 -> 0x009 ping: exit
exc trap 0x852 -> 0x009 pong: exit
halt cycles=1014
Подписи справа мои, машина печатает только левую часть. Адрес в строке exc это адрес возврата, следующая инструкция после trap: 0x429 и 0x434 лежат в образе ping, 0x829 в образе pong. После yield из ping следующий вход в ядро приходит уже из pong. А вот одно переключение в полной трассе, от trap в одном процессе до первой инструкции другого:
U 0x433 d0 AOK ping: trap с номером 24
exc trap 0x434 -> 0x009
K 0x009 a0 AOK on_trap: pushq %rax
K 0x00b 30 AOK
K 0x015 70 AOK jmp save
K 0x02a a0 AOK тринадцать pushq
...
K 0x042 a0 AOK
K 0x044 30 AOK свой стек, current, разбор номера
...
K 0x178 50 AOK schedule: следующий PCB, жив ли он
...
K 0x1ab 30 AOK resume: %rsp на PCB, ksp на его 0x88
...
K 0x1df b0 AOK четырнадцать popq
...
K 0x1fb d1 AOK iret
U 0x800 30 AOK pong: первая инструкция
Первая буква строки это режим. Между U 0x433 и U 0x800 ровно 53 инструкции в режиме ядра, из них 28 это pushq и popq. Весь прогон занимает 1014 тактов, полезной работы процессов в нём 54 такта, остальные 960 это ядро: в основном побайтный цикл write и сохранение с восстановлением. У настоящего процессора пропорция другая, но порядок рассуждения тот же: переключение это чистые накладные расходы, и его цена определяется размером контекста.
Теперь сбой:
$ zig build run -- kernel programs/kernel/yield.ys programs/kernel/ping.ys programs/kernel/crash.ys --events
exc trap 0x429 -> 0x009
exc trap 0x434 -> 0x009
exc adr 0x80a -> 0x01e
exc trap 0x429 -> 0x009
...
halt cycles=569
После первого yield управление получает crash, читает слово по адресу 0xfff и получает сбой ADR. Железо входит в ядро через вторую строку таблицы, on_fault кладёт в %rax единицу, kill снимает процесс. Адрес возврата 0x80a это сама сбойная инструкция, но возвращаться туда никто не собирается. ping спокойно допечатывает свои три слова: ошибка в одном процессе не задела другой. Наполовину это заслуга ядра, наполовину везение: памяти друг от друга наши процессы не защищены, и crash, который вместо чтения за границей записал бы мусор в 0x2a0, убил бы ядро. Настоящая изоляция придёт с MMU в уроке 56.
Тесты шага
//! Шаг 48: контекст процесса в PCB, два образа в памяти, переключение по `yield`.
const std = @import("std");
const y86 = @import("y86");
const testing = std.testing;
const computer = y86.computer;
const kernel = y86.programs.kernel;
/// Собирает каждый исходник отдельно, как это делает `y86 kernel`.
fn run(sources: []const []const u8, config: computer.Config) !computer.Output {
var images: [4][]u8 = undefined;
var count: usize = 0;
defer for (images[0..count]) |image| testing.allocator.free(image);
for (sources) |source| {
var result = try y86.assembler.assemble(testing.allocator, source, null);
defer result.deinit(testing.allocator);
images[count] = try testing.allocator.dupe(u8, result.image);
count += 1;
}
return computer.runImages(testing.allocator, images[0..count], config);
}
test "два процесса по очереди печатают свои слова" {
var out = try run(&.{ kernel.yield, kernel.ping, kernel.pong }, .{});
defer out.deinit(testing.allocator);
try testing.expectEqualStrings("ping\npong\nping\npong\nping\npong\n", out.console);
try testing.expectEqual(y86.Stat.hlt, out.summary.stat);
}
test "контекст переживает переключение" {
// Счётчик цикла каждого процесса живёт в %rbx. Если бы ядро путало
// регистры, слов было бы не по три.
var out = try run(&.{ kernel.yield, kernel.ping, kernel.pong }, .{});
defer out.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, out.console, "ping\n"));
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, out.console, "pong\n"));
}
test "образы накладываются: ядро и процессы собраны по отдельности" {
var result = try y86.assembler.assemble(testing.allocator, kernel.ping, null);
defer result.deinit(testing.allocator);
var machine: computer.Computer = .init(.{});
machine.st.mem[0] = 0x10;
machine.load(result.image);
// Нулевые байты образа чужую память не затирают.
try testing.expectEqual(@as(u8, 0x10), machine.st.mem[0]);
try testing.expectEqual(@as(u8, 0x30), machine.st.mem[0x400]);
}
test "упавший процесс снимается, сосед доживает до конца" {
var out = try run(&.{ kernel.yield, kernel.ping, kernel.crash }, .{});
defer out.deinit(testing.allocator);
try testing.expectEqualStrings("ping\nping\nping\n", out.console);
try testing.expect(std.mem.indexOf(u8, out.trace, "U 0x80a 50 ADR\nexc adr 0x80a -> ") != null);
try testing.expectEqual(y86.Stat.hlt, out.summary.stat);
}
$ zig build test -Dstep=48 --summary all
Build Summary: 5/5 steps succeeded; 45/45 tests passed
(41 тест это модульные тесты самого симулятора, они идут с каждым шагом. Тестов шага четыре.)
Наше ядро кооперативное: процесс, который не зовёт yield, не отдаст процессор никогда. Замени в ping.ys вызов yield на пустой цикл, и pong не напечатает ни слова. Лекарство то же, что в Linux: таймер. До него два урока.
Практика
Библиотечная system(3) это fork, execve и waitpid в одной функции. Напиши её на сырых вызовах std.os.linux, без libc: fork там возвращает usize, и отличать ребёнка от родителя и от ошибки придётся через @bitCast в isize. Вторая функция, exitCode, разбирает слово состояния по правилам оболочки.
Упражнения
Итоги
- Процесс это программа в исполнении плюс контекст. Он даёт две иллюзии: непрерывный логический поток управления и собственное адресное пространство. Первая держится на переключении контекста, вторая на виртуальной памяти.
- Потоки конкурентны, если перекрываются во времени, независимо от числа ядер. Для конкурентных потоков любой порядок инструкций надо считать возможным.
- Переключение контекста случается только в ядре: по системному вызову, который не может завершиться сразу, или по прерыванию таймера. Прямая цена это микросекунды, косвенная это холодные кэши и TLB, поэтому квант измеряется миллисекундами (3 мс у EEVDF на восьми ядрах).
forkзовут раз, возвращается он дважды: ноль ребёнку, pid родителю. Память копируется (лениво), открытые файлы разделяются. В Zig 0.16forkберут изstd.c(с libc) или изstd.os.linux(сырой вызов).- Программу с
forkчитают как граф процессов, допустимый вывод это топологическая сортировка. Редкий вывод не значит невозможный. - Перед
forkбуферы вывода пусты, ребёнок после неудачи выходит через_exit. В унаследованный дескриптор пишут потоково (writerStreaming), иначе родитель и ребёнок затрут друг друга. - Завершённый процесс остаётся зомби, пока родитель не утилизирует его через
waitpid. Сирот усыновляет процесс 1. Слово состояния разбирают макросамиW: вышел сам с кодом, убит сигналом, остановлен.EINTRзначит повторить. execveзаменяет программу в том же процессе и не возвращается. Переживают его pid, открытые дескрипторы безCLOEXEC, каталог, лимиты, маска сигналов. В промежутке междуforkиexecveребёнка настраивают, на этом стоят оболочки и песочницы.std.process.spawn,waitиrunпокрывают обычные случаи и делают всё правильно: память доfork, пайп сCLOEXECдля ошибкиexecve, повтор приEINTR. Свой код в ребёнке они выполнить не дают.zbox runэтоfork,execvp,wait4и печатьrusageв JSON.ru_maxrssна Linux в килобайтах, на macOS в байтах.- На Y86 контекст процесса это четырнадцать регистров плюс кадр исключения, всё лежит в PCB.
kspсмотрит внутрь PCB текущего процесса, поэтому сохранение это 14pushq, восстановление это 14popqиiret, а переключение это смена одного указателя. Одно переключение стоит 53 инструкции.
Дальше
У нас остались два долга. zbox run sleep 1000000 висит вечно, потому что родитель умеет только спать в wait4. А родитель из восьмого упражнения вынужден выбирать между сном и опросом. Оба долга закрывает один механизм: ядро умеет постучаться в процесс снаружи, в произвольный момент, и заставить его выполнить функцию. В следующем уроке разберём сигналы: как их посылают, как ловят, почему обработчик это самое опасное место в системной программе, и поставим в zbox лимит времени.
домашка