Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Сигналы
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Сигналы
В прошлом уроке у нас остались два долга.
zbox run sleep 1000000висит вечно, потому что родитель умеет только спать вwait4. А родитель из восьмого упражнения выбирал между сном и опросом, и оба варианта были плохими. Оба долга закрывает один механизм: ядро умеет постучаться в процесс снаружи, в любой момент, между двумя любыми инструкциями, и заставить его выполнить функцию. Это сигналы, исключения уровня процесса. Сегодня разберём, кто их посылает и когда они доходят, почему обработчик это самое опасное место в системной программе, и что такое гонка, которую нельзя увидеть в отладчике. К концу урока уztпоявится сэмплирующий профилировщик, который живёт внутри обработчика сигнала и соблюдает все правила урока, аzboxполучит лимит времени.
Цели урока
- Различать четыре слова: сигнал отправлен, доставлен, ожидает, заблокирован. Знать, в какой момент ядро доставляет сигналы и в каком порядке.
- Посылать сигналы тремя способами:
killиз программы и из оболочки,alarmиsetitimerот таймера, клавиатура через терминал. Понимать, что такое группа процессов и зачем она сигналам. - Ставить обработчик через
sigactionна Zig 0.16 и знать три флага, которые меняют его поведение:SA_RESTART,SA_NOCLDSTOP,SA_SIGINFO. - Видеть, что стандартные сигналы не копятся: бит
pendingодин на тип, второй такой же сигнал пропадает. - Блокировать сигналы через
sigprocmaskи понимать маску обработчика. - Помнить шесть правил безопасного обработчика и уметь применить их в Zig:
callconv(.c),std.atomic.Value, сохранениеerrno, никаких аллокаций. - Воспроизвести гонку между
forkи регистрацией задания и вылечить её маской. - Ждать сигнала правильно: не циклом, не парой
sigprocmaskиpause, аsigsuspend. - Написать
zt prof: обработчикSIGPROFсобирает адреса возврата в кольцо, а снаружи адреса превращаются в свёрнутые стеки для flame graph. - Поставить в
zboxлимит времени: будильник,SIGKILLвсей группе, ожиданиеSIGCHLDбез гонки.
Идея: исключение уровня процесса
В уроке про исключения железо прерывало программу и уводило процессор в ядро: по сбою страницы, по прерыванию от таймера, по инструкции syscall. Программа этого не видела: ядро возвращалось в ту же инструкцию, и поток инструкций оставался непрерывным. Сигнал это тот же приём этажом выше. Сигнал это сообщение, которым ядро уведомляет процесс о событии, и процесс реагирует на него сам: либо действием по умолчанию, либо своей функцией. Событие может быть переводом аппаратного исключения (деление на ноль стало SIGFPE, сбой страницы вне отображения стал SIGSEGV), а может быть чисто программным: другой процесс позвал kill, ребёнок завершился, пользователь нажал Ctrl+C.
Словарь, без которого дальше не разобраться:
- Отправка (send). Ядро меняет состояние процесса-получателя: ставит бит в его наборе ожидающих сигналов. Отправить может само ядро, заметив событие, или другой процесс через
kill. Отправка занимает микросекунды и никогда не ждёт получателя. - Ожидающий (pending) сигнал: отправлен, но ещё не доставлен. Именно этот бит хранит ядро.
- Доставка (deliver). Ядро заставляет процесс отреагировать: выполнить действие по умолчанию или войти в обработчик. Между отправкой и доставкой может пройти сколько угодно времени.
- Заблокированный (blocked) сигнал: процесс попросил ядро не доставлять ему сигналы этого типа. Отправлять можно, бит
pendingвстанет, а доставка отложится, пока блокировку не снимут.
Каждый сигнал это маленькое целое число, на Linux от 1 до 31 у стандартных сигналов и с 32 по 64 у сигналов реального времени. Имена и действия по умолчанию:
| Номер | Имя | Действие по умолчанию | Откуда берётся |
|---|---|---|---|
| 2 | SIGINT | завершить | Ctrl+C в терминале |
| 3 | SIGQUIT | завершить с дампом | Ctrl+\ в терминале |
| 6 | SIGABRT | завершить с дампом | abort, невыполненный assert |
| 8 | SIGFPE | завершить с дампом | деление на ноль, исключение 0 у x86-64 |
| 9 | SIGKILL | завершить | только kill; перехватить и заблокировать нельзя |
| 10 | SIGUSR1 | завершить | программа сама решает, что это значит |
| 11 | SIGSEGV | завершить с дампом | сбой страницы, исключение 14 у x86-64 |
| 13 | SIGPIPE | завершить | запись в пайп, который никто не читает |
| 14 | SIGALRM | завершить | таймер alarm или setitimer(ITIMER_REAL) |
| 15 | SIGTERM | завершить | kill без аргументов |
| 17 | SIGCHLD | игнорировать | ребёнок завершился или остановлен |
| 18 | SIGCONT | продолжить | fg, bg, kill -CONT |
| 19 | SIGSTOP | остановить | только kill; перехватить и заблокировать нельзя |
| 20 | SIGTSTP | остановить | Ctrl+Z в терминале |
| 27 | SIGPROF | завершить | таймер setitimer(ITIMER_PROF), на нём стоит наш профилировщик |
Действий по умолчанию всего пять: завершить процесс, завершить и записать дамп памяти, игнорировать, остановить, продолжить. Обрати внимание на SIGCHLD: по умолчанию он игнорируется, и поэтому программа, которая никогда не слышала о сигналах, не замечает смерти детей. И на два сигнала, у которых умолчание нельзя изменить: SIGKILL и SIGSTOP доходят всегда. Это гарантия для администратора, что любой процесс можно убить и остановить снаружи.
Номера на macOS другие у части сигналов (SIGCHLD там 20, SIGSTOP 17), поэтому в коде мы всегда пишем имена, а std.posix.SIG подставляет число для своей системы. Полный список твоя оболочка печатает командой kill -l.
Отправка
Группы процессов
У каждого процесса кроме pid есть второй номер: номер группы процессов. Ребёнок наследует группу родителя, и по умолчанию все процессы одного конвейера a | b | c оказываются в одной группе, которую оболочка создала под это задание. Свою группу возвращает getpgrp, а setpgid(pid, pgid) переводит процесс в другую: setpgid(0, 0) значит «меня в новую группу с номером, равным моему pid».
Зачем группы сигналам? Потому что kill умеет адресовать группу целиком: отрицательный pid значит «всем процессам группы с номером минус pid». На этом стоит и Ctrl+C в терминале, и наш zbox: убить надо не только ребёнка, но и всех, кого он успел породить.
const std = @import("std");
const c = std.c;
extern "c" fn getpgrp() c.pid_t;
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 {
say("оболочка дала группу {d}, мой pid {d}\n", .{ getpgrp(), c.getpid() });
// Ребёнок уходит в свою группу и рожает внука: внук наследует группу.
const child = c.fork();
if (child == 0) {
_ = c.setpgid(0, 0);
const grandchild = c.fork();
if (grandchild == 0) {
say("внук: pid {d}, группа {d}\n", .{ c.getpid(), getpgrp() });
const forever: c.timespec = .{ .sec = 100, .nsec = 0 };
_ = c.nanosleep(&forever, null);
c._exit(0);
}
say("ребёнок: pid {d}, группа {d}\n", .{ c.getpid(), getpgrp() });
const forever: c.timespec = .{ .sec = 100, .nsec = 0 };
_ = c.nanosleep(&forever, null);
c._exit(0);
}
_ = c.setpgid(child, child); // родитель делает то же, кто первый успеет
const tenth: c.timespec = .{ .sec = 0, .nsec = 100_000_000 };
_ = c.nanosleep(&tenth, null);
// Отрицательный pid: сигнал всей группе. Внука мы по pid не знаем,
// но он в группе ребёнка, и этого достаточно.
_ = c.kill(-child, .TERM);
var status: c_int = 0;
_ = c.waitpid(child, &status, 0);
const word: u32 = @bitCast(status);
say("ребёнок убит сигналом {d}; внук осиротел, но тоже мёртв\n", .{@intFromEnum(c.W.TERMSIG(word))});
}
$ zig build-exe groups.zig -lc
$ ./groups
оболочка дала группу 33687, мой pid 39677
ребёнок: pid 39678, группа 39678
внук: pid 39679, группа 39678
ребёнок убит сигналом 15; внук осиротел, но тоже мёртв
Этот и остальные выводы урока сняты на macOS на Apple Silicon, если рядом не сказано другое. setpgid здесь зовут оба, и ребёнок, и родитель, и это не оплошность. После fork неизвестно, кто побежит первым. Если родитель успеет послать сигнал группе раньше, чем ребёнок себя в неё перевёл, сигнал уйдёт в пустую группу. Поэтому родитель переводит ребёнка сам, а ребёнок повторяет то же: второй вызов ничего не меняет. Через полурока это станет строкой в zbox.
getpgrp объявлен через extern "c": его нет в std.c версии 0.16, зато setpgid, kill и getpid есть. Так мы будем поступать весь урок: чего в std.c нет, одна строка extern "c" fn с сигнатурой из man-страницы.
kill
Из программы сигнал посылает kill(pid, sig). Кроме обычного pid и отрицательного номера группы у него есть два особых значения: ноль значит «моей группе», а минус единица «всем, кому я имею право». Право определяется пользователем: процесс может посылать сигналы только процессам того же пользователя, кроме root.
Сигнал с номером 0 не посылается, но проверка прав и существования получателя выполняется. kill(pid, 0) возвращает ноль, если процесс есть, и минус единицу с ESRCH, если такого pid нет. Это единственный переносимый способ спросить у ядра «жив ли процесс», и тесты zbox в конце урока им пользуются. Ловушка: зомби для kill жив. Пока родитель не позвал waitpid, pid занят, и kill(pid, 0) отвечает успехом.
Из оболочки то же самое делает команда kill. Имя сигнала через дефис, номер процесса или минус номер группы:
$ sleep 100 &
[1] 41120
$ kill -TSTP 41120
[1]+ Stopped sleep 100
$ kill -CONT 41120
$ kill 41120
[1]+ Terminated sleep 100
Без имени kill шлёт SIGTERM, вежливую просьбу завершиться, которую программа вправе перехватить и сначала закрыть файлы. kill -9 это SIGKILL, просьб больше нет.
Клавиатура и терминал
Ctrl+C не имеет никакого отношения к программе, которая сейчас работает. Нажатие видит драйвер терминала в ядре, и он посылает SIGINT всей группе переднего плана терминала. Ctrl+Z точно так же посылает SIGTSTP, а Ctrl+\ посылает SIGQUIT. Какая группа сейчас передняя, решает оболочка: перед запуском команды она переводит ребёнка в новую группу и отдаёт терминал ей, а после завершения забирает обратно. Поэтому Ctrl+C убивает sleep, а не оболочку, и убивает весь конвейер, а не только его последний процесс.
Отсюда же берётся вывод Stopped выше: после SIGTSTP процесс остановлен, оболочка получает SIGCHLD с пометкой «остановлен» (тот самый WUNTRACED из прошлого урока), запоминает задание и возвращает себе терминал. fg шлёт группе SIGCONT и снова отдаёт ей терминал. Всю эту механику мы соберём руками в уроке про оболочку.
alarm и setitimer
Процесс может попросить сигнал у часов. alarm(seconds) заводит будильник: через столько-то секунд ядро пришлёт SIGALRM. Точнее и богаче setitimer: три таймера и микросекундная точность.
struct itimerval {
struct timeval it_interval; /* период повтора, ноль значит один раз */
struct timeval it_value; /* время до первого срабатывания, ноль выключает */
};
int setitimer(int which, const struct itimerval *new, struct itimerval *old);
which | Что считает | Сигнал |
|---|---|---|
ITIMER_REAL | настенное время, идёт всегда | SIGALRM |
ITIMER_VIRTUAL | процессорное время в режиме пользователя | SIGVTALRM |
ITIMER_PROF | процессорное время в обоих режимах | SIGPROF |
ITIMER_REAL это будильник для лимитов и таймаутов, им займётся zbox. ITIMER_PROF тикает только пока процесс работает на процессоре, в любом режиме, и поэтому годится для профилировщика: спящая программа сэмплов не получает, и тысяча сигналов в секунду ложится на тысячу миллисекунд именно её работы.
Тут первая ловушка стандартной библиотеки, и она тебе встретится в задаче урока. std.os.linux.setitimer объявлена с аргументом linux.itimerspec, где второе поле называется nsec. Но системный вызов setitimer читает эту память как itimerval, и второе поле для него микросекунды. Раскладка структур совпадает (два поля по 64 бита), смысл числа нет. Программа компилируется, а таймер идёт в тысячу раз быстрее: 150 мс, записанные как .nsec = 150_000_000, превращаются в 150 секунд. В программах с libc мы объявляем setitimer сами с правильной структурой, как в zbox ниже; в задаче без libc, где приходится звать linux.setitimer, помни, что .nsec это микросекунды.
Первое применение будильника это ответ на седьмое упражнение прошлого урока. snooze засыпал через nanosleep, Ctrl+C его убивал. Поставим обработчик, которому нечего делать:
const std = @import("std");
const c = std.c;
const posix = std.posix;
// Обработчику нечего делать: он существует, чтобы SIGINT не убил процесс,
// а только прервал nanosleep.
fn onInt(_: posix.SIG) callconv(.c) void {}
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(init: std.process.Init) !void {
const args = try init.minimal.args.toSlice(init.arena.allocator());
const seconds = if (args.len > 1) try std.fmt.parseInt(i64, args[1], 10) else 5;
posix.sigaction(.INT, &.{
.handler = .{ .handler = onInt },
.mask = posix.sigemptyset(),
.flags = 0,
}, null);
const asked: c.timespec = .{ .sec = seconds, .nsec = 0 };
var left: c.timespec = .{ .sec = 0, .nsec = 0 };
const started = nowMs();
const rc = c.nanosleep(&asked, &left);
const slept_ms = nowMs() - started;
if (rc == 0) {
say("проспал все {d} с\n", .{seconds});
} else {
say("nanosleep: {t} через {d} мс, во втором аргументе осталось {d} мс\n", .{
posix.errno(rc),
slept_ms,
left.sec * 1000 + @divTrunc(left.nsec, 1_000_000),
});
}
}
fn nowMs() i64 {
var now: c.timespec = undefined;
_ = c.clock_gettime(.MONOTONIC, &now);
return now.sec * 1000 + @divTrunc(now.nsec, 1_000_000);
}
$ ./snooze 5 &
$ kill -INT %1
nanosleep: INTR через 636 мс, во втором аргументе осталось 4363 мс
Сигнал с обработчиком не убивает процесс, а прерывает медленный системный вызов: nanosleep возвращает минус единицу с EINTR и кладёт во второй аргумент, сколько осталось спать. Без обработчика тот же SIGINT завершил бы процесс, и печатать было бы некому. Что именно происходит между нажатием и возвратом из nanosleep, разберём в следующем разделе.
Получение
Момент доставки
Сигнал доставляется не тогда, когда отправлен, а когда ядро в следующий раз возвращает процесс в режим пользователя: после системного вызова, после прерывания, после переключения контекста на этот процесс. В этот момент ядро вычисляет pending & ~blocked, набор ожидающих и не заблокированных сигналов. Если он пуст, процесс продолжает с прерванной инструкции, как будто ничего не было. Если не пуст, ядро выбирает сигнал (Linux берёт младший номер) и заставляет процесс среагировать: выполняет действие по умолчанию либо переключает его на обработчик. Обработчик отработал, и ядро снова смотрит на набор: там может ждать следующий.
Отсюда следствие, которое стоит проговорить. Процесс, который крутит цикл без единого системного вызова, всё равно получит сигнал за миллисекунды: прерывание таймера регулярно уводит его в ядро, и на обратном пути ядро проверит pending. А процесс, который спит в read или nanosleep, ядро будит специально: системный вызов прерывается, и вот откуда берётся EINTR.
Действия и sigaction
Программа меняет реакцию на сигнал через sigaction. У неё есть старший брат signal из ранних Unix, но у него плавает поведение между системами, и в Zig его нет вовсе. В std.posix версии 0.16 это выглядит так:
pub const Sigaction = struct {
handler: extern union {
handler: ?*align(1) const fn (SIG) callconv(.c) void,
sigaction: ?*const fn (SIG, *const siginfo_t, ?*anyopaque) callconv(.c) void,
},
mask: sigset_t,
flags: c_ulong,
};
pub fn sigaction(sig: SIG, act: ?*const Sigaction, oact: ?*Sigaction) void;
Три поля.
handlerэто либо простой обработчик, которому приходит только номер, либо расширенный с тремя аргументами, для него нужен флагSA_SIGINFO. Второй аргумент расширенного,siginfo_t, говорит, кто прислал сигнал и по какому адресу упалSIGSEGV; третий этоucontext, сохранённые регистры прерванного кода. Наш профилировщик прочитает оттуда счётчик команд и указатель кадра. Вместо функции можно передатьSIG.DFL(вернуть умолчание) илиSIG.IGN(игнорировать).maskэто маска обработчика: набор сигналов, которые ядро дополнительно заблокирует на время его работы. Сам доставляемый сигнал блокируется всегда, без просьбы: пока работает обработчикSIGCHLD, второйSIGCHLDподождёт.flags. Три флага, которые нужны на практике:SA_RESTARTвелит ядру перезапускать медленные системные вызовы после обработчика вместоEINTR;SA_NOCLDSTOPпросит слатьSIGCHLDтолько о смерти ребёнка, не об остановке;SA_SIGINFOвключает расширенный обработчик.
Функция обработчика обязана быть callconv(.c): ядро зовёт её как функцию C, по соглашению о вызовах из урока про кадры стека, а не по внутреннему соглашению Zig. Забудешь, и компилятор откажется класть указатель на неё в Sigaction, потому что типы не совпадут. Это редкий случай, когда система типов ловит ошибку соглашения о вызовах до запуска.
Действие живёт в процессе, не в программе: fork копирует таблицу действий ребёнку, а execve сбрасывает обработчики к умолчанию (функций-то больше нет), но оставляет SIG.IGN и маску. Про маску мы ещё вспомним в zbox.
Обработчик и EINTR
Вернёмся к прерванному nanosleep. Что видит программа, когда сигнал приходит посреди медленного системного вызова, зависит от флага SA_RESTART. Проверим на read из пайпа, в который никто не пишет. Будильник бьёт каждые 300 мс, а на третьем ударе обработчик сам пишет в пайп байт: write входит в список того, что обработчику можно.
const std = @import("std");
const c = std.c;
const posix = std.posix;
extern "c" fn pipe(fds: *[2]c_int) c_int;
var alarms: std.atomic.Value(u32) = .init(0);
var wake_fd: c_int = -1;
fn onAlarm(_: posix.SIG) callconv(.c) void {
const n = alarms.fetchAdd(1, .seq_cst) + 1;
// На третьем ударе будим read по-честному: write входит в список
// async-signal-safe, а один байт в пайп никого не блокирует.
if (n == 3) _ = c.write(wake_fd, "!", 1);
}
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(init: std.process.Init) !void {
const args = try init.minimal.args.toSlice(init.arena.allocator());
const restart = args.len > 1 and std.mem.eql(u8, args[1], "--restart");
var fds: [2]c_int = undefined;
_ = pipe(&fds);
wake_fd = fds[1];
posix.sigaction(.ALRM, &.{
.handler = .{ .handler = onAlarm },
.mask = posix.sigemptyset(),
.flags = if (restart) posix.SA.RESTART else 0,
}, null);
// Будильник каждые 300 мс. ualarm есть в libc и на Linux, и на macOS.
_ = ualarm(300_000, 300_000);
var byte: [1]u8 = undefined;
const got = c.read(fds[0], &byte, 1);
if (got < 0) {
say("read вернул ошибку {t} после {d} будильника\n", .{ posix.errno(got), alarms.load(.seq_cst) });
} else {
say("read вернул {d} байт после {d} будильников\n", .{ got, alarms.load(.seq_cst) });
}
}
extern "c" fn ualarm(usecs: c_uint, interval: c_uint) c_uint;
$ ./eintr
read вернул ошибку INTR после 1 будильника
$ ./eintr --restart
read вернул 1 байт после 3 будильников
Без флага первый же сигнал вышибает read: он возвращает минус единицу, в errno лежит EINTR, а прочитать ничего не удалось. С флагом ядро после обработчика молча перезапускает read, и тот дожидается байта. Обе стратегии законны, но у них разные обязанности. Без SA_RESTART каждый медленный вызов в программе надо оборачивать в цикл «если EINTR, повторить»: именно так написан wait4 в zbox, и именно так std.process.Child.wait из прошлого урока. С SA_RESTART этого цикла не нужно, но и прервать вызов сигналом нельзя. Задача урока про таймаут чтения держится на первом варианте: read должен вылететь по SIGALRM, поэтому обработчик там ставится без SA_RESTART.
Перезапуск покрывает не всё. nanosleep и sleep не перезапускаются никогда, они возвращают EINTR при любых флагах, иначе Ctrl+C не мог бы прервать сон. Полный список медленных вызовов и исключений есть в man 7 signal, раздел «Interruption of system calls».
Блокировка и потеря
Процесс может отложить доставку. sigprocmask(how, set, old) меняет маску заблокированных сигналов: SIG_BLOCK добавляет сигналы из set, SIG_UNBLOCK убирает, SIG_SETMASK ставит ровно set. Старая маска возвращается через третий аргумент, и это важно: правильно не «разблокировать SIGCHLD», а «вернуть маску, какой она была». Функция, которую позвали с уже заблокированным сигналом, не должна его нечаянно открыть.
Пока сигнал заблокирован, отправка работает: бит pending встаёт. Что случится, если за это время придут пять одинаковых сигналов?
const std = @import("std");
const c = std.c;
const posix = std.posix;
// Этой функции нет в std.c, объявляем сами.
extern "c" fn sigpending(set: *posix.sigset_t) c_int;
var delivered: std.atomic.Value(u32) = .init(0);
fn onUsr1(_: posix.SIG) callconv(.c) void {
_ = delivered.fetchAdd(1, .seq_cst);
}
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 {
posix.sigaction(.USR1, &.{
.handler = .{ .handler = onUsr1 },
.mask = posix.sigemptyset(),
.flags = 0,
}, null);
// Закрываем SIGUSR1: он будет копиться в pending, но не доставляться.
var set = posix.sigemptyset();
posix.sigaddset(&set, .USR1);
posix.sigprocmask(posix.SIG.BLOCK, &set, null);
for (0..5) |_| _ = c.kill(c.getpid(), .USR1);
var waiting: posix.sigset_t = undefined;
_ = sigpending(&waiting);
say("отправлено 5, в pending бит USR1 = {}, доставлено {d}\n", .{
posix.sigismember(&waiting, .USR1),
delivered.load(.seq_cst),
});
// Снимаем блокировку: всё, что ждало, доставляется прямо сейчас.
posix.sigprocmask(posix.SIG.UNBLOCK, &set, null);
say("после разблокировки доставлено {d}\n", .{delivered.load(.seq_cst)});
}
$ ./lost
отправлено 5, в pending бит USR1 = true, доставлено 0
после разблокировки доставлено 1
Пять отправили, один дошёл. Набор ожидающих сигналов это битовая маска, по биту на тип, и пять одинаковых сигналов ставят один и тот же бит. Очереди у стандартных сигналов нет. На Linux это видно снаружи: в /proc/<pid>/status строки SigPnd, SigBlk, SigIgn и SigCgt печатают эти маски в hex. Вот процесс sh, который заблокировал SIGUSR1 (бит 10, то есть 0x200):
$ grep -E '^Sig(Pnd|Blk|Ign|Cgt)' /proc/self/status
SigPnd: 0000000000000000
SigBlk: 0000000000000200
SigIgn: 0000000000000000
SigCgt: 0000000000000000
(снято в контейнере Linux 7.0, aarch64.) Потеря сигнала не ошибка ядра, а его контракт, и на него надо рассчитывать. Правило: один сигнал значит «как минимум одно событие», а не «ровно одно». Обработчик SIGCHLD собирает детей циклом waitpid, пока тот не вернёт ноль, а не одним вызовом: пока обработчик утилизировал первого, могли умереть ещё двое, и их сигналы слились в один. В уроке про ядро Y86 то же правило встретится на прерываниях таймера.
Поиграй с этим в песочнице. Нажми kill у одного сигнала три раза подряд и потом «доставить»: дойдёт один. Поставь обработчик, доставь сигнал и, не выходя из обработчика, пошли тот же сигнал ещё дважды: один встанет в pending (сам сигнал на время обработчика заблокирован), второй пропадёт. Заблокируй SIGINT и попробуй его доставить. SIGKILL заблокировать не получится, чекбокс выключен.
Виджет считает биты так же, как ядро: pending и blocked это маски, кандидат на доставку это младший номер из pending & ~blocked, при входе в обработчик его сигнал добавляется в blocked, при выходе маска восстанавливается. Остановленному процессу доставляются только SIGKILL и SIGCONT, остальные ждут.
Шесть правил безопасного обработчика
Обработчик это функция, которую вызывают между двумя произвольными инструкциями твоей программы. Она видит те же глобальные переменные, тот же аллокатор, те же буферы вывода, и все они могут быть в состоянии «наполовину изменено». Это как многопоточность, только хуже: поток можно заставить ждать на мьютексе, а обработчик прерывает код, который уже держит мьютекс, и никакой мьютекс его не остановит, потому что взять его во второй раз некому. Отсюда шесть правил.
-
Обработчик делает как можно меньше. Поднял флаг, записал число, послал сигнал дальше, и вышел. Вся настоящая работа делается в главном цикле, когда он увидит флаг.
-
Только async-signal-safe функции. Список закрытый, он в
man 7 signal-safety. В нём естьwrite,read,kill,waitpid,sigprocmask,clock_gettime,_exit. В нём нетmalloc,printf,exit, ничего из stdio. Причина в первом абзаце:mallocмог быть прерван посреди перестройки списка свободных блоков,printfпосреди записи в буфер stdout. Для Zig это значит: никаких аллокаторов, никакогоstd.Io.Writerс буфером, никакогоstd.debug.print. Форматирование в буфер на стеке черезstd.fmt.bufPrintи потом голыйwriteдопустимо:bufPrintне трогает ничего общего. -
Сохранять и восстанавливать
errno. Многие разрешённые функции меняютerrno, а обработчик вклинился, возможно, между системным вызовом и проверкой егоerrnoв главном коде. Прочиталerrnoна входе, вернул на выходе. В программе с libc этоstd.c._errno().*. -
Блокировать сигналы вокруг общих структур. Если главный код и обработчик трогают одну и ту же структуру (список заданий, кольцо сэмплов), главный код перед работой с ней закрывает сигнал через
sigprocmaskи открывает после. Иначе обработчик увидит структуру наполовину изменённой. Следующий раздел целиком об этом. -
Общие переменные объявлять так, чтобы компилятор ходил за ними в память. Главный цикл
while (!flag) {}оптимизатор вправе превратить вif (!flag) while (true) {}: он не видит, кто мог бы изменитьflag. В C для этого служитvolatile. В Zig естьvolatileдля указателей, но лучше то, что решает и следующую проблему. -
Читать и писать общие переменные атомарно. Флаг должен меняться одной инструкцией, чтобы обработчик или главный цикл не увидели половину значения. В C это тип
sig_atomic_t. В Zig оба последних правила закрываетstd.atomic.Value(T):loadиstoreкомпилятор не переупорядочивает, не кэширует в регистре и делает одной инструкцией. Все флаги в этом уроке объявлены именно так.
Обработчики, которые ты уже видел в уроке, соблюдают всё это: один fetchAdd, один write. Профилировщик в конце урока покажет, как соблюдать правила, когда работы в обработчике по-настоящему много.
Гонка при регистрации задания
Возьмём кусок оболочки. Главный цикл делает fork, потом записывает pid ребёнка в список заданий (addjob). Обработчик SIGCHLD утилизирует ребёнка и вычёркивает его из списка (deletejob). Кажется, всё правильно. Но ребёнок конкурентен с родителем: он может отработать и умереть раньше, чем родитель вернулся из fork и дошёл до addjob. Тогда SIGCHLD доставляется до addjob, обработчик ищет pid в списке и не находит, а потом родитель записывает в список задание, которого уже нет. Второго SIGCHLD не будет. Призрак останется в списке навсегда.
Пройди сценарий по шагам в виджете. Сначала без маски: смотри, как deletejob отрабатывает впустую, а addjob записывает мертвеца. Потом переключи на версию с маской.
Лечение: закрыть SIGCHLD до fork и открыть после addjob. Тогда сигнал, пришедший в этот промежуток, честно подождёт в pending, а когда маска снимется, обработчик найдёт задание на месте. Одна деталь: ребёнок наследует маску родителя, поэтому в ребёнке её надо вернуть до execve, иначе новая программа стартует с заблокированным SIGCHLD и никогда не узнает о своих детях.
Та же гонка на Zig, с тысячей повторов и подсчётом призраков. Список заданий здесь массив на шестнадцать pid, обработчик соблюдает правила: цикл waitpid с WNOHANG, сохранение errno, ни одного вызова вне списка.
const std = @import("std");
const c = std.c;
const posix = std.posix;
// Список заданий: общая память главного цикла и обработчика SIGCHLD.
const max_jobs = 16;
var jobs: [max_jobs]c.pid_t = .{0} ** max_jobs;
fn addjob(pid: c.pid_t) void {
for (&jobs) |*slot| {
if (slot.* == 0) {
slot.* = pid;
return;
}
}
}
fn deletejob(pid: c.pid_t) void {
for (&jobs) |*slot| {
if (slot.* == pid) slot.* = 0;
}
}
fn onChild(_: posix.SIG) callconv(.c) void {
const saved = posix.errno(@as(c_int, -1)); // правило: сохранить errno
defer setErrno(saved);
// Один SIGCHLD значит «как минимум один ребёнок», поэтому цикл.
while (true) {
const pid = c.waitpid(-1, null, c.W.NOHANG);
if (pid <= 0) break;
deletejob(pid);
}
}
fn setErrno(value: posix.E) void {
c._errno().* = @intFromEnum(value);
}
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(init: std.process.Init) !void {
const args = try init.minimal.args.toSlice(init.arena.allocator());
const fixed = args.len > 1 and std.mem.eql(u8, args[1], "--fixed");
posix.sigaction(.CHLD, &.{
.handler = .{ .handler = onChild },
.mask = posix.sigemptyset(),
.flags = posix.SA.RESTART,
}, null);
var chld = posix.sigemptyset();
posix.sigaddset(&chld, .CHLD);
var leftovers: u32 = 0;
for (0..1000) |_| {
var prev: posix.sigset_t = undefined;
// Лечение: закрыть SIGCHLD до fork, открыть после addjob.
if (fixed) posix.sigprocmask(posix.SIG.BLOCK, &chld, &prev);
const pid = c.fork();
if (pid == 0) {
// Ребёнок наследует маску, снимаем её перед своей работой.
if (fixed) posix.sigprocmask(posix.SIG.SETMASK, &prev, null);
c._exit(0); // ребёнок умирает сразу: гонке так удобнее
}
// Родитель занят своими делами, а ребёнок за это время уже умер.
// Настоящая оболочка между fork и addjob тоже работает, просто
// короче, и гонка стреляет раз в тысячу запусков, а не в каждом.
const busy: c.timespec = .{ .sec = 0, .nsec = 200_000 };
_ = c.nanosleep(&busy, null);
addjob(pid);
if (fixed) posix.sigprocmask(posix.SIG.SETMASK, &prev, null);
// Ждём, пока обработчик утилизирует ребёнка: после waitpid его pid
// свободен, и kill с нулевым сигналом отвечает ошибкой ESRCH.
const pause: c.timespec = .{ .sec = 0, .nsec = 100_000 };
while (c.kill(pid, @enumFromInt(0)) == 0) _ = c.nanosleep(&pause, null);
// Ребёнка нет, а задание в списке осталось: призрак.
for (&jobs) |*slot| {
if (slot.* == pid) {
leftovers += 1;
slot.* = 0;
}
}
}
say("{s}: заданий-призраков после 1000 запусков: {d}\n", .{ if (fixed) "с маской" else "без маски", leftovers });
}
$ ./race
без маски: заданий-призраков после 1000 запусков: 989
$ ./race --fixed
с маской: заданий-призраков после 1000 запусков: 0
Гонка здесь растянута нарочно: между fork и addjob родитель спит 200 мкс, и ребёнок почти всегда успевает умереть первым. Без паузы на этой машине призрак появляется реже, чем раз в тысячу запусков, и это самое неприятное свойство гонок: тесты их не ловят, а сервер под нагрузкой ловит. Читать программу с обработчиком надо так же, как программу с fork из прошлого урока: обработчик может вклиниться между любыми двумя инструкциями, и если хоть одно такое место ломает данные, программа сломана.
В уроке про оболочку эта гонка перестанет быть учебной: там она в настоящем коде с настоящей таблицей заданий.
Ожидание сигнала: sigsuspend
Последний приём урока отвечает на восьмое упражнение прошлого. Родителю надо дождаться события, о котором сообщит сигнал, например смерти ребёнка. Три способа, от плохого к правильному.
Опрос. while (!child_exited.load()) {}. Работает, но жжёт процессор впустую всё время ожидания. Вставить в цикл nanosleep можно, но тогда реакция запаздывает на длину сна, а процессор всё равно горит на каждом пробуждении.
Пара sigprocmask и pause. pause усыпляет процесс до первого доставленного сигнала. Кажется, вот оно: заблокировали SIGCHLD вокруг работы с общими данными, разблокировали, позвали pause. Но между sigprocmask(UNBLOCK) и pause есть несколько инструкций, и если SIGCHLD придёт именно туда, обработчик поднимет флаг, а pause после этого уснёт навсегда: сигнал уже доставлен, будить некому. Это та же гонка, что с addjob, только теперь окно между двумя системными вызовами.
posix.sigprocmask(posix.SIG.UNBLOCK, &chld, null);
// <- если SIGCHLD придёт сюда, pause его уже не увидит
_ = pause();
posix.sigprocmask(posix.SIG.BLOCK, &chld, null);
sigsuspend. sigsuspend(&mask) делает три вещи одним неделимым системным вызовом: ставит переданную маску, засыпает до доставки сигнала, а по возвращении восстанавливает прежнюю маску. Окна нет: пока маска не подменена, сигнал заблокирован, а подмена и сон это одно действие ядра. Правильный цикл ожидания выглядит так:
// Сигналы заблокированы. Проверяем флаг с заблокированными сигналами:
// обработчик не может вклиниться между проверкой и сном.
while (!child_exited.load(.seq_cst)) {
_ = sigsuspend(&old_mask); // старая маска, без блокировки SIGCHLD
// Вернулись: маска снова наша, сигналы заблокированы, флаг перечитаем.
}
sigsuspend всегда возвращает минус единицу с EINTR, это не ошибка, а единственный способ вернуться. В std.posix версии 0.16 обёртки для него нет, zbox объявит его через extern "c". Это ровно то, что нужно сторожу песочницы, и в проекте zbox в конце урока ты увидишь sigsuspend в деле.
Проект zt: сэмплирующий профилировщик
В уроке про профилирование ты строил flame graph из того, что собрал perf, и профилировщик был чёрным ящиком. Теперь у нас есть всё, чтобы открыть его. Сэмплирующий профилировщик устроен просто: таймер тысячу раз в секунду прерывает программу, в момент прерывания мы записываем, где она была и кто её туда позвал, а после прогона считаем, какие стеки встречались чаще. Функция, в которой программа провела три четверти времени, окажется примерно в трёх четвертях снимков.
Таймер у нас уже есть: setitimer(ITIMER_PROF) шлёт SIGPROF за каждую миллисекунду процессорного времени. Обработчик получает ucontext, а в нём регистры прерванного кода, то есть счётчик команд и указатель кадра. Трудность в другом: вся работа сэмплера происходит в обработчике сигнала, посреди чужой программы, которая могла быть прервана где угодно, хоть внутри malloc. Это лучший экзамен на шесть правил, и дальше ты увидишь, как они легли в код.
Работа делится на две половины, и граница между ними проходит ровно по правилам:
- внутри программы: библиотека
libzt-prof.soзаводит таймер, обработчик складывает сырые адреса в статическое кольцо, а на выходе программы кольцо и карта памяти сбрасываются в файл. Никаких имён, никакого разбора ELF, никакого аллокатора; - снаружи:
zt profчитает дамп, превращает адреса в имена функций через таблицы символов и печатает свёрнутые стеки, готовые дляflamegraph.pl.
Внутрь чужой программы библиотека попадает тем же путём, что перехватчик zt ltrace из урока про подмену функций: через LD_PRELOAD. Только подменять ей нечего, ей нужны два момента, до main и после.
src/prof.zig подкоманда: окружение, сырой дамп в свёрнутые стеки
src/prof/ring.zig кольцо сэмплов в статической памяти
src/prof/frames.zig обход стека по указателю кадра
src/prof/sampler.zig таймер и обработчик SIGPROF
src/prof/preload.zig библиотека для LD_PRELOAD: .init_array и .fini_array
src/prof/dump.zig формат сырого дампа
src/prof/symbolize.zig адрес в имя функции
src/prof/folded.zig свёртка стеков
src/pmap.zig разбор /proc/<pid>/maps, пока только то, что нужно prof
fixtures/prof/spin.c подопытная программа
tests/step_49.zig тесты шага
tests/prof/ x86-64 сборка spin, её сырой дамп и свёрнутые стеки
Всё, кроме sampler.zig и preload.zig, это чистый код над байтами, и он проверяется на любой машине.
Кольцо сэмплов
Куда обработчику складывать снимки? В список, который растёт через аллокатор, нельзя: правило два. Значит, память выделяется один раз, заранее, и обработчик пишет в неё по кругу. Когда место кончается, новый снимок затирает самый старый.
//! Кольцевой буфер сэмплов. Живёт в статической памяти и ничего не выделяет:
//! в него пишет обработчик сигнала, а там нельзя ни `malloc`, ни блокировок.
//!
//! Когда место кончается, новый сэмпл затирает самый старый. Профиль долгой
//! программы это её последние `capacity` сэмплов, а сколько потеряно, видно
//! по `dropped`.
const std = @import("std");
/// Глубже стек не записываем: хвост у корня обрезается.
pub const max_depth = 32;
/// Один сэмпл: цепочка адресов от прерванной инструкции к `main`.
/// `addresses[0]` это счётчик команд, дальше адреса возврата.
pub const Sample = struct {
depth: u32,
addresses: [max_depth]u64,
pub fn stack(sample: *const Sample) []const u64 {
return sample.addresses[0..sample.depth];
}
};
pub fn Ring(comptime capacity: usize) type {
return struct {
const Self = @This();
samples: [capacity]Sample = undefined,
/// Сколько сэмплов записано за всё время, включая затёртые.
written: u64 = 0,
/// Только присваивания и копирование: годится для обработчика сигнала.
pub fn push(ring: *Self, addresses: []const u64) void {
const depth = @min(addresses.len, max_depth);
const slot = &ring.samples[@intCast(ring.written % capacity)];
@memcpy(slot.addresses[0..depth], addresses[0..depth]);
slot.depth = @intCast(depth);
// Счётчик двигаем последним: сэмпл виден читателю уже целым.
ring.written += 1;
}
pub fn count(ring: *const Self) usize {
return @intCast(@min(ring.written, capacity));
}
pub fn dropped(ring: *const Self) u64 {
return ring.written - ring.count();
}
/// Сэмпл по порядку записи: нулевой это самый старый из уцелевших.
pub fn get(ring: *const Self, index: usize) *const Sample {
std.debug.assert(index < ring.count());
const oldest = if (ring.written > capacity) ring.written % capacity else 0;
return &ring.samples[@intCast((oldest + index) % capacity)];
}
pub fn clear(ring: *Self) void {
ring.written = 0;
}
};
}
test "пока место есть, сэмплы лежат по порядку" {
var ring: Ring(4) = .{};
ring.push(&.{ 0x10, 0x20 });
ring.push(&.{0x30});
try std.testing.expectEqual(2, ring.count());
try std.testing.expectEqual(0, ring.dropped());
try std.testing.expectEqualSlices(u64, &.{ 0x10, 0x20 }, ring.get(0).stack());
try std.testing.expectEqualSlices(u64, &.{0x30}, ring.get(1).stack());
}
test "полный буфер затирает самые старые сэмплы" {
var ring: Ring(4) = .{};
for (1..7) |i| ring.push(&.{i});
try std.testing.expectEqual(4, ring.count());
try std.testing.expectEqual(2, ring.dropped());
// Сэмплы 1 и 2 затёрты, уцелели 3, 4, 5, 6.
for (0..4) |i| try std.testing.expectEqualSlices(u64, &.{i + 3}, ring.get(i).stack());
}
test "слишком глубокий стек обрезается у корня" {
var ring: Ring(2) = .{};
var deep: [max_depth + 8]u64 = undefined;
for (&deep, 0..) |*address, i| address.* = i;
ring.push(&deep);
try std.testing.expectEqual(max_depth, ring.get(0).depth);
try std.testing.expectEqual(0, ring.get(0).addresses[0]);
}
В push только присваивания и копирование, ни одного вызова, который мог бы что-то захватить. @memcpy компилятор может превратить в вызов memcpy, но она в списке man 7 signal-safety есть, начиная с POSIX 2016. Счётчик written двигается последним, после того как адреса легли в ячейку. Кольцо на 16 тысяч снимков по 264 байта (глубина плюс 32 адреса, с выравниванием) это четыре с небольшим мегабайта в .bss, и ядро выделит под них страницы, только когда запись до них дойдёт: об этой ленивости подробно поговорим в уроке про виртуальную память.
Обход стека по указателю кадра
Где взять цепочку вызовов? В уроке про кадры стека функция, собранная с кадром, начиналась с push %rbp; mov %rsp, %rbp. По адресу в %rbp лежат два слова: сохранённый %rbp вызвавшей функции и сразу над ним адрес возврата. Кадры сцеплены в односвязный список от текущей функции к main. На aarch64 то же самое, только регистры другие: x29 показывает на пару x29, x30, где x30 это адрес возврата. Обходить список умеет одна функция:
//! Обход стека по указателю кадра.
//!
//! Функция, собранная с `-fno-omit-frame-pointer`, в прологе делает
//! `push %rbp; mov %rsp, %rbp`. Поэтому по адресу из регистра кадра лежит
//! пара слов: сохранённый указатель кадра вызвавшей функции и сразу за ним
//! адрес возврата. Кадры образуют односвязный список от прерванной функции
//! к `main`. На aarch64 то же самое: пара `x29`, `x30` в начале кадра.
const std = @import("std");
/// Складывает в `out` счётчик команд и адреса возврата, возвращает глубину.
///
/// Указателю кадра нельзя верить на слово: код без кадров (часть libc,
/// ассемблерные вставки) держит в этом регистре что угодно. Разыменование
/// мусора в обработчике сигнала убьёт программу, поэтому каждый шаг
/// проверяется: адрес выровнен, лежит внутри стека между `low` и `high`
/// и строго выше предыдущего кадра, ведь стек растёт вниз.
pub fn walk(pc: usize, frame_pointer: usize, low: usize, high: usize, out: []u64) usize {
if (out.len == 0) return 0;
out[0] = pc;
var depth: usize = 1;
var floor = low;
var fp = frame_pointer;
while (depth < out.len) {
if (fp <= floor or fp % @alignOf(usize) != 0) break;
if (fp > high or high - fp < 2 * @sizeOf(usize)) break;
const frame: *const [2]usize = @ptrFromInt(fp);
const return_address = frame[1];
// У самого первого кадра процесса адрес возврата нулевой.
if (return_address == 0) break;
out[depth] = return_address;
depth += 1;
floor = fp;
fp = frame[0];
}
return depth;
}
test "цепочка из трёх кадров" {
// Стек как массив слов: младшие индексы это младшие адреса.
var stack = [_]usize{0} ** 16;
const base = @intFromPtr(&stack);
const word = @sizeOf(usize);
// Кадр листа по индексу 2, над ним кадр по индексу 6, над ним по 10.
stack[2] = base + 6 * word;
stack[3] = 0x2000;
stack[6] = base + 10 * word;
stack[7] = 0x3000;
stack[10] = 0;
stack[11] = 0x4000;
var out: [8]u64 = undefined;
const depth = walk(0x1000, base + 2 * word, base, base + stack.len * word, &out);
try std.testing.expectEqualSlices(u64, &.{ 0x1000, 0x2000, 0x3000, 0x4000 }, out[0..depth]);
}
test "мусор в регистре кадра: остаётся один счётчик команд" {
var stack = [_]usize{0} ** 8;
const base = @intFromPtr(&stack);
const high = base + stack.len * @sizeOf(usize);
var out: [8]u64 = undefined;
try std.testing.expectEqual(1, walk(0x1000, 0, base, high, &out)); // ноль
try std.testing.expectEqual(1, walk(0x1000, base + 3, base, high, &out)); // не выровнен
try std.testing.expectEqual(1, walk(0x1000, high + 64, base, high, &out)); // выше стека
try std.testing.expectEqual(1, walk(0x1000, high - 8, base, high, &out)); // пара не помещается
}
test "кадр, который показывает вниз или на себя, обрывает обход" {
var stack = [_]usize{0} ** 8;
const base = @intFromPtr(&stack);
const word = @sizeOf(usize);
stack[4] = base + 4 * word; // петля
stack[5] = 0x2000;
var out: [8]u64 = undefined;
const depth = walk(0x1000, base + 4 * word, base, base + stack.len * word, &out);
try std.testing.expectEqualSlices(u64, &.{ 0x1000, 0x2000 }, out[0..depth]);
}
test "глубина ограничена буфером" {
var stack = [_]usize{0} ** 16;
const base = @intFromPtr(&stack);
const word = @sizeOf(usize);
for (0..7) |i| {
stack[2 * i] = base + (2 * i + 2) * word;
stack[2 * i + 1] = 0x100 * (i + 1);
}
var out: [3]u64 = undefined;
try std.testing.expectEqual(3, walk(0x1, base, base - word, base + stack.len * word, &out));
}
Главное здесь в проверках. Регистр кадра в момент сигнала может держать что угодно: программа могла быть прервана в прологе, между push и mov, или внутри кода libc, собранного без кадров. Разыменуешь мусор, и SIGSEGV прилетит прямо внутри обработчика SIGPROF, то есть профилировщик убьёт программу, которую изучает. Поэтому каждый шаг проверяет, что адрес выровнен, лежит внутри стека и строго выше предыдущего: стек растёт вниз, и кадр вызвавшей функции всегда старше. Петля или кадр, показывающий вниз, обрывают обход. Проверки дешёвые, это несколько сравнений.
Нижняя граница стека известна обработчику сама собой: он работает на том же стеке, ниже прерванного кода, и его собственный @frameAddress() годится как пол. Верхнюю границу надо узнать заранее, и для этого нужна карта памяти.
src/pmap.zig, пока только разбор карты
/proc/self/maps это текст, строка на каждую область адресного пространства: начало, конец, права, смещение в файле, устройство, inode и путь. Карта пригодится дважды: сэмплеру при запуске, чтобы найти область [stack] с его собственным кадром, и zt prof после прогона, чтобы понять, из какого файла пришёл адрес. Разбор строки и перевод адреса в пару «файл и смещение в нём» живут в своём файле. Сегодня в нём только то, что нужно профилировщику; в уроке 54 файл дорастёт до полной подкоманды zt pmap с печатью, как у pmap из procps.
//! Карта памяти процесса по `/proc/<pid>/maps`: пока только разбор строк
//! и перевод адреса в пару (файл, смещение). Всё это чистый код. Полный
//! `zt pmap` с печатью как у procps появится в уроке 54.
const std = @import("std");
/// Одна строка `/proc/<pid>/maps`, то есть одна область виртуальной памяти:
///
/// 7f1c2a600000-7f1c2a628000 r--p 00000000 08:01 1835 /usr/lib/libc.so.6
/// начало конец права смещение устр. inode путь
pub const Region = struct {
start: u64,
/// Первый адрес за областью.
end: u64,
/// Четыре символа: `r`, `w`, `x` и `p` (частная, копирование при записи)
/// либо `s` (разделяемая).
perms: [4]u8,
/// С какого байта файла начинается область. Для анонимной памяти ноль.
offset: u64,
inode: u64,
/// Путь к файлу, псевдоимя вроде `[stack]` или пустая строка у анонимной памяти.
/// Срез смотрит в исходный текст.
path: []const u8,
pub fn size(region: Region) u64 {
return region.end - region.start;
}
pub fn contains(region: Region, address: u64) bool {
return address >= region.start and address < region.end;
}
/// За областью стоит настоящий файл на диске.
pub fn isFile(region: Region) bool {
return region.path.len > 0 and region.path[0] == '/';
}
};
pub const ParseError = error{BadMapsLine};
pub fn parseLine(line: []const u8) ParseError!Region {
// Первые пять полей разделены пробелами. Путь берём как остаток строки:
// в нём самом пробелы законны, а ядро дописывает к удалённым файлам ` (deleted)`.
var rest = line;
const range = nextField(&rest) orelse return error.BadMapsLine;
const perms = nextField(&rest) orelse return error.BadMapsLine;
const offset = nextField(&rest) orelse return error.BadMapsLine;
_ = nextField(&rest) orelse return error.BadMapsLine; // устройство, старший:младший
const inode = nextField(&rest) orelse return error.BadMapsLine;
const dash = std.mem.indexOfScalar(u8, range, '-') orelse return error.BadMapsLine;
if (perms.len != 4) return error.BadMapsLine;
return .{
.start = std.fmt.parseInt(u64, range[0..dash], 16) catch return error.BadMapsLine,
.end = std.fmt.parseInt(u64, range[dash + 1 ..], 16) catch return error.BadMapsLine,
.perms = perms[0..4].*,
.offset = std.fmt.parseInt(u64, offset, 16) catch return error.BadMapsLine,
.inode = std.fmt.parseInt(u64, inode, 10) catch return error.BadMapsLine,
.path = std.mem.trim(u8, rest, " "),
};
}
fn nextField(rest: *[]const u8) ?[]const u8 {
const trimmed = std.mem.trimStart(u8, rest.*, " ");
if (trimmed.len == 0) return null;
const end = std.mem.indexOfScalar(u8, trimmed, ' ') orelse trimmed.len;
rest.* = trimmed[end..];
return trimmed[0..end];
}
/// Адрес в терминах файла: какой файл и какой его байт лежит по этому адресу.
pub const FileAddress = struct {
path: []const u8,
offset: u64,
};
/// Переводит адрес живого процесса в пару (файл, смещение в файле).
/// Это первая половина символизации адреса внутри разделяемой библиотеки:
/// библиотека каждый раз грузится по новому адресу, а смещение в файле
/// постоянно. Вторая половина уже дело читателя ELF: по сегментам `PT_LOAD`
/// перевести смещение в адрес из таблицы символов и найти символ через `nm`.
/// null значит, что адрес не отображён или за ним нет файла.
pub fn locate(regions: []const Region, address: u64) ?FileAddress {
for (regions) |region| {
if (!region.contains(address)) continue;
if (!region.isFile()) return null;
return .{ .path = region.path, .offset = region.offset + (address - region.start) };
}
return null;
}
locate это ключ к символизации адресов в библиотеках. libc при каждом запуске грузится по новому адресу, и 0xffff95b07744 в её таблице символов искать бесполезно: там адреса посчитаны так, будто файл лежит с нуля. Постоянно другое: смещение байта внутри файла. locate находит область, куда попал адрес, и возвращает путь и offset области плюс расстояние от её начала. Остальное сделает читатель ELF.
Сэмплер: src/prof/sampler.zig
Сердце шага. Здесь таймер, обработчик и чтение карты без аллокатора.
//! Сэмплер: таймер, обработчик `SIGPROF` и кольцо сэмплов.
//!
//! `setitimer(ITIMER_PROF)` считает процессорное время процесса и раз в
//! период присылает `SIGPROF`. Ядро прерывает программу где придётся,
//! сохраняет её регистры в `ucontext` и зовёт обработчик. В сохранённых
//! регистрах есть счётчик команд и указатель кадра: этого хватает, чтобы
//! узнать, какая функция работала и кто её вызвал.
//!
//! Обработчик прерывает программу в произвольном месте, возможно, посреди
//! `malloc` с захваченной блокировкой. Поэтому в нём только чтение памяти
//! и запись в заранее выделенное кольцо: ни аллокаций, ни блокировок,
//! ни форматирования, ни вывода.
const std = @import("std");
const builtin = @import("builtin");
const frames = @import("frames.zig");
const pmap = @import("../pmap.zig");
const ring_mod = @import("ring.zig");
const linux = std.os.linux;
const posix = std.posix;
/// 16 тысяч сэмплов по 260 байт это 4 МБ в `.bss`. Страницы, до которых
/// запись не дошла, ядро так и не выделит.
pub const capacity = 16 * 1024;
/// Простое число, а не ровные 1000: таймер не попадает в такт с работой,
/// которую сама программа делает раз в миллисекунду.
pub const default_hz = 997;
pub const Samples = ring_mod.Ring(capacity);
var ring: Samples = .{};
var current_hz: u32 = 0;
/// Верхняя граница стека потока, который вызвал `start`.
var stack_high: usize = 0;
pub const Error = error{ NoStackBounds, TimerFailed, BadFrequency };
/// Включает сэмплирование. Стеки собираются у потока, который вызвал
/// `start`: у сэмпла с другого потока останется один счётчик команд.
// ponytail: один поток. Границы стека на каждый поток и его имя в префиксе
// стека это `zt prof --threads` из урока про потоки.
pub fn start(hz: u32) Error!void {
if (hz == 0 or hz > 10_000) return error.BadFrequency;
stack_high = stackHigh(@frameAddress()) orelse return error.NoStackBounds;
ring.clear();
current_hz = hz;
const action: posix.Sigaction = .{
.handler = .{ .sigaction = handler },
.mask = posix.sigemptyset(),
// RESTART: прерванные таймером read и write ядро перезапустит само,
// и программа не увидит EINTR, которого без профилировщика не было.
.flags = posix.SA.SIGINFO | posix.SA.RESTART,
};
posix.sigaction(.PROF, &action, null);
try setTimer(1_000_000 / hz);
}
/// Выключает таймер. Обработчик остаётся на месте: сигнал, который ядро
/// уже отправило, иначе убил бы программу.
pub fn stop() void {
setTimer(0) catch {};
}
pub fn samples() *const Samples {
return ˚
}
pub fn frequency() u32 {
return current_hz;
}
fn handler(_: posix.SIG, _: *const posix.siginfo_t, context_pointer: ?*anyopaque) callconv(.c) void {
const context = std.debug.cpu_context.fromPosixSignalContext(context_pointer) orelse return;
var addresses: [ring_mod.max_depth]u64 = undefined;
// Обработчик работает на том же стеке, ниже прерванного кода. Значит,
// его собственный кадр это нижняя граница для кадров программы.
const depth = frames.walk(context.getPc(), context.getFp(), @frameAddress(), stack_high, &addresses);
ring.push(addresses[0..depth]);
}
/// `struct itimerval`: период и время до первого срабатывания.
const Itimerval = extern struct {
interval: posix.timeval,
value: posix.timeval,
};
extern "c" fn setitimer(which: c_int, new_value: *const Itimerval, old_value: ?*Itimerval) c_int;
const itimer_prof = 2;
fn setTimer(period_us: u32) Error!void {
const period: posix.timeval = .{
.sec = @intCast(period_us / 1_000_000),
.usec = @intCast(period_us % 1_000_000),
};
const value: Itimerval = .{ .interval = period, .value = period };
const failed = switch (builtin.os.tag) {
// Обёртка std.os.linux.setitimer ждёт itimerspec с наносекундами,
// а вызов работает с микросекундами. Зовём его напрямую.
.linux => linux.errno(linux.syscall3(.setitimer, itimer_prof, @intFromPtr(&value), 0)) != .SUCCESS,
else => setitimer(itimer_prof, &value, null) != 0,
};
if (failed) return error.TimerFailed;
}
extern "c" fn pthread_self() ?*anyopaque;
extern "c" fn pthread_get_stackaddr_np(thread: ?*anyopaque) usize;
/// Первый адрес над стеком, в котором лежит `inside`.
fn stackHigh(inside: usize) ?usize {
switch (builtin.os.tag) {
.linux => {
var lines = std.mem.tokenizeScalar(u8, readSelfMaps(), '\n');
while (lines.next()) |line| {
const region = pmap.parseLine(line) catch continue;
if (region.contains(inside)) return @intCast(region.end);
}
return null;
},
.macos => return pthread_get_stackaddr_np(pthread_self()),
else => return null,
}
}
var maps_buffer: [256 * 1024]u8 = undefined;
/// `/proc/self/maps` сырыми системными вызовами в статический буфер:
/// библиотеке-сэмплеру внутри чужой программы не положены ни аллокатор,
/// ни `std.Io`.
// ponytail: карта длиннее 256 КБ обрежется. Это тысячи областей; понадобится
// больше, читай и разбирай по кускам.
pub fn readSelfMaps() []const u8 {
if (builtin.os.tag != .linux) return "";
const opened = linux.open("/proc/self/maps", .{ .ACCMODE = .RDONLY }, 0);
if (linux.errno(opened) != .SUCCESS) return "";
const fd: i32 = @intCast(opened);
defer _ = linux.close(fd);
var length: usize = 0;
while (length < maps_buffer.len) {
const got = linux.read(fd, maps_buffer[length..].ptr, maps_buffer.len - length);
if (linux.errno(got) != .SUCCESS or got == 0) break;
length += got;
}
return maps_buffer[0..length];
}
Обработчик короче, чем комментарий над ним. Пройдём по шести правилам и найдём каждое в коде.
- Минимум работы. В
handlerтри действия: достать контекст, пройти кадры, положить адреса в кольцо. Ни имён, ни форматирования, ни вывода: всё это сделаютfiniна выходе программы иzt profснаружи. - Только async-signal-safe. Обработчик не зовёт ни одной функции libc.
fromPosixSignalContextтолько читает поляucontextпо указателю,frames.walkиring.pushэто чтение и запись памяти. Вне обработчика та же забота видна вreadSelfMaps: карту читает сырымиlinux.openиlinux.readв статический буфер, потому что библиотеке внутри чужой программы не положены ни аллокатор, ниstd.Io. - errno. Сохранять нечего: ни одна строка обработчика не делает системных вызовов и не трогает
errno. Правило соблюдено тем, что поводов нарушить его нет. Добавишь в обработчикwrite, и сохранение станет обязательным. - Общие данные под защитой. Кольцо общее у обработчика и у кода, который его читает. Вместо
sigprocmaskздесь приём сильнее: главный код вообще не трогает кольцо, пока таймер идёт.startчистит его доsigactionиsetitimer, аfiniзовётstopраньше, чем прочитать хоть один сэмпл. Сигнал, успевший встать вpending, ядро доставит на выходе из того жеsetitimer, то есть до чтения. - и 6. Память и атомарность. Раз главный код читает кольцо только после
stop, одновременного чтения и записи нет, иwrittenможет быть обычнымu64. Исключение одно, цикл в тесте шага, и ему посвящено девятое упражнение.
Флаг SA_RESTART в start к правилам не относится, это забота о самой программе: без него каждый медленный read в профилируемой программе начал бы возвращать EINTR, которого без профилировщика не было, и программа повела бы себя иначе, чем без нас.
Обрати внимание, что обработчик ставится навсегда: stop выключает таймер, но не возвращает SIG_DFL. Действие SIGPROF по умолчанию это смерть процесса, и сигнал, отправленный ядром за мгновение до выключения таймера, иначе убил бы программу на выходе.
Две ловушки платформ. Первая это setitimer на Linux: обёртка из std.os.linux ждёт itimerspec с наносекундами, поэтому сэмплер зовёт системный вызов через syscall3 со своей структурой Itimerval. Вторая это верхняя граница стека. На Linux её даёт карта памяти, а на macOS карты в /proc нет, зато есть pthread_get_stackaddr_np, которая возвращает именно верх стека потока. Частота по умолчанию 997 Гц, простое число: если программа сама делает что-то раз в миллисекунду, ровные 1000 Гц попадали бы каждый раз в одну и ту же фазу её работы.
Библиотека: src/prof/preload.zig
Как завести таймер в чужой программе до её main? Ответ даёт формат ELF. В уроке про загрузку __libc_start_main перед main выполнял конструкторы из секции .init_array. Загрузчик делает то же для каждой библиотеки. Значит, достаточно положить в эту секцию указатель на свою функцию:
//! Библиотека-сэмплер для `zt prof`. Собирается в `libzt-prof.so` и
//! подсовывается программе через `LD_PRELOAD`.
//!
//! Ни одной функции программы она не подменяет. Ей нужны два момента:
//! до `main`, чтобы завести таймер, и после него, чтобы сбросить сэмплы в
//! файл. Оба даёт сам формат ELF: загрузчик вызывает функции из секции
//! `.init_array` каждой библиотеки до передачи управления программе, а
//! функции из `.fini_array` на выходе через `exit` или возврат из `main`.
//!
//! Только Linux: на других системах этот файл не собирается.
const std = @import("std");
const zt = @import("zt");
const linux = std.os.linux;
const prof = zt.prof;
extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8;
fn init() callconv(.c) void {
var hz: u32 = prof.sampler.default_hz;
if (getenv(prof.frequency_variable)) |text| hz = std.fmt.parseInt(u32, std.mem.span(text), 10) catch hz;
prof.sampler.start(hz) catch {};
}
// ponytail: программа, которая вышла через _exit или убита сигналом, дампа
// не оставит: .fini_array до неё не дойдёт. Нужен профиль и таких программ,
// пиши дамп из обработчика SIGTERM или отображай кольцо в файл через mmap.
fn fini() callconv(.c) void {
prof.sampler.stop();
const path = getenv(prof.output_variable) orelse return;
const opened = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
if (linux.errno(opened) != .SUCCESS) return;
const fd: i32 = @intCast(opened);
defer _ = linux.close(fd);
const samples = prof.sampler.samples();
var buffer: [prof.dump.sample_line_size]u8 = undefined;
writeAll(fd, prof.dump.magic);
writeAll(fd, std.fmt.bufPrint(&buffer, "hz {d}\ndropped {d}\nmaps\n", .{
prof.sampler.frequency(),
samples.dropped(),
}) catch return);
// Карту снимаем сейчас, а не на старте: к выходу в ней есть и то, что
// программа догрузила через dlopen.
writeAll(fd, prof.sampler.readSelfMaps());
writeAll(fd, "samples\n");
for (0..samples.count()) |index| {
writeAll(fd, prof.dump.formatSample(&buffer, samples.get(index).stack()));
}
}
fn writeAll(fd: i32, bytes: []const u8) void {
var rest = bytes;
while (rest.len > 0) {
const written = linux.write(fd, rest.ptr, rest.len);
if (linux.errno(written) != .SUCCESS) return;
rest = rest[written..];
}
}
export const zt_prof_init linksection(".init_array") = [_]*const fn () callconv(.c) void{&init};
export const zt_prof_fini linksection(".fini_array") = [_]*const fn () callconv(.c) void{&fini};
Две последние строки это вся магия: массивы из одного указателя с атрибутом linksection, и компоновщик кладёт их в нужные секции. export не даёт компилятору выбросить переменные, на которые никто не ссылается.
fini работает, когда main уже вернул управление, но процесс ещё жив. Выход через exit штатный, можно было бы звать что угодно. Но библиотека написана так, будто и здесь действуют правила обработчика: сырые linux.open и linux.write, форматирование строки в буфер на стеке, writeAll в цикле, потому что write имеет право записать меньше, чем просили. Причина в домашнем задании: программа, убитая сигналом, до .fini_array не дойдёт, и дамп придётся писать прямо из обработчика. Код, который уже сейчас обходится без аллокатора, переедет туда почти без правок.
Комментарий ponytail над fini честно называет потолок: программа, вышедшая через _exit или убитая сигналом, дампа не оставит.
Формат дампа: src/prof/dump.zig
Сырой дамп это текст. Заголовок, копия карты памяти на момент выхода и сэмплы по строке, адреса в hex от прерванной инструкции к корню. Карту приходится сохранять вместе с адресами: после выхода программы её адресного пространства больше нет, а без карты адрес ffff95b07744 ничего не значит.
//! Сырой дамп профиля: то, что библиотека-сэмплер оставляет после программы.
//!
//! Это текст из двух частей. Карта памяти, копия `/proc/self/maps`, и сэмплы,
//! по строке на сэмпл: адреса в hex через пробел, от прерванной инструкции
//! к `main`. Имён функций тут нет: искать их внутри программы, да ещё на
//! выходе из неё, незачем. Адреса превратит в имена `zt prof`, а карта
//! нужна ему затем, что при каждом запуске библиотеки лежат по новым адресам.
//!
//! zt-prof 1
//! hz 997
//! dropped 0
//! maps
//! 00400000-00401000 r--p 00000000 08:01 42 /tmp/spin
//! samples
//! 401136 40117b 4011a2
const std = @import("std");
const pmap = @import("../pmap.zig");
const ring_mod = @import("ring.zig");
pub const magic = "zt-prof 1\n";
/// Строка дампа для одного сэмпла. Пишет в готовый буфер, без аллокаций.
pub fn formatSample(buffer: []u8, stack: []const u64) []const u8 {
var out: std.Io.Writer = .fixed(buffer);
for (stack, 0..) |address, position| {
if (position > 0) out.writeByte(' ') catch break;
out.print("{x}", .{address}) catch break;
}
out.writeByte('\n') catch {};
return out.buffered();
}
/// Места хватит на самый глубокий стек: 16 цифр и разделитель на адрес.
pub const sample_line_size = ring_mod.max_depth * 17 + 1;
pub const Dump = struct {
hz: u32,
dropped: u64,
regions: []pmap.Region,
/// Стеки лежат подряд в `addresses`, у каждого свой срез.
stacks: [][]const u64,
addresses: []u64,
pub fn deinit(dump: Dump, gpa: std.mem.Allocator) void {
gpa.free(dump.regions);
gpa.free(dump.stacks);
gpa.free(dump.addresses);
}
};
pub const ParseError = error{ NotAProfile, BadSample } || pmap.ParseError || std.mem.Allocator.Error;
/// Пути в `regions` смотрят в `text`: он должен жить дольше результата.
pub fn parse(gpa: std.mem.Allocator, text: []const u8) ParseError!Dump {
if (!std.mem.startsWith(u8, text, magic)) return error.NotAProfile;
var hz: u32 = 0;
var dropped: u64 = 0;
var regions: std.ArrayList(pmap.Region) = .empty;
errdefer regions.deinit(gpa);
var addresses: std.ArrayList(u64) = .empty;
errdefer addresses.deinit(gpa);
// Срезы нельзя брать, пока список адресов растёт: сначала копим длины.
var lengths: std.ArrayList(usize) = .empty;
defer lengths.deinit(gpa);
const Part = enum { header, maps, samples };
var part: Part = .header;
var lines = std.mem.tokenizeScalar(u8, text[magic.len..], '\n');
while (lines.next()) |line| {
if (std.mem.eql(u8, line, "maps")) {
part = .maps;
} else if (std.mem.eql(u8, line, "samples")) {
part = .samples;
} else switch (part) {
.header => {
if (cutPrefix(line, "hz ")) |value| hz = std.fmt.parseInt(u32, value, 10) catch return error.NotAProfile;
if (cutPrefix(line, "dropped ")) |value| dropped = std.fmt.parseInt(u64, value, 10) catch return error.NotAProfile;
},
.maps => try regions.append(gpa, try pmap.parseLine(line)),
.samples => {
const before = addresses.items.len;
var words = std.mem.tokenizeScalar(u8, line, ' ');
while (words.next()) |word| {
try addresses.append(gpa, std.fmt.parseInt(u64, word, 16) catch return error.BadSample);
}
try lengths.append(gpa, addresses.items.len - before);
},
}
}
const all = try addresses.toOwnedSlice(gpa);
errdefer gpa.free(all);
const stacks = try gpa.alloc([]const u64, lengths.items.len);
var start: usize = 0;
for (lengths.items, stacks) |length, *stack| {
stack.* = all[start..][0..length];
start += length;
}
return .{
.hz = hz,
.dropped = dropped,
.regions = try regions.toOwnedSlice(gpa),
.stacks = stacks,
.addresses = all,
};
}
fn cutPrefix(line: []const u8, prefix: []const u8) ?[]const u8 {
return if (std.mem.startsWith(u8, line, prefix)) line[prefix.len..] else null;
}
test "строка сэмпла" {
var buffer: [sample_line_size]u8 = undefined;
try std.testing.expectEqualStrings("401136 40117b 4011a2\n", formatSample(&buffer, &.{ 0x401136, 0x40117b, 0x4011a2 }));
const deepest = [_]u64{std.math.maxInt(u64)} ** ring_mod.max_depth;
try std.testing.expectEqual(sample_line_size - 1, formatSample(&buffer, &deepest).len);
}
test "разбор дампа" {
const gpa = std.testing.allocator;
const text = magic ++
\\hz 997
\\dropped 3
\\maps
\\00400000-00401000 r--p 00000000 08:01 42 /tmp/spin
\\00401000-00402000 r-xp 00001000 08:01 42 /tmp/spin
\\samples
\\401136 40117b 4011a2
\\401150
\\
;
const dump = try parse(gpa, text);
defer dump.deinit(gpa);
try std.testing.expectEqual(997, dump.hz);
try std.testing.expectEqual(3, dump.dropped);
try std.testing.expectEqual(2, dump.regions.len);
try std.testing.expectEqualStrings("/tmp/spin", dump.regions[1].path);
try std.testing.expectEqual(2, dump.stacks.len);
try std.testing.expectEqualSlices(u64, &.{ 0x401136, 0x40117b, 0x4011a2 }, dump.stacks[0]);
try std.testing.expectEqualSlices(u64, &.{0x401150}, dump.stacks[1]);
}
test "чужой файл и битый адрес" {
const gpa = std.testing.allocator;
try std.testing.expectError(error.NotAProfile, parse(gpa, "perf.data"));
try std.testing.expectError(error.BadSample, parse(gpa, magic ++ "samples\n40zz\n"));
}
formatSample пишет в готовый буфер через std.Io.Writer.fixed: это форматирование без аллокатора, поэтому его и зовёт fini. sample_line_size посчитан под самый глубокий стек, и тест проверяет, что строка из 32 адресов по u64 влезает ровно. parse работает уже снаружи, в zt prof, и там аллокатор законен.
Символизация: src/prof/symbolize.zig
Путь от адреса к имени в три шага. Карта памяти через pmap.locate даёт файл и смещение в нём. Сегменты PT_LOAD этого файла переводят смещение в адрес, каким его видел компоновщик: ровно та таблица, которую загрузчик читал в уроке 44. По этому адресу таблица символов из урока 42 находит функцию, у которой value <= адрес < value + size.
//! Символизация: адрес из живого процесса в имя функции.
//!
//! Путь в три шага. Карта памяти переводит адрес в пару «файл и смещение в
//! файле» (`pmap.locate`, урок 54). Сегменты `PT_LOAD` этого файла переводят
//! смещение в адрес, каким его видел компоновщик. По этому адресу в таблице
//! символов находится функция: та, у которой `value <= адрес < value + size`.
const std = @import("std");
const elf = @import("../elf.zig");
const pmap = @import("../pmap.zig");
const format = elf.format;
const Function = struct {
address: u64,
size: u64,
name: []const u8,
fn before(_: void, a: Function, b: Function) bool {
return a.address < b.address;
}
};
/// Кусок файла, который ядро кладёт в память: из `Elf64_Phdr` типа `PT_LOAD`.
const Load = struct {
offset: u64,
vaddr: u64,
filesz: u64,
};
/// Функции одного файла ELF, отсортированные по адресу.
pub const FunctionTable = struct {
functions: []Function,
loads: []Load,
/// Срезы имён смотрят в `bytes`: файл должен жить дольше таблицы.
pub fn init(gpa: std.mem.Allocator, bytes: []const u8) !FunctionTable {
const file = try elf.File.parse(bytes);
var loads: std.ArrayList(Load) = .empty;
errdefer loads.deinit(gpa);
var index: u16 = 0;
while (index < file.header.phnum) : (index += 1) {
const header = try elf.program.programHeader(file, index);
if (header.kind() != .load) continue;
try loads.append(gpa, .{ .offset = header.offset, .vaddr = header.vaddr, .filesz = header.filesz });
}
// У обычной программы есть `.symtab`. У библиотеки из дистрибутива её
// срезали командой strip, и остаётся `.dynsym`: только то, что
// библиотека экспортирует.
const table = elf.SymbolTable.find(file, .symtab) catch try elf.SymbolTable.find(file, .dynsym);
var functions: std.ArrayList(Function) = .empty;
errdefer functions.deinit(gpa);
var number: u32 = 1;
while (number < table.count()) : (number += 1) {
const symbol = try table.get(number);
if (symbol.kind() != .func or symbol.isUndefined()) continue;
try functions.append(gpa, .{ .address = symbol.value, .size = symbol.size, .name = table.name(symbol) });
}
std.mem.sort(Function, functions.items, {}, Function.before);
return .{ .functions = try functions.toOwnedSlice(gpa), .loads = try loads.toOwnedSlice(gpa) };
}
pub fn deinit(table: FunctionTable, gpa: std.mem.Allocator) void {
gpa.free(table.functions);
gpa.free(table.loads);
}
/// Смещение в файле в адрес времени компоновки.
pub fn virtualAddress(table: FunctionTable, offset: u64) ?u64 {
for (table.loads) |load| {
if (offset >= load.offset and offset - load.offset < load.filesz) return load.vaddr + (offset - load.offset);
}
return null;
}
/// Имя функции, внутри которой лежит адрес.
pub fn find(table: FunctionTable, address: u64) ?[]const u8 {
// Двоичный поиск последней функции, которая начинается не позже адреса.
var low: usize = 0;
var high: usize = table.functions.len;
while (low < high) {
const middle = low + (high - low) / 2;
if (table.functions[middle].address <= address) low = middle + 1 else high = middle;
}
if (low == 0) return null;
const function = table.functions[low - 1];
// Размер ноль бывает у функций на ассемблере: верим началу.
if (function.size != 0 and address - function.address >= function.size) return null;
return function.name;
}
};
pub const Symbolizer = struct {
regions: []const pmap.Region,
tables: std.StringHashMapUnmanaged(FunctionTable) = .empty,
pub fn deinit(symbolizer: *Symbolizer, gpa: std.mem.Allocator) void {
var tables = symbolizer.tables.valueIterator();
while (tables.next()) |table| table.deinit(gpa);
symbolizer.tables.deinit(gpa);
}
/// Файл, без которого адрес не назвать, или `null`: адрес не из файла
/// либо файл уже загружен.
pub fn missingFile(symbolizer: *const Symbolizer, address: u64) ?[]const u8 {
const located = pmap.locate(symbolizer.regions, address) orelse return null;
return if (symbolizer.tables.contains(located.path)) null else located.path;
}
/// `path` как в карте памяти, `bytes` это содержимое файла.
pub fn addFile(symbolizer: *Symbolizer, gpa: std.mem.Allocator, path: []const u8, bytes: []const u8) !void {
const table = try FunctionTable.init(gpa, bytes);
errdefer table.deinit(gpa);
try symbolizer.tables.put(gpa, path, table);
}
/// Имя для адреса. Когда функции не нашлось, остаётся то, что известно:
/// `[libc.so.6]` для файла без символа, `[vdso]` для области без файла,
/// `[unknown]` для адреса вне карты.
pub fn name(symbolizer: *const Symbolizer, buffer: []u8, address: u64) []const u8 {
const located = pmap.locate(symbolizer.regions, address) orelse {
for (symbolizer.regions) |region| {
if (region.contains(address) and region.path.len > 0) return region.path;
}
return "[unknown]";
};
if (symbolizer.tables.get(located.path)) |table| {
if (table.virtualAddress(located.offset)) |virtual| {
if (table.find(virtual)) |function| return function;
}
}
return std.fmt.bufPrint(buffer, "[{s}]", .{std.fs.path.basenamePosix(located.path)}) catch "[?]";
}
};
У программы из твоей сборки есть .symtab, и в ней все функции, даже static. У libc из дистрибутива таблицу срезали командой strip, осталась .dynsym: только то, что библиотека экспортирует. Поэтому внутренняя функция libc получит в профиле имя файла в квадратных скобках, а не своё. Адрес, который не принадлежит ни одному файлу ([vdso], анонимная память JIT), подписывается псевдоименем области.
Свёртка: src/prof/folded.zig
//! Свёрнутые стеки, формат `flamegraph.pl` и speedscope: строка на каждый
//! уникальный стек, имена от корня к листу через точку с запятой, в конце
//! число сэмплов.
//!
//! main;work;hot_a 312
//! main;work;hot_b 104
const std = @import("std");
const symbolize = @import("symbolize.zig");
pub const Folded = struct {
/// Ключи принадлежат таблице и освобождаются в `deinit`.
counts: std.StringArrayHashMapUnmanaged(u64) = .empty,
pub fn deinit(folded: *Folded, gpa: std.mem.Allocator) void {
for (folded.counts.keys()) |key| gpa.free(key);
folded.counts.deinit(gpa);
}
/// `stack` идёт от листа к корню, как его записал сэмплер.
pub fn add(folded: *Folded, gpa: std.mem.Allocator, symbolizer: *const symbolize.Symbolizer, stack: []const u64) !void {
var line: std.ArrayList(u8) = .empty;
defer line.deinit(gpa);
var position = stack.len;
while (position > 0) {
position -= 1;
// Адрес возврата показывает на инструкцию после `call`. Если вызов
// стоял в функции последним, это уже начало соседней функции.
// Шаг на байт назад возвращает внутрь `call`. У листа в
// `stack[0]` лежит настоящий счётчик команд, его не трогаем.
const address = if (position == 0) stack[0] else stack[position] - 1;
var buffer: [256]u8 = undefined;
if (line.items.len > 0) try line.append(gpa, ';');
try line.appendSlice(gpa, symbolizer.name(&buffer, address));
}
if (folded.counts.getPtr(line.items)) |count| {
count.* += 1;
return;
}
const key = try line.toOwnedSlice(gpa);
errdefer gpa.free(key);
try folded.counts.put(gpa, key, 1);
}
/// Строки по алфавиту: так соседние стеки стоят рядом и вывод стабилен.
pub fn print(folded: *Folded, out: *std.Io.Writer) !void {
const Context = struct {
keys: []const []const u8,
pub fn lessThan(context: @This(), a: usize, b: usize) bool {
return std.mem.order(u8, context.keys[a], context.keys[b]) == .lt;
}
};
folded.counts.sort(Context{ .keys = folded.counts.keys() });
for (folded.counts.keys(), folded.counts.values()) |stack, count| {
try out.print("{s} {d}\n", .{ stack, count });
}
}
};
Одна тонкость спрятана в stack[position] - 1. Адрес возврата показывает на инструкцию после call. Если вызов стоял в функции последним (компилятор так делает с noreturn), адрес возврата это уже первый байт следующей функции, и профиль приписал бы кадр соседу. Шаг на байт назад возвращает адрес внутрь самой инструкции call. У листа, stack[0], лежит настоящий счётчик команд прерванной инструкции, его не трогаем.
Подкоманда: src/prof.zig
//! `zt prof`: сэмплирующий профилировщик.
//!
//! Работа поделена на две половины. Внутри программы живёт сэмплер
//! (`prof/sampler.zig`): по таймеру он записывает цепочки адресов в кольцо,
//! а на выходе сбрасывает их в файл как есть. Снаружи `zt prof` читает этот
//! файл, превращает адреса в имена функций и печатает свёрнутые стеки для
//! flame graph.
//!
//! Попасть внутрь чужой программы помогает тот же `LD_PRELOAD`, что и в
//! `zt ltrace`: библиотека `libzt-prof.so` из `prof/preload.zig`. Свою
//! программу можно профилировать и без неё, вызвав `sampler.start` прямо
//! из кода.
const std = @import("std");
pub const ring = @import("prof/ring.zig");
pub const frames = @import("prof/frames.zig");
pub const sampler = @import("prof/sampler.zig");
pub const dump = @import("prof/dump.zig");
pub const symbolize = @import("prof/symbolize.zig");
pub const folded = @import("prof/folded.zig");
pub const library_name = "libzt-prof.so";
/// Переменная, которой можно указать библиотеку явно.
pub const library_variable = "ZT_PROF_LIB";
/// Куда библиотеке писать дамп и с какой частотой сэмплировать.
pub const output_variable = "ZT_PROF_OUT";
pub const frequency_variable = "ZT_PROF_HZ";
/// `zig build` кладёт программу в `zig-out/bin`, а библиотеку в `zig-out/lib`.
pub fn libraryPath(gpa: std.mem.Allocator, executable_directory: []const u8) ![]u8 {
return std.fs.path.join(gpa, &.{ executable_directory, "..", "lib", library_name });
}
/// Готовит окружение для профилируемой программы.
pub fn prepareEnvironment(
gpa: std.mem.Allocator,
environment: *std.process.Environ.Map,
library: []const u8,
output: []const u8,
hz: u32,
) !void {
// ponytail: чужой LD_PRELOAD затираем, как и в zt ltrace. И потомки
// программы унаследуют переменные и перезапишут дамп: профилируем
// программу из одного процесса.
try environment.put("LD_PRELOAD", library);
try environment.put(output_variable, output);
const text = try std.fmt.allocPrint(gpa, "{d}", .{hz});
defer gpa.free(text);
try environment.put(frequency_variable, text);
}
pub const Stats = struct {
samples: usize,
dropped: u64,
hz: u32,
};
/// Откуда брать содержимое файлов из карты памяти. В программе это диск,
/// в тестах заранее снятые байты.
pub const Loader = struct {
context: *anyopaque,
load: *const fn (context: *anyopaque, arena: std.mem.Allocator, path: []const u8) ?[]const u8,
};
/// Сырой дамп в свёрнутые стеки.
pub fn report(gpa: std.mem.Allocator, out: *std.Io.Writer, text: []const u8, loader: Loader) !Stats {
// Таблицы символов смотрят в байты файлов, поэтому файлы живут в арене
// до конца отчёта.
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
const parsed = try dump.parse(gpa, text);
defer parsed.deinit(gpa);
var symbolizer: symbolize.Symbolizer = .{ .regions = parsed.regions };
defer symbolizer.deinit(gpa);
// Файл, который не открылся или не разобрался, второй раз не пробуем.
var failed: std.StringHashMapUnmanaged(void) = .empty;
defer failed.deinit(gpa);
for (parsed.addresses) |address| {
const path = symbolizer.missingFile(address) orelse continue;
if (failed.contains(path)) continue;
const bytes = loader.load(loader.context, arena, path) orelse {
try failed.put(gpa, path, {});
continue;
};
symbolizer.addFile(gpa, path, bytes) catch try failed.put(gpa, path, {});
}
var result: folded.Folded = .{};
defer result.deinit(gpa);
for (parsed.stacks) |stack| try result.add(gpa, &symbolizer, stack);
try result.print(out);
return .{ .samples = parsed.stacks.len, .dropped = parsed.dropped, .hz = parsed.hz };
}
test "библиотека ищется рядом с программой" {
const gpa = std.testing.allocator;
const path = try libraryPath(gpa, "/opt/zt/bin");
defer gpa.free(path);
try std.testing.expectEqualStrings("/opt/zt/bin/../lib/libzt-prof.so", path);
}
test {
_ = ring;
_ = frames;
_ = dump;
_ = symbolize;
_ = folded;
}
Loader это пара «контекст и функция»: программа читает файлы с диска, а тест подставляет байты, вшитые через @embedFile. Так символизация проверяется без файловой системы и без Linux.
Подключение
В main.zig в текст usage добавляются строка zt prof [--hz=N] [--raw=файл] [-o файл] <программа> [аргументы...] и блок флагов, в цепочку подкоманд новая ветка, а в конец файла сама команда и загрузчик файлов с диска:
} else if (std.mem.eql(u8, command, "prof")) {
try cmdProf(init, gpa, out, rest);
fn cmdProf(
init: std.process.Init,
gpa: std.mem.Allocator,
out: *std.Io.Writer,
args: []const []const u8,
) !void {
// Как и ltrace, профилировщик попадает в программу через LD_PRELOAD.
if (builtin.os.tag != .linux) return fail(out, "zt prof: работает только на Linux\n");
var hz: u32 = zt.prof.sampler.default_hz;
var raw_path: ?[]const u8 = null;
var output_path: ?[]const u8 = null;
var index: usize = 0;
while (index < args.len and std.mem.startsWith(u8, args[index], "-")) : (index += 1) {
const arg = args[index];
if (std.mem.startsWith(u8, arg, "--hz=")) {
hz = std.fmt.parseInt(u32, arg["--hz=".len..], 10) catch 0;
if (hz == 0 or hz > 10_000) return fail(out, "zt prof: --hz ждёт число от 1 до 10000\n");
} else if (std.mem.startsWith(u8, arg, "--raw=")) {
raw_path = arg["--raw=".len..];
} else if (std.mem.eql(u8, arg, "-o")) {
index += 1;
if (index == args.len) return fail(out, "zt prof: после -o нужно имя файла\n");
output_path = args[index];
} else {
try out.print("zt prof: неизвестный флаг {s}\n", .{arg});
return error.BadUsage;
}
}
const program = args[index..];
if (program.len == 0) return fail(out, "zt prof: нужна программа для запуска\n");
const library = init.environ_map.get(zt.prof.library_variable) orelse
try zt.prof.libraryPath(gpa, try std.process.executableDirPathAlloc(init.io, gpa));
std.Io.Dir.cwd().access(init.io, library, .{}) catch {
try out.print("zt prof: нет библиотеки {s}, собери её через zig build\n", .{library});
return error.BadUsage;
};
const dump_path = raw_path orelse
try std.fmt.allocPrint(gpa, "/tmp/zt-prof-{d}.raw", .{std.os.linux.getpid()});
defer if (raw_path == null) std.Io.Dir.cwd().deleteFile(init.io, dump_path) catch {};
// Программе достаётся копия окружения: своё не портим.
var environment = try init.environ_map.clone(gpa);
try zt.prof.prepareEnvironment(gpa, &environment, library, dump_path, hz);
try out.flush();
var child = std.process.spawn(init.io, .{ .argv = program, .environ_map = &environment }) catch |err| {
try out.print("zt prof: не удалось запустить {s} ({t})\n", .{ program[0], err });
return error.BadUsage;
};
const term = try child.wait(init.io);
const text = std.Io.Dir.cwd().readFileAlloc(init.io, dump_path, gpa, file_limit) catch {
try out.print("zt prof: программа не оставила дампа {s}: она статическая, вышла через _exit или убита\n", .{dump_path});
return error.BadUsage;
};
var disk: DiskLoader = .{ .io = init.io };
const loader: zt.prof.Loader = .{ .context = &disk, .load = DiskLoader.load };
const stats = if (output_path) |path| stats: {
var file = try std.Io.Dir.cwd().createFile(init.io, path, .{});
defer file.close(init.io);
var file_buf: [4096]u8 = undefined;
var writer = file.writerStreaming(init.io, &file_buf);
const stats = try zt.prof.report(gpa, &writer.interface, text, loader);
try writer.interface.flush();
break :stats stats;
} else try zt.prof.report(gpa, out, text, loader);
try out.flush();
var note_buf: [256]u8 = undefined;
var stderr = std.Io.File.stderr().writerStreaming(init.io, ¬e_buf);
try stderr.interface.print("zt prof: сэмплов {d}, частота {d} Гц, затёрто {d}\n", .{ stats.samples, stats.hz, stats.dropped });
try stderr.interface.flush();
switch (term) {
.exited => |code| if (code != 0) std.process.exit(code),
else => std.process.exit(1),
}
}
/// Читает файлы из карты памяти с диска. Библиотека, которой уже нет на
/// месте, просто останется без имён функций.
const DiskLoader = struct {
io: std.Io,
fn load(context: *anyopaque, arena: std.mem.Allocator, path: []const u8) ?[]const u8 {
const loader: *DiskLoader = @ptrCast(@alignCast(context));
return std.Io.Dir.cwd().readFileAlloc(loader.io, path, arena, file_limit) catch null;
}
};
Порядок в cmdProf: разобрать флаги, найти библиотеку рядом с собой (zig-out/bin/../lib) или по ZT_PROF_LIB, собрать программе окружение с LD_PRELOAD, путём к дампу и частотой, запустить через std.process.spawn из прошлого урока, дождаться и прочитать дамп. Если дампа нет, программа либо статическая (загрузчику нечего подгружать, LD_PRELOAD не работает), либо ушла мимо .fini_array. Код возврата программы zt prof отдаёт как свой.
В src/root.zig одна строка: pub const prof = @import("prof.zig");. В build.zig внутри ветки для Linux, рядом с libzt-ltrace.so, появляются библиотека-сэмплер и подопытная программа:
// Библиотека-сэмплер для `zt prof`. Сам сэмплер живёт в модуле `zt`,
// она добавляет к нему только вход и выход через .init_array и
// .fini_array. libc нужна ради getenv.
const sampler = b.addLibrary(.{
.name = "zt-prof",
.linkage = .dynamic,
.root_module = b.createModule(.{
.root_source_file = b.path("src/prof/preload.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
.imports = &.{.{ .name = "zt", .module = zt }},
}),
});
b.installArtifact(sampler);
// Цель для сквозного теста `zt prof`. Всегда Debug: это -O0, без
// встраивания и с кадром у каждой функции.
const spin = b.addExecutable(.{
.name = "spin",
.root_module = b.createModule(.{
.target = target,
.optimize = .Debug,
.link_libc = true,
.sanitize_c = .off,
}),
});
spin.root_module.addCSourceFile(.{
.file = b.path("fixtures/prof/spin.c"),
// Второй флаг нужен листовым функциям: без него на aarch64 лист
// не заводит кадра, и тот, кто его вызвал, выпадает из стека.
.flags = &.{ "-fno-omit-frame-pointer", "-mno-omit-leaf-frame-pointer" },
});
const install_spin = b.addInstallArtifact(spin, .{ .dest_dir = .{ .override = .{ .custom = "fixtures" } } });
b.getInstallStep().dependOn(&install_spin.step);
Библиотека собирается из preload.zig, а сам сэмплер берёт из модуля zt через imports: один и тот же sampler.zig работает и в библиотеке, и в тестах. Номер шага попадает в project_steps, тестам шага нужен путь к spin (строка paths.addOption([]const u8, "spin", ...)), и условие для сквозных тестов становится number == 46 or number == 47 or number == 49.
Подопытная программа написана на C, чтобы её код не зависел от того, как Zig раскладывает кадры:
/* Цель для zt prof: три четверти времени в hot_a, четверть в hot_b.
* Собирается с -O0, -fno-omit-frame-pointer и -mno-omit-leaf-frame-pointer,
* чтобы кадр был у каждой функции, включая листовые, и чтобы компилятор
* не встроил одну функцию в другую. */
#include <stdio.h>
static volatile unsigned long sink;
__attribute__((noinline)) void hot_a(unsigned long rounds) {
for (unsigned long i = 0; i < rounds; i++) sink += i * 3;
}
__attribute__((noinline)) void hot_b(unsigned long rounds) {
for (unsigned long i = 0; i < rounds; i++) sink ^= i;
}
__attribute__((noinline)) void work(unsigned long rounds) {
hot_a(3 * rounds);
hot_b(rounds);
}
int main(void) {
for (int step = 0; step < 40; step++) work(2000000);
printf("sink = %lu\n", sink % 1000);
return 0;
}
hot_a делает втрое больше оборотов, чем hot_b, и профиль обязан это показать. Флаги сборки не случайны. -O0 и noinline сохраняют каждую функцию отдельной. -fno-omit-frame-pointer оставляет кадр, а -mno-omit-leaf-frame-pointer нужен листьям: без него на aarch64 листовая функция не заводит кадра, её регистр x29 ещё показывает на кадр вызвавшей, и из стека выпадает work.
Тесты шага
//! Шаг 49: `zt prof`, сэмплирующий профилировщик.
//!
//! Кольцо, обход кадров, формат дампа, символизация и свёртка стеков это
//! чистый код. Он проверяется везде, в том числе на настоящем дампе: файлы в
//! `tests/prof/` сняты с программы `fixtures/prof/spin.c` под x86-64 Linux
//! (Debian 12, glibc 2.36, контейнер под Rosetta, отсюда её области в карте).
//!
//! Живых тестов два. Сэмплер профилирует сам тестовый процесс: это работает
//! везде, где есть `setitimer` и `SIGPROF`, то есть и на Linux, и на macOS.
//! Сквозной `zt prof spin` идёт через `LD_PRELOAD`, поэтому только на Linux.
const std = @import("std");
const builtin = @import("builtin");
const paths = @import("paths");
const zt = @import("zt");
const prof = zt.prof;
const spin_elf = @embedFile("prof/spin");
const spin_raw = @embedFile("prof/spin.raw.txt");
test "кольцо: последние сэмплы вытесняют первые" {
var ring: prof.ring.Ring(3) = .{};
for (0..5) |i| ring.push(&.{ 0x1000 + i, 0x2000 });
try std.testing.expectEqual(3, ring.count());
try std.testing.expectEqual(2, ring.dropped());
try std.testing.expectEqualSlices(u64, &.{ 0x1002, 0x2000 }, ring.get(0).stack());
try std.testing.expectEqualSlices(u64, &.{ 0x1004, 0x2000 }, ring.get(2).stack());
}
test "обход кадров по поддельному стеку" {
var stack = [_]usize{0} ** 12;
const base = @intFromPtr(&stack);
const word = @sizeOf(usize);
stack[2] = base + 6 * word; // кадр листа: сохранённый указатель кадра
stack[3] = 0xaaa; // и адрес возврата
stack[6] = 0; // кадр main: выше никого
stack[7] = 0xbbb;
var out: [prof.ring.max_depth]u64 = undefined;
const depth = prof.frames.walk(0x111, base + 2 * word, base, base + stack.len * word, &out);
try std.testing.expectEqualSlices(u64, &.{ 0x111, 0xaaa, 0xbbb }, out[0..depth]);
}
test "таблица функций настоящего ELF" {
const gpa = std.testing.allocator;
const table = try prof.symbolize.FunctionTable.init(gpa, spin_elf);
defer table.deinit(gpa);
// Адреса сверены с `nm -S spin` из GNU binutils:
// 0000000001001530 0000000000000040 T hot_a
// 0000000001001570 000000000000003f T hot_b
try std.testing.expectEqualStrings("hot_a", table.find(0x1001530).?);
try std.testing.expectEqualStrings("hot_a", table.find(0x100156f).?);
try std.testing.expectEqualStrings("hot_b", table.find(0x1001570).?);
try std.testing.expectEqualStrings("work", table.find(0x10015b0 + 0x24).?);
// Сразу за концом work до начала main лежит выравнивание: там никого.
try std.testing.expectEqual(@as(?[]const u8, null), table.find(0x10015b0 + 0x25));
// Смещение в файле в адрес: код лежит в сегменте с offset 0x530.
try std.testing.expectEqual(0x1001540, table.virtualAddress(0x540).?);
}
/// Отдаёт вшитую программу вместо чтения с диска. Остальных файлов из
/// карты (libc, загрузчик) на машине с тестами нет: они останутся без имён.
fn loadEmbedded(_: *anyopaque, _: std.mem.Allocator, path: []const u8) ?[]const u8 {
return if (std.mem.eql(u8, path, "/tmp/spin")) spin_elf else null;
}
test "настоящий дамп в свёрнутые стеки" {
const gpa = std.testing.allocator;
var buffer: [1024]u8 = undefined;
var out: std.Io.Writer = .fixed(&buffer);
var unused: u8 = 0;
const stats = try prof.report(gpa, &out, spin_raw, .{ .context = &unused, .load = loadEmbedded });
try std.testing.expectEqual(198, stats.samples);
try std.testing.expectEqual(997, stats.hz);
try std.testing.expectEqual(0, stats.dropped);
// hot_a делает втрое больше оборотов, чем hot_b, и сэмплов на неё
// пришлось втрое больше. Два сэмпла попали в libc до входа в main:
// с настоящей libc на диске один из них назван, см. spin.folded.txt.
try std.testing.expectEqualStrings(
\\[libc.so.6] 2
\\[libc.so.6];main;work;hot_a 146
\\[libc.so.6];main;work;hot_b 50
\\
, out.buffered());
}
test "адрес возврата в самом конце функции остаётся в ней" {
const gpa = std.testing.allocator;
const text = prof.dump.magic ++
\\maps
\\01001000-01002000 r-xp 00000000 00:102 1 /tmp/spin
\\samples
\\1001540 10015d5
\\
;
// 0x10015d5 это первый байт за work (0x10015b0 + 0x25). Так выглядел бы
// адрес возврата, стой вызов в work последней инструкцией. Без шага
// назад он не принадлежал бы никому.
var buffer: [256]u8 = undefined;
var out: std.Io.Writer = .fixed(&buffer);
var unused: u8 = 0;
_ = try prof.report(gpa, &out, text, .{ .context = &unused, .load = loadEmbedded });
try std.testing.expectEqualStrings("work;hot_a 1\n", out.buffered());
}
test "окружение профилируемой программы" {
const gpa = std.testing.allocator;
var environment: std.process.Environ.Map = .init(gpa);
defer environment.deinit();
try prof.prepareEnvironment(gpa, &environment, "/opt/zt/lib/libzt-prof.so", "/tmp/out.raw", 250);
try std.testing.expectEqualStrings("/opt/zt/lib/libzt-prof.so", environment.get("LD_PRELOAD").?);
try std.testing.expectEqualStrings("/tmp/out.raw", environment.get(prof.output_variable).?);
try std.testing.expectEqualStrings("250", environment.get(prof.frequency_variable).?);
}
var sink: u64 = 0;
/// Лист: крутится, пока сэмплер не наберёт нужное число сэмплов.
noinline fn burn(wanted: u64) void {
const samples = prof.sampler.samples();
var i: u64 = 0;
// Предел оборотов на случай, если таймер не работает: тест упадёт, а не зависнет.
while (samples.written < wanted and i < 20_000_000_000) : (i += 1) {
sink = sink *% 31 +% i;
}
}
noinline fn outer(wanted: u64) void {
burn(wanted);
// Работа после вызова: без неё компилятор вправе заменить call на jmp,
// и кадр outer исчезнет из стека.
sink +%= 1;
}
fn inside(address: u64, function: *const anyopaque) bool {
// Размера функции тест не знает. Обе функции крошечные, 512 байт хватит с запасом.
const start = @intFromPtr(function);
return address >= start and address < start + 512;
}
test "сэмплер профилирует сам тестовый процесс" {
if (builtin.os.tag != .linux and builtin.os.tag != .macos) return error.SkipZigTest;
const wanted = 60;
try prof.sampler.start(prof.sampler.default_hz);
outer(wanted);
prof.sampler.stop();
const samples = prof.sampler.samples();
try std.testing.expect(samples.count() >= wanted);
// Почти всё время процесс провёл в burn, вызванной из outer.
var matched: usize = 0;
for (0..samples.count()) |index| {
const stack = samples.get(index).stack();
if (stack.len >= 2 and inside(stack[0], &burn) and inside(stack[1], &outer)) matched += 1;
}
try std.testing.expect(matched * 2 > samples.count());
}
test "zt prof: сквозной прогон через LD_PRELOAD" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
const gpa = std.testing.allocator;
const result = try std.process.run(gpa, std.testing.io, .{ .argv = &.{ paths.zt, "prof", paths.spin } });
defer gpa.free(result.stdout);
defer gpa.free(result.stderr);
try std.testing.expectEqual(std.process.Child.Term{ .exited = 0 }, result.term);
// Сначала вывод самой программы, потом свёрнутые стеки.
try std.testing.expect(std.mem.startsWith(u8, result.stdout, "sink = 0\n"));
const hot_a = countOf(result.stdout, "main;work;hot_a ") orelse return error.NoHotA;
const hot_b = countOf(result.stdout, "main;work;hot_b ") orelse return error.NoHotB;
// Отношение 3 к 1 с запасом на шум: соседи по машине крадут время неровно.
try std.testing.expect(hot_a > hot_b);
try std.testing.expect(hot_a + hot_b >= 20);
try std.testing.expect(std.mem.indexOf(u8, result.stderr, "zt prof: ") != null);
}
/// Число в конце строки, в которой встретился стек с таким хвостом.
fn countOf(folded: []const u8, tail: []const u8) ?u64 {
var lines = std.mem.tokenizeScalar(u8, folded, '\n');
while (lines.next()) |line| {
const at = std.mem.indexOf(u8, line, tail) orelse continue;
return std.fmt.parseInt(u64, line[at + tail.len ..], 10) catch null;
}
return null;
}
В tests/prof/ лежат три файла, снятые один раз под x86-64 Linux (Debian 12, glibc 2.36): сама программа spin, её сырой дамп на 198 сэмплов и свёрнутые стеки, которые дал zt prof с настоящей libc на диске. Тест «настоящий дамп» символизирует тот же дамп, но libc у него нет, поэтому все её адреса названы [libc.so.6]. Числа 146 и 50 это отношение три к одному с точностью до шума.
Тест сэмплера профилирует собственный процесс: запускает таймер, крутит burn внутри outer, пока кольцо не наберёт шестьдесят снимков, и проверяет, что в большинстве из них лист внутри burn, а над ним outer. Работа после вызова в outer нужна, чтобы компилятор не превратил call в jmp: при таком хвостовом вызове кадр outer исчез бы из стека. Сквозной тест запускает настоящий zt prof spin.
$ zig build test -Dstep=49 --summary all # macOS, Apple M4 Max
Build Summary: 7/7 steps succeeded; 7/8 tests passed (1 skipped)
$ zig build test -Dstep=49 --summary all # контейнер linux/arm64
Build Summary: 13/13 steps succeeded; 8/8 tests passed
На macOS пропущен сквозной тест: LD_PRELOAD там не работает. Сэмплер на Mac проходит, setitimer и SIGPROF у macOS те же.
Прогон
Снято в контейнере Debian 12 (glibc 2.36) на ядре Linux 7.0 aarch64, без эмуляции:
$ zig build
$ ./zig-out/bin/zt prof ./zig-out/fixtures/spin
sink = 0
_start;__libc_start_main;[libc.so.6];main;work;hot_a 160
_start;__libc_start_main;[libc.so.6];main;work;hot_b 49
zt prof: сэмплов 209, частота 997 Гц, затёрто 0
Читай снизу вверх по стеку, то есть слева направо по строке. _start из самой программы, __libc_start_main из libc, её .dynsym это имя экспортирует. Дальше [libc.so.6]: это внутренняя __libc_start_call_main, которой в срезанной таблице нет. Потом main, work и листья. Отношение 160 к 49 близко к трём, как и обещал исходник. А 209 сэмплов при 997 Гц это 0,21 секунды процессорного времени: time ./spin в том же контейнере показывает user 0m0.205s. Профилировщик не соврал.
Сырой дамп видно флагом --raw:
$ ./zig-out/bin/zt prof --raw=/tmp/spin.raw -o /tmp/spin.folded ./zig-out/fixtures/spin
sink = 0
zt prof: сэмплов 198, частота 997 Гц, затёрто 0
$ grep -A3 '^samples' /tmp/spin.raw
samples
10105d0 101069c 10106e4 ffff95b07744 ffff95b07818 10105b0
10105d0 101069c 10106e4 ffff95b07744 ffff95b07818 10105b0
1010638 10106a4 10106e4 ffff95b07744 ffff95b07818 10105b0
$ grep 'libc.so.6$' /tmp/spin.raw
ffff95ae0000-ffff95c6c000 r-xp 00000000 00:5f 9081551 /usr/lib/aarch64-linux-gnu/libc.so.6
ffff95c6c000-ffff95c7c000 ---p 0018c000 00:5f 9081551 /usr/lib/aarch64-linux-gnu/libc.so.6
ffff95c7c000-ffff95c80000 r--p 0018c000 00:5f 9081551 /usr/lib/aarch64-linux-gnu/libc.so.6
ffff95c80000-ffff95c82000 rw-p 00190000 00:5f 9081551 /usr/lib/aarch64-linux-gnu/libc.so.6
(пробелы перед путями в карте сокращены.) Возьми ffff95b07744 и проделай символизацию руками. Адрес попал в первую область libc, её начало ffff95ae0000, смещение области ноль, значит, смещение в файле 0x27744. Первый PT_LOAD этой libc начинается с нулевого смещения и нулевого адреса (objdump -p показывает LOAD off 0x0 vaddr 0x0), так что в таблице символов искать надо 0x27744. В .dynsym ближайшие соседи это __libc_init_first по 0x276c0 длиной 4 байта и __libc_start_main по 0x27780. Адрес попал в щель между ними, в функцию, которую libc не экспортирует: в glibc 2.34 и новее это __libc_start_call_main, та, что зовёт main. Отсюда [libc.so.6]. Соседний адрес ffff95b07818 даёт 0x27818, это внутри __libc_start_main, и имя нашлось.
Частота это компромисс между точностью и ценой. На 200 Гц той же программе досталось сорок снимков:
$ ./zig-out/bin/zt prof --hz=200 ./zig-out/fixtures/spin
sink = 0
zt prof: сэмплов 40, частота 200 Гц, затёрто 0
_start;__libc_start_main;[libc.so.6];main;work;hot_a 19
_start;__libc_start_main;[libc.so.6];main;work;hot_b 21
hot_b вдруг обогнала hot_a, хотя работы у неё втрое меньше. Сорок снимков это слишком мало: погрешность счёта порядка корня из числа снимков, то есть плюс-минус шесть на каждую функцию. Профиль короткой программы надо снимать на высокой частоте или много раз.
Проект zbox: лимит времени
Второй долг прошлого урока. zbox run sleep 1000000 ждёт миллион секунд, потому что родитель умеет только спать в wait4. Нам нужен сторож: будильник, который по истечении лимита убивает ребёнка и всех, кого он успел породить, и ожидание, которое просыпается и от смерти ребёнка, и от будильника. Всё это уже было в уроке по отдельности, осталось собрать.
$ zbox run --time-ms 200 sleep 5
{"exit_code":null,"signal":9,"timed_out":true,"cpu_user_ms":0,"cpu_sys_ms":0,"max_rss_kb":1248,"wall_ms":204}
План родителя:
- До
forkзаблокироватьSIGCHLDиSIGALRMи поставить обработчики. Короткая программа вродеtrueможет умереть раньше, чемforkвернётся в родителя, иSIGCHLDдолжен дождаться нас вpending. Это гонка из раздела про задания, и лечение то же. - В ребёнке до
execvpуйти в свою группу и вернуть прежнюю маску: маска переживаетexecve, и чужая программа иначе стартовала бы с заблокированными сигналами. - В родителе после
forkтоже перевести ребёнка в группу (кто успеет первым, неизвестно, этоgroups.zigиз начала урока) и завести будильник один раз на весь лимит. - Ждать в
sigsuspend, пока обработчикSIGCHLDне поднимет флаг. Если раньше прозвенит будильник, его обработчик пошлётSIGKILLгруппе, ребёнок умрёт, иSIGCHLDвсё равно придёт. - Убрать за собой: выключить таймер, вернуть маску, добить брошенных внуков и забрать ребёнка через
wait4.
Сторож: src/box/watchdog.zig
//! Сторож: сигналы SIGCHLD и SIGALRM, маска, таймер и убийство группы.
//!
//! Порядок работы родителя:
//! 1. `arm` блокирует оба сигнала и ставит обработчики. Это до fork.
//! 2. После fork `startTimer` заводит будильник на весь лимит времени.
//! 3. `waitForChild` спит в `sigsuspend`, пока не придёт SIGCHLD.
//! 4. `disarm` выключает будильник и возвращает старую маску.
//!
//! Сигналы заблокированы всё время, кроме сна внутри `sigsuspend`. Поэтому
//! обработчики выполняются только там, и гонки с основным кодом нет.
const std = @import("std");
const posix = std.posix;
const c = std.c;
// Этих двух функций нет ни в std.posix, ни в std.c, объявляем сами.
extern "c" fn setitimer(which: c_int, new: *const Itimerval, old: ?*Itimerval) c_int;
extern "c" fn sigsuspend(mask: *const posix.sigset_t) c_int;
/// `struct itimerval` из `<sys/time.h>`: период повтора и время до первого срабатывания.
const Itimerval = extern struct {
interval: c.timeval,
value: c.timeval,
};
const ITIMER_REAL = 0;
// Обработчик и основной код общаются только через эти переменные.
// Атомарная запись слова входит в список того, что обработчику можно.
var child_exited: std.atomic.Value(bool) = .init(false);
var alarm_fired: std.atomic.Value(bool) = .init(false);
var group: std.atomic.Value(c.pid_t) = .init(0);
fn onChild(_: posix.SIG) callconv(.c) void {
child_exited.store(true, .seq_cst);
}
fn onAlarm(_: posix.SIG) callconv(.c) void {
alarm_fired.store(true, .seq_cst);
killGroup();
}
/// kill async-signal-safe, его можно звать из обработчика. Отрицательный pid
/// значит всю группу процессов: внуки умирают вместе с ребёнком.
/// Ошибку не смотрим: группа могла уже опустеть.
pub fn killGroup() void {
const pgid = group.load(.seq_cst);
if (pgid > 0) _ = c.kill(-pgid, .KILL);
}
/// Блокирует SIGCHLD и SIGALRM и ставит обработчики. Возвращает прежнюю
/// маску: она нужна ребёнку, sigsuspend и `disarm`.
pub fn arm() posix.sigset_t {
child_exited.store(false, .seq_cst);
alarm_fired.store(false, .seq_cst);
group.store(0, .seq_cst);
var blocked = posix.sigemptyset();
posix.sigaddset(&blocked, .CHLD);
posix.sigaddset(&blocked, .ALRM);
var old_mask: posix.sigset_t = undefined;
posix.sigprocmask(posix.SIG.BLOCK, &blocked, &old_mask);
// В маске обработчика оба сигнала: пока работает один, второй подождёт.
// NOCLDSTOP: SIGCHLD нужен только о смерти ребёнка, не об остановке.
posix.sigaction(.CHLD, &.{
.handler = .{ .handler = onChild },
.mask = blocked,
.flags = posix.SA.NOCLDSTOP,
}, null);
posix.sigaction(.ALRM, &.{
.handler = .{ .handler = onAlarm },
.mask = blocked,
.flags = 0,
}, null);
return old_mask;
}
/// Зовётся в ребёнке между fork и execvp. Только async-signal-safe вызовы.
pub fn childSetup(old_mask: *const posix.sigset_t) void {
// Своя группа с pgid, равным pid ребёнка.
_ = c.setpgid(0, 0);
// Маска сигналов переживает execve. Не вернём её, и чужая программа
// стартует с заблокированными SIGCHLD и SIGALRM.
posix.sigprocmask(posix.SIG.SETMASK, old_mask, null);
}
/// Зовётся в родителе сразу после fork.
pub fn startTimer(child: c.pid_t, time_ms: ?u32) void {
// Тот же setpgid, что и в ребёнке. Кто из двоих успеет первым, неизвестно,
// а группа нужна уже сейчас. Второй вызов безвреден. После execve ядро
// ответит родителю EACCES, но к этому моменту ребёнок всё сделал сам.
_ = c.setpgid(child, child);
group.store(child, .seq_cst);
const ms = time_ms orelse return;
// Один удар на весь лимит: interval нулевой, повторов не будет.
const timer: Itimerval = .{
.interval = .{ .sec = 0, .usec = 0 },
.value = .{ .sec = ms / 1000, .usec = @intCast((ms % 1000) * std.time.us_per_ms) },
};
_ = setitimer(ITIMER_REAL, &timer, null);
}
/// Спит, пока не придёт SIGCHLD. sigsuspend атомарно ставит старую маску
/// и засыпает, а по возвращении возвращает нашу. Между проверкой флага и
/// сном нет окна, в которое сигнал мог бы проскочить незамеченным.
pub fn waitForChild(old_mask: *const posix.sigset_t) void {
while (!child_exited.load(.seq_cst)) {
_ = sigsuspend(old_mask);
// Здесь сигналы снова заблокированы, обработчик нас не перебьёт.
}
}
/// Выключает таймер и возвращает маску. Сообщает, срабатывал ли будильник.
pub fn disarm(old_mask: *const posix.sigset_t) bool {
const off: Itimerval = .{
.interval = .{ .sec = 0, .usec = 0 },
.value = .{ .sec = 0, .usec = 0 },
};
_ = setitimer(ITIMER_REAL, &off, null);
posix.sigprocmask(posix.SIG.SETMASK, old_mask, null);
return alarm_fired.load(.seq_cst);
}
Шапка файла формулирует инвариант, на котором держится вся конструкция: сигналы заблокированы всё время, кроме сна внутри sigsuspend. Обработчики поэтому выполняются в известных местах (во сне и в момент, когда маску снимают насовсем) и не могут вклиниться посреди run. Это правило четыре, доведённое до предела: вместо того чтобы закрывать сигналы вокруг каждой общей структуры, мы открываем их только на время сна.
Обработчики соблюдают остальные правила. Они делают по одному-два действия. Общие переменные это std.atomic.Value. kill в списке async-signal-safe. errno обработчик onAlarm может испортить через kill, но это не страшно ровно благодаря инварианту. Обработчик выполняется либо внутри sigsuspend, либо в тот миг, когда disarm снимает маску и ядро доставляет то, что ждало в pending. sigsuspend всегда возвращает EINTR, и его errno никто не читает, а sigprocmask успешна, и её errno тоже никто не смотрит. В маске каждого обработчика оба сигнала: пока onAlarm посылает SIGKILL, обработчик SIGCHLD не перебьёт его.
SA_NOCLDSTOP здесь для порядка: ребёнка никто не останавливает, но если кто-то пошлёт ему SIGSTOP, родитель не проснётся впустую. Флага SA_RESTART нет ни у одного обработчика, и это тоже осознанно: прерывать нечего, кроме sigsuspend, а его ядро не перезапускает никогда.
setitimer объявлен через extern "c" со своей структурой Itimerval и полями timeval: это ответ на ловушку из раздела про таймеры. Будильник одноразовый: interval нулевой. Лимит 200 мс становится .sec = 0, .usec = 200_000.
Запуск: src/box/run.zig
//! Запуск чужой программы: `fork`, `execvp`, `wait4`.
//! Сигналы и лимит времени живут рядом, в `watchdog.zig`.
const std = @import("std");
const c = std.c;
const report = @import("report.zig");
const watchdog = @import("watchdog.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, time_ms: ?u32) 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();
// Сигналы блокируем до fork. Иначе короткая программа вроде `true`
// завершится раньше, чем родитель приготовится ждать, SIGCHLD придёт
// в пустоту, и родитель уснёт навсегда.
const old_mask = watchdog.arm();
const pid = c.fork();
if (pid < 0) {
_ = watchdog.disarm(&old_mask);
return error.ForkFailed;
}
if (pid == 0) {
watchdog.childSetup(&old_mask);
// Мы в ребёнке. Успешный execvp не возвращается: образ процесса
// заменён, этого кода в памяти больше нет.
_ = execvp(c_argv[0].?, c_argv.ptr);
childFail();
}
// Мы в родителе.
watchdog.startTimer(pid, time_ms);
watchdog.waitForChild(&old_mask);
const alarm_fired = watchdog.disarm(&old_mask);
// Ребёнок завершился, но мог оставить в группе фоновых внуков. Пока он
// зомби, его pid и pgid заняты, так что чужую группу мы не заденем.
watchdog.killGroup();
// 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;
// Будильник мог прозвенеть в тот же миг, когда программа закончила сама.
// Лимит превышен, только если её действительно убил наш SIGKILL.
outcome.timed_out = alarm_fired and outcome.signal == @intFromEnum(std.posix.SIG.KILL);
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 получила третий аргумент и пять строк про сторожа, а wait4 поменял роль. Раньше он ждал, теперь только забирает зомби: к моменту вызова SIGCHLD уже пришёл. Цикл с EINTR остался, он ничего не стоит.
Две строки заслуживают отдельного взгляда.
watchdog.killGroup() после waitForChild стреляет, даже когда лимит не превышен. Программа sh -c 'sleep 30 & echo $!' выходит сразу, а её sleep остаётся жить в фоне, в той же группе. Песочница, которая оставляет после себя процессы, плохая песочница. Безопасно ли слать SIGKILL группе, когда ребёнок уже мёртв? Да, пока мы не позвали wait4: ребёнок зомби, его pid занят, и номер группы, равный этому pid, ядро никому другому не выдаст. Поэтому killGroup стоит строго до wait4.
timed_out вычисляется из двух условий. Будильник мог прозвенеть в ту же миллисекунду, когда программа закончила сама с кодом 0. Флаг alarm_fired будет поднят, но программа уложилась, и называть это превышением нечестно. Превышение это будильник и смерть от SIGKILL.
Командная строка, отчёт, точка входа
args.zig учится флагу --time-ms. Флаги zbox стоят строго до имени программы, всё после него принадлежит ей:
//! Разбор командной строки `zbox run`. Чистая функция: ни процессов,
//! ни вывода, поэтому проверяется обычными тестами.
const std = @import("std");
pub const Command = struct {
/// Лимит времени по настенным часам, миллисекунды. null значит без лимита.
time_ms: ?u32 = null,
/// Программа и её аргументы: то, что уйдёт в `execvp`.
argv: []const []const u8,
};
pub const Error = error{BadUsage};
/// `args` это командная строка без имени самого zbox:
/// `run [--time-ms N] <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;
var command: Command = .{ .argv = &.{} };
var rest = args[1..];
// Флаги zbox стоят строго до программы. Всё после её имени принадлежит
// ей самой: `zbox run ls --time-ms` передаст `--time-ms` в ls.
while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) {
if (rest.len < 2) return error.BadUsage;
if (!std.mem.eql(u8, rest[0], "--time-ms")) return error.BadUsage;
const ms = std.fmt.parseInt(u32, rest[1], 10) catch return error.BadUsage;
// Нулевой лимит выключил бы таймер, а не запретил бы работу.
if (ms == 0) return error.BadUsage;
command.time_ms = ms;
rest = rest[2..];
}
if (rest.len == 0) return error.BadUsage;
command.argv = rest;
return command;
}
Нулевой лимит запрещён не из вредности: setitimer с нулевым it_value выключает таймер, и --time-ms 0 значил бы «без лимита».
В report.zig одно новое поле и одна правка в writeJson:
//! Итог запуска и его печать одной строкой 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,
/// Программа не уложилась в лимит времени, и zbox убил её группу.
timed_out: bool = false,
/// Время процессора в режиме пользователя и в режиме ядра.
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(",\"timed_out\":{},\"cpu_user_ms\":{d},\"cpu_sys_ms\":{d},\"max_rss_kb\":{d},\"wall_ms\":{d}}}\n", .{
outcome.timed_out,
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");
}
}
В main.zig поменялись текст usage и вызов run:
//! Точка входа `zbox`. Здесь только командная строка и печать ответа:
//! вся работа живёт в `src/box`, чтобы её можно было проверить тестами.
const std = @import("std");
const zbox = @import("zbox");
const usage =
\\zbox, своя песочница
\\
\\Использование:
\\ zbox run [--time-ms N] <программа> [аргументы...]
\\
\\Флаги:
\\ --time-ms N убить программу и всю её группу процессов через N миллисекунд
\\
\\Ответ: одна строка 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, command.time_ms);
try zbox.report.writeJson(out, outcome);
try out.flush();
}
В src/root.zig добавь pub const watchdog = @import("box/watchdog.zig");, в build.zig номер шага: const project_steps = [_]u8{ 48, 49 };. В tests/support.zig структура ответа получает поле со значением по умолчанию, чтобы тесты шага 48 не заметили разницы:
wall_ms: u64,
/// Появилось в шаге 49. Значение по умолчанию оставляет ранние тесты зелёными.
timed_out: bool = false,
Тесты шага
//! Шаг 49: лимит времени, группа процессов, SIGCHLD и SIGALRM без гонок.
const std = @import("std");
const zbox = @import("zbox");
const support = @import("support.zig");
const testing = std.testing;
/// Машина под тестами бывает загружена, поэтому допуски щедрые: проверяем,
/// что программу убили задолго до её собственного конца, а не точность таймера.
const generous_ms = 4000;
test "parse: --time-ms перед программой" {
const command = try zbox.args.parse(&.{ "run", "--time-ms", "200", "sleep", "5" });
try testing.expectEqual(200, command.time_ms);
try testing.expectEqual(2, command.argv.len);
try testing.expectEqualStrings("sleep", command.argv[0]);
}
test "parse: без флага лимита нет" {
const command = try zbox.args.parse(&.{ "run", "true" });
try testing.expectEqual(null, command.time_ms);
}
test "parse: флаг после программы принадлежит программе" {
const command = try zbox.args.parse(&.{ "run", "echo", "--time-ms", "5" });
try testing.expectEqual(null, command.time_ms);
try testing.expectEqual(3, command.argv.len);
}
test "parse: плохие значения лимита" {
try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "run", "--time-ms" }));
try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "run", "--time-ms", "200" }));
try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "run", "--time-ms", "abc", "true" }));
try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "run", "--time-ms", "0", "true" }));
try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "run", "--memory", "1", "true" }));
}
test "writeJson печатает timed_out" {
var buf: [1024]u8 = undefined;
var out: std.Io.Writer = .fixed(&buf);
try zbox.report.writeJson(&out, .{ .signal = 9, .timed_out = true });
try testing.expect(std.mem.indexOf(u8, out.buffered(), "\"signal\":9,\"timed_out\":true,") != null);
}
test "sleep 5 с лимитом 200 мс убит по таймеру" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const run = try support.zbox(arena_state.allocator(), &.{ "run", "--time-ms", "200", "sleep", "5" });
try testing.expect(run.reply.timed_out);
try testing.expectEqual(null, run.reply.exit_code);
try testing.expectEqual(9, run.reply.signal);
try testing.expect(run.reply.wall_ms >= 200);
try testing.expect(run.reply.wall_ms < generous_ms);
}
test "программа уложилась в лимит: timed_out ложь, код возврата цел" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const run = try support.zbox(arena_state.allocator(), &.{ "run", "--time-ms", "30000", "sh", "-c", "exit 7" });
try testing.expect(!run.reply.timed_out);
try testing.expectEqual(7, run.reply.exit_code);
}
test "гонка: мгновенный ребёнок не вешает родителя" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
// Без маски этот тест зависал бы изредка, поэтому повторяем много раз.
for (0..30) |_| {
const run = try support.zbox(arena_state.allocator(), &.{ "run", "--time-ms", "30000", "true" });
try testing.expectEqual(0, run.reply.exit_code);
try testing.expect(!run.reply.timed_out);
}
}
test "внук в группе убит вместе с ребёнком" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
// sh запускает фоновый sleep, печатает его pid и ждёт. Вывод внука уводим
// в /dev/null: иначе он держал бы наш конец pipe, и тест ждал бы его.
const script = "sleep 30 >/dev/null 2>&1 & echo $!; wait";
const run = try support.zbox(arena_state.allocator(), &.{ "run", "--time-ms", "300", "sh", "-c", script });
try testing.expect(run.reply.timed_out);
try expectDead(run.program_stdout);
}
test "внук, брошенный ребёнком, убит и без таймера" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const script = "sleep 30 >/dev/null 2>&1 & echo $!";
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.timed_out);
try expectDead(run.program_stdout);
}
/// `pid_line` это строка с pid внука. Убитый внук сначала становится зомби,
/// и пока его не подберёт init, `kill(pid, 0)` отвечает успехом. Поэтому
/// опрашиваем с паузами, а не один раз.
fn expectDead(pid_line: []const u8) !void {
const pid = try std.fmt.parseInt(std.c.pid_t, std.mem.trim(u8, pid_line, " \n"), 10);
for (0..generous_ms / 20) |_| {
if (std.c.kill(pid, @enumFromInt(0)) != 0) return;
try std.testing.io.sleep(.fromMilliseconds(20), .awake);
}
return error.GrandchildStillAlive;
}
Тест «гонка» стоит тридцать запусков true подряд. Без блокировки до fork он иногда зависал бы навсегда: SIGCHLD приходил бы до sigsuspend и уходил в обработчик, а после этого родитель уснул бы и ждал второго. Двум тестам про внуков нужен kill(pid, 0) из раздела про отправку, и в expectDead видна ловушка с зомби: убитый внук остаётся зомби, пока его не усыновит и не утилизирует процесс 1, и всё это время kill отвечает успехом. Поэтому проверка опрашивает с паузами.
$ zig build test -Dstep=49 --summary all # macOS, Apple M4 Max
Build Summary: 5/5 steps succeeded; 10/10 tests passed
$ zig build test --summary all
Build Summary: 7/7 steps succeeded; 21/21 tests passed
Прогон
Снято на macOS, Apple M4 Max, без контейнера:
$ zbox run --time-ms 200 sleep 5
{"exit_code":null,"signal":9,"timed_out":true,"cpu_user_ms":0,"cpu_sys_ms":0,"max_rss_kb":1248,"wall_ms":204}
$ zbox run --time-ms 30000 sh -c 'exit 7'
{"exit_code":7,"signal":null,"timed_out":false,"cpu_user_ms":0,"cpu_sys_ms":1,"max_rss_kb":2016,"wall_ms":3}
$ zbox run --time-ms 300 sh -c 'sleep 30 & echo "внук $!"; wait'
внук 44946
{"exit_code":null,"signal":9,"timed_out":true,"cpu_user_ms":0,"cpu_sys_ms":1,"max_rss_kb":2032,"wall_ms":305}
$ zbox run sh -c 'sleep 30 >/dev/null 2>&1 & echo "внук $!"'
внук 45354
{"exit_code":0,"signal":null,"timed_out":false,"cpu_user_ms":0,"cpu_sys_ms":1,"max_rss_kb":2032,"wall_ms":3}
$ pgrep -fl 'sleep 30' || echo 'сирот нет'
сирот нет
Будильник опоздал на 4 мс, это цена пробуждения и доставки сигнала. Второй прогон показывает, что лимит не мешает быстрой программе: 3 мс вместо 30 секунд, потому что родитель спит до SIGCHLD, а не до будильника. Третий и четвёртый про внуков: и по лимиту, и без него sleep 30 не пережил своего родителя.
Сторож, собранный сегодня, проживёт два урока. В уроке 54 будильник станет периодическим, чтобы родитель просыпался снимать показания памяти, а в уроке 61 sigsuspend уступит место poll, потому что родителю придётся ждать ещё и данные из пайпов.
На macOS
Всё из первой половины урока собирается и работает на macOS напрямую: std.c там это libSystem, а sigaction, sigprocmask, kill, setitimer и sigsuspend ведут себя так же, как на Linux. Отличий четыре.
- Номера сигналов. У части сигналов они другие:
SIGCHLD20,SIGSTOP17,SIGCONT19,SIGUSR130. Поэтому в коде только имена, аstd.posix.SIGподставляет число своей системы. - Сигналов реального времени нет, и
SIGRTMINиз первого домашнего задания проверяется только на Linux. - Нет
/proc. МаскиSigPndиSigBlkсмотреть нечем, кромеsigpendingиз самой программы, и карты памяти тоже нет. Сэмплерzt profпоэтому берёт верхнюю границу стека черезpthread_get_stackaddr_np, и тест, который профилирует тестовый процесс, на Mac проходит. LD_PRELOADэто механизм загрузчика Linux. Сквознойzt prof spinидёт только там: тест на Mac пропускает себя сам, а живой прогон в уроке снят в контейнереlinux/arm64. Команда та же, что в README проекта:docker run --rm --platform linux/arm64 -v "$PWD":/w:ro ghcr.io/bondiano/runner-zig:dev sh -c '...'с копией исходников в/tmp, потому что в примонтированную только для чтения папку кэш сборки не запишется. Дляzt profгодится иlinux/amd64под Rosetta: в отличие отzt strace, ему не нужны регистры чужого процесса, сэмплер читает свои.
zbox с лимитом времени работает на Mac целиком, все выводы его раздела сняты там. Задача урока написана на сырых вызовах std.os.linux, она решается в редакторе на сайте или в Docker; локально на Mac её тесты пропустят себя сами.
Практика
Классическое домашнее задание к этой главе книги: чтение строки, которое сдаётся по таймеру. Напиши tfgets(fd, buf, timeout_ms) на сырых вызовах std.os.linux: setitimer(ITIMER_REAL) заводит будильник, обработчик SIGALRM поставлен без SA_RESTART, и спящий read возвращается с EINTR. Всё нужное есть в уроке: ловушка itimerspec против itimerval (в .nsec пишутся микросекунды), EINTR из раздела про обработчики и уборка за собой. Порядок уборки важен: сначала остановить таймер, потом вернуть прежний обработчик. Наоборот нельзя: удар будильника, пришедший при обработчике по умолчанию, убьёт процесс.
И подумай про гонку того же вида, что с pause: сигнал может прийти после setitimer, но за мгновение до входа в read. Обработчик отработает, а read уснёт навсегда. Закрыть её дёшево помогает поле it_interval: если будильник бьёт повторно, следующий удар прервёт уже сам read.
Упражнения
Итоги
- Сигнал это исключение уровня процесса: ядро сообщает о событии номером, процесс реагирует действием по умолчанию или своим обработчиком. Отправка ставит бит
pending, доставка случается при следующем возвращении процесса в режим пользователя, заблокированный сигнал ждёт, пока блокировку не снимут. - Отправляют сигналы ядро (исключения, таймеры, смерть ребёнка, терминал) и процессы через
kill. Отрицательный pid это группа процессов. Ctrl+C и Ctrl+Z терминал шлёт группе переднего плана целиком. setitimerдаёт три таймера:ITIMER_REALдля таймаутов,ITIMER_PROFдля профилировщиков. Обёрткаstd.os.linux.setitimerждётitimerspec, а ядро читает микросекунды.sigactionставит обработчик, маску обработчика и флаги. Обработчик в Zig этоcallconv(.c). БезSA_RESTARTмедленные вызовы возвращаютEINTR, и их надо повторять; с ним ядро перезапускает их само, ноnanosleepиsigsuspendпрерываются всегда.- Стандартные сигналы не копятся: один сигнал значит «как минимум одно событие». Обработчик
SIGCHLDутилизирует детей цикломwaitpidсWNOHANG. - Шесть правил обработчика: минимум работы, только async-signal-safe, сохранять
errno, защищать общие данные маской, общие переменные черезstd.atomic.Value.SIGKILLиSIGSTOPне перехватываются и не блокируются. - Гонку между
forkи регистрацией задания лечит блокировкаSIGCHLDдоforkи снятие после записи. Ребёнок возвращает маску доexecve. - Ждать сигнала надо через
sigsuspend: подмена маски и сон это одно действие ядра, окна между ними нет. Опрос жжёт процессор, параsigprocmaskиpauseтеряет сигналы. zt profэто обработчикSIGPROF, который идёт по указателю кадра отucontextи пишет адреса в статическое кольцо. Библиотека подLD_PRELOADзаводит таймер из.init_arrayи сбрасывает дамп из.fini_array, а имена функций появляются снаружи: карта памяти,PT_LOAD, таблица символов.zbox run --time-ms Nблокирует сигналы доfork, переводит ребёнка в свою группу, заводит будильник на весь лимит, спит вsigsuspendи по будильнику шлётSIGKILLвсей группе. Брошенных внуков добивает доwait4, пока pid ребёнка занят зомби.
Дальше
Сегодня сигналы врывались в программу между двумя любыми инструкциями, и ради этого обработчику понадобились маска и шесть правил. В следующем уроке то же самое случится этажом ниже, в нашем ядре Y86: таймер как устройство, прерывание, которое приходит в середину процесса, сохранение контекста прямо в PCB и планировщик по кругу. Ядро перестанет быть кооперативным, и ping больше не сможет отнять процессор у pong. Там же вернётся правило о потерянных сигналах: тик, пришедший поверх ещё не обработанного, тоже пропадает.
домашка