Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Оболочка с управлением заданиями: shlab на Zig
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Оболочка с управлением заданиями: shlab на Zig
За пять уроков ты собрал все детали по отдельности: исключения и системные вызовы, fork, execve и waitpid, сигналы с масками и гонками, ядро для Y86 и нелокальные переходы. Сегодня из них складывается программа, которой ты пользуешься каждый день и внутрь которой, скорее всего, ни разу не заглядывал: командная оболочка. Не игрушка на двадцать строк с
forkиwait, а оболочка с управлением заданиями: с фоновыми конвейерами, Ctrl-C и Ctrl-Z, которые достаются нужной группе процессов, сjobs,bgиfg, с пайпами и переадресацией. В курсе CS:APP это лабораторная shlab и домашняя задача 8.26. Мы напишем её на Zig целиком, шесть файлов и около тысячи строк, и проверим тринадцатью сценариями, в которых нет ни одногоsleep.
Цели урока
- Понимать, что такое задание, группа процессов, сеанс и управляющий терминал, и кому ядро шлёт
SIGINT, когда ты жмёшь Ctrl-C. - Собрать оболочку
ourshиз шести модулей: слой над libc, разбор строки, таблица заданий, обработчики сигналов, запуск конвейера, главный цикл. - Объяснить, почему таблица заданий лежит в массиве фиксированного размера, почему в обработчике нет печати и зачем он сохраняет
errno. - Закрыть гонку между
forkи записью задания в таблицу маской сигналов и увидеть своими глазами, что бывает без неё. - Ждать переднее задание через
sigsuspendи объяснить, почему пара изpauseи проверки флага здесь не годится. - Связать команды конвейера пайпами, не оставив ни одного лишнего открытого конца, и сделать переадресацию через
openиdup2. - Отдавать терминал переднему заданию через
tcsetpgrpи забирать обратно, не получив при этомSIGTTOU. - Проверять программу, которая вся состоит из процессов и сигналов, детерминированными сценариями: драйвер ждёт вывода, а не спит.
Идея: оболочка как диспетчер заданий
Простейшая оболочка из урока про процессы умела одно: прочитать строку, сделать fork, в потомке позвать execve, в родителе дождаться потомка через waitpid. Её хватает ровно до первой команды, которая работает долго. Ты запустил сборку на десять минут, и оболочка десять минут молчит. Хочется трёх вещей: отпустить команду в фон и работать дальше, прервать то, что зависло, и приостановить то, что мешает, чтобы потом продолжить. Всё вместе это называется управлением заданиями, по-английски job control.
Единица учёта здесь не процесс, а задание. Строка cat log | grep err | sort рождает три процесса, но для тебя это одна вещь: одна строка в списке jobs, один номер для fg, и Ctrl-C должен убить все три процесса разом, а не тот, которому повезло. Ядро про задания ничего не знает, у него для этого есть другое понятие.
Группа процессов
У каждого процесса кроме pid есть идентификатор группы процессов, pgid. Потомок наследует группу родителя, а вызов setpgid(pid, pgid) переводит процесс в другую группу или основывает новую. Группа нужна ровно для одного: сигнал можно послать всем её членам одним вызовом, kill(-pgid, sig), с минусом перед номером.
Оболочка заводит на каждое задание свою группу. Номер группы это pid первого процесса конвейера, остальные процессы того же конвейера вступают в неё. Отсюда соответствие, на котором стоит весь урок: задание оболочки это группа процессов ядра.
Сеанс и управляющий терминал
Группы объединяются в сеанс. Когда ты открываешь окно терминала, эмулятор создаёт псевдотерминал, основывает сеанс и запускает в нём оболочку. У сеанса есть управляющий терминал, а у терминала есть одно поле, ради которого всё затевалось: группа переднего плана.
Правила вокруг этого поля такие:
- Ctrl-C в терминале это не символ для программы. Драйвер терминала в ядре видит байт
0x03и шлётSIGINTвсем процессам группы переднего плана. Ctrl-Z, байт0x1a, так же превращается вSIGTSTP. Оболочка в доставке не участвует. - Читать с терминала может только группа переднего плана. Фоновый процесс, который полез читать, получает
SIGTTINи останавливается. С записью мягче:SIGTTOUприлетает, только если включён режимtostop, иначе фоновые задания спокойно пишут поверх твоего приглашения. - Поле меняет вызов
tcsetpgrp(fd, pgid). Если его зовёт процесс из фоновой группы, он сам получаетSIGTTOU, если только не заблокировал или не игнорирует этот сигнал. Эта мелочь нам ещё встретится.
Получается такой танец. Оболочка запускает переднее задание: заводит группу, отдаёт ей терминал через tcsetpgrp, засыпает. С этого момента клавиатура принадлежит заданию, и Ctrl-C ядро шлёт ему напрямую, минуя оболочку. Задание закончилось или остановилось: оболочка узнаёт об этом из SIGCHLD, забирает терминал себе и печатает приглашение. Фоновому заданию терминал не отдают, вот и вся разница между cmd и cmd & с точки зрения ядра.
Четыре сигнала
| Сигнал | Кто шлёт | Действие по умолчанию | Что делает оболочка |
|---|---|---|---|
SIGINT | драйвер терминала по Ctrl-C | завершить процесс | если сигнал пришёл ей самой, пересылает группе переднего задания |
SIGTSTP | драйвер терминала по Ctrl-Z | остановить процесс | то же |
SIGCHLD | ядро, когда потомок завершился или остановился | игнорировать | собирает потомков через waitpid и правит таблицу заданий |
SIGCONT | оболочка из bg и fg | продолжить остановленный процесс | шлёт всей группе: kill(-pgid, SIGCONT) |
Зачем оболочке пересылать SIGINT, если ядро шлёт его заданию напрямую? Потому что терминал есть не всегда. Когда стандартный ввод оболочки это пайп или файл (так её запускают тесты, так работает ssh host команда), группы переднего плана нет, и сигнал, посланный оболочке, получает только оболочка. В лабораторной shlab весь драйвер тестов устроен именно так, поэтому там пересылка это единственный путь. Наша оболочка умеет оба: с терминалом отдаёт его заданию, без терминала пересылает сама.
Демонстрация
Ниже модель такой оболочки. Запусти sleep 10 &, потом sleep 10 без амперсанда, нажми Ctrl-Z, посмотри jobs, верни задание командой fg %2 и убей через Ctrl-C. Следи за двумя вещами: у каждого задания своя группа, и подпись “передний план” над консолью меняется вместе с tcsetpgrp.
А так выглядит тот же сюжет на настоящей оболочке, которую мы сегодня напишем. Сеанс снят на macOS в псевдотерминале, ^Z и ^C это эхо терминала на мои нажатия:
oursh> ./myspin 30 &
[1] (11750) ./myspin 30 &
oursh> ./myspin 60
^ZЗадание [2] (11779) остановлено: SIGTSTP
oursh> jobs
[1] (11750) Running ./myspin 30 &
[2] (11779) Stopped ./myspin 60
oursh> bg %2
[2] (11779) ./myspin 60
oursh> fg %1
./myspin 30 &
^CЗадание [1] (11750) убито: SIGINT
oursh> jobs
[2] (11779) Running ./myspin 60
oursh> /bin/echo one two three | tr a-z A-Z
ONE TWO THREE
oursh> quit
myspin N это подопытная программа, которая просто живёт N секунд, мы напишем её ближе к концу. В скобках pid первого процесса задания, он же pgid.
План проекта
Каталог our-sh, сборка через zig build, зависимость одна: libc.
build.zig граф сборки: oursh, четыре подопытные программы, тесты
src/sys.zig слой над libc: extern для execvp, sigsuspend, tcsetpgrp, маски
src/parse.zig разбор строки: слова, кавычки, | < > >> &. Чистый модуль
src/jobs.zig таблица заданий: jid, pgid, Running/Stopped/Done. Чистый модуль
src/signals.zig обработчики SIGCHLD, SIGINT, SIGTSTP и глобальная таблица
src/exec.zig запуск конвейера: fork, setpgid, pipe, dup2, execvp, sigsuspend
src/main.zig главный цикл, встроенные команды, сообщения о заданиях
tools/ myspin, mysplit, mystop, myint и общий common.zig
tests/traces.zig драйвер сценариев
traces/traceNN.txt сценарий, traceNN.out ожидаемый stdout
В .gitignore две строки, .zig-cache/ и zig-out/: кэш и результаты сборки в репозитории не нужны.
Главное решение видно уже по списку: два модуля из шести чистые. parse.zig превращает текст в структуру, jobs.zig ведёт таблицу, и ни тот ни другой не делает ни одного системного вызова. Их тесты лежат рядом с кодом, работают за миллисекунды и не зависят от платформы. Всё, что трогает процессы и сигналы, собрано в трёх небольших файлах, и проверяется оно уже сценариями на живой оболочке. Чем тоньше слой, который нельзя проверить обычным тестом, тем спокойнее спишь.
Граф сборки:
//! Сборка our-sh.
//!
//! `zig build` кладёт в `zig-out/bin` оболочку `oursh` и подопытные
//! программы для трасс, `zig build run` запускает оболочку,
//! `zig build test` гоняет тесты парсера, списка заданий и трассы.
const std = @import("std");
const helpers = [_][]const u8{ "myspin", "mysplit", "mystop", "myint" };
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const oursh = b.addExecutable(.{
.name = "oursh",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
// fork, execvp, waitpid, sigsuspend берём из libc.
.link_libc = true,
}),
});
b.installArtifact(oursh);
inline for (helpers) |name| {
const helper = b.addExecutable(.{
.name = name,
.root_module = b.createModule(.{
.root_source_file = b.path("tools/" ++ name ++ ".zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
}),
});
b.installArtifact(helper);
}
const run_cmd = b.addRunArtifact(oursh);
run_cmd.step.dependOn(b.getInstallStep());
const run_step = b.step("run", "Запустить оболочку");
run_step.dependOn(&run_cmd.step);
const test_step = b.step("test", "Тесты парсера, списка заданий и трассы");
// Чистые модули: тесты лежат рядом с кодом.
inline for (.{ "src/parse.zig", "src/jobs.zig" }) |path| {
const unit = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path(path),
.target = target,
.optimize = optimize,
}),
});
test_step.dependOn(&b.addRunArtifact(unit).step);
}
// Трассы запускают настоящую оболочку из zig-out/bin, поэтому ждут
// установки и работают из корня проекта.
const traces = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("tests/traces.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
}),
});
const run_traces = b.addRunArtifact(traces);
run_traces.step.dependOn(b.getInstallStep());
run_traces.setCwd(b.path("."));
// Процессы и сигналы настоящие: результат не кэшируем.
run_traces.has_side_effects = true;
test_step.dependOn(&run_traces.step);
}
Три места стоит заметить. link_libc = true стоит у всего, что зовёт fork: почему, разберём через минуту. Тесты чистых модулей собираются без libc, и это проверка чистоты бесплатно: появись в jobs.zig вызов std.c.kill, сборка теста упадёт. А сценарные тесты зависят от шага установки (b.getInstallStep()), потому что запускают готовую оболочку из zig-out/bin, и помечены has_side_effects: система сборки Zig кэширует результаты тестов, а результат теста с живыми процессами и сигналами кэшировать нельзя.
sys.zig: тонкий слой над libc
В уроке про системные вызовы ты звал ядро напрямую, инструкцией syscall. Для оболочки этот путь неудобен по двум причинам. Первая: на macOS у ядра нет стабильного интерфейса системных вызовов, единственная официальная дверь это libSystem, то есть libc. Вторая причина неожиданнее: в std.posix версии 0.16 нет обёрток fork, execve, waitpid, pipe и dup2. Стандартная библиотека переезжает на интерфейс std.Io, где процессы запускает std.process.spawn, и низкоуровневый слой сильно похудел. В std.os.linux всё это есть, но только для Linux. В std.c тоже есть, и работает везде, где есть libc.
Поэтому оболочка собирается с libc и берёт вызовы из std.c. Но и там не всё: execvp, sigsuspend, tcsetpgrp и getpgrp в std.c 0.16 не объявлены. Это не беда. Функция из libc это просто имя в таблице динамической компоновки (вспомни урок про PLT и GOT), и чтобы её позвать, достаточно объявить прототип словом extern. Всё платформенное собрано в одном файле, остальные модули про libc не знают.
//! Тонкий слой над libc. В `std.posix` версии 0.16 нет `fork`, `execve`,
//! `waitpid`, `pipe` и `dup2`, поэтому оболочка собирается с `-lc` и зовёт
//! libc напрямую. Чего нет даже в `std.c`, объявлено здесь через `extern`.
const std = @import("std");
const c = std.c;
const posix = std.posix;
pub const pid_t = posix.pid_t;
pub const fd_t = posix.fd_t;
pub const sigset_t = posix.sigset_t;
pub const SIG = posix.SIG;
pub extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
pub extern "c" fn sigsuspend(mask: *const sigset_t) c_int;
pub extern "c" fn tcsetpgrp(fd: fd_t, pgrp: pid_t) c_int;
pub extern "c" fn getpgrp() pid_t;
pub const fork = c.fork;
pub const waitpid = c.waitpid;
pub const setpgid = c.setpgid;
pub const dup2 = c.dup2;
pub const close = c.close;
pub const pipe = c.pipe;
pub const _exit = c._exit;
pub const W = c.W;
/// Сигналы, которые правят таблицу заданий или читают её.
pub fn jobSignals() sigset_t {
var set = posix.sigemptyset();
posix.sigaddset(&set, .CHLD);
posix.sigaddset(&set, .INT);
posix.sigaddset(&set, .TSTP);
return set;
}
/// Блокирует сигналы заданий и возвращает прежнюю маску.
pub fn block() sigset_t {
var old: sigset_t = undefined;
const set = jobSignals();
posix.sigprocmask(SIG.BLOCK, &set, &old);
return old;
}
pub fn restore(old: *const sigset_t) void {
posix.sigprocmask(SIG.SETMASK, old, null);
}
pub fn handle(sig: SIG, handler: ?posix.Sigaction.handler_fn) void {
// Без SA_RESTART: `read` в главном цикле вернёт EINTR, и оболочка
// сразу расскажет про закончившееся фоновое задание.
const action: posix.Sigaction = .{
.handler = .{ .handler = handler },
.mask = jobSignals(),
.flags = 0,
};
posix.sigaction(sig, &action, null);
}
pub fn kill(pid: pid_t, sig: SIG) void {
_ = c.kill(pid, sig);
}
pub fn errno() c_int {
return c._errno().*;
}
pub fn setErrno(value: c_int) void {
c._errno().* = value;
}
/// Пишет весь срез в дескриптор. Годится и для обработчика сигнала.
pub fn writeAll(fd: fd_t, bytes: []const u8) void {
var rest = bytes;
while (rest.len > 0) {
const n = c.write(fd, rest.ptr, rest.len);
if (n < 0) {
if (errno() == @intFromEnum(posix.E.INTR)) continue;
return;
}
rest = rest[@intCast(n)..];
}
}
pub fn print(comptime fmt: []const u8, args: anytype) void {
var buf: [512]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch buf[0..];
writeAll(1, text);
}
pub fn eprint(comptime fmt: []const u8, args: anytype) void {
var buf: [512]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch buf[0..];
writeAll(2, text);
}
/// Имя сигнала для сообщений. Номера на Linux и macOS разные, имена одни.
pub fn signalName(signo: u32) []const u8 {
const sig: SIG = @enumFromInt(signo);
return switch (sig) {
.INT => "SIGINT",
.TSTP => "SIGTSTP",
.STOP => "SIGSTOP",
.KILL => "SIGKILL",
.TERM => "SIGTERM",
.HUP => "SIGHUP",
.QUIT => "SIGQUIT",
.SEGV => "SIGSEGV",
.PIPE => "SIGPIPE",
.TTIN => "SIGTTIN",
.TTOU => "SIGTTOU",
else => "другой сигнал",
};
}
Разберём решения по порядку.
Прототипы extern "c". Типы в них надо выписать так же аккуратно, как в заголовке C. Посмотри на execvp: второй аргумент это [*:null]const ?[*:0]const u8. Читается справа налево: указатель на строку с нулём в конце ([*:0]const u8), который может быть пустым (?), и таких указателей массив, оканчивающийся на null ([*:null]). Это ровно char *const argv[] из C, только теперь компилятор знает про оба терминатора и не даст передать массив без завершающего null.
jobSignals, block, restore. Три сигнала, SIGCHLD, SIGINT и SIGTSTP, запускают обработчики, которые читают или правят таблицу заданий. Эту тройку мы всегда блокируем и разблокируем вместе. block возвращает прежнюю маску, restore ставит её обратно. Именно ставит прежнюю, а не снимает блокировку с трёх сигналов: функция, которую позвали с уже заблокированными сигналами, не должна их нечаянно разблокировать. Это правило из урока про сигналы, здесь оно зашито в пару функций, и ошибиться негде.
handle и флаги. У sigaction три поля, и каждое здесь выбрано не случайно.
mask = jobSignals(). Пока работает любой из наших обработчиков, заблокированы все три сигнала. Значит, обработчикSIGCHLDне будет прерван обработчикомSIGINTна середине правки таблицы. Обработчики не перемежаются друг с другом, и внутри них про гонки можно не думать.flags = 0, то есть безSA_RESTART. С этим флагом ядро само перезапускает медленный системный вызов, прерванный сигналом. Обычно это удобно, но оболочке нужно обратное: она почти всё время спит вreadна стандартном вводе, и когда фоновое задание закончилось,readдолжен вернуться с ошибкойEINTR, чтобы оболочка сразу напечатала строкуDone. СSA_RESTARTона узнала бы о новости только после твоего следующего Enter.- Цена этого решения: любой медленный вызов в оболочке теперь может вернуть
EINTR, и каждый надо проверять. Таких вызовов у нас два,readв главном цикле иwriteздесь, вwriteAll.
writeAll, print, eprint. Оболочка не пользуется буферизованным выводом из std.Io, и дело не в упрямстве. Сообщение форматируется в буфер на стеке функцией std.fmt.bufPrint и уходит одним write. Ни глобального буфера, ни блокировок, ни аллокатора: такой вывод можно звать откуда угодно, и он не перепутает порядок строк с выводом потомков, которые пишут в тот же дескриптор. writeAll дописывает при короткой записи и повторяет при EINTR. Про короткие счёты подробно поговорим в уроке про системный ввод и вывод, пока хватит того, что write имеет право записать меньше, чем просили.
errno и setErrno. В libc errno это не переменная, а макрос над функцией, которая возвращает адрес ячейки текущего потока. В std.c эта функция называется _errno. Обёртки нужны обработчикам сигналов, зачем именно, увидим в signals.zig.
signalName. Оболочка печатает убито: SIGINT, а не убито: сигнал 2, и на это есть причина кроме красоты. Номера сигналов у Linux и macOS разные: SIGTSTP это 20 на Linux и 18 на macOS, SIGCHLD 17 против 20, SIGCONT 18 против 19. Совпадают только старые сигналы вроде SIGINT (2) и SIGKILL (9). Перечисление std.posix.SIG при компиляции берёт номера целевой платформы, поэтому switch по именам работает везде, а ожидаемый вывод сценариев одинаков на обеих системах.
parse.zig: от строки к структуре
Разбор строки это единственная часть оболочки, где нет ни процессов, ни сигналов, и потому самая спокойная. На входе текст, на выходе структура: список команд конвейера, у каждой аргументы и переадресации, плюс признак фона.
Грамматика умещается в три строки:
строка := конвейер ['&']
конвейер := команда ('|' команда)*
команда := (слово | '<' слово | '>' слово | '>>' слово)+
Работа разделена на два этажа, как в любом разборе. Лексер режет текст на лексемы: слово, |, <, >, >>, &. Он знает про пробелы и кавычки и ничего не знает про то, что после > обязано идти имя файла. Функция parse собирает лексемы в команды и следит за грамматикой, зато ей уже всё равно, были ли в слове кавычки.
//! Разбор командной строки оболочки. Чистый модуль: ни одного системного
//! вызова, только текст на входе и структура на выходе.
//!
//! Грамматика:
//!
//! строка := конвейер ['&']
//! конвейер := команда ('|' команда)*
//! команда := (слово | '<' слово | '>' слово | '>>' слово)+
//!
//! Слово это подряд идущие обычные символы и куски в кавычках, одинарных
//! или двойных. Внутри кавычек пробелы и спецсимволы теряют смысл,
//! экранирования нет. Соседние куски склеиваются: `a"b c"d` это слово `ab cd`.
//!
//! Всю память разбор берёт у переданного аллокатора. Оболочка даёт арену
//! и сбрасывает её после каждой строки. Слова оканчиваются нулём, потому
//! что дальше они уйдут в `execvp`.
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const Word = [:0]const u8;
/// Куда направить стандартный вывод команды.
pub const Redirect = struct {
path: Word,
/// `>>` дописывает в конец, `>` обрезает файл.
append: bool = false,
};
/// Одна команда конвейера.
pub const Command = struct {
argv: []const Word,
stdin: ?Word = null,
stdout: ?Redirect = null,
};
/// Разобранная строка. Пустая строка даёт ноль команд.
pub const Line = struct {
commands: []const Command,
/// Строка кончалась на `&`.
background: bool = false,
};
pub const Error = error{
/// Кавычка открыта и не закрыта.
UnterminatedQuote,
/// Между `|` нет ни одного слова: `| wc`, `ls | | wc`, `ls |`.
MissingCommand,
/// После `<`, `>` или `>>` нет имени файла.
MissingRedirectTarget,
/// `&` стоит не в конце строки.
MisplacedAmpersand,
} || Allocator.Error;
const Token = union(enum) {
word: Word,
pipe,
less,
great,
dgreat,
amp,
};
/// Режет текст на лексемы по одной за вызов `next`.
const Lexer = struct {
text: []const u8,
pos: usize = 0,
arena: Allocator,
fn next(self: *Lexer) Error!?Token {
while (self.pos < self.text.len and isSpace(self.text[self.pos])) self.pos += 1;
if (self.pos == self.text.len) return null;
const c = self.text[self.pos];
switch (c) {
'|' => return self.single(.pipe),
'<' => return self.single(.less),
'&' => return self.single(.amp),
'>' => {
if (self.pos + 1 < self.text.len and self.text[self.pos + 1] == '>') {
self.pos += 2;
return .dgreat;
}
return self.single(.great);
},
else => return .{ .word = try self.word() },
}
}
fn single(self: *Lexer, token: Token) Token {
self.pos += 1;
return token;
}
fn word(self: *Lexer) Error!Word {
var buf: std.ArrayList(u8) = .empty;
while (self.pos < self.text.len) {
const c = self.text[self.pos];
if (isSpace(c) or isSpecial(c)) break;
self.pos += 1;
if (c == '\'' or c == '"') {
const close = std.mem.indexOfScalarPos(u8, self.text, self.pos, c) orelse
return error.UnterminatedQuote;
try buf.appendSlice(self.arena, self.text[self.pos..close]);
self.pos = close + 1;
} else {
try buf.append(self.arena, c);
}
}
return buf.toOwnedSliceSentinel(self.arena, 0);
}
};
fn isSpace(c: u8) bool {
return c == ' ' or c == '\t' or c == '\n' or c == '\r';
}
fn isSpecial(c: u8) bool {
return c == '|' or c == '<' or c == '>' or c == '&';
}
pub fn parse(arena: Allocator, text: []const u8) Error!Line {
var lexer: Lexer = .{ .text = text, .arena = arena };
var commands: std.ArrayList(Command) = .empty;
var argv: std.ArrayList(Word) = .empty;
var current: Command = .{ .argv = &.{} };
var background = false;
var seen_any = false;
while (try lexer.next()) |token| {
// После `&` не может идти ничего.
if (background) return error.MisplacedAmpersand;
seen_any = true;
switch (token) {
.word => |w| try argv.append(arena, w),
.less => current.stdin = try target(&lexer),
.great => current.stdout = .{ .path = try target(&lexer) },
.dgreat => current.stdout = .{ .path = try target(&lexer), .append = true },
.pipe => {
if (argv.items.len == 0) return error.MissingCommand;
current.argv = try argv.toOwnedSlice(arena);
try commands.append(arena, current);
current = .{ .argv = &.{} };
},
.amp => background = true,
}
}
if (!seen_any) return .{ .commands = &.{} };
if (argv.items.len == 0) return error.MissingCommand;
current.argv = try argv.toOwnedSlice(arena);
try commands.append(arena, current);
return .{ .commands = try commands.toOwnedSlice(arena), .background = background };
}
/// Имя файла после знака переадресации: следующая лексема обязана быть словом.
fn target(lexer: *Lexer) Error!Word {
const token = try lexer.next() orelse return error.MissingRedirectTarget;
return switch (token) {
.word => |w| w,
else => error.MissingRedirectTarget,
};
}
const testing = std.testing;
fn expectArgv(expected: []const []const u8, command: Command) !void {
try testing.expectEqual(expected.len, command.argv.len);
for (expected, command.argv) |want, got| try testing.expectEqualStrings(want, got);
}
test "пустая строка и строка из пробелов дают ноль команд" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
try testing.expectEqual(0, (try parse(arena.allocator(), "")).commands.len);
try testing.expectEqual(0, (try parse(arena.allocator(), " \t \n")).commands.len);
}
test "слова делятся по пробелам" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const line = try parse(arena.allocator(), " ls -l\t/tmp\n");
try testing.expectEqual(1, line.commands.len);
try testing.expect(!line.background);
try expectArgv(&.{ "ls", "-l", "/tmp" }, line.commands[0]);
}
test "кавычки прячут пробелы и спецсимволы, куски склеиваются" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const line = try parse(arena.allocator(), "echo 'a | b' \"x > y\" pre\"in side\"post ''");
try expectArgv(&.{ "echo", "a | b", "x > y", "prein sidepost", "" }, line.commands[0]);
}
test "амперсанд в конце ставит фон, слитно или через пробел" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const spaced = try parse(arena.allocator(), "./myspin 10 &");
try testing.expect(spaced.background);
try expectArgv(&.{ "./myspin", "10" }, spaced.commands[0]);
const glued = try parse(arena.allocator(), "./myspin 10&");
try testing.expect(glued.background);
try expectArgv(&.{ "./myspin", "10" }, glued.commands[0]);
}
test "конвейер и переадресации" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const line = try parse(arena.allocator(), "sort < in.txt | uniq -c|tr a-z A-Z >> out.txt &");
try testing.expectEqual(3, line.commands.len);
try testing.expect(line.background);
try expectArgv(&.{"sort"}, line.commands[0]);
try testing.expectEqualStrings("in.txt", line.commands[0].stdin.?);
try testing.expectEqual(null, line.commands[0].stdout);
try expectArgv(&.{ "uniq", "-c" }, line.commands[1]);
try expectArgv(&.{ "tr", "a-z", "A-Z" }, line.commands[2]);
try testing.expectEqualStrings("out.txt", line.commands[2].stdout.?.path);
try testing.expect(line.commands[2].stdout.?.append);
}
test "переадресация может стоять где угодно в команде" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const line = try parse(arena.allocator(), ">out.txt echo hi");
try expectArgv(&.{ "echo", "hi" }, line.commands[0]);
try testing.expectEqualStrings("out.txt", line.commands[0].stdout.?.path);
try testing.expect(!line.commands[0].stdout.?.append);
}
test "ошибки разбора" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
try testing.expectError(error.UnterminatedQuote, parse(a, "echo 'oops"));
try testing.expectError(error.MissingCommand, parse(a, "| wc"));
try testing.expectError(error.MissingCommand, parse(a, "ls | | wc"));
try testing.expectError(error.MissingCommand, parse(a, "ls |"));
try testing.expectError(error.MissingCommand, parse(a, "&"));
try testing.expectError(error.MissingCommand, parse(a, "> out.txt"));
try testing.expectError(error.MissingRedirectTarget, parse(a, "ls >"));
try testing.expectError(error.MissingRedirectTarget, parse(a, "ls > | wc"));
try testing.expectError(error.MisplacedAmpersand, parse(a, "ls & wc"));
try testing.expectError(error.MisplacedAmpersand, parse(a, "ls & &"));
}
Что здесь стоит внимания.
Слова с нулём на конце. Тип Word это [:0]const u8, срез с нулевым байтом за последним элементом. Обычному Zig-коду ноль не нужен, длина лежит в срезе. Но слова уйдут в execvp, а ядро и libc понимают только строки C. Проще сразу собрать слово с терминатором (toOwnedSliceSentinel), чем копировать его второй раз перед запуском.
Кавычки склеиваются. Слово это цикл по символам до пробела или спецсимвола, и кавычка внутри него не заканчивает слово, а включает режим “всё до парной кавычки как есть”. Поэтому pre"in side"post это одно слово prein sidepost, а '' это честное пустое слово. Так ведут себя все настоящие оболочки, и на этом держится привычное --name="John Smith". Экранирования обратной чертой у нас нет, подстановок переменных тоже: кавычки двух видов ведут себя одинаково.
Арена на строку. Лексеры и парсеры рождают много мелких выделений с одним сроком жизни: всё, что выделено при разборе строки, умирает, когда строка выполнена. Это учебный случай для арены из урока про аллокаторы. parse не освобождает ничего и не содержит ни одного defer, а главный цикл сбрасывает арену одним вызовом перед следующей строкой. Обрати внимание и на errdefer: его тут тоже нет. При ошибке разбора недособранные списки просто остаются в арене до сброса.
Ошибки это множество, а не строки. parse возвращает Error!Line, и в Error перечислено всё, что может пойти не так, плюс Allocator.Error. Текст для человека живёт в main.zig, в switch по ошибке. Если завтра в грамматике появится новая ошибка, компилятор сам покажет то место, где для неё нет сообщения: switch по множеству ошибок обязан быть полным.
& только в конце. Проверка стоит первой строкой цикла: если лексема пришла, а флаг фона уже поднят, значит после & что-то было. Настоящие оболочки разрешают a & b, это две команды, первая в фоне. Мы оставим такое расширение на упражнения.
Переадресация где угодно. В команда := (слово | '<' слово | ...)+ переадресации и слова перемешаны, поэтому >out.txt echo hi разбирается так же, как echo hi >out.txt. Зато > out.txt без единого слова это MissingCommand: запускать нечего.
Тесты лежат в том же файле. Запусти их отдельно, пока остального проекта ещё нет:
$ zig test src/parse.zig
All 7 tests passed.
jobs.zig: таблица заданий
Таблица заданий это общая память двух потоков управления: главного цикла и обработчика SIGCHLD. Поток исполнения один, но обработчик вклинивается между любыми двумя инструкциями главного цикла, так что все беды общей памяти здесь настоящие. Из урока про сигналы ты помнишь два следствия. В обработчике можно звать только async-signal-safe функции. И общие структуры главный поток трогает, только заблокировав сигналы.
Из первого следствия вырастает вся форма модуля. malloc в списке безопасных функций нет, значит аллокатора в таблице быть не может: сигнал мог прервать malloc главного потока посреди правки его внутренних списков, и второй malloc из обработчика их доломает. Поэтому таблица это массив фиксированного размера, задания лежат в нём по значению, командная строка копируется в буфер внутри задания. Шестнадцать заданий по восемь процессов хватит любому человеку, а кому не хватит, тот получит честную ошибку TooManyJobs.
//! Список заданий. Задание это один конвейер: группа процессов с общим
//! pgid, номером jid для человека и состоянием.
//!
//! Таблицу правит обработчик SIGCHLD, поэтому здесь нет ни аллокатора, ни
//! системных вызовов: массив фиксированного размера и чистые функции.
//! Главный поток трогает таблицу только с заблокированными сигналами.
const std = @import("std");
pub const pid_t = std.posix.pid_t;
pub const max_jobs = 16;
/// Сколько процессов бывает в одном конвейере.
pub const max_procs = 8;
pub const max_cmdline = 128;
pub const State = enum { running, stopped, done };
/// Что `waitpid` рассказал про один процесс.
pub const Event = union(enum) {
/// Вышел сам, внутри код возврата.
exited: u8,
/// Убит сигналом с этим номером.
killed: u32,
/// Остановлен сигналом с этим номером.
stopped: u32,
};
pub const Job = struct {
/// Номер задания. Ноль значит, что ячейка свободна.
jid: u32 = 0,
pgid: pid_t = 0,
state: State = .running,
/// Оболочка ждёт это задание и отдала ему терминал.
foreground: bool = false,
/// Живые, ещё не собранные `waitpid` процессы конвейера.
pids: [max_procs]pid_t = undefined,
live: usize = 0,
/// Сигнал, который остановил или убил задание. Ноль, если такого не было.
signal: u32 = 0,
/// Состояние изменилось, а пользователю об этом ещё не сказали.
announce: bool = false,
cmdline_buf: [max_cmdline]u8 = undefined,
cmdline_len: usize = 0,
pub fn cmdline(self: *const Job) []const u8 {
return self.cmdline_buf[0..self.cmdline_len];
}
fn forget(self: *Job, pid: pid_t) void {
const i = std.mem.indexOfScalar(pid_t, self.pids[0..self.live], pid) orelse return;
self.pids[i] = self.pids[self.live - 1];
self.live -= 1;
}
};
pub const Table = struct {
slots: [max_jobs]Job = @splat(.{}),
next_jid: u32 = 1,
pub const AddError = error{ TooManyJobs, TooManyProcesses };
/// Первый pid конвейера служит pgid всей группы.
pub fn add(self: *Table, pids: []const pid_t, foreground: bool, text: []const u8) AddError!*Job {
if (pids.len == 0 or pids.len > max_procs) return error.TooManyProcesses;
for (&self.slots) |*job| {
if (job.jid != 0) continue;
job.* = .{ .jid = self.next_jid, .pgid = pids[0], .foreground = foreground, .live = pids.len };
@memcpy(job.pids[0..pids.len], pids);
job.cmdline_len = @min(text.len, max_cmdline);
@memcpy(job.cmdline_buf[0..job.cmdline_len], text[0..job.cmdline_len]);
self.next_jid += 1;
return job;
}
return error.TooManyJobs;
}
pub fn remove(self: *Table, job: *Job) void {
job.* = .{};
// Номера переиспользуются, как только освободился хвост списка.
var max: u32 = 0;
for (self.slots) |other| max = @max(max, other.jid);
self.next_jid = max + 1;
}
pub fn byJid(self: *Table, jid: u32) ?*Job {
if (jid == 0) return null;
for (&self.slots) |*job| if (job.jid == jid) return job;
return null;
}
pub fn byPgid(self: *Table, pgid: pid_t) ?*Job {
for (&self.slots) |*job| if (job.jid != 0 and job.pgid == pgid) return job;
return null;
}
pub fn byPid(self: *Table, pid: pid_t) ?*Job {
for (&self.slots) |*job| {
if (job.jid == 0) continue;
if (std.mem.indexOfScalar(pid_t, job.pids[0..job.live], pid) != null) return job;
}
return null;
}
pub fn foregroundJob(self: *Table) ?*Job {
for (&self.slots) |*job| {
if (job.jid != 0 and job.foreground and job.state == .running) return job;
}
return null;
}
/// Применяет одно событие `waitpid`. Зовётся из обработчика SIGCHLD.
pub fn update(self: *Table, pid: pid_t, event: Event) void {
const job = self.byPid(pid) orelse return;
switch (event) {
.stopped => |sig| {
if (job.state != .stopped) job.announce = true;
job.state = .stopped;
job.signal = sig;
},
.exited => self.finish(job, pid),
.killed => |sig| {
job.signal = sig;
self.finish(job, pid);
},
}
}
/// Задание закончено, когда собран последний процесс конвейера.
fn finish(_: *Table, job: *Job, pid: pid_t) void {
job.forget(pid);
if (job.live > 0) return;
job.state = .done;
job.announce = true;
}
/// Задание продолжили через `bg` или `fg`.
pub fn resumed(_: *Table, job: *Job, foreground: bool) void {
job.state = .running;
job.foreground = foreground;
job.signal = 0;
job.announce = false;
}
/// Задания в порядке номеров, для встроенной `jobs`.
pub fn sorted(self: *Table, buf: *[max_jobs]*Job) []*Job {
var n: usize = 0;
for (&self.slots) |*job| {
if (job.jid == 0) continue;
buf[n] = job;
n += 1;
}
std.mem.sort(*Job, buf[0..n], {}, struct {
fn less(_: void, a: *Job, b: *Job) bool {
return a.jid < b.jid;
}
}.less);
return buf[0..n];
}
};
const testing = std.testing;
test "add раздаёт номера по порядку и помнит командную строку" {
var table: Table = .{};
const a = try table.add(&.{100}, false, "./myspin 1 &");
const b = try table.add(&.{ 200, 201 }, true, "ls | wc");
try testing.expectEqual(1, a.jid);
try testing.expectEqual(2, b.jid);
try testing.expectEqual(200, b.pgid);
try testing.expectEqualStrings("ls | wc", b.cmdline());
try testing.expectEqual(b, table.foregroundJob().?);
try testing.expectEqual(b, table.byPid(201).?);
try testing.expectEqual(a, table.byJid(1).?);
try testing.expectEqual(null, table.byJid(3));
}
test "конвейер закончен только после последнего процесса" {
var table: Table = .{};
const job = try table.add(&.{ 10, 11 }, true, "a | b");
table.update(10, .{ .exited = 0 });
try testing.expectEqual(State.running, job.state);
try testing.expect(!job.announce);
table.update(11, .{ .exited = 0 });
try testing.expectEqual(State.done, job.state);
try testing.expect(job.announce);
try testing.expectEqual(0, job.signal);
try testing.expectEqual(null, table.foregroundJob());
}
test "остановка и убийство запоминают сигнал" {
var table: Table = .{};
const job = try table.add(&.{ 10, 11 }, true, "a | b");
table.update(10, .{ .stopped = 20 });
table.update(11, .{ .stopped = 20 });
try testing.expectEqual(State.stopped, job.state);
try testing.expectEqual(20, job.signal);
try testing.expectEqual(2, job.live);
job.state = .running;
job.announce = false;
table.update(10, .{ .killed = 2 });
table.update(11, .{ .killed = 2 });
try testing.expectEqual(State.done, job.state);
try testing.expectEqual(2, job.signal);
}
test "чужой pid не трогает таблицу" {
var table: Table = .{};
const job = try table.add(&.{10}, false, "a &");
table.update(999, .{ .exited = 1 });
try testing.expectEqual(State.running, job.state);
}
test "remove освобождает ячейку и номер" {
var table: Table = .{};
const a = try table.add(&.{10}, false, "a &");
const b = try table.add(&.{20}, false, "b &");
table.remove(b);
try testing.expectEqual(2, (try table.add(&.{30}, false, "c &")).jid);
table.remove(a);
try testing.expectEqual(3, (try table.add(&.{40}, false, "d &")).jid);
}
test "таблица не резиновая" {
var table: Table = .{};
for (0..max_jobs) |i| _ = try table.add(&.{@intCast(i + 1)}, false, "x &");
try testing.expectError(error.TooManyJobs, table.add(&.{999}, false, "y &"));
try testing.expectError(error.TooManyProcesses, table.add(&.{}, false, ""));
}
test "sorted отдаёт задания по номерам" {
var table: Table = .{};
const a = try table.add(&.{10}, false, "a &");
_ = try table.add(&.{20}, false, "b &");
table.remove(a);
_ = try table.add(&.{30}, false, "c &");
var buf: [max_jobs]*Job = undefined;
const list = table.sorted(&buf);
try testing.expectEqual(2, list.len);
try testing.expectEqual(2, list[0].jid);
try testing.expectEqual(3, list[1].jid);
}
Ноль системных вызовов. Модуль не зовёт ни waitpid, ни kill. Обработчик сигнала сам вызывает waitpid, переводит статус в Event и отдаёт таблице готовый факт: процесс такой-то вышел, убит или остановлен. Благодаря этому поведение таблицы проверяется обычными тестами с выдуманными pid, без единого настоящего процесса. Тест “конвейер закончен только после последнего процесса” в живой оболочке пришлось бы ловить сценарием с тремя программами и удачей.
Задание это несколько процессов. В shlab у задания один pid, потому что конвейеров там нет. У нас в задании массив pids и счётчик live. waitpid сообщает про каждый процесс отдельно, и задание закончено, когда собран последний. forget убирает pid приёмом “переставь последний на место удалённого”: порядок нам не важен, зато сдвигать ничего не надо.
С остановкой проще, чем с завершением. SIGTSTP получает вся группа, и остановятся все её процессы, каждый со своим событием stopped. Задание считается остановленным по первому же событию, а флаг announce поднимается один раз, при смене состояния: проверка job.state != .stopped не даёт второму и третьему процессу конвейера объявить остановку повторно.
announce: обработчик молчит, главный цикл рассказывает. Печатать из обработчика сигнала нельзя по двум причинам. Формально write безопасен, но наше сообщение вклинилось бы в середину строки, которую в этот момент печатает главный цикл. И по существу: строка [1] Done посреди вывода jobs выглядит как ошибка. Поэтому обработчик только помечает задание флагом, а сообщает о перемене функция report в главном цикле, в удобный момент, перед приглашением. Настоящие оболочки делают так же: bash рассказывает про законченные фоновые задания перед очередным приглашением.
foreground переживает завершение. Когда переднее задание закончилось, флаг foreground не сбрасывается. По нему report решит, печатать ли строку Done: про переднее задание, которое просто отработало, говорить нечего, ты и так видишь приглашение. А остановленное переднее задание флаг теряет (это делает exec.waitForeground), потому что оно уходит в фон.
Номера заданий переиспользуются. remove пересчитывает next_jid как максимум занятых номеров плюс один. Закончилось задание 2, осталось задание 1: следующее снова получит номер 2. Так же ведёт себя bash, и без этого после часа работы ты бы набирал fg %147.
@splat(.{}). Массив из шестнадцати заданий со значениями по умолчанию. Встроенная функция @splat размножает одно значение на весь массив, а .{} это задание, у которого все поля взяты из объявления структуры: jid = 0, то есть ячейка свободна.
$ zig test src/jobs.zig
All 7 tests passed.
signals.zig: три обработчика
Теперь та часть, ради которой были пять предыдущих уроков. Файл короткий, но в нём нет ни одной случайной строки.
//! Обработчики сигналов оболочки. Внутри только async-signal-safe вызовы:
//! `waitpid`, `kill` и правка таблицы фиксированного размера. Печати нет,
//! про перемены пользователю расскажет главный цикл.
const std = @import("std");
const sys = @import("sys.zig");
const jobs = @import("jobs.zig");
/// Единственная таблица заданий. Глобальная, потому что обработчику
/// сигнала аргументы не передать.
pub var table: jobs.Table = .{};
pub fn install() void {
sys.handle(.CHLD, onChild);
sys.handle(.INT, onForward);
sys.handle(.TSTP, onForward);
// Оболочка зовёт `tcsetpgrp`, когда сама сидит в фоновой группе.
// Без этого ядро остановило бы её сигналом SIGTTOU.
sys.handle(.TTOU, sys.SIG.IGN);
sys.handle(.TTIN, sys.SIG.IGN);
}
/// В потомке перед `execvp`: игнорируемые сигналы переживают exec,
/// поэтому всё возвращаем к действию по умолчанию.
pub fn reset() void {
for ([_]sys.SIG{ .CHLD, .INT, .TSTP, .TTOU, .TTIN }) |sig| sys.handle(sig, sys.SIG.DFL);
}
/// SIGCHLD: собираем всех, кто успел измениться. Сигналы не копятся в
/// очередь, один SIGCHLD может значить нескольких потомков, отсюда цикл.
fn onChild(_: sys.SIG) callconv(.c) void {
const saved = sys.errno();
defer sys.setErrno(saved);
while (true) {
var status: c_int = 0;
const pid = sys.waitpid(-1, &status, sys.W.NOHANG | sys.W.UNTRACED);
if (pid <= 0) break;
table.update(pid, decode(@bitCast(status)));
}
}
/// SIGINT и SIGTSTP: пересылаем всей группе переднего задания. Минус
/// перед pgid значит "всем в группе".
fn onForward(sig: sys.SIG) callconv(.c) void {
const saved = sys.errno();
defer sys.setErrno(saved);
if (table.foregroundJob()) |job| sys.kill(-job.pgid, sig);
}
fn decode(status: u32) jobs.Event {
if (sys.W.IFSTOPPED(status)) return .{ .stopped = @intFromEnum(sys.W.STOPSIG(status)) };
if (sys.W.IFSIGNALED(status)) return .{ .killed = @intFromEnum(sys.W.TERMSIG(status)) };
return .{ .exited = sys.W.EXITSTATUS(status) };
}
Глобальная таблица. В Zig глобальное изменяемое состояние принято избегать, и весь остальной курс так и делает. Здесь выбора нет: сигнатура обработчика задана ядром, fn (SIG) callconv(.c) void, и передать в него указатель на таблицу нечем. Единственная глобальная переменная проекта живёт в этом модуле, рядом с теми, кто без неё не может.
onChild: цикл, а не один waitpid. Сигналы не копятся в очередь. Если три потомка завершились, пока SIGCHLD был заблокирован, доставлен он будет один раз. Обработчик, который зовёт waitpid однократно, оставит двух зомби и два вечно живых задания в таблице. Поэтому цикл: собираем всех, кто успел измениться, пока waitpid не ответит “больше никого”.
Флаги у waitpid такие:
-1первым аргументом: любой потомок. Оболочка не знает, кто именно изменился.WNOHANG: не ждать. Если готовых потомков нет, вернуть ноль сразу. Без этого флага обработчик повис бы вwaitpidдо смерти самого долгого фонового задания, а с ним и вся оболочка.WUNTRACED: сообщать и про остановленных потомков, не только про завершившихся. Без него оболочка не узнала бы про Ctrl-Z.
Условие выхода pid <= 0 покрывает оба конца: ноль значит “живые потомки есть, новостей нет”, минус единица с ECHILD значит “потомков нет вообще”.
errno сохраняется и возвращается. Обработчик может вклиниться между системным вызовом главного потока и проверкой его errno. Главный цикл позвал read, получил минус единицу и EINTR, и тут приходит ещё один SIGCHLD. Внутри обработчика waitpid в конце цикла вернёт ошибку и запишет в errno значение ECHILD. Главный поток проснётся, посмотрит в errno, увидит там не EINTR, решит, что ввод сломан, и оболочка выйдет. Пара строк с defer закрывает эту дыру навсегда. Такую ошибку почти невозможно поймать тестом, её можно только не сделать.
decode. Статус из waitpid это упакованное слово, устройство которого ты разбирал в уроке про процессы. Макросы WIFSTOPPED, WTERMSIG и остальные в Zig стали функциями в std.c.W, по платформе на каждую. Порядок проверок важен: сначала остановка, потом смерть от сигнала, и только остаток считается обычным выходом.
onForward: минус перед pgid. Если сигнал пришёл самой оболочке, она пересылает его переднему заданию, причём всей группе: kill(-pgid, sig). Послать сигнал одному pid это классическая ошибка в shlab. Она не видна, пока задание состоит из одного процесса, и превращается в бессмертных сирот, как только команда сама делает fork. Для этого случая у нас будет отдельная подопытная программа и отдельный сценарий.
Если переднего задания нет, обработчик не делает ничего. Но сигнал не пропал зря: он прервал read в главном цикле, и оболочка пережила Ctrl-C, а не умерла, как умерла бы с действием по умолчанию.
SIGTTOU и SIGTTIN игнорируются. Вспомни правило из начала урока: процесс из фоновой группы, который зовёт tcsetpgrp, получает SIGTTOU. Когда оболочка отдала терминал заданию, фоновой группой стала она сама. Задание закончилось, оболочка хочет забрать терминал, зовёт tcsetpgrp и получает сигнал, действие которого по умолчанию “остановить процесс”. Оболочка, остановленная собственным ядром за попытку вернуть себе клавиатуру, это тупик. Игнорирование SIGTTOU снимает его: так поступают все оболочки с управлением заданиями.
reset: в потомке всё возвращаем назад. Из урока про процессы ты помнишь, что execve сбрасывает пойманные сигналы к действию по умолчанию: адрес обработчика в новой программе не имеет смысла. Но игнорируемые сигналы остаются игнорируемыми и после execve. Если бы потомок унаследовал наше SIG_IGN для SIGTTOU и SIGTTIN, запущенный из оболочки cat & мог бы читать терминал из фона, отбирая у тебя нажатия. Поэтому перед execvp потомок явно возвращает все пять сигналов к SIG_DFL.
exec.zig: запуск конвейера
Самый плотный файл проекта. В ста пятидесяти строках встречаются fork, setpgid, pipe, dup2, open, execvp, tcsetpgrp, sigprocmask и sigsuspend, и почти каждая пара соседних строк стоит именно в этом порядке по причине, которую можно назвать. Сначала листинг целиком, потом разбор по темам.
//! Запуск конвейера: `fork` на каждую команду, общая группа процессов,
//! пайпы между соседями, переадресация, `execvp`. И ожидание переднего
//! задания через `sigsuspend`.
const std = @import("std");
const sys = @import("sys.zig");
const jobs = @import("jobs.zig");
const parse = @import("parse.zig");
const signals = @import("signals.zig");
/// Стандартный ввод оболочки это терминал: тогда его надо передавать
/// переднему заданию и забирать обратно.
pub var interactive = false;
pub fn launch(arena: std.mem.Allocator, line: parse.Line, text: []const u8) !void {
if (line.commands.len > jobs.max_procs) return error.TooManyProcesses;
// Блокируем сигналы заданий ДО fork. Иначе потомок может умереть, а
// обработчик SIGCHLD отработать раньше, чем задание попадёт в таблицу:
// запись о мертвеце осталась бы в ней навсегда.
const old = sys.block();
defer sys.restore(&old);
var pids: [jobs.max_procs]sys.pid_t = undefined;
var started: usize = 0;
var pgid: sys.pid_t = 0;
var input: sys.fd_t = -1;
// Конвейер не собрался до конца: уже запущенных добиваем, обработчик
// SIGCHLD их соберёт, а таблица про них не знает и не узнает.
errdefer if (pgid > 0) sys.kill(-pgid, .KILL);
for (line.commands, 0..) |command, i| {
const argv = try toArgv(arena, command.argv);
const last = i + 1 == line.commands.len;
var fds: [2]sys.fd_t = .{ -1, -1 };
if (!last and sys.pipe(&fds) != 0) return error.PipeFailed;
const pid = sys.fork();
if (pid < 0) return error.ForkFailed;
if (pid == 0) {
if (fds[0] >= 0) _ = sys.close(fds[0]);
child(command, argv, pgid, input, fds[1], !line.background, &old);
}
// Группу назначают оба, родитель и потомок: кто бы ни успел
// первым, к моменту `kill(-pgid)` группа уже есть.
if (i == 0) pgid = pid;
_ = sys.setpgid(pid, pgid);
if (input >= 0) _ = sys.close(input);
if (fds[1] >= 0) _ = sys.close(fds[1]);
input = fds[0];
pids[started] = pid;
started += 1;
}
const job = try signals.table.add(pids[0..started], !line.background, text);
if (line.background) {
sys.print("[{d}] ({d}) {s}\n", .{ job.jid, job.pgid, job.cmdline() });
} else {
waitForeground(job, &old);
}
}
/// Ждёт, пока задание перестанет быть передним: закончится или встанет.
/// Зовётся с заблокированными сигналами; `sigsuspend` атомарно ставит
/// старую маску и засыпает, так что сигнал между проверкой и сном не
/// потеряется.
pub fn waitForeground(job: *jobs.Job, old: *const sys.sigset_t) void {
if (interactive) _ = sys.tcsetpgrp(0, job.pgid);
while (job.state == .running) _ = sys.sigsuspend(old);
// Остановленное задание уходит в фон. Законченное метку хранит, по ней
// главный цикл решает, печатать ли строку Done.
if (job.state == .stopped) job.foreground = false;
if (interactive) _ = sys.tcsetpgrp(0, sys.getpgrp());
}
fn child(
command: parse.Command,
argv: [*:null]const ?[*:0]const u8,
pgid: sys.pid_t,
input: sys.fd_t,
output: sys.fd_t,
foreground: bool,
old: *const sys.sigset_t,
) noreturn {
_ = sys.setpgid(0, pgid);
// SIGTTOU пока игнорируется (унаследовано от оболочки), поэтому
// потомок из фоновой группы может забрать терминал сам.
if (interactive and foreground) _ = sys.tcsetpgrp(0, sys.getpgrp());
if (input >= 0) moveFd(input, 0);
if (output >= 0) moveFd(output, 1);
if (command.stdin) |path| {
const fd = std.c.open(path, .{ .ACCMODE = .RDONLY });
if (fd < 0) fail(path, "не открывается на чтение");
moveFd(fd, 0);
}
if (command.stdout) |redirect| {
const fd = std.c.open(
redirect.path,
.{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = !redirect.append, .APPEND = redirect.append },
@as(c_uint, 0o644),
);
if (fd < 0) fail(redirect.path, "не открывается на запись");
moveFd(fd, 1);
}
// Обработчики сбрасываем до снятия блокировки: отложенный SIGTSTP
// должен остановить потомка, а не запустить в нём обработчик оболочки.
signals.reset();
sys.restore(old);
_ = sys.execvp(argv[0].?, argv);
fail(std.mem.span(argv[0].?), "команда не найдена");
}
fn moveFd(from: sys.fd_t, to: sys.fd_t) void {
_ = sys.dup2(from, to);
_ = sys.close(from);
}
fn fail(name: []const u8, what: []const u8) noreturn {
sys.eprint("{s}: {s}\n", .{ name, what });
sys._exit(127);
}
fn toArgv(arena: std.mem.Allocator, words: []const parse.Word) ![*:null]const ?[*:0]const u8 {
const argv = try arena.allocSentinel(?[*:0]const u8, words.len, null);
for (words, argv) |word, *slot| slot.* = word.ptr;
return argv.ptr;
}
Гонка при регистрации задания
Эту гонку ты уже видел в уроке про сигналы, там она была учебным примером. Здесь она настоящая. Родитель делает fork, потом записывает задание в таблицу. Потомок тем временем может успеть запуститься, отработать и умереть. Если SIGCHLD придёт раньше записи, обработчик не найдёт pid в таблице и ничего не сделает (тест “чужой pid не трогает таблицу” проверяет именно это). А потом родитель запишет в таблицу задание, которого уже нет. Второго SIGCHLD не будет, и мертвец останется в списке jobs навсегда.
Лечение ты знаешь: заблокировать SIGCHLD до fork и снять блокировку после записи. Сигнал, пришедший в промежутке, ядро придержит и доставит, когда маска откроется, а к тому моменту задание уже в таблице. В коде это первые две строки launch: sys.block() и defer sys.restore(&old). Конструкция defer здесь работает как страховка: из функции пять выходов с ошибкой, и маска восстановится на каждом.
Теоретическая гонка убеждает хуже, чем наблюдаемая, так что давай её устроим. Окно между fork и записью у нас узкое, микросекунды, а потомку на execvp и работу нужна хотя бы миллисекунда. Чтобы гонка проявилась наверняка, расширим окно паузой в пятьдесят миллисекунд перед table.add, а блокировку уберём (маску old оставим, она нужна дальше):
// Было: const old = sys.block(); defer sys.restore(&old);
var old: sys.sigset_t = undefined;
std.posix.sigprocmask(sys.SIG.BLOCK, null, &old);
// ... цикл с fork без изменений ...
const pause: std.c.timespec = .{ .sec = 0, .nsec = 50_000_000 };
_ = std.c.nanosleep(&pause, null);
const job = try signals.table.add(pids[0..started], !line.background, text);
Сценарий из трёх строк: короткая фоновая команда, секунда ожидания, jobs.
/bin/echo hi &
./myspin 1
jobs
Вывод испорченной оболочки (Linux, Debian 12 в контейнере):
hi
[1] (323) /bin/echo hi &
[1] (323) Running /bin/echo hi &
echo отработал и напечатал hi ещё до того, как оболочка сообщила про задание. Процесса 323 давно нет, а jobs уверяет, что он Running, и будет уверять до выхода из оболочки. Теперь вернём блокировку и оставим паузу:
hi
[1] (542) /bin/echo hi &
[1] (542) Done /bin/echo hi &
Пауза та же, потомок так же умер раньше записи, но SIGCHLD дождался своей очереди. Убери паузу из обоих вариантов, и испорченная оболочка почти всегда будет работать правильно. В этом и коварство: гонка с окном в микросекунды проходит все тесты на твоей машине и стреляет раз в месяц на загруженном сервере.
Мы блокируем не один SIGCHLD, а всю тройку. SIGINT и SIGTSTP тоже читают таблицу, через foregroundJob. Если Ctrl-C придёт между fork и записью задания, обработчик не найдёт переднего задания и сигнал пропадёт. С блокировкой он подождёт и будет переслан уже существующему заданию. На этом свойстве держатся наши сценарные тесты, к нему мы ещё вернёмся.
Потомок: обработчики, потом маска, потом exec
Потомок наследует от родителя всё, включая заблокированную тройку сигналов и обработчики оболочки. И то и другое надо вернуть к исходному состоянию перед execvp: программа, запущенная с заблокированным SIGINT, не реагировала бы на Ctrl-C. Порядок этих двух действий важен, и комментарий в коде говорит, почему. Пусть SIGTSTP пришёл, пока потомок ещё не дошёл до execvp. Сигнал заблокирован и ждёт. Если сначала снять блокировку, он будет доставлен немедленно, и в потомке запустится обработчик оболочки, onForward, с копией таблицы заданий внутри потомка. Ничего хорошего он там не сделает. Если сначала вернуть действие по умолчанию, отложенный SIGTSTP после снятия блокировки просто остановит потомка, как и положено.
Обрати внимание, что restore(old) ставит именно ту маску, которая была у оболочки до block. Не пустую: если саму оболочку запустили с какими-то заблокированными сигналами, потомки унаследуют их честно.
Группа: setpgid зовут оба
Первый процесс конвейера основывает группу, остальные в неё вступают. Вызов setpgid(pid, pgid) стоит и в родителе, и в потомке (там он записан как setpgid(0, pgid), ноль значит “я сам”). Это не перестраховка, а закрытие ещё одной гонки. После fork неизвестно, кто побежит первым.
- Если группу назначает только потомок, родитель может успеть позвать
kill(-pgid, sig)илиtcsetpgrpраньше, чем группа появилась, и получит ошибкуESRCH. - Если только родитель, потомок может успеть сделать
execvpи начать работу в группе оболочки. Тогда сигнал, адресованный оболочке, заденет и его. К тому же послеexecveродитель уже не имеет права менять группу потомка:setpgidвернётEACCES.
Когда зовут оба, кто бы ни успел первым, к нужному моменту группа уже правильная. Второй вызов делает то, что уже сделано, и его результат нас не интересует. Для первого процесса pgid в момент fork равен нулю, и в потомке setpgid(0, 0) означает “основать группу с моим pid”, а родитель тут же запоминает pgid = pid и зовёт setpgid(pid, pid): то же самое с другой стороны.
Проверим на живой системе. У Linux есть /proc, и группу каждого процесса можно прочитать из /proc/PID/stat: пятое поле. Короткий сценарий для sh:
for f in /proc/[0-9]*/stat; do
read pid comm state ppid pgid rest < $f
echo "$pid $ppid $pgid $state $comm"
done
Запускаем в оболочке одиночное фоновое задание, фоновый конвейер из трёх команд и этот сценарий (колонки: pid, ppid, pgid, состояние, имя):
[1] (546) ./myspin 5 &
[2] (547) /bin/sleep 4 | /bin/sleep 4 | /bin/sleep 4 &
1 0 1 S (sh)
545 1 1 S (oursh)
546 545 546 R (myspin)
547 545 547 S (sleep)
548 545 547 S (sleep)
549 545 547 S (sleep)
550 545 550 R (sh)
Родитель у всех один, оболочка с pid 545. А группы разные: myspin один в группе 546, три sleep делят группу 547 по pid первого из них, и даже сам сценарий, переднее задание, сидит в собственной группе 550. Оболочка осталась в группе 1, в которой её запустили.
Пайпы: закрыть всё лишнее
Конвейер из N команд это N процессов и N минус один пайп. Цикл в launch на каждом шаге держит в руках не больше трёх дескрипторов: input, читающий конец пайпа от предыдущей команды, и пара fds нового пайпа к следующей. У последней команды нового пайпа нет.
Подробно три таблицы ядра, которые стоят за дескрипторами, и то, как fork и dup2 меняют связи между ними, мы разберём в уроке про общие файлы, переадресацию и пайпы. Сейчас хватит одного правила: читатель пайпа увидит конец файла, только когда закрыты все копии пишущего конца. Все, во всех процессах. После fork копий у каждого дескриптора становится вдвое больше, и забытая копия означает конвейер, который никогда не закончится: sort будет вечно ждать данных от пишущего конца, который держит, сам того не зная, родитель.
Поэтому дисциплина такая. Потомок закрывает читающий конец нового пайпа (fds[0]), он нужен не ему, а следующей команде. Свои два конца он переносит на место стандартных дескрипторов функцией moveFd: dup2(from, to) делает to копией from, после чего оригинал закрывается. Родитель после fork закрывает input и fds[1]: оба уже живут в потомке. Себе он оставляет только fds[0], который на следующем шаге станет input для следующей команды. К концу цикла у оболочки не остаётся ни одного открытого конца.
Переадресация: open и dup2
Переадресация это тот же приём с другим источником дескриптора. < in.txt: открыть файл на чтение, перенести дескриптор на место нуля. > out.txt: открыть на запись с O_CREAT и O_TRUNC, перенести на место единицы. >> отличается одним флагом: вместо O_TRUNC стоит O_APPEND, и ядро перед каждой записью само ставит позицию в конец файла. Запущенная программа ничего не замечает, она как писала в дескриптор 1, так и пишет.
Три детали. Переадресация применяется после пайпов, поэтому в a | b > out.txt у последней команды выигрывает файл, а в странной строке a > f | b первая команда пишет в файл, и b сразу видит конец ввода. Права нового файла 0o644 пройдут через umask процесса, как у любой программы. И приведение @as(c_uint, 0o644) здесь не украшение: open в C это функция с переменным числом аргументов, и третий аргумент обязан иметь тип, который C передал бы через ..., то есть не уже int.
Если файл не открылся или команда не нашлась, потомок пишет сообщение в stderr и выходит через _exit(127). Именно _exit, без обработчиков atexit и сброса буферов: потомок это копия оболочки, и всё, что оболочка собиралась сделать на выходе, потомку делать нельзя. Код 127 это старое соглашение оболочек: “команда не найдена”.
Терминал: кому принадлежит клавиатура
interactive выставляется в main один раз: isatty(0). Если стандартный ввод это терминал, переднему заданию надо отдать клавиатуру. tcsetpgrp(0, pgid) зовут снова оба, и родитель в waitForeground, и потомок перед execvp, по той же причине, что и setpgid: программа, которая первым делом читает терминал, не должна начать раньше, чем получила его. Потомок в этот момент сидит в фоновой группе и получил бы за tcsetpgrp сигнал SIGTTOU, но игнорирование унаследовано от оболочки, а signals.reset() стоит ниже. Порядок строк опять несёт смысл.
После ожидания оболочка возвращает терминал себе: tcsetpgrp(0, getpgrp()).
Ожидание: sigsuspend
Осталось самое тонкое место: как оболочке дождаться переднего задания. waitpid в главном потоке звать нельзя, потомков собирает обработчик SIGCHLD, и двух хозяев у waitpid быть не должно. Значит, главный поток должен спать, пока обработчик не поменяет состояние задания. В раздаточном коде shlab это цикл с sleep(1): проверил, поспал секунду, проверил. Работает, но каждая команда завершается с задержкой до секунды. Уменьшать интервал значит жечь процессор. Нужен сон до сигнала.
Наивная версия сна до сигнала выглядит так:
while (job.state == .running) {
// <-- SIGCHLD пришёл здесь
_ = pause();
}
Между проверкой и pause есть окно. Если SIGCHLD придёт в него, обработчик отработает, состояние сменится, а главный поток, который уже решил, что задание бежит, уснёт в pause и не проснётся: сигнал, которого он ждёт, уже был. Блокировать сигнал на время проверки бесполезно: тогда его придётся разблокировать перед pause, и окно просто переедет на строчку ниже.
Нужна операция, которая делает два действия атомарно: ставит маску и засыпает. Это sigsuspend, вызов sigsuspend(mask). Мы входим в цикл с заблокированными сигналами, так что между проверкой и сном ничего прийти не может. sigsuspend(old) одним движением открывает маску и засыпает. Сигнал, который ждал с самого fork, доставляется сразу. Обработчик отрабатывает, sigsuspend возвращается, и возвращается он с восстановленной блокировкой, так что следующая проверка условия снова защищена.
Почему цикл, а не один вызов? sigsuspend будит любой сигнал: SIGCHLD от чужого фонового задания, SIGINT, который мы переслали и который ещё никого не убил. Проснулись, проверили, задание всё ещё бежит, спим дальше.
И последнее. Условие цикла читает поле, которое меняет обработчик. В уроке про сигналы для такого флага требовался volatile или атомарный тип, иначе оптимизатор вправе прочитать его один раз и превратить цикл в бесконечный. Здесь спасает то, что в теле цикла стоит вызов внешней функции. Компилятор не видит, что внутри sigsuspend, обязан считать, что она может изменить любую доступную ей память, и перечитывает job.state после каждого возврата. Это законное рассуждение, но хрупкое: вставь вместо sigsuspend пустое тело, и цикл сломается в сборке с оптимизацией.
После цикла задание либо закончено, либо остановлено. Остановленное теряет флаг foreground и остаётся в таблице как фоновое, про него расскажет report.
main.zig: главный цикл и встроенные команды
Все опасные места позади. Главный модуль связывает остальные: читает строку, разбирает, решает, встроенная это команда или внешняя, и рассказывает про перемены в заданиях.
//! oursh: оболочка с управлением заданиями.
//!
//! Главный цикл: рассказать про перемены в заданиях, показать приглашение,
//! прочитать строку, разобрать, выполнить встроенную команду или запустить
//! конвейер.
const std = @import("std");
const sys = @import("sys.zig");
const jobs = @import("jobs.zig");
const parse = @import("parse.zig");
const exec = @import("exec.zig");
const signals = @import("signals.zig");
const prompt = "oursh> ";
pub fn main() void {
exec.interactive = std.c.isatty(0) != 0;
signals.install();
var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena_state.deinit();
var reader: LineReader = .{};
while (true) {
report();
if (exec.interactive) sys.writeAll(1, prompt);
const text = reader.next() orelse break;
_ = arena_state.reset(.retain_capacity);
if (!run(arena_state.allocator(), text)) break;
}
hangUp();
}
/// Выполняет одну строку. Ложь значит "пора выходить".
fn run(arena: std.mem.Allocator, text: []const u8) bool {
const line = parse.parse(arena, text) catch |err| {
sys.print("ошибка разбора: {s}\n", .{describe(err)});
return true;
};
if (line.commands.len == 0) return true;
// Встроенные команды работают только в одиночку, не в конвейере.
if (line.commands.len == 1) {
const argv = line.commands[0].argv;
if (std.mem.eql(u8, argv[0], "quit")) return false;
if (std.mem.eql(u8, argv[0], "jobs")) return listJobs();
if (std.mem.eql(u8, argv[0], "bg")) return resumeJob(argv, false);
if (std.mem.eql(u8, argv[0], "fg")) return resumeJob(argv, true);
}
exec.launch(arena, line, std.mem.trim(u8, text, " \t\r\n")) catch |err| {
sys.print("не удалось запустить: {s}\n", .{@errorName(err)});
};
return true;
}
fn describe(err: parse.Error) []const u8 {
return switch (err) {
error.UnterminatedQuote => "кавычка не закрыта",
error.MissingCommand => "пустая команда",
error.MissingRedirectTarget => "после переадресации нет имени файла",
error.MisplacedAmpersand => "& стоит не в конце строки",
error.OutOfMemory => "кончилась память",
};
}
fn listJobs() bool {
const old = sys.block();
defer sys.restore(&old);
var buf: [jobs.max_jobs]*jobs.Job = undefined;
for (signals.table.sorted(&buf)) |job| {
if (job.state == .done) continue;
const state = if (job.state == .running) "Running" else "Stopped";
sys.print("[{d}] ({d}) {s} {s}\n", .{ job.jid, job.pgid, state, job.cmdline() });
}
return true;
}
/// `bg %2`, `fg %2` или то же с pgid без процента.
fn resumeJob(argv: []const parse.Word, foreground: bool) bool {
const name = argv[0];
if (argv.len < 2) {
sys.print("{s}: нужен %номер задания или pid\n", .{name});
return true;
}
const old = sys.block();
defer sys.restore(&old);
const job = findJob(argv[1]) orelse {
sys.print("{s}: {s}: нет такого задания\n", .{ name, argv[1] });
return true;
};
if (job.state == .done) return true;
signals.table.resumed(job, foreground);
sys.kill(-job.pgid, .CONT);
if (foreground) {
sys.print("{s}\n", .{job.cmdline()});
exec.waitForeground(job, &old);
} else {
sys.print("[{d}] ({d}) {s}\n", .{ job.jid, job.pgid, job.cmdline() });
}
return true;
}
fn findJob(arg: []const u8) ?*jobs.Job {
if (arg.len > 1 and arg[0] == '%') {
const jid = std.fmt.parseInt(u32, arg[1..], 10) catch return null;
return signals.table.byJid(jid);
}
const pgid = std.fmt.parseInt(sys.pid_t, arg, 10) catch return null;
return signals.table.byPgid(pgid);
}
/// Рассказывает про задания, у которых сменилось состояние, и убирает
/// законченные. Печать живёт здесь, а не в обработчике сигнала.
fn report() void {
const old = sys.block();
defer sys.restore(&old);
var buf: [jobs.max_jobs]*jobs.Job = undefined;
for (signals.table.sorted(&buf)) |job| {
if (job.announce) {
job.announce = false;
if (job.state == .stopped) {
sys.print("Задание [{d}] ({d}) остановлено: {s}\n", .{ job.jid, job.pgid, sys.signalName(job.signal) });
} else if (job.signal != 0) {
sys.print("Задание [{d}] ({d}) убито: {s}\n", .{ job.jid, job.pgid, sys.signalName(job.signal) });
} else if (!job.foreground) {
sys.print("[{d}] ({d}) Done {s}\n", .{ job.jid, job.pgid, job.cmdline() });
}
}
if (job.state == .done) signals.table.remove(job);
}
}
/// На выходе: оставшимся заданиям SIGHUP, а остановленным ещё и SIGCONT,
/// иначе они SIGHUP не увидят.
fn hangUp() void {
const old = sys.block();
defer sys.restore(&old);
for (&signals.table.slots) |*job| {
if (job.jid == 0 or job.state == .done) continue;
sys.kill(-job.pgid, .HUP);
sys.kill(-job.pgid, .CONT);
}
}
/// Читает стандартный ввод построчно через `read`. Свой, а не из std,
/// ради EINTR: сигнал прерывает чтение, и оболочка успевает рассказать
/// про закончившееся задание, не дожидаясь Enter.
const LineReader = struct {
buf: [1024]u8 = undefined,
start: usize = 0,
end: usize = 0,
fn next(self: *LineReader) ?[]const u8 {
while (true) {
if (std.mem.indexOfScalarPos(u8, self.buf[0..self.end], self.start, '\n')) |nl| {
const line = self.buf[self.start..nl];
self.start = nl + 1;
return line;
}
// Строки нет: сдвигаем остаток в начало и дочитываем.
std.mem.copyForwards(u8, &self.buf, self.buf[self.start..self.end]);
self.end -= self.start;
self.start = 0;
if (self.end == self.buf.len) self.end = 0; // строка длиннее буфера, бросаем
const n = std.c.read(0, self.buf[self.end..].ptr, self.buf.len - self.end);
if (n < 0) {
if (sys.errno() != @intFromEnum(std.posix.E.INTR)) return null;
report();
continue;
}
if (n == 0) {
// Конец ввода. Последняя строка могла быть без перевода строки.
if (self.end == 0) return null;
const line = self.buf[0..self.end];
self.end = 0;
return line;
}
self.end += @intCast(n);
}
}
};
pub fn main() void. Во всех уроках раздела main принимал std.process.Init и доставал оттуда io, аллокатор и аргументы. Оболочке из этого набора не нужно ничего: вывод она делает своим write, аргументов у неё нет, а аллокатор нужен один, арена поверх страничного. Zig разрешает main без параметров, и мы этим пользуемся: лишнего в оболочку не передаётся.
Цикл. Пять шагов: рассказать новости, показать приглашение, прочитать строку, сбросить арену, выполнить. Приглашение печатается только в интерактивном режиме: когда ввод идёт из файла или пайпа, оно лишь засоряло бы вывод, и сценарным тестам пришлось бы его вычищать. Арена сбрасывается с retain_capacity: память предыдущей строки не возвращается системе, а переиспользуется, и после первых нескольких команд оболочка перестаёт просить память вообще.
Встроенные команды. Почему jobs, bg, fg и quit нельзя сделать обычными программами? Потому что потомок получает копию памяти оболочки. Внешняя программа jobs напечатала бы копию таблицы (это ещё полбеды), а внешняя fg поменяла бы состояние задания в своей копии и умерла вместе с ней. Всё, что должно изменить саму оболочку, обязано выполняться внутри её процесса. По той же причине встроенной бывает cd: текущий каталог это свойство процесса, и потомок, сменивший каталог, меняет его только себе.
Встроенные команды у нас распознаются, только если стоят в строке одни. jobs | wc -l пойдёт искать программу jobs и не найдёт. Настоящие оболочки для такого случая делают fork и выполняют встроенную команду в потомке, мы это ограничение честно оставляем.
bg и fg. Обе делают одно и то же: находят задание, помечают его бегущим и шлют группе SIGCONT. fg после этого ещё и ждёт задание тем же waitForeground, что и при запуске. Состояние мы правим сами, вызовом resumed, а не ждём вестей от ядра: наш waitpid зовётся без флага WCONTINUED и о продолжении потомка не сообщает. Порядок “сначала пометить, потом послать сигнал” безопасен, потому что всё происходит под блокировкой. Задание, которое получило SIGCONT и тут же умерло, не сможет обновить таблицу раньше, чем мы закончим.
Аргумент принимается в двух видах, как в shlab: %2 это номер задания, голое число это pgid. fg без аргумента у нас ошибка, хотя bash взял бы последнее задание: ещё одно упражнение.
report. Единственное место, где оболочка рассказывает про задания. Три вида сообщений: остановлено сигналом, убито сигналом, фоновое закончилось само. Про переднее задание, которое закончилось штатно, сообщать нечего. Законченные задания удаляются из таблицы здесь же, а не в обработчике: сначала рассказать, потом забыть. report зовётся перед каждым приглашением и ещё в одном месте, до которого мы сейчас дойдём.
hangUp. Оболочка выходит, а фоновые задания остались. По традиции им шлют SIGHUP, “терминал повесил трубку”: действие по умолчанию завершает процесс, а демоны, которым надо пережить выход, этот сигнал игнорируют (так работает nohup). Следом идёт SIGCONT, и он нужен остановленным заданиям. Остановленный процесс не выполняет ничего, в том числе не умирает от SIGHUP: сигнал будет висеть, пока процесс кто-нибудь не продолжит. Без SIGCONT остановленные задания пережили бы оболочку и остались висеть в системе навсегда.
LineReader и EINTR. Читать строки можно было бы через std.Io.Reader, но нам нужен доступ к тому, что стандартные читатели прячут: ошибке EINTR. Обработчики стоят без SA_RESTART, поэтому SIGCHLD от закончившегося фонового задания прерывает read. Читатель видит EINTR, зовёт report() (вот оно, второе место) и возвращается в read. Так строка Done появляется в тот момент, когда задание закончилось, а не когда ты в следующий раз нажмёшь Enter.
Сам читатель устроен как любой буферизованный ввод: буфер, два индекса, поиск перевода строки в уже прочитанном, сдвиг остатка в начало перед дочитыванием. Про такие буферы будет отдельный урок про буферизованный ввод. Одна оговорка: строка длиннее килобайта молча отбрасывается. Для учебной оболочки годится, для настоящей нет.
В этой схеме осталось одно окно, и про него надо знать. Между report() и read сигналы разблокированы. SIGCHLD, пришедший именно там, отработает, пометит задание, но read ещё не начался, прерывать нечего, и оболочка уснёт на вводе с несказанной новостью. Ты узнаешь про неё после следующей строки. Вреда от этого нет, таблица верна, опаздывает только сообщение. Закрывается окно тем же приёмом, что в sigsuspend: вызовом pselect, который атомарно открывает маску и ждёт ввода. Это вторая домашняя задача урока.
Подопытные программы
Чтобы проверять оболочку, нужны команды с предсказуемым поведением. sleep подходит плохо: он молчит, и тесту неоткуда узнать, что программа уже запущена. В shlab для этого есть четыре маленькие программы, мы повторим их с одной добавкой: каждая первым делом пишет в stderr метку имя N: start. По этой метке драйвер сценариев поймёт, что процесс жив и execvp уже позади.
Общая часть:
//! Общее для подопытных программ из трасс.
const std = @import("std");
/// Первый аргумент как число секунд, по умолчанию `fallback`.
pub fn seconds(args: std.process.Args, fallback: u32) u32 {
var it = args.iterate();
_ = it.skip();
const first = it.next() orelse return fallback;
return std.fmt.parseInt(u32, first, 10) catch fallback;
}
/// Метка в stderr: драйвер трасс ждёт её, чтобы знать, что программа уже
/// запущена. В stdout не пишем, он сверяется с эталоном.
pub fn mark(comptime name: []const u8, arg: u32) void {
var buf: [64]u8 = undefined;
const text = std.fmt.bufPrint(&buf, name ++ " {d}: start\n", .{arg}) catch return;
_ = std.c.write(2, text.ptr, text.len);
}
/// Спит по секунде, как myspin из shlab: сигнал прерывает сон, но не цикл.
pub fn spin(secs: u32) void {
for (0..secs) |_| {
const one: std.c.timespec = .{ .sec = 1, .nsec = 0 };
_ = std.c.nanosleep(&one, null);
}
}
spin спит секундными кусками не случайно. Сигнал с обработчиком прерывает nanosleep, и программа с одним длинным сном проснулась бы досрочно. Цикл по секундам теряет от прерывания самое большее одну секунду.
Здесь main принимает std.process.Init.Minimal: облегчённый вариант, в котором есть аргументы и окружение, но нет io и аллокатора.
myspin N просто живёт N секунд:
//! myspin N: просто живёт N секунд.
const std = @import("std");
const common = @import("common.zig");
pub fn main(init: std.process.Init.Minimal) void {
const secs = common.seconds(init.args, 1);
common.mark("myspin", secs);
common.spin(secs);
}
mysplit N рожает потомка и ждёт его. Потомок наследует группу, так что сигнал, посланный группе, достанет обоих, а посланный одному pid оставит потомка жить:
//! mysplit N: рожает потомка, потомок живёт N секунд, родитель его ждёт.
//! Проверяет, что сигнал оболочки достаётся всей группе, а не одному pid.
const std = @import("std");
const common = @import("common.zig");
pub fn main(init: std.process.Init.Minimal) void {
const secs = common.seconds(init.args, 1);
const pid = std.c.fork();
if (pid == 0) {
common.spin(secs);
std.c._exit(0);
}
common.mark("mysplit", secs);
_ = std.c.waitpid(pid, null, 0);
}
mystop N останавливает сам себя. Оболочка сигнала не посылала, но обязана заметить остановку:
//! mystop N: останавливает сам себя сигналом SIGTSTP, после SIGCONT живёт
//! ещё N секунд. Оболочка должна заметить остановку, которую устроила не она.
const std = @import("std");
const common = @import("common.zig");
pub fn main(init: std.process.Init.Minimal) void {
const secs = common.seconds(init.args, 1);
common.mark("mystop", secs);
_ = std.c.kill(std.c.getpid(), .TSTP);
common.spin(secs);
}
myint N сам себя убивает сигналом SIGINT:
//! myint N: убивает сам себя сигналом SIGINT. Оболочка должна сообщить
//! про смерть от сигнала, который послала не она.
const std = @import("std");
const common = @import("common.zig");
pub fn main(init: std.process.Init.Minimal) void {
const secs = common.seconds(init.args, 1);
common.mark("myint", secs);
_ = std.c.kill(std.c.getpid(), .INT);
common.spin(secs);
}
Сценарные тесты без sleep
Как проверить программу, весь смысл которой в процессах, сигналах и их взаимном порядке? Обычный тест тут бессилен. В shlab ответ такой: драйвер запускает оболочку, подаёт ей на ввод сценарий (по-английски trace, трассу) и сравнивает вывод с выводом эталонной оболочки. Между шагами сценария драйвер shlab спит: послал команду, подождал секунду, послал Ctrl-C, подождал ещё. Расчёт на то, что за секунду всё успеет.
Тесты на sleep плохи с обеих сторон. На быстрой машине они впустую тратят время, тридцать сценариев по пять секунд это две с половиной минуты ожидания на каждую правку. На медленной или загруженной они падают без причины: секунды не хватило, сигнал ушёл раньше, чем программа запустилась. А хуже всего то, что такой тест не различает “работает” и “обычно успевает”.
Наш драйвер не спит никогда. Он синхронизируется по выводу: ждёт, пока в stdout или stderr оболочки появится нужный текст, и только потом делает следующий шаг. Язык сценария:
# текст комментарий
WAITERR текст ждать, пока текст появится в stderr
WAITOUT текст ждать, пока текст появится в stdout
INT послать оболочке SIGINT (как Ctrl-C)
TSTP послать оболочке SIGTSTP (как Ctrl-Z)
всё остальное строка для оболочки
Типичный сценарий: запустить ./myspin 200 на переднем плане, дождаться в stderr метки myspin 200: start, послать INT. Метка гарантирует, что процесс существует и уже прошёл execvp. Но гарантирует ли она, что оболочка успела записать задание в таблицу? Нет, и это не страшно. Если SIGINT придёт раньше записи, он упрётся в блокировку, дождётся sigsuspend и будет переслан заданию, которое к тому моменту уже в таблице. То самое свойство, которое мы обсуждали в разделе про гонку, делает тесты детерминированными: правильная оболочка не теряет сигнал, в какой бы момент он ни пришёл, а значит драйверу незачем гадать, когда его посылать. Тесты без sleep возможны только потому, что в оболочке закрыты гонки. И наоборот: оболочка с гонкой на таких тестах рано или поздно упадёт, а не проскочит благодаря удачной паузе.
//! Сценарные тесты в духе трасс shlab. Драйвер запускает настоящую
//! оболочку, подаёт ей строки трассы на stdin, шлёт сигналы и сверяет
//! stdout с эталоном `traces/<имя>.out`.
//!
//! Строка трассы это команда для оболочки либо директива драйвера:
//!
//! # текст комментарий
//! WAITERR текст ждать, пока текст появится в stderr
//! WAITOUT текст ждать, пока текст появится в stdout
//! INT послать оболочке SIGINT (как Ctrl-C)
//! TSTP послать оболочке SIGTSTP (как Ctrl-Z)
//!
//! Никаких sleep: драйвер синхронизируется только по выводу. Подопытные
//! программы пишут в stderr метку старта, оболочка пишет в stdout строку
//! задания. Каждый WAIT ищет текст после места прошлого совпадения.
//! Pid в эталоне заменён на `(PID)`.
const std = @import("std");
const testing = std.testing;
const posix = std.posix;
const bin_dir = "zig-out/bin";
/// Машина бывает занята, поэтому ждём долго. Зелёный тест столько не ждёт.
const timeout_ms = 120_000;
/// Поток вывода оболочки: всё прочитанное и место, до которого дошли WAIT.
const Stream = struct {
fd: posix.fd_t,
data: std.ArrayList(u8) = .empty,
consumed: usize = 0,
eof: bool = false,
};
const Driver = struct {
gpa: std.mem.Allocator,
out: Stream,
err: Stream,
/// Читает оба потока, пока не появится что-то новое. Ошибка по таймауту.
fn pump(self: *Driver) !void {
var fds = [_]posix.pollfd{
.{ .fd = if (self.out.eof) -1 else self.out.fd, .events = posix.POLL.IN, .revents = 0 },
.{ .fd = if (self.err.eof) -1 else self.err.fd, .events = posix.POLL.IN, .revents = 0 },
};
if (try posix.poll(&fds, timeout_ms) == 0) return error.TraceTimeout;
for ([_]*Stream{ &self.out, &self.err }, fds) |stream, pfd| {
if (pfd.revents == 0) continue;
var buf: [4096]u8 = undefined;
const n = try posix.read(stream.fd, &buf);
if (n == 0) stream.eof = true;
try stream.data.appendSlice(self.gpa, buf[0..n]);
}
}
fn waitFor(self: *Driver, stream: *Stream, text: []const u8) !void {
while (true) {
if (std.mem.indexOfPos(u8, stream.data.items, stream.consumed, text)) |at| {
stream.consumed = at + text.len;
return;
}
if (stream.eof) return error.TraceTextNeverCame;
try self.pump();
}
}
fn drain(self: *Driver) !void {
while (!self.out.eof or !self.err.eof) try self.pump();
}
};
fn runTrace(comptime name: []const u8) !void {
const gpa = testing.allocator;
const io = testing.io;
const cwd = std.Io.Dir.cwd();
const script = try cwd.readFileAlloc(io, "traces/" ++ name ++ ".txt", gpa, .limited(1 << 16));
defer gpa.free(script);
const expected = try cwd.readFileAlloc(io, "traces/" ++ name ++ ".out", gpa, .limited(1 << 16));
defer gpa.free(expected);
var child = try std.process.spawn(io, .{
.argv = &.{"./oursh"},
.cwd = .{ .path = bin_dir },
.stdin = .pipe,
.stdout = .pipe,
.stderr = .pipe,
});
// После удачного wait это пустая операция, а упавший тест не оставит
// оболочку висеть.
defer child.kill(io);
var driver: Driver = .{
.gpa = gpa,
.out = .{ .fd = child.stdout.?.handle },
.err = .{ .fd = child.stderr.?.handle },
};
defer driver.out.data.deinit(gpa);
defer driver.err.data.deinit(gpa);
var lines = std.mem.splitScalar(u8, script, '\n');
while (lines.next()) |line| {
if (line.len == 0 or line[0] == '#') continue;
if (std.mem.startsWith(u8, line, "WAITERR ")) {
try driver.waitFor(&driver.err, line["WAITERR ".len..]);
} else if (std.mem.startsWith(u8, line, "WAITOUT ")) {
try driver.waitFor(&driver.out, line["WAITOUT ".len..]);
} else if (std.mem.eql(u8, line, "INT")) {
try posix.kill(child.id.?, .INT);
} else if (std.mem.eql(u8, line, "TSTP")) {
try posix.kill(child.id.?, .TSTP);
} else {
try sendLine(child.stdin.?.handle, line);
}
}
// Конец трассы: закрываем stdin, оболочка выходит и разгоняет задания.
// EOF на stdout придёт, когда умрёт последний, кто держит пайп, так
// что выживший после сигнала потомок уронит тест по таймауту.
child.stdin.?.close(io);
child.stdin = null;
try driver.drain();
try testing.expectEqual(std.process.Child.Term{ .exited = 0 }, try child.wait(io));
const actual = try normalize(gpa, driver.out.data.items);
defer gpa.free(actual);
try testing.expectEqualStrings(expected, actual);
}
fn sendLine(fd: posix.fd_t, line: []const u8) !void {
for ([_][]const u8{ line, "\n" }) |part| {
var rest = part;
while (rest.len > 0) {
const n = std.c.write(fd, rest.ptr, rest.len);
if (n <= 0) return error.ShellStdinClosed;
rest = rest[@intCast(n)..];
}
}
}
/// Меняет `(12345)` на `(PID)`: номера процессов от запуска к запуску разные.
fn normalize(gpa: std.mem.Allocator, text: []const u8) ![]u8 {
var result: std.ArrayList(u8) = .empty;
errdefer result.deinit(gpa);
var i: usize = 0;
while (i < text.len) {
if (text[i] == '(') {
var j = i + 1;
while (j < text.len and std.ascii.isDigit(text[j])) j += 1;
if (j > i + 1 and j < text.len and text[j] == ')') {
try result.appendSlice(gpa, "(PID)");
i = j + 1;
continue;
}
}
try result.append(gpa, text[i]);
i += 1;
}
return result.toOwnedSlice(gpa);
}
test "normalize прячет pid и не трогает остальное" {
const got = try normalize(testing.allocator, "[1] (4242) Running f(x) (12a) ()\n");
defer testing.allocator.free(got);
try testing.expectEqualStrings("[1] (PID) Running f(x) (12a) ()\n", got);
}
test "trace01: конец ввода завершает оболочку" {
try runTrace("trace01");
}
test "trace02: встроенная quit" {
try runTrace("trace02");
}
test "trace03: задание на переднем плане" {
try runTrace("trace03");
}
test "trace04: фоновое задание и jobs" {
try runTrace("trace04");
}
test "trace05: SIGINT достаётся переднему заданию" {
try runTrace("trace05");
}
test "trace06: SIGTSTP останавливает только переднее задание" {
try runTrace("trace06");
}
test "trace07: bg продолжает остановленное задание" {
try runTrace("trace07");
}
test "trace08: fg возвращает задание на передний план" {
try runTrace("trace08");
}
test "trace09: сигнал идёт всей группе процессов" {
try runTrace("trace09");
}
test "trace10: потомок остановил и убил себя сам" {
try runTrace("trace10");
}
test "trace11: пайпы и переадресация" {
try runTrace("trace11");
}
test "trace12: ошибки разбора и встроенных команд" {
try runTrace("trace12");
}
test "trace13: фоновое задание закончилось само" {
try runTrace("trace13");
}
Оболочка запускается через std.process.spawn. Это тот самый высокоуровневый интерфейс, ради которого из std.posix убрали fork. Для драйвера он идеален: три пайпа, рабочий каталог, kill и wait. Для оболочки не годится, потому что не даёт вставить свой код между fork и exec, а вся соль оболочки именно там: setpgid, tcsetpgrp, маски. Хороший пример того, что высокоуровневая обёртка и низкоуровневый вызов не заменяют друг друга.
pump и poll. Драйвер читает два потока сразу и не знает, в каком появится ожидаемый текст. Читать их по очереди блокирующим read нельзя: пока ждёшь stderr, оболочка может заполнить пайп stdout и встать. poll спит, пока хоть в одном дескрипторе не появятся данные, с общим таймаутом. Две минуты таймаута это не время работы теста, а страховка на случай зависания; зелёный прогон всех тринадцати сценариев занимает секунды. Подробнее про poll будет в блоке про конкурентные серверы.
Каждый WAIT ищет после прошлого совпадения. У потока есть позиция consumed. Без неё второй WAITOUT 100 & в одном сценарии нашёл бы тот же текст, что и первый, и ничего бы не дождался.
Сверяется только stdout. В stderr пишут подопытные программы, и порядок их меток относительно вывода оболочки зависит от планировщика. В stdout пишет только оболочка и команды вроде echo, порядок там жёсткий. Номера процессов заменяются на (PID) функцией normalize.
Конец сценария. Драйвер закрывает stdin оболочки. Та видит конец ввода, выходит из цикла и разгоняет задания через hangUp. Драйвер ждёт конца обоих потоков, и тут спрятана бесплатная проверка. Конец файла в пайпе наступает, когда пишущий конец закрыт у всех, кто его держит, а stdout оболочки наследуют все её потомки. Если хоть один потомок пережил выход оболочки, например потому, что сигнал послали одному pid вместо группы, пайп останется открытым, и тест упадёт по таймауту. Утёкший процесс сам себя выдаёт.
Тринадцать сценариев
Сценарии лежат в traces/, рядом с каждым файл .out с ожидаемым stdout. Оболочка запускается из zig-out/bin, отсюда ./myspin в командах.
Первые четыре проверяют основу: конец ввода, quit, переднее и фоновое задание. У trace01 ожидаемый вывод пуст, файл trace01.out нулевой длины.
# trace01: конец ввода (Ctrl-D) завершает оболочку.
# trace02: встроенная quit завершает оболочку, фоновое задание получает SIGHUP.
./myspin 100 &
quit
[1] (PID) ./myspin 100 &
В trace03 порядок строк before и after доказывает, что оболочка действительно ждёт переднее задание: after не может появиться, пока myspin 1 не отработал свою секунду.
# trace03: переднее задание оболочка ждёт, поэтому порядок строк жёсткий.
/bin/echo before
./myspin 1
WAITERR myspin 1: start
/bin/echo after
before
after
# trace04: фоновые задания не ждём, jobs показывает оба.
./myspin 100 &
./myspin 200 &
jobs
[1] (PID) ./myspin 100 &
[2] (PID) ./myspin 200 &
[1] (PID) Running ./myspin 100 &
[2] (PID) Running ./myspin 200 &
Следующие четыре про сигналы и встроенные команды. Во всех есть фоновое задание-свидетель: сигнал обязан достаться переднему заданию и не задеть фоновое.
# trace05: SIGINT убивает переднее задание и не трогает фоновое.
./myspin 100 &
./myspin 200
WAITERR myspin 200: start
INT
jobs
[1] (PID) ./myspin 100 &
Задание [2] (PID) убито: SIGINT
[1] (PID) Running ./myspin 100 &
# trace06: SIGTSTP останавливает переднее задание и не трогает фоновое.
./myspin 100 &
./myspin 200
WAITERR myspin 200: start
TSTP
jobs
[1] (PID) ./myspin 100 &
Задание [2] (PID) остановлено: SIGTSTP
[1] (PID) Running ./myspin 100 &
[2] (PID) Stopped ./myspin 200
# trace07: bg продолжает остановленное задание в фоне.
./myspin 100 &
./myspin 200
WAITERR myspin 200: start
TSTP
jobs
bg %2
jobs
[1] (PID) ./myspin 100 &
Задание [2] (PID) остановлено: SIGTSTP
[1] (PID) Running ./myspin 100 &
[2] (PID) Stopped ./myspin 200
[2] (PID) ./myspin 200
[1] (PID) Running ./myspin 100 &
[2] (PID) Running ./myspin 200
trace08 самый длинный. Задание уходит в фон, возвращается через fg, останавливается, возвращается снова и погибает от SIGINT. Два подряд WAITOUT 100 & после WAITOUT Stopped ждут две разные строки: хвост строки из jobs и эхо от второго fg.
# trace08: fg выводит фоновое задание вперёд, дальше его достают Ctrl-Z и Ctrl-C.
./myspin 100 &
WAITOUT 100 &
fg %1
WAITOUT 100 &
TSTP
jobs
fg %1
WAITOUT Stopped
WAITOUT 100 &
WAITOUT 100 &
INT
jobs
[1] (PID) ./myspin 100 &
./myspin 100 &
Задание [1] (PID) остановлено: SIGTSTP
[1] (PID) Stopped ./myspin 100 &
./myspin 100 &
Задание [1] (PID) убито: SIGINT
trace09 ловит посылку сигнала одному pid вместо группы, механизм ты уже знаешь: выживший потомок mysplit держит пайп, и тест падает по таймауту. Число 600 в нём намеренно большое: правильная оболочка убьёт обоих за миллисекунды, а неправильной не поможет дождаться.
# trace09: mysplit рожает потомка. Сигнал обязан дойти до всей группы:
# выживший потомок держит stdout, и драйвер упадёт по таймауту.
./mysplit 600
WAITERR mysplit 600: start
TSTP
jobs
fg %1
WAITOUT Stopped ./mysplit 600
WAITOUT ./mysplit 600
INT
jobs
Задание [1] (PID) остановлено: SIGTSTP
[1] (PID) Stopped ./mysplit 600
./mysplit 600
Задание [1] (PID) убито: SIGINT
В trace10 оболочка сигналов не посылает вообще, потомки останавливают и убивают себя сами. Проверяется, что состояние заданий оболочка берёт из waitpid, а не из собственных представлений о том, что она кому послала. Частая ошибка в shlab: помечать задание остановленным прямо в обработчике SIGTSTP. Тогда mystop остаётся для оболочки вечно бегущим передним заданием, и она зависает.
# trace10: потомок остановил себя сам, другой сам себя убил. Оболочка
# узнаёт про это из SIGCHLD, хотя сигналов не посылала.
./mystop 100
jobs
./myint 100
jobs
Задание [1] (PID) остановлено: SIGTSTP
[1] (PID) Stopped ./mystop 100
Задание [2] (PID) убито: SIGINT
[1] (PID) Stopped ./mystop 100
trace11 про пайпы и переадресацию, trace12 про ошибки, которые не должны ронять оболочку, trace13 про фоновое задание, которое закончилось само: строка Done обязана появиться без единой новой команды, её ждёт WAITOUT.
# trace11: пайпы и переадресация.
/bin/echo one two | tr a-z A-Z
/bin/echo first > out11.txt
/bin/echo second >> out11.txt
/bin/cat < out11.txt
/bin/cat < out11.txt | tr a-z A-Z | sort -r
/bin/echo 'quoted | not a pipe' > out11.txt
/bin/cat out11.txt
/bin/rm out11.txt
ONE TWO
first
second
SECOND
FIRST
quoted | not a pipe
# trace12: ошибки разбора и встроенных команд оболочку не роняют.
ls |
/bin/echo 'oops
/bin/echo hi & /bin/echo there
/bin/echo hi >
fg
bg %9
fg abc
./nosuch
WAITERR ./nosuch: команда не найдена
/bin/echo alive
ошибка разбора: пустая команда
ошибка разбора: кавычка не закрыта
ошибка разбора: & стоит не в конце строки
ошибка разбора: после переадресации нет имени файла
fg: нужен %номер задания или pid
bg: %9: нет такого задания
fg: abc: нет такого задания
alive
# trace13: фоновое задание закончилось само. Оболочка говорит об этом
# сразу, не дожидаясь следующей команды: SIGCHLD прерывает read.
./myspin 1 &
WAITOUT Done
jobs
[1] (PID) ./myspin 1 &
[1] (PID) Done ./myspin 1 &
Прогон
Linux, Debian 12 в контейнере, ядро 7.0, aarch64, Zig 0.16.0:
$ zig build test --summary all
Build Summary: 18/18 steps succeeded; 28/28 tests passed
test success
+- run test 7 pass (7 total) 7ms MaxRSS:6M
| +- compile test Debug native success 3s MaxRSS:373M
+- run test 7 pass (7 total) 9ms MaxRSS:6M
| +- compile test Debug native success 3s MaxRSS:383M
+- run test 14 pass (14 total) 2s MaxRSS:6M
+- compile test Debug native success 7s MaxRSS:385M
+- install success
+- install oursh success
| +- compile exe oursh Debug native success 6s MaxRSS:370M
+- install myspin success
| +- compile exe myspin Debug native success 6s MaxRSS:341M
+- install mysplit success
| +- compile exe mysplit Debug native success 6s MaxRSS:396M
+- install mystop success
| +- compile exe mystop Debug native success 6s MaxRSS:344M
+- install myint success
+- compile exe myint Debug native success 6s MaxRSS:344M
Двадцать восемь тестов: по семь в parse.zig и jobs.zig, тринадцать сценариев и проверка normalize. Все сценарии вместе идут около двух секунд, и почти всё это время занимают честные секунды в trace03 и trace13, где myspin 1 должен отработать до конца.
То же на macOS 26, Apple Silicon:
$ zig build test --summary all
Build Summary: 18/18 steps succeeded; 28/28 tests passed
test success
+- run test 7 pass (7 total) 61ms MaxRSS:2M
| +- compile test Debug native cached 255ms MaxRSS:35M
+- run test 7 pass (7 total) 14ms MaxRSS:2M
| +- compile test Debug native cached 192ms MaxRSS:35M
+- run test 14 pass (14 total) 2s MaxRSS:3M
+- compile test Debug native cached 182ms MaxRSS:35M
+- install cached
+- install oursh cached
| +- compile exe oursh Debug native cached 186ms MaxRSS:35M
+- install myspin cached
| +- compile exe myspin Debug native cached 160ms MaxRSS:35M
+- install mysplit cached
| +- compile exe mysplit Debug native cached 219ms MaxRSS:35M
+- install mystop cached
| +- compile exe mystop Debug native cached 219ms MaxRSS:35M
+- install myint cached
+- compile exe myint Debug native cached 180ms MaxRSS:35M
Ожидаемый вывод один на обе системы. Ради этого оболочка печатает имена сигналов вместо номеров, а сценарии пользуются только тем, что ведёт себя одинаково: /bin/echo, tr, sort, cat.
На macOS. Всё в этом уроке собирается и работает на macOS напрямую: группы процессов, сеансы,
tcsetpgrpиsigsuspendэто POSIX, и ядро XNU унаследовало их от BSD, где управление заданиями и было придумано. Отличия мелкие. Номера сигналов другие (SIGTSTP18,SIGCHLD20,SIGCONT19 против 20, 17 и 18 на Linux), поэтому никогда не пиши номера числами./procнет, группы процессов смотри командойps -o pid,ppid,pgid,stat,command: плюс в колонкеSTATпомечает группу переднего плана терминала. Утилиты из BSD форматируют вывод иначе:wc -lна macOS печатает число с отступом в семь пробелов, на Linux без него, и сценарий сwcдал бы разный эталон. Libc на macOS (libSystem) подключается всегда, флагlink_libcтам ничего не меняет, а на Linux он решает, будет ли программа статической без libc или динамической с glibc. И наконецstd.os.linux.forkна macOS просто не существует, поэтому выборstd.cдля оболочки это ещё и выбор переносимости.
Чего в этой оболочке нет
Честный список, чтобы ты не принял учебную оболочку за настоящую.
- Встроенные команды не работают в конвейере и не умеют переадресацию.
- Нет экранирования обратной чертой, переменных, подстановки команд, шаблонов имён файлов,
2>,&&,||и;. - Режимы терминала не сохраняются. Настоящая оболочка запоминает
tcgetattrдля каждого остановленного задания: еслиvimостановлен по Ctrl-Z, терминал надо вернуть из сырого режима в обычный, а приfgснова в сырой. fgсначала шлётSIGCONT, а потом отдаёт терминал. Задание, которое сразу после пробуждения читает клавиатуру, может успеть получитьSIGTTINи остановиться снова. Правильный порядок обратный, и это одно из упражнений.- Продолжение задания чужим
SIGCONTоболочка не замечает: первая домашняя задача. - Окно между
reportиread: вторая домашняя задача.
Практика
Из шести модулей оболочки в песочнице курса можно запустить только чистые: fork, сигналов и терминала там нет. Поэтому задача про разбор строки. Она нарочно поставлена иначе, чем parse.zig из урока, чтобы не сводиться к переписыванию листинга: аллокатора нет вообще. Слова это срезы исходной строки, а не копии, команды лежат в массивах фиксированного размера, и к ошибкам добавляются TooManyArgs и TooManyCommands. Отсюда и другое правило кавычек: раз слово обязано быть непрерывным куском исходного текста, склеить pre"in side"post в одно слово нельзя, и кавычка открывает слово только в его начале. Переадресации >> в задаче нет.
Подумай, прежде чем писать, чем ты платишь за отказ от аллокатора и что получаешь взамен. Такой разбор можно было бы позвать даже из обработчика сигнала.
Упражнения
Итоги
- Оболочка с управлением заданиями ведёт учёт не процессов, а заданий. Задание это конвейер, а для ядра это группа процессов: pgid равен pid первой команды, сигнал всей группе шлёт
kill(-pgid, sig). - У терминала есть группа переднего плана. Ей ядро шлёт
SIGINTпо Ctrl-C иSIGTSTPпо Ctrl-Z, только ей разрешено читать клавиатуру. Оболочка отдаёт терминал заданию черезtcsetpgrpи забирает обратно, а чтобы не получить за этоSIGTTOU, игнорирует его. Без терминала оболочка пересылает сигналы группе сама. setpgidиtcsetpgrpзовут и родитель, и потомок: послеforkнеизвестно, кто побежит первым, а так группа готова в любом случае.- Таблицу заданий правит обработчик
SIGCHLD. Поэтому в ней нет аллокатора, главный поток трогает её только с заблокированными сигналами, а обработчик зовётwaitpidв цикле сWNOHANG | WUNTRACED, сохраняетerrnoи ничего не печатает. - Сигналы заданий блокируются до
forkи разблокируются после записи задания в таблицу. Иначе потомок может умереть раньше записи, и мертвец останется в таблице навсегда. Потомок передexecvpсначала возвращает обработчики по умолчанию, потом снимает блокировку. - Переднее задание оболочка ждёт через
sigsuspend: смена маски и засыпание неделимы, и сигнал не может проскочить между проверкой условия и сном.pauseс проверкой флага здесь содержит гонку, цикл соsleepтормозит каждую команду. - В конвейере каждый лишний открытый конец пайпа это зависший читатель. Потомок закрывает чужой конец и переносит свои на дескрипторы 0 и 1 через
dup2, родитель послеforkзакрывает всё, что отдал. Переадресация этоopenплюс тот жеdup2. - Чистые модули (разбор строки, таблица заданий) проверяются обычными тестами, а всё про процессы проверяют сценарии на живой оболочке. Драйвер сценариев не спит, а ждёт вывода, и это возможно ровно потому, что правильная оболочка не теряет сигнал, когда бы он ни пришёл.
Дальше
Блок про исключения, процессы и сигналы закрыт. Ты прошёл путь от таблицы исключений процессора до программы, которая дирижирует чужими процессами, и по дороге заметил, сколько раз звучало слово “копия”: fork копирует память, потомок получает копию таблицы заданий, копию дескрипторов, копию маски. Оболочка, которая на каждую команду по-настоящему копировала бы свои мегабайты, была бы невыносимо медленной. На деле fork почти ничего не копирует, и чтобы понять, как это возможно, надо разобраться в том, что такое адрес, который видит процесс. Следующий блок про виртуальную память, и начинается он с виртуальных адресов и страниц. А к дескрипторам, dup2 и пайпам мы вернёмся в уроке про общие файлы и переадресацию, уже зная, что происходит по ту сторону системного вызова.
домашка