Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Буферизованный ввод-вывод и метаданные файлов
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Буферизованный ввод-вывод и метаданные файлов
В прошлом уроке ты узнал неприятную правду про
read: он отдаёт столько байт, сколько есть сейчас, а не сколько просили, и написалreadnсwriten, которые дочитывают и дописывают циклом. Этого хватает, пока ты знаешь, сколько байт тебе нужно. Но текст устроен иначе: строка кончается там, где встретился\n, и заранее её длина неизвестна. Честный способ найти конец строки черезreadодин: читать по байту. На файле в десять мегабайт это десять миллионов системных вызовов и три секунды вместо четырёх миллисекунд. Сегодня мы разберём лекарство, которое стоит в каждой стандартной библиотеке: буфер в памяти программы. Сначала напишем его сами по образцу пакета RIO из книги, потом найдём тот же механизм внутриstd.Io.Readerи увидим черезstrace, что они делают одно и то же. Потом посмотрим на обратную сторону,Writerи забытыйflush, спросим у ядра метаданные файла, обойдём каталог, скопируем файл блоками разного размера и замерим. А в конце научимzboxзабирать вывод чужой программы через два пайпа и не зависать при этом.
Цели урока
- Объяснить, зачем чтению нужен буфер в пользовательской памяти, и назвать цену системного вызова в числах на своей машине.
- Написать
Rioсread,readlinebиreadnbповерх сырогоreadи посчитать походы в ядро своим счётчиком. - Узнать тот же механизм в
std.Io.Reader: поляbuffer,seek,end, семействоtake*иpeek*, ошибкаStreamTooLong. - Проверить число системных вызовов через
strace -cи уметь читать его таблицу. - Воспроизвести ошибку смешивания буферизованного и небуферизованного чтения на одном дескрипторе и объяснить, куда делись байты.
- Понимать, когда
Writerна самом деле зовётwrite, и что бывает с выводом безflush. - Получить метаданные файла через
stat, обойти каталог и объяснить, почему размер файла не лежит в записи каталога. - Скопировать файл блоками и выбрать размер буфера по замеру, а не на глаз.
- Захватить stdout и stderr дочернего процесса двумя пайпами через
pollи объяснить, почему чтение по очереди приводит к тупику.
Идея: системный вызов дорог, память дешёвая
Вспомни урок, где ты звал сисколл руками через ассемблерную вставку: syscall это не вызов функции. Процессор меняет уровень привилегий, ядро сохраняет регистры, проверяет дескриптор и указатель, находит файл, копирует байты из своего кэша страниц в твою память и возвращается обратно. На железе 2026 года один read на один байт из файла, который уже лежит в кэше страниц, стоит около 300 наносекунд (замер ниже, Apple M-серия, macOS 15; на Linux x86-64 с защитами от Spectre цифра того же порядка). Копирование одного байта внутри программы стоит меньше наносекунды. Разница в три порядка, и вся она накладные расходы: полезная работа в обоих случаях одна и та же, переместить байт.
Отсюда идея, которой полвека. Раз поход в ядро дорог, а стоит он почти одинаково за один байт и за восемь тысяч, надо ходить редко и брать помногу. Программа заводит у себя буфер ввода, одним read наполняет его целиком, а дальше раздаёт из него по байту, по строке, по сколько попросят. Когда буфер опустел, снова один read. Функция, которой нужна строка, больше не платит за каждый байт: почти все её обращения попадают в память.
Книга строит на этой идее пакет RIO (robust I/O). Небуферизованную половину, rio_readn и rio_writen, ты уже написал в прошлом уроке. Сегодня вторая половина: структура rio_t с буфером на 8192 байта и три функции поверх неё.
rio_readэто заменаreadс тем же поведением, только источник у неё буфер. Пуст буфер: пополнить одним настоящимread. Дальше скопировать вызывающему минимум из того, что он просил, и того, что лежит в буфере. Короткий счёт тут законен, как и у настоящегоread.rio_readlinebзовётrio_readпо одному байту, пока не встретит\n, не упрётся в размер приёмника или не дойдёт до конца файла.rio_readnbзовётrio_readв цикле, пока не наберёт ровно n байт. Этоreadnиз прошлого урока, у которогоreadзаменён наrio_read.
Заметь, как мало кода отличает вторую и третью функции от наивных версий: одна буква в имени вызова. Вся хитрость сидит в rio_read, и она занимает десять строк.
Свой Rio на сыром read
Перенесём это на Zig. Отличий от книги три. Буфер не зашит в структуру, его даёт вызывающий: так делает вся стандартная библиотека Zig 0.16, и ты сам выбираешь размер и место (стек, куча, статическая память). Нуль-терминатора нет: rio_readlineb в C резервирует байт под \0 и поэтому читает не больше maxlen - 1, а у нас длина возвращается числом и вызывающий берёт срез. И в структуре есть поле syscalls: свой счётчик походов в ядро, чтобы видеть цену без внешних инструментов.
//! RIO из главы 10 на Zig: внутренний буфер поверх сырого `read`.
//! Память под буфер даёт вызывающий, счётчик `syscalls` считает походы в ядро.
const std = @import("std");
const posix = std.posix;
pub const Rio = struct {
fd: posix.fd_t,
/// Внутренний буфер. Непрочитанные байты лежат в `buf[seek..end]`.
buf: []u8,
seek: usize = 0,
end: usize = 0,
/// Сколько раз звали `read`.
syscalls: usize = 0,
pub fn init(fd: posix.fd_t, buf: []u8) Rio {
return .{ .fd = fd, .buf = buf };
}
/// `rio_read`: как `read`, только из буфера. Пополняет буфер, когда он
/// пуст, и отдаёт не больше того, что в нём лежит. Ноль значит EOF.
/// `posix.read` сам повторяет вызов после EINTR.
pub fn read(rio: *Rio, out: []u8) posix.ReadError!usize {
if (out.len == 0) return 0;
if (rio.seek == rio.end) {
rio.seek = 0;
rio.end = 0;
rio.syscalls += 1;
rio.end = try posix.read(rio.fd, rio.buf);
if (rio.end == 0) return 0;
}
const n = @min(out.len, rio.end - rio.seek);
@memcpy(out[0..n], rio.buf[rio.seek..][0..n]);
rio.seek += n;
return n;
}
/// `rio_readlineb`: строка вместе с `\n`. Строка длиннее `out` приходит
/// кусками, последняя строка файла может быть без `\n`. Ноль значит EOF.
pub fn readlineb(rio: *Rio, out: []u8) posix.ReadError!usize {
var n: usize = 0;
while (n < out.len) {
if (try rio.read(out[n..][0..1]) == 0) break;
n += 1;
if (out[n - 1] == '\n') break;
}
return n;
}
/// `rio_readnb`: ровно `out.len` байт, меньше только на EOF.
pub fn readnb(rio: *Rio, out: []u8) posix.ReadError!usize {
var n: usize = 0;
while (n < out.len) {
const got = try rio.read(out[n..]);
if (got == 0) break;
n += got;
}
return n;
}
};
test "строки и блок через один буфер, один системный вызов" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "in.txt", .data = "LEN 5\nhello" });
const file = try tmp.dir.openFile(io, "in.txt", .{});
defer file.close(io);
var buf: [64]u8 = undefined;
var rio: Rio = .init(file.handle, &buf);
var line: [32]u8 = undefined;
try std.testing.expectEqualStrings("LEN 5\n", line[0..try rio.readlineb(&line)]);
var body: [5]u8 = undefined;
try std.testing.expectEqual(5, try rio.readnb(&body));
try std.testing.expectEqualStrings("hello", &body);
try std.testing.expectEqual(1, rio.syscalls);
try std.testing.expectEqual(0, try rio.readlineb(&line));
try std.testing.expectEqual(2, rio.syscalls);
}
Пройдись по read. Условие пополнения одно: seek == end, то есть всё, что было в буфере, уже роздано. Пока в буфере лежит хоть байт, в ядро мы не идём, даже если просят больше: отдаём что есть, а за остальным вызывающий придёт ещё раз. Именно поэтому readnb нужен цикл. Про EINTR заботиться не пришлось: std.posix.read в 0.16 сам повторяет вызов, если его прервал сигнал (открой lib/std/posix.zig и найди ветку .INTR => continue). В книжной версии этот повтор написан руками.
Тест показывает главное свойство. Файл из одиннадцати байт, две операции разного вида, строка и блок, и один системный вызов на обе. Второй вызов случается, только когда мы спрашиваем строку после конца данных: буфер пуст, read возвращает ноль.
readlineb читает по одному байту, и это нормально. Каждый такой вызов это проверка, @min, @memcpy на один байт и сложение. Это не бесплатно (ниже увидим, сколько именно), но это обычный код в обычной памяти, без смены привилегий.
Виджет: буфер, который едят по кускам
Прежде чем мерить, посмотри на механизм глазами. Сверху файл: серые байты уже отданы программе, цветные лежат в буфере, остальные ядро ещё не читало. Нажми rio_readlineb один раз. Один read принёс шестнадцать байт, а строка длиннее, поэтому случилось второе пополнение, и после него в буфере осталось начало следующей строки. Теперь жми дальше и следи за счётчиком: функций вызвано много, а походов в ядро мало. Поставь буфер на 64 байта, и весь запрос приедет одним read, а четыре строки разберут его без единого системного вызова. Поставь на 4 и посмотри, как буфер вырождается в чтение по четыре байта.
Кнопку “мимо буфера” и вторую вкладку пока не трогай, до них дойдём через два раздела.
Считаем системные вызовы
Теперь числа. Программа countlines считает строки файла тремя способами: read по одному байту, наш Rio и std.Io.Reader. Первые два способа считают свои вызовы сами.
//! Считает строки файла тремя способами и показывает цену каждого:
//!
//! countlines byte <файл> read по одному байту
//! countlines rio <файл> [буфер] свой Rio с буфером, по умолчанию 8192
//! countlines std <файл> [буфер] std.Io.Reader с тем же буфером
const std = @import("std");
const Rio = @import("rio.zig").Rio;
const Result = struct { lines: usize, syscalls: ?usize };
fn countByByte(fd: std.posix.fd_t) !Result {
var lines: usize = 0;
var syscalls: usize = 0;
var byte: [1]u8 = undefined;
while (true) {
syscalls += 1;
if (try std.posix.read(fd, &byte) == 0) break;
if (byte[0] == '\n') lines += 1;
}
return .{ .lines = lines, .syscalls = syscalls };
}
fn countWithRio(fd: std.posix.fd_t, buf: []u8) !Result {
var rio: Rio = .init(fd, buf);
var line: [4096]u8 = undefined;
var lines: usize = 0;
while (true) {
const n = try rio.readlineb(&line);
if (n == 0) break;
if (line[n - 1] == '\n') lines += 1;
}
return .{ .lines = lines, .syscalls = rio.syscalls };
}
fn countWithStd(io: std.Io, file: std.Io.File, buf: []u8) !Result {
var file_reader = file.readerStreaming(io, buf);
const reader = &file_reader.interface;
var lines: usize = 0;
// Срез указывает прямо во внутренний буфер: копии нет.
while (reader.takeDelimiterInclusive('\n')) |_| {
lines += 1;
} else |err| switch (err) {
error.EndOfStream => {},
else => |e| return e,
}
// Свои вызовы Reader не считает. Их покажет strace.
return .{ .lines = lines, .syscalls = null };
}
pub fn main(init: std.process.Init) !void {
const arena = init.arena.allocator();
const args = try init.minimal.args.toSlice(arena);
var out_buf: [256]u8 = undefined;
var stdout = std.Io.File.stdout().writerStreaming(init.io, &out_buf);
const out = &stdout.interface;
if (args.len < 3) {
try out.writeAll("countlines byte|rio|std <файл> [буфер]\n");
try out.flush();
std.process.exit(2);
}
const mode = args[1];
const buf_size = if (args.len > 3) try std.fmt.parseInt(usize, args[3], 10) else 8192;
const buf = try arena.alloc(u8, buf_size);
const file = try std.Io.Dir.cwd().openFile(init.io, args[2], .{});
defer file.close(init.io);
const started = std.Io.Clock.awake.now(init.io);
const result: Result = if (std.mem.eql(u8, mode, "byte"))
try countByByte(file.handle)
else if (std.mem.eql(u8, mode, "rio"))
try countWithRio(file.handle, buf)
else
try countWithStd(init.io, file, buf);
const elapsed = started.durationTo(std.Io.Clock.awake.now(init.io));
try out.print("{s}: строк {d}, ", .{ mode, result.lines });
if (result.syscalls) |n| try out.print("вызовов read {d}, ", .{n});
try out.print("{d} мс\n", .{elapsed.toMilliseconds()});
try out.flush();
}
Файл для опыта: двести тысяч строк случайной длины, 10 280 578 байт. Сборка с -O ReleaseSafe, три прогона, медиана; Apple M-серия, macOS 15, файл в кэше страниц:
$ zig build-exe -O ReleaseSafe countlines.zig
$ ./countlines byte big.txt
byte: строк 200000, вызовов read 10280579, 3174 мс
$ ./countlines rio big.txt
rio: строк 200000, вызовов read 1256, 22 мс
$ ./countlines std big.txt
std: строк 200000, 4 мс
Десять миллионов вызовов против 1256, три секунды против 22 миллисекунд. Число 1256 легко проверить: 10 280 578 делим на 8192, получаем 1254 полных буфера и один неполный, плюс последний read, который вернул ноль. Подели 3174 мс на 10,28 миллиона: 309 наносекунд на вызов. Это и есть цена пересечения границы ядра на этой машине.
Размер буфера это ручка. Уменьшим его до 64 байт:
$ ./countlines rio big.txt 64
rio: строк 200000, вызовов read 160636, 105 мс
Вызовов стало в 128 раз больше, время выросло в пять раз, а не в 128: при буфере 8192 мы уже упёрлись не в ядро, а в побайтовое копирование внутри readlineb. Это видно и по третьей строке первой таблицы: std.Io.Reader с тем же буфером и тем же числом системных вызовов работает впятеро быстрее нашего Rio. Почему, разберём в следующем разделе, а сейчас проверим слова “тем же числом”: у Reader счётчика нет.
strace -c: счётчик, который не обманешь
Свой счётчик считает то, что ты попросил его считать. Независимый свидетель это strace: он стоит на границе ядра и видит каждый вызов, кто бы его ни сделал. С ключом -c он молчит во время работы, а в конце печатает таблицу. Снято в контейнере Debian 12 на Linux 7.0 (aarch64), файл mid.txt это первый мегабайт того же текста, потому что побайтовая версия под strace работает в десятки раз медленнее: каждый вызов теперь останавливает процесс дважды.
$ strace -c ./countlines byte mid.txt
byte: строк 19860, вызовов read 1000001, 11725 мс
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ------------------
99.23 1.863271 1 1000001 read
0.71 0.013375 13375 1 close
0.03 0.000591 591 1 execve
0.01 0.000180 180 1 openat
0.01 0.000137 8 17 munmap
0.00 0.000061 6 10 mmap
0.00 0.000016 2 8 rt_sigaction
0.00 0.000004 2 2 prlimit64
0.00 0.000003 3 1 sched_getaffinity
0.00 0.000002 2 1 sigaltstack
0.00 0.000000 0 1 writev
------ ----------- ----------- --------- --------- ------------------
100.00 1.877640 1 1000044 total
Колонка calls сходится с нашим счётчиком до единицы: миллион байт и один read, вернувший ноль. Остальные сорок три вызова это старт любой Zig-программы (стек, обработчики сигналов для паники, арена) и один writev с итоговой строкой. Теперь буферизованные версии:
$ strace -c ./countlines rio mid.txt
rio: строк 19860, вызовов read 125, 8 мс
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ------------------
52.46 0.000671 5 125 read
24.55 0.000314 314 1 execve
...
$ strace -c ./countlines std mid.txt
std: строк 19860, 2 мс
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ------------------
62.56 0.000518 4 124 readv
17.75 0.000147 147 1 openat
...
125 и 124. Миллион байт это 122 полных буфера и один неполный, 123 вызова с данными. У std к ним добавляется один вызов с нулём, итого 124. У Rio таких два, и это не ошибка счётчика, а честное поведение нашего кода: mid.txt обрезан посреди строки, последний readlineb получил ноль от read, вернул хвост без \n, а следующий readlineb пошёл в ядро ещё раз и снова получил ноль. Конец файла не запоминается, и для обычного файла это правильно: он мог вырасти. Второе отличие: std зовёт readv, а не read. Это тот же вызов, только принимает несколько буферов сразу (зачем это нужно Reader, увидим в конце урока на копировании). По сути же обе реализации делают одно: раз в 8192 байта ходят в ядро.
На macOS. Все программы урока собираются и работают на macOS без изменений, и свой счётчик в
Rioпоказывает те же числа. Нет толькоstrace. Штатная заменаdtrussтребует root и отключённой защиты целостности системы, поэтому на практике удобнее два пути: свой счётчик, как вcountlines, или контейнер. Кросс-компиляция у Zig встроена:zig build-exe -O ReleaseSafe -target aarch64-linux countlines.zigдаёт статический бинарник для Linux, его остаётся положить в контейнер, где естьstrace(docker run --platform linux/arm64 debian:bookworm, внутриapt-get install strace). На Mac с процессором Intel целевая тройкаx86_64-linux. Бери платформу контейнера, родную для своего процессора: под эмуляторомstraceвидит вызовы эмулятора, а не твоей программы. Ещё два отличия пригодятся ниже: ёмкость пайпа на macOS 16 КБ (вырастает до 64 КБ под нагрузкой) против 64 КБ на Linux, а полеblock_sizeизstatу устройств вроде/dev/nullпоказывает 65536 вместо 4096.
Тот же буфер внутри std.Io.Reader
Открой lib/std/Io/Reader.zig (путь к библиотеке покажет zig env, поле lib_dir). Первые строки структуры:
vtable: *const VTable,
buffer: []u8,
/// Number of bytes which have been consumed from `buffer`.
seek: usize,
/// In `buffer` before this are buffered bytes, after this is `undefined`.
end: usize,
Это наш Rio почти поле в поле. buffer это срез, который ты передаёшь при создании (file.reader(io, &buf)), seek и end значат ровно то же, что у нас: непрочитанное лежит в buffer[seek..end]. Вместо fd стоит vtable, таблица из нескольких функций, которыми Reader просит у источника ещё байтов. Источником может быть файл, сокет, распаковщик gzip, срез в памяти. Всё, что выше vtable (поиск строки, разбор чисел, peek), написано один раз и работает поверх буфера одинаково для любого источника. Вот почему буфер в Zig 0.16 живёт в интерфейсе, а не в реализации: быстрый путь, когда данные уже в буфере, не проходит через косвенный вызов.
Соответствие с книгой:
| RIO в книге | наш Rio | std.Io.Reader | что делает |
|---|---|---|---|
rio_readinitb(&rio, fd) | Rio.init(fd, &buf) | file.reader(io, &buf) | связать буфер с источником |
rio_read | read | readVec | сколько есть, но не больше просимого |
rio_readlineb | readlineb | takeDelimiterInclusive('\n') | строка вместе с \n |
rio_readnb | readnb | readSliceShort | ровно n байт, меньше только в конце потока |
| нет | нет | readSliceAll | ровно n байт, недобор это error.EndOfStream |
| нет | нет | peek, peekByte, toss | посмотреть, не забирая |
Главное отличие в том, что возвращают строчные функции. rio_readlineb копирует строку в приёмник вызывающего, байт за байтом. takeDelimiterInclusive ничего не копирует: она возвращает срез, который указывает прямо во внутренний буфер, и сдвигает seek. А конец строки она ищет не циклом по байту, а через std.mem.findScalarPos, который на современных процессорах сравнивает по 16 или 32 байта за инструкцию (те самые векторные регистры из урока про SIMD). Отсюда разница 22 мс против 4 мс при одинаковом числе походов в ядро.
У среза в чужой буфер есть цена: он живёт до следующего обращения к читателю. Следующий take может пополнить буфер и сдвинуть содержимое к началу (rebase), и старый срез будет показывать на другие байты. Нужна строка надолго, скопируй её. Это то же правило, что с указателем на элемент ArrayList после append.
Вариантов взять строку три, и они отличаются поведением на краях. Блок ниже это тест, он запускается как есть (zig test reader_api.zig); Reader.fixed делает читателя из готового среза, ядро для этого не нужно.
const std = @import("std");
const testing = std.testing;
test "три способа взять строку" {
// Reader.fixed читает из готового среза: удобно для тестов, ядро не нужно.
var r: std.Io.Reader = .fixed("раз\nдва\nхвост");
// Inclusive: вместе с разделителем, как rio_readlineb.
try testing.expectEqualStrings("раз\n", try r.takeDelimiterInclusive('\n'));
// takeDelimiter: без разделителя, конец потока считается разделителем,
// а когда данных больше нет, приходит null. Под него удобно писать while.
try testing.expectEqualStrings("два", (try r.takeDelimiter('\n')).?);
try testing.expectEqualStrings("хвост", (try r.takeDelimiter('\n')).?);
try testing.expectEqual(null, try r.takeDelimiter('\n'));
}
test "Inclusive не прощает строку без перевода строки в конце" {
var r: std.Io.Reader = .fixed("хвост");
try testing.expectError(error.EndOfStream, r.takeDelimiterInclusive('\n'));
// Поток при этом не сдвинулся: хвост можно забрать иначе.
try testing.expectEqualStrings("хвост", r.buffered());
}
test "Exclusive останавливается перед разделителем и сам его не съедает" {
var r: std.Io.Reader = .fixed("a\nb\n");
try testing.expectEqualStrings("a", try r.takeDelimiterExclusive('\n'));
// Следующий байт это всё ещё \n. Забудешь toss, и получишь пустые строки.
try testing.expectEqual('\n', try r.peekByte());
r.toss(1);
try testing.expectEqualStrings("b", try r.takeDelimiterExclusive('\n'));
}
test "peek смотрит, take забирает, readSliceAll это rio_readnb" {
var r: std.Io.Reader = .fixed("LEN 5\nhello!");
try testing.expectEqualStrings("LEN", try r.peek(3));
try testing.expectEqualStrings("LEN 5\n", try r.takeDelimiterInclusive('\n'));
var body: [5]u8 = undefined;
try r.readSliceAll(&body);
try testing.expectEqualStrings("hello", &body);
try testing.expectEqual('!', try r.takeByte());
try testing.expectError(error.EndOfStream, r.takeByte());
}
Запомни три вещи. takeDelimiterInclusive на файле без \n в конце вернёт error.EndOfStream и потеряет для тебя последнюю строку, если ты молча выйдешь из цикла: в countlines это безвредно (строка без \n и не считается), а в программе, которая строки обрабатывает, хвост надо забрать через buffered() или сразу писать цикл на takeDelimiter. takeDelimiterExclusive в 0.16 останавливается перед разделителем и не съедает его: без toss(1) следующий вызов вернёт пустой срез, и так бесконечно. И конец потока у Reader это ошибка error.EndOfStream, а не ноль: ноль байт в std значит “сейчас ничего нет”, а не “больше не будет”.
StreamTooLong: строка не лезет в буфер
У нашего readlineb есть приёмник, и строка длиннее приёмника приходит кусками. У takeDelimiterInclusive приёмника нет, строка обязана целиком лежать в буфере, потому что возвращается срез в него. Что будет, если буфер меньше строки?
$ ./countlines std big.txt 64
error: StreamTooLong
В файле есть строки длиннее 64 байт, и читатель честно отказывается: места нет, разделитель не найден. Состояние потока при этом не меняется, так что выбор за тобой: взять то, что есть, через buffered() и toss, слить строку в Writer через streamDelimiter (она не требует, чтобы строка помещалась в буфер) или просто дать буфер побольше. Размер буфера у Reader это не только ручка скорости, но и самая длинная строка, которую ты согласен принять. Для сетевого сервера это удобно: буфер на 8 КБ сам собой защищает от клиента, который шлёт бесконечную строку заголовка. С этим мы встретимся, когда будем писать веб-сервер.
Нельзя мешать два вида чтения на одном дескрипторе
Буфер это состояние, которое живёт в твоей программе и о котором ядро не знает. Позиция файла в ядре стоит там, куда дошёл последний read, то есть на конце того, что утащил буфер. Позиция программы отстаёт от неё на end - seek байт. Пока всё чтение идёт через один буфер, это расхождение никому не видно. Видно оно становится в тот момент, когда кто-то читает тот же дескриптор напрямую.
Сценарий из жизни: простой протокол, строка заголовка LEN 5 и за ней пять байт тела. Заголовок удобно взять построчно, а тело, раз длина известна, хочется прочитать одним read прямо в приёмник. Открой в виджете вкладку “ошибка смешивания” и пройди два шага. Теперь то же на настоящем файле:
//! Ошибка смешивания: заголовок читаем через буфер, тело мимо него.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const cwd = std.Io.Dir.cwd();
try cwd.writeFile(io, .{ .sub_path = "msg.bin", .data = "LEN 5\nhelloworld" });
var out_buf: [512]u8 = undefined;
var stdout = std.Io.File.stdout().writerStreaming(io, &out_buf);
const out = &stdout.interface;
inline for (.{ 8, 64 }) |size| {
const file = try cwd.openFile(io, "msg.bin", .{});
defer file.close(io);
var buf: [size]u8 = undefined;
var file_reader = file.readerStreaming(io, &buf);
const reader = &file_reader.interface;
const header = try reader.takeDelimiterInclusive('\n');
try out.print("буфер {d}: заголовок {f}, в буфере осталось {f}\n", .{
size,
std.zig.fmtString(header),
std.zig.fmtString(reader.buffered()),
});
// Неправильно: сырой read ничего не знает про буфер читателя.
var body: [5]u8 = undefined;
const got = try std.posix.read(file.handle, &body);
try out.print(" мимо буфера: {d} байт {f}\n", .{ got, std.zig.fmtString(body[0..got]) });
}
// Правильно: всё через один и тот же Reader.
const file = try cwd.openFile(io, "msg.bin", .{});
defer file.close(io);
var buf: [8]u8 = undefined;
var file_reader = file.readerStreaming(io, &buf);
const reader = &file_reader.interface;
_ = try reader.takeDelimiterInclusive('\n');
var body: [5]u8 = undefined;
try reader.readSliceAll(&body);
try out.print("через буфер: 5 байт {f}\n", .{std.zig.fmtString(&body)});
try out.flush();
}
$ zig build-exe mixing.zig && ./mixing
буфер 8: заголовок LEN 5\n, в буфере осталось he
мимо буфера: 5 байт llowo
буфер 64: заголовок LEN 5\n, в буфере осталось helloworld
мимо буфера: 0 байт
через буфер: 5 байт hello
С буфером на 8 байт первый read принёс LEN 5\nhe. Заголовок отдан, he осталось в буфере, позиция в ядре стоит на восьмом байте. Сырой read начинает оттуда и получает llowo: пять байт, без ошибки, правдоподобного вида. С буфером на 64 весь файл уехал в буфер первым же вызовом, и сырой read получает ноль, то есть конец файла, хотя тело никто не читал. Хуже всего здесь то, что результат зависит от размера буфера и от того, какими порциями данные приходили. На файле ошибка воспроизводится стабильно, а на сокете будет проявляться раз в неделю, когда заголовок и тело случайно приедут одним сегментом.
Правило из книги: буферизованные вызовы можно свободно чередовать между собой (readlineb, потом readnb, потом снова readlineb, как в тесте rio.zig), а небуферизованные нельзя чередовать с буферизованными на одном дескрипторе. В Zig то же правило звучит проще: один дескриптор, один Reader, и после его создания к file.handle руками не ходи.
В следующем уроке у этого правила появится второе лицо. Буфер живёт в памяти процесса, значит, fork его копирует. Если в буфере родителя на момент fork лежали непрочитанные строки, они есть теперь у обоих, а позиция в ядре одна на двоих.
Writer: буфер в обратную сторону
С записью та же арифметика: десять тысяч print по строчке это десять тысяч походов в ядро, если писать напрямую. std.Io.Writer устроен зеркально: buffer, end, и vtable с функцией drain, которая выливает накопленное в приёмник. print и writeAll кладут байты в буфер; drain зовётся, только когда очередная порция в буфер не влезла, или когда ты сказал flush.
Проверим на программе, которая печатает десять тысяч строк с буфером заданного размера:
//! Десять тысяч строк на stdout с буфером заданного размера: writes <буфер>.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const arena = init.arena.allocator();
const args = try init.minimal.args.toSlice(arena);
const buf = try arena.alloc(u8, try std.fmt.parseInt(usize, args[1], 10));
var stdout = std.Io.File.stdout().writerStreaming(init.io, buf);
const out = &stdout.interface;
for (0..10_000) |i| try out.print("строка {d}\n", .{i});
try out.flush();
}
Считает strace (Linux 7.0, aarch64, вывод программы отправлен в /dev/null, от каждой таблицы оставлена одна строка):
$ strace -c -e trace=write,writev ./writes 0 > /dev/null
100.00 0.018744 0 30000 writev
$ strace -c -e trace=write,writev ./writes 64 > /dev/null
100.00 0.001309 0 2498 writev
$ strace -c -e trace=write,writev ./writes 4096 > /dev/null
0.00 0.000000 0 44 writev
$ strace -c -e trace=write,writev ./writes 65536 > /dev/null
0.00 0.000000 0 3 writev
Без буфера вызовов не десять тысяч, а тридцать: print("строка {d}\n") отдаёт приёмнику три куска (текст до числа, число, перевод строки), и каждый без буфера становится отдельным writev. Заодно это значит, что строка попадает в файл не атомарно: между кусками может вклиниться вывод другого процесса. С буфером на 4 КБ весь вывод (около 180 КБ) уходит за 44 вызова, с буфером на 64 КБ за три.
Забытый flush
У буфера записи есть ловушка, которой нет у буфера чтения. Непрочитанные байты в Reader никому не нужны, если программа завершилась. Незаписанные байты в Writer это вывод, которого никто не увидит.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var buf: [64]u8 = undefined;
var stdout = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &stdout.interface;
for (1..6) |i| try out.print("строка {d}\n", .{i});
// Здесь должен стоять try out.flush();
}
$ zig build-exe noflush.zig && ./noflush
строка 1
строка 2
строка 3
строка 4
строка $
Четыре строки целиком, от пятой только слово, и приглашение оболочки прилипло к нему без перевода строки. strace показывает, что случилось (кириллицу он печатает восьмеричными кодами байтов, здесь они раскрыты обратно в буквы):
writev(1, [{iov_base="строка 1\nстрока 2\nстрока 3\nстрока 4\n", iov_len=60},
{iov_base="строка ", iov_len=13}], 2) = 73
+++ exited with 0 +++
Каждая строка занимает 15 байт (кириллица в UTF-8 по два байта на букву), четыре строки это 60. Пятый print принёс кусок строка на 13 байт, в буфер на 64 он не влез, и писатель вылил всё одним writev из двух частей: накопленный буфер и новый кусок, без лишнего копирования. Следом пришли 5 и \n, легли в пустой буфер и остались там навсегда: main вернулся, flush никто не позвал. Код возврата ноль, ошибок нет, два байта пропали. В C ту же работу делает exit, который сбрасывает буферы stdio сам. В Zig неявных действий на выходе нет: буфер это твой массив на стеке, и писатель не узнает, что программа кончается.
Отсюда бойлерплейт, который ты видел в каждом уроке раздела: завести писателя, в конце try out.flush(). Три практических правила.
flushставится на каждом пути выхода.defer out.flush() catch {};сразу после создания писателя закрывает все пути разом, но глотает ошибку записи; для утилиты командной строки это обычно приемлемо, для программы, которая пишет данные в файл, нет.- Перед
std.process.exitсбрасывай руками:exitне выполняетdefer. Посмотри на ветку сargs.len < 3вcountlines.zig. - Перед
forkсбрасывай всегда. Иначе несброшенный буфер скопируется в ребёнка, и один и тот же текст выйдет дважды. Это классическая загадка “почемуprintfпередforkпечатается два раза, когда вывод перенаправлен в файл”, и теперь ты знаешь ответ без отладчика.
Интерактивной программе нужен ещё и сброс перед ожиданием ввода: приглашение > без \n лежит в буфере, пока ты не скажешь flush, а пользователь смотрит в пустой экран. stdio в C сбрасывает строчный буфер терминала сам на каждом \n; у Writer в Zig такого режима нет, сброс всегда явный.
Метаданные: что ядро знает о файле
До сих пор нас интересовало содержимое. Но у файла есть ещё паспорт: тип, размер, права, владелец, времена, число имён. Всё это метаданные, и лежат они не в самом файле и не в каталоге, а в отдельной структуре файловой системы, inode. Системный вызов stat(path, &buf) заполняет структуру по имени файла, fstat(fd, &buf) по открытому дескриптору. Открывать файл для stat не нужно, и права на чтение самого файла тоже не нужны: хватит права пройти по каталогам до него.
В книге из всей структуры используются два поля: st_size и st_mode. В st_mode упакованы сразу тип файла (старшие биты) и девять бит прав, и C разбирает его макросами S_ISREG, S_ISDIR, S_ISSOCK и масками вроде S_IRUSR. В Zig 0.16 stat возвращает уже разобранную структуру std.Io.File.Stat: тип лежит отдельным полем kind типа enum, и switch по нему компилятор проверит на полноту. Права остаются числом, потому что это и есть девять независимых битов.
Перепишем statcheck из книги. Он печатает побольше полей, чем оригинал, чтобы было на что посмотреть:
//! statcheck из главы 10: что ядро знает о файле, не открывая его.
const std = @import("std");
fn kindName(kind: std.Io.File.Kind) []const u8 {
return switch (kind) {
.file => "обычный файл",
.directory => "каталог",
.sym_link => "символическая ссылка",
.named_pipe => "именованный канал",
.unix_domain_socket => "сокет",
.character_device => "символьное устройство",
.block_device => "блочное устройство",
else => "что-то другое",
};
}
pub fn main(init: std.process.Init) !void {
const args = try init.minimal.args.toSlice(init.arena.allocator());
var buf: [1024]u8 = undefined;
var stdout = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &stdout.interface;
for (args[1..]) |path| {
const stat = std.Io.Dir.cwd().statFile(init.io, path, .{}) catch |err| {
try out.print("{s}: {t}\n", .{ path, err });
continue;
};
const mode = stat.permissions.toMode();
try out.print("{s}\n", .{path});
try out.print(" тип: {s}\n", .{kindName(stat.kind)});
try out.print(" размер: {d} байт\n", .{stat.size});
try out.print(" права: {o:0>3}, владелец читать {s}\n", .{
mode & 0o777,
if (mode & 0o400 != 0) "может" else "не может",
});
try out.print(" inode: {d}, ссылок {d}\n", .{ stat.inode, stat.nlink });
try out.print(" блок I/O: {d} байт\n", .{stat.block_size});
try out.print(" изменён: {d} с от начала эпохи\n", .{@divTrunc(stat.mtime.nanoseconds, std.time.ns_per_s)});
}
try out.flush();
}
Подготовим площадку: обычный файл, каталог, символическая ссылка.
$ mkdir -p play/sub && printf 'привет\n' > play/hello.txt
$ head -c 5000 /dev/zero > play/zeros.bin && ln -s hello.txt play/link
$ ./statcheck play/hello.txt play/link play/sub /dev/null /nope
play/hello.txt
тип: обычный файл
размер: 13 байт
права: 644, владелец читать может
inode: 319928118, ссылок 1
блок I/O: 4096 байт
изменён: 1789972431 с от начала эпохи
play/link
тип: обычный файл
размер: 13 байт
права: 644, владелец читать может
inode: 319928118, ссылок 1
блок I/O: 4096 байт
изменён: 1789972431 с от начала эпохи
play/sub
тип: каталог
размер: 64 байт
права: 755, владелец читать может
inode: 319928117, ссылок 2
блок I/O: 4096 байт
изменён: 1789972431 с от начала эпохи
/dev/null
тип: символьное устройство
размер: 0 байт
права: 666, владелец читать может
inode: 336, ссылок 1
блок I/O: 65536 байт
изменён: 1789972431 с от начала эпохи
/nope: FileNotFound
Снято на macOS 15, файловая система APFS. Что здесь стоит заметить.
Ссылка прозрачна. У play/link тот же номер inode, тот же размер и тип “обычный файл”: stat прошёл по символической ссылке и рассказал про цель. Чтобы спросить про саму ссылку, в C есть отдельный вызов lstat, а в Zig флаг: statFile(io, path, .{ .follow_symlinks = false }). Он понадобится нам через минуту в обходе каталога.
Размер 13, а не 7. В слове “привет” шесть букв, но st_size считает байты, а кириллица в UTF-8 занимает по два. Шесть букв по два байта и перевод строки.
У каталога тоже есть размер и счётчик ссылок. Каталог это файл, в котором лежит таблица “имя, номер inode”. Ссылок у пустого sub две: его имя в play и запись . внутри него самого. Каждый вложенный каталог добавит ещё одну своей записью ... Размер 64 это особенность APFS; на ext4 ты увидишь 4096, на tmpfs в контейнере ноль. Никакого переносимого смысла у размера каталога нет.
Блок I/O это подсказка. Поле block_size (в C st_blksize) говорит, какими порциями с этим файлом выгодно работать. Для обычных файлов почти везде 4096, размер страницы и блока файловой системы. Проверим эту подсказку замером в разделе про копирование.
Время это число. mtime хранится в наносекундах от начала 1970 года по UTC. Часовых поясов и календаря в ядре нет, это забота программы, которая время показывает.
Под Linux видно, каким вызовом всё это добывается:
$ strace -e trace=statx ./statcheck play/hello.txt
statx(AT_FDCWD, "play/hello.txt", AT_STATX_SYNC_AS_STAT|AT_NO_AUTOMOUNT,
STATX_TYPE|STATX_MODE|STATX_NLINK|STATX_ATIME|STATX_MTIME|STATX_CTIME|STATX_INO|STATX_SIZE|STATX_BLOCKS,
{stx_mask=STATX_ALL|STATX_MNT_ID|0x8000, stx_attributes=0, stx_mode=S_IFREG|0644, stx_size=13, ...}) = 0
Не stat, а statx: это его современный наследник в Linux (с ядра 4.11), где вызывающий маской перечисляет, какие поля ему нужны, и файловая система может не вычислять дорогие. AT_FDCWD в первом аргументе значит “путь считать от текущего каталога”. Запомни этот первый аргумент, он сейчас сыграет.
Зачем это серверу и раннеру
Метаданные это не справочная информация, а рабочий инструмент. Веб-сервер из книги перед отдачей файла зовёт stat: убедиться, что это обычный файл, а не каталог и не устройство, что его можно читать, и взять размер для заголовка Content-Length, который надо отправить раньше тела. Утилита сборки сравнивает mtime исходника и объектника, чтобы не пересобирать лишнее. std.Io.Dir.readFileAlloc спрашивает размер, чтобы выделить память один раз. На этом, кстати, есть грабли: у файлов в /proc размер в stat равен нулю, потому что содержимое сочиняется в момент чтения. Программа, которая верит st_size, прочтёт из /proc/self/maps ноль байт. Для таких файлов размер узнают единственным честным способом: читают до конца потока.
Каталог это тоже файл, но читать его надо по-особому
Каталог хранит пары “имя, номер inode”. Читать его обычным read ядро не даст (вернёт EISDIR): формат записей у каждой файловой системы свой, и наружу их отдаёт отдельный вызов, в Linux это getdents64. В C над ним стоят opendir, readdir, closedir; readdir возвращает структуру dirent, в которой переносимо есть только имя и номер inode. В Zig это Dir.iterate() и next(io), а запись Dir.Entry содержит name, kind и inode.
Размера в записи каталога нет, и это не недосмотр. Размер принадлежит файлу, то есть inode, а имён у одного inode может быть несколько в разных каталогах. Лежи размер рядом с именем, каждая запись в файл обновляла бы все каталоги, где он упомянут. Поэтому ls -l устроен так: прочитать записи каталога, потом для каждого имени спросить stat.
//! Обход каталога: имена из записей каталога, размеры из stat.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const args = try init.minimal.args.toSlice(init.arena.allocator());
const path = if (args.len > 1) args[1] else ".";
var buf: [4096]u8 = undefined;
var stdout = std.Io.File.stdout().writerStreaming(io, &buf);
const out = &stdout.interface;
// Без .iterate = true каталог откроется, но читать его записи нельзя.
var dir = try std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true });
defer dir.close(io);
var total: u64 = 0;
var it = dir.iterate();
while (try it.next(io)) |entry| {
// В записи каталога есть только имя, тип и номер inode.
// За размером идём отдельным вызовом, относительно dir.
const stat = try dir.statFile(io, entry.name, .{ .follow_symlinks = false });
const mark: u8 = switch (entry.kind) {
.directory => 'd',
.sym_link => 'l',
.file => '-',
else => '?',
};
try out.print("{c} {d:>10} {s}\n", .{ mark, stat.size, entry.name });
if (entry.kind == .file) total += stat.size;
}
try out.print("всего в файлах: {d} байт\n", .{total});
try out.flush();
}
$ ./ls play
- 5000 zeros.bin
d 64 sub
l 9 link
- 13 hello.txt
всего в файлах: 5013 байт
Порядок не алфавитный и вообще никакой: записи приходят так, как их хранит файловая система (на Linux в том же каталоге вышло sub, hello.txt, zeros.bin, link). Сортирует ls, а не ядро. Записи . и .. итератор Zig пропускает сам, в C их надо отсеивать руками. У ссылки размер 9: с follow_symlinks = false мы получили метаданные самой ссылки, а её содержимое это текст hello.txt, девять байт.
Теперь посмотри, что происходит под капотом (Linux 7.0, aarch64, лишние флаги в statx вырезаны):
$ strace -e trace=openat,getdents64,statx,close ./ls play
openat(AT_FDCWD, "play", O_RDONLY|O_CLOEXEC|O_DIRECTORY) = 3
getdents64(3, 0xffffe5904ae8 /* 6 entries */, 2048) = 160
statx(3, "sub", AT_SYMLINK_NOFOLLOW|..., {stx_mode=S_IFDIR|0755, stx_size=0, ...}) = 0
statx(3, "hello.txt", AT_SYMLINK_NOFOLLOW|..., {stx_mode=S_IFREG|0644, stx_size=13, ...}) = 0
statx(3, "zeros.bin", AT_SYMLINK_NOFOLLOW|..., {stx_mode=S_IFREG|0644, stx_size=5000, ...}) = 0
statx(3, "link", AT_SYMLINK_NOFOLLOW|..., {stx_mode=S_IFLNK|0777, stx_size=9, ...}) = 0
getdents64(3, 0xffffe5904ae8 /* 0 entries */, 2048) = 0
close(3) = 0
Три наблюдения, и все по теме урока.
Первое: getdents64 это буферизованное чтение. Итератор не ходит в ядро за каждой записью: один вызов принёс все шесть записей (четыре наших, . и ..) в буфер на 2048 байт внутри итератора, а next раздаёт их оттуда по одной. Второй вызов вернул ноль, конец каталога. Тот же Rio, только для каталогов, и ноль в роли конца файла тот же.
Второе: у statx первым аргументом стоит не AT_FDCWD, а 3, дескриптор открытого каталога. Имя sub ищется относительно него, а не относительно текущего каталога процесса. Вот почему в Zig почти все файловые операции это методы Dir: dir.openFile, dir.statFile, dir.createFile. Это не стиль, а защита от гонки. Пока ты обходишь дерево, кто-то может переименовать каталог выше по пути; склеенная строка play/sub укажет уже не туда, а дескриптор продолжит указывать на тот же каталог. Для песочницы, которая работает с чужими файлами, это вопрос безопасности, а не удобства.
Третье: цена ls -l это один statx на файл. На каталоге в сто тысяч файлов это сто тысяч системных вызовов, и буфером их не сократить: каждый ходит за своим inode. Поэтому ls без -l мгновенный на любом каталоге, а ls -l задумывается: тип записи приходит прямо в getdents64 (поле d_type, у нас entry.kind), а за размером надо идти отдельно.
Для обхода в глубину в std есть dir.walk(allocator): он держит стек открытых каталогов и отдаёт записи с путём от корня обхода. Внутри тот же iterate и openDir относительно родителя.
Копирование файла блоками
Соберём чтение и запись в одну программу и ответим на вопрос, который обычно решают на глаз: какого размера брать буфер. Программа копирует файл кусками заданного размера и считает вызовы.
//! Копирование файла блоками: cp <откуда> <куда> <размер буфера>.
//! Печатает число системных вызовов и время.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const arena = init.arena.allocator();
const args = try init.minimal.args.toSlice(arena);
var out_buf: [256]u8 = undefined;
var stdout = std.Io.File.stdout().writerStreaming(io, &out_buf);
const out = &stdout.interface;
if (args.len != 4) {
try out.writeAll("cp <откуда> <куда> <размер буфера>\n");
try out.flush();
std.process.exit(2);
}
const block = try arena.alloc(u8, try std.fmt.parseInt(usize, args[3], 10));
const cwd = std.Io.Dir.cwd();
const src = try cwd.openFile(io, args[1], .{});
defer src.close(io);
const dst = try cwd.createFile(io, args[2], .{});
defer dst.close(io);
var reads: usize = 0;
var writes: usize = 0;
var bytes: u64 = 0;
const started = std.Io.Clock.awake.now(io);
while (true) {
reads += 1;
// У std конец файла это ошибка EndOfStream, а не ноль, как у read.
const got = src.readStreaming(io, &.{block}) catch |err| switch (err) {
error.EndOfStream => break,
else => |e| return e,
};
// read отдал got байт, а write может принять меньше: дописываем циклом.
var done: usize = 0;
while (done < got) {
writes += 1;
done += try dst.writeStreaming(io, &.{}, &.{block[done..got]}, 1);
}
bytes += got;
}
const elapsed = started.durationTo(std.Io.Clock.awake.now(io));
try out.print("буфер {d}: {d} байт, read {d}, write {d}, {d} мкс\n", .{
block.len, bytes, reads, writes, elapsed.toMicroseconds(),
});
try out.flush();
}
Здесь мы спустились на этаж ниже Reader: readStreaming и writeStreaming это тонкие обёртки над readv и writev, без буфера. Обрати внимание на две вещи. Конец файла у readStreaming приходит ошибкой error.EndOfStream, а не нулём, как у системного вызова: тот же выбор, что у Reader. И внутренний цикл вокруг записи это writen из прошлого урока: write вправе принять меньше, чем дали, и на пайпе или сокете он так и делает.
Тот же файл на 10 280 578 байт, -O ReleaseSafe, три прогона, медиана; Apple M-серия, macOS 15, APFS на SSD, исходник в кэше страниц:
| буфер | вызовов read | вызовов write | время | на мегабайт |
|---|---|---|---|---|
| 1 | 10 280 579 | 10 280 578 | 15 008 мс | 1 530 мс |
| 64 | 160 636 | 160 635 | 265 мс | 27 мс |
| 512 | 20 081 | 20 080 | 38 мс | 3,9 мс |
| 4 096 | 2 511 | 2 510 | 10,6 мс | 1,1 мс |
| 65 536 | 158 | 157 | 2,0 мс | 0,20 мс |
| 1 048 576 | 11 | 10 | 1,3 мс | 0,13 мс |
Кривая знакомой формы: сначала время падает почти пропорционально числу вызовов (от 1 до 512 байт время и вызовы оба падают примерно в 400 раз), потом выходит на полку. На полке мы платим уже не за пересечение границы ядра, а за само копирование байтов из кэша страниц в буфер и обратно, и дальше буфер ничего не даёт. Переход от 64 КБ к мегабайту сэкономил 0,7 мс на десяти мегабайтах и стоил мегабайта памяти, который к тому же перестал помещаться в кэш L2 (вспомни гору памяти).
Практический вывод. Подсказка block_size из stat (4096) это нижняя граница разумного, а не оптимум: с ней мы в пять раз медленнее полки. Разумный выбор для последовательного чтения и копирования сегодня лежит между 64 КБ и 256 КБ, и cp из GNU coreutils давно берёт 128 КБ и больше. Для разбора текста по строкам, где за буфером идёт работа подороже копирования, хватает 4 до 16 КБ: на countlines с 8 КБ системные вызовы занимали уже пренебрежимую долю времени. А мерить надо на своей задаче: у сетевого сокета, у терминала и у файла на сетевом диске кривые разные.
Как копирует std
Руками цикл копирования пишут редко. В std это одна строка: читатель льёт всё, что осталось, в писателя.
//! То же копирование средствами std: Reader льёт прямо во Writer.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const args = try init.minimal.args.toSlice(init.arena.allocator());
const cwd = std.Io.Dir.cwd();
const src = try cwd.openFile(io, args[1], .{});
defer src.close(io);
const dst = try cwd.createFile(io, args[2], .{});
defer dst.close(io);
// Буфер читателя пустой: если ядро умеет копировать файл в файл само,
// байты вообще не заходят в память программы.
var reader = src.reader(io, &.{});
var write_buf: [64 * 1024]u8 = undefined;
var writer = dst.writer(io, &write_buf);
const copied = try reader.interface.streamRemaining(&writer.interface);
try writer.interface.flush();
var out_buf: [128]u8 = undefined;
var stdout = std.Io.File.stdout().writerStreaming(io, &out_buf);
try stdout.interface.print("скопировано {d} байт\n", .{copied});
try stdout.interface.flush();
}
Буфер читателя здесь пустой, &.{}, и это не ошибка. Посмотри на хвост strace (Linux 7.0, aarch64; данные в кавычках вырезаны):
preadv(3, [{iov_base=..., iov_len=65536}], 1, 10158080) = 65536
pwritev(4, [{iov_base=..., iov_len=65536}], 1, 10158080) = 65536
preadv(3, [{iov_base=..., iov_len=65536}], 1, 10223616) = 56962
preadv(3, [{iov_base="", iov_len=8574}], 1, 10280578) = 0
pwritev(4, [{iov_base=..., iov_len=56962}], 1, 10223616) = 56962
Читатель читает прямо в буфер писателя, по 64 КБ: промежуточной копии в собственный буфер нет, потому что vtable.stream получает писателя и сам решает, куда класть байты. Вот зачем в интерфейсе Reader вместо простого “прочитай в срез” стоит “перелей в Writer”, и вот откуда readv в таблицах strace выше: векторный вызов позволяет одним походом наполнить и чужой буфер, и свой. Вторая деталь: вызовы preadv и pwritev позиционные, смещение идёт последним аргументом. file.reader и file.writer по умолчанию не трогают общую позицию файла в ядре, а ведут свою. Для обычного файла это безопаснее (два читателя одного дескриптора не мешают друг другу), но на пайпе, терминале или файле, открытом на дозапись, позиционные вызовы не работают или работают не так, как ты ждёшь. Для них есть readerStreaming и writerStreaming, которые мы и берём для stdout во всех программах раздела.
Если приёмник и источник это файлы, у ядра есть путь ещё короче: copy_file_range и sendfile в Linux, fcopyfile в macOS копируют из файла в файл внутри ядра, и байты вообще не заходят в память процесса. Writer умеет этим пользоваться через sendFile, а Dir.copyFile зовёт его за тебя. Когда мы дойдём до веб-сервера, sendfile будет отдавать статические файлы в сокет.
Шаг проекта: zbox забирает вывод программы
Вернёмся к zbox. После урока про управление памятью он умеет запустить чужую программу, убить её по времени, ограничить ей память и ответить одной строкой JSON. Но вывод программы до сих пор шёл прямо на наш терминал, вперемешку с ответом. Раннеру курса так нельзя: ему нужно положить stdout и stderr в ответ, сравнить с ожидаемым и показать студенту. Значит, вывод надо перехватить. И здесь сходится всё, о чём был этот урок и прошлый: короткие счёты, буфер ограниченного размера, конец файла, который может не наступить, и ещё один буфер, про который мы пока не говорили: буфер пайпа в ядре.
План и две ловушки
Механика простая, ты видел её в оболочке. Пайп создаётся вызовом pipe(&fds): fds[0] для чтения, fds[1] для записи. До fork заводим два пайпа. В ребёнке dup2(out.write_fd, 1) и dup2(err.write_fd, 2) подменяют stdout и stderr пишущими концами, и программа после execvp пишет в пайпы, ничего об этом не зная. Родитель читает из fds[0]. Подробно про dup2 и таблицы ядра будет следующий урок, сейчас хватит этого.
Первая ловушка: тупик на двух пайпах. Наивный родитель читает stdout до конца файла, потом stderr до конца файла. Пусть программа сначала пишет 200 КБ в stderr. Ёмкость пайпа 64 КБ; когда он заполнится, write в программе уснёт и будет ждать, пока кто-нибудь прочтёт. А родитель в это время спит в read на stdout, куда программа ещё ничего не написала и не напишет, пока не разберётся с stderr. Оба ждут друг друга вечно. Это не экзотика: компилятор, который печатает тысячу предупреждений в stderr перед тем как написать что-то в stdout, воспроизводит сценарий в точности. Лекарство: не читать пайпы по очереди, а ждать оба сразу. Системный вызов poll принимает массив дескрипторов и спит, пока хоть на одном не появятся данные.
Вторая ловушка: конец файла может не наступить. read из пайпа вернёт ноль, только когда закрыты все копии пишущего конца. Копии есть у каждого, кто унаследовал дескриптор: у родителя после fork (поэтому parentSetup закрывает свои пишущие концы первым делом), у ребёнка (поэтому childSetup после dup2 закрывает все пять оригиналов), и у любого внука. Программа sh -c 'sleep 30 & echo готово' завершается мгновенно, но фоновый sleep держит пишущий конец stdout ещё тридцать секунд. Родитель, который ждёт конца файла, прождёт их все. Поэтому zbox ждёт не конца файла, а SIGCHLD: ребёнок умер, значит, дочитываем то, что уже лежит в пайпе, и уходим.
Отсюда решения шага.
- Читающие концы неблокирующие (
O_NONBLOCK). После пробуждения мы читаем оба пайпа циклом доEAGAIN, и ни одинreadне может усыпить родителя, пока второй пайп переполняется. Это цикл из прошлого урока с тремя исходами вместо двух: данные, ноль (писателей не осталось) иEAGAIN(пока пусто). - Вывод копится в
Sink: буфер фиксированного размера, выделенный заранее, и флагtruncated. Это чистая структура без системных вызовов, и она тестируется отдельно. - Лимит на размер вывода (
--out-kb, по умолчанию 64 КБ на поток). Программа, которая его превысила, убивается вместе с группой. Альтернатива, продолжать читать и выбрасывать, хуже:yesбез лимита времени не остановится никогда и будет жечь процессор, а вердикт ясен уже сейчас. Закрыть пайп и ждать, пока программа умрёт отSIGPIPE, ненадёжно: этот сигнал можно игнорировать. - stdin программа получает из файла (
--stdin), по умолчанию из/dev/null. Терминал ей не достаётся: иначеcatбез аргументов повис бы в ожидании клавиатуры. Файл открывает родитель доfork, чтобы об ошибке сказать по-человечески, а не из ребёнка.
capture.zig: пайпы, Sink и насос
Новый файл шага, целиком.
//! Захват вывода: два пайпа, `dup2` в ребёнке, чтение обоих через `poll`
//! в родителе, лимит на размер. `Sink` это чистая часть (байты и лимит),
//! остальное системное.
const std = @import("std");
const posix = std.posix;
const c = std.c;
const memlimit = @import("memlimit.zig");
const watchdog = @import("watchdog.zig");
/// Приёмник одного потока вывода: хранит первые `buf.len` байт.
pub const Sink = struct {
buf: []u8,
len: usize = 0,
/// Программа написала больше лимита, хвост отброшен.
truncated: bool = false,
pub fn append(sink: *Sink, bytes: []const u8) void {
const room = sink.buf.len - sink.len;
const taken = @min(room, bytes.len);
@memcpy(sink.buf[sink.len..][0..taken], bytes[0..taken]);
sink.len += taken;
if (taken < bytes.len) sink.truncated = true;
}
pub fn written(sink: *const Sink) []const u8 {
return sink.buf[0..sink.len];
}
};
pub const Error = error{ PipeFailed, StdinUnavailable, OutOfMemory };
/// Один захваченный поток: читающий конец у родителя, пишущий уйдёт ребёнку.
const Stream = struct {
read_fd: c.fd_t,
write_fd: c.fd_t,
sink: Sink,
fn open(arena: std.mem.Allocator, limit: usize) Error!Stream {
var fds: [2]c.fd_t = undefined;
if (c.pipe(&fds) != 0) return error.PipeFailed;
// Читающий конец неблокирующий: после `poll` читаем до EAGAIN
// и не рискуем уснуть в `read`, пока второй пайп переполняется.
const flags = c.fcntl(fds[0], c.F.GETFL);
const nonblock: c_int = @bitCast(@as(u32, @bitCast(c.O{ .NONBLOCK = true })));
_ = c.fcntl(fds[0], c.F.SETFL, flags | nonblock);
return .{ .read_fd = fds[0], .write_fd = fds[1], .sink = .{ .buf = try arena.alloc(u8, limit) } };
}
fn closeBoth(stream: *Stream) void {
stream.closeRead();
_ = c.close(stream.write_fd);
}
fn closeRead(stream: *Stream) void {
if (stream.read_fd >= 0) _ = c.close(stream.read_fd);
stream.read_fd = -1;
}
/// Читает всё, что есть в пайпе сейчас. `read` отдаёт сколько есть,
/// а не сколько просили (короткий счёт), поэтому читаем циклом:
/// до EAGAIN (пайп пуст, но писатели живы) или до нуля (писателей нет).
fn drain(stream: *Stream) void {
var chunk: [4096]u8 = undefined;
while (stream.read_fd >= 0) {
const got = c.read(stream.read_fd, &chunk, chunk.len);
if (got > 0) {
stream.sink.append(chunk[0..@intCast(got)]);
} else if (got == 0) {
stream.closeRead();
} else switch (posix.errno(got)) {
.INTR => continue,
.AGAIN => return,
else => stream.closeRead(),
}
}
}
};
/// Всё, что переезжает в ребёнка на место дескрипторов 0, 1 и 2.
pub const Capture = struct {
stdin_fd: c.fd_t,
out: Stream,
err: Stream,
/// Зовётся до fork. `limit` это лимит на каждый поток отдельно.
pub fn open(arena: std.mem.Allocator, stdin_path: []const u8, limit: usize) Error!Capture {
const path = try arena.dupeZ(u8, stdin_path);
const stdin_fd = c.open(path, .{ .ACCMODE = .RDONLY });
if (stdin_fd < 0) return error.StdinUnavailable;
errdefer _ = c.close(stdin_fd);
var out: Stream = try .open(arena, limit);
errdefer out.closeBoth();
const err: Stream = try .open(arena, limit);
return .{ .stdin_fd = stdin_fd, .out = out, .err = err };
}
/// Зовётся в ребёнке между fork и execvp: dup2 и close async-signal-safe.
/// После dup2 оригиналы закрываем все до одного. Забытый пишущий конец
/// в самой программе не даст родителю дождаться конца файла.
pub fn childSetup(capture: *const Capture) void {
_ = c.dup2(capture.stdin_fd, 0);
_ = c.dup2(capture.out.write_fd, 1);
_ = c.dup2(capture.err.write_fd, 2);
for ([_]c.fd_t{ capture.stdin_fd, capture.out.read_fd, capture.out.write_fd, capture.err.read_fd, capture.err.write_fd }) |fd| {
_ = c.close(fd);
}
}
/// Зовётся в родителе сразу после fork. Пишущие концы родителю не нужны,
/// и держать их нельзя: пока жив хоть один писатель, `read` не вернёт ноль.
pub fn parentSetup(capture: *Capture) void {
_ = c.close(capture.stdin_fd);
_ = c.close(capture.out.write_fd);
_ = c.close(capture.err.write_fd);
}
/// fork не удался: закрываем всё, что открыли.
pub fn abandon(capture: *Capture) void {
capture.parentSetup();
capture.out.closeRead();
capture.err.closeRead();
}
/// Ждёт конца ребёнка и по дороге вычитывает оба пайпа. Программу,
/// которая превысила лимит вывода, убивает вместе с группой.
pub fn pump(capture: *Capture, old_mask: *const posix.sigset_t, sampler: *memlimit.Sampler) void {
var killed = false;
while (!watchdog.childExited()) {
// Закрытый поток идёт в poll с fd = -1: такие записи ядро пропускает.
var fds = [_]c.pollfd{
.{ .fd = capture.out.read_fd, .events = c.POLL.IN, .revents = 0 },
.{ .fd = capture.err.read_fd, .events = c.POLL.IN, .revents = 0 },
};
watchdog.nap(old_mask, &fds);
// Не смотрим в revents: читающие концы неблокирующие, лишний
// read стоит один системный вызов и вернёт EAGAIN.
capture.out.drain();
capture.err.drain();
sampler.sample();
if (!killed and (capture.out.sink.truncated or capture.err.sink.truncated)) {
// Лимит превышен: вердикт уже ясен, хвост никому не нужен.
// Убиваем группу и продолжаем читать, пока ребёнок не умрёт.
killed = true;
watchdog.killGroup();
}
}
// Ребёнок завершился, но последние байты могли остаться в пайпе.
// Внук с унаследованным концом пайпа нас не задержит: читаем
// только то, что уже лежит, и закрываем.
capture.out.drain();
capture.err.drain();
capture.out.closeRead();
capture.err.closeRead();
}
};
Пройдись по drain и сравни с readn. Тот же цикл вокруг read, та же ветка .INTR => continue. Но readn знал, сколько байт ему нужно, и крутился, пока не наберёт. drain не знает ничего: он берёт, сколько дают, и останавливается по одной из двух причин. EAGAIN значит “сейчас пусто, писатели живы”, и мы возвращаемся в poll. Ноль значит “писателей нет и не будет”, и мы закрываем свой конец, чтобы poll больше о нём не спрашивал. Кусок в 4096 байт на стеке это буфер чтения из первой половины урока: без него мы ходили бы в ядро за каждым байтом чужого вывода.
В pump обрати внимание на две мелочи. Закрытый поток отдаётся в poll с fd = -1: такие записи ядро пропускает, и массив не надо перестраивать. И revents мы не читаем вовсе: концы неблокирующие, лишний read на пустом пайпе стоит один системный вызов и вернёт EAGAIN. Код короче, а ошибиться с битами POLLHUP и POLLERR негде.
После цикла идёт последний drain. Ребёнок уже умер, но его последние байты могли остаться в пайпе: SIGCHLD и данные приходят независимо. Читаем только то, что уже лежит (концы неблокирующие, ждать внука мы не станем) и закрываем.
watchdog.zig: сон с дескрипторами
До этого шага родитель спал в sigsuspend: атомарно снять маску, уснуть до сигнала, вернуть маску. Теперь причин проснуться три: SIGCHLD, тик будильника и данные в пайпе. sigsuspend про дескрипторы не знает. Поэтому waitForChild исчезла, а вместо неё появились childExited и nap. Файл целиком:
//! Сторож: сигналы SIGCHLD и SIGALRM, маска, таймер и убийство группы.
//!
//! Порядок работы родителя:
//! 1. `arm` блокирует оба сигнала и ставит обработчики. Это до fork.
//! 2. После fork `startTimer` заводит будильник, который тикает раз в 10 мс.
//! 3. Пока `childExited` ложно, родитель спит в `nap`: до сигнала или
//! до данных в пайпах. На каждом пробуждении он читает пайпы
//! и снимает показания памяти.
//! 4. `disarm` выключает будильник и возвращает старую маску.
//!
//! Сигналы заблокированы всё время, кроме сна внутри `nap`. Поэтому
//! обработчики выполняются только там, и гонки с основным кодом нет.
const std = @import("std");
const posix = std.posix;
const c = std.c;
// Этой функции нет ни в std.posix, ни в std.c, объявляем сами.
extern "c" fn setitimer(which: c_int, new: *const Itimerval, old: ?*Itimerval) c_int;
/// `struct itimerval` из `<sys/time.h>`: период повтора и время до первого срабатывания.
const Itimerval = extern struct {
interval: c.timeval,
value: c.timeval,
};
const ITIMER_REAL = 0;
/// Период будильника. Раз в тик обработчик сверяет часы с крайним сроком,
/// а основной цикл просыпается и читает `/proc/<pid>/status`. Он же
/// ограничивает сверху цену гонки в `nap`.
const tick_ms = 10;
// Обработчик и основной код общаются только через эти переменные.
// Атомарная запись слова входит в список того, что обработчику можно.
var child_exited: std.atomic.Value(bool) = .init(false);
var alarm_fired: std.atomic.Value(bool) = .init(false);
var group: std.atomic.Value(c.pid_t) = .init(0);
/// Крайний срок по монотонным часам, миллисекунды. Ноль значит без лимита.
var deadline_ms: std.atomic.Value(u64) = .init(0);
fn onChild(_: posix.SIG) callconv(.c) void {
child_exited.store(true, .seq_cst);
}
fn onAlarm(_: posix.SIG) callconv(.c) void {
const deadline = deadline_ms.load(.seq_cst);
// clock_gettime тоже в списке async-signal-safe.
if (deadline == 0 or nowMs() < deadline) return;
alarm_fired.store(true, .seq_cst);
killGroup();
}
pub fn nowMs() u64 {
var now: c.timespec = undefined;
_ = c.clock_gettime(.MONOTONIC, &now);
return @intCast(now.sec * 1000 + @divTrunc(now.nsec, std.time.ns_per_ms));
}
/// kill async-signal-safe, его можно звать из обработчика. Отрицательный pid
/// значит всю группу процессов: внуки умирают вместе с ребёнком.
/// Ошибку не смотрим: группа могла уже опустеть.
pub fn killGroup() void {
const pgid = group.load(.seq_cst);
if (pgid > 0) _ = c.kill(-pgid, .KILL);
}
/// Блокирует SIGCHLD и SIGALRM и ставит обработчики. Возвращает прежнюю
/// маску: она нужна ребёнку, sigsuspend и `disarm`.
pub fn arm() posix.sigset_t {
child_exited.store(false, .seq_cst);
alarm_fired.store(false, .seq_cst);
group.store(0, .seq_cst);
deadline_ms.store(0, .seq_cst);
var blocked = posix.sigemptyset();
posix.sigaddset(&blocked, .CHLD);
posix.sigaddset(&blocked, .ALRM);
var old_mask: posix.sigset_t = undefined;
posix.sigprocmask(posix.SIG.BLOCK, &blocked, &old_mask);
// В маске обработчика оба сигнала: пока работает один, второй подождёт.
// NOCLDSTOP: SIGCHLD нужен только о смерти ребёнка, не об остановке.
posix.sigaction(.CHLD, &.{
.handler = .{ .handler = onChild },
.mask = blocked,
.flags = posix.SA.NOCLDSTOP,
}, null);
posix.sigaction(.ALRM, &.{
.handler = .{ .handler = onAlarm },
.mask = blocked,
.flags = 0,
}, null);
return old_mask;
}
/// Зовётся в ребёнке между fork и execvp. Только async-signal-safe вызовы.
pub fn childSetup(old_mask: *const posix.sigset_t) void {
// Своя группа с pgid, равным pid ребёнка.
_ = c.setpgid(0, 0);
// Маска сигналов переживает execve. Не вернём её, и чужая программа
// стартует с заблокированными SIGCHLD и SIGALRM.
posix.sigprocmask(posix.SIG.SETMASK, old_mask, null);
}
/// Зовётся в родителе сразу после fork.
pub fn startTimer(child: c.pid_t, time_ms: ?u32) void {
// Тот же setpgid, что и в ребёнке. Кто из двоих успеет первым, неизвестно,
// а группа нужна уже сейчас. Второй вызов безвреден. После execve ядро
// ответит родителю EACCES, но к этому моменту ребёнок всё сделал сам.
_ = c.setpgid(child, child);
group.store(child, .seq_cst);
if (time_ms) |ms| deadline_ms.store(nowMs() + ms, .seq_cst);
// Таймер периодический и заводится всегда: даже без лимита времени
// тики нужны, чтобы снимать показания памяти, пока программа жива.
const tick: c.timeval = .{ .sec = 0, .usec = tick_ms * std.time.us_per_ms };
const timer: Itimerval = .{ .interval = tick, .value = tick };
_ = setitimer(ITIMER_REAL, &timer, null);
}
pub fn childExited() bool {
return child_exited.load(.seq_cst);
}
/// Спит, пока не придёт сигнал или в одном из `fds` не появятся данные.
/// С одним SIGCHLD хватало sigsuspend, но он не умеет ждать дескрипторы.
/// Поэтому снимаем маску, зовём poll и ставим маску обратно.
///
/// Это уже не атомарно: SIGCHLD может проскочить между sigprocmask и poll,
/// и poll уснёт, хотя ребёнок мёртв. Спасает периодический будильник:
/// следующий тик прервёт poll (EINTR), и цикл снаружи перечитает флаг.
/// Цена гонки не больше одного тика. На Linux окно закрывает ppoll,
/// который принимает маску, как sigsuspend, но в macOS его нет.
pub fn nap(old_mask: *const posix.sigset_t, fds: []c.pollfd) void {
var blocked: posix.sigset_t = undefined;
posix.sigprocmask(posix.SIG.SETMASK, old_mask, &blocked);
_ = c.poll(fds.ptr, @intCast(fds.len), -1);
posix.sigprocmask(posix.SIG.SETMASK, &blocked, null);
}
/// Выключает таймер и возвращает маску. Сообщает, срабатывал ли будильник.
pub fn disarm(old_mask: *const posix.sigset_t) bool {
const off: Itimerval = .{
.interval = .{ .sec = 0, .usec = 0 },
.value = .{ .sec = 0, .usec = 0 },
};
_ = setitimer(ITIMER_REAL, &off, null);
posix.sigprocmask(posix.SIG.SETMASK, old_mask, null);
return alarm_fired.load(.seq_cst);
}
nap честно хуже, чем был sigsuspend, и комментарий этого не скрывает. Между sigprocmask и poll есть окно в несколько инструкций: SIGCHLD может прийти именно туда, обработчик выставит флаг, а poll после этого спокойно уснёт, хотя ждать уже некого. С sigsuspend такого окна не было, в этом и был его смысл. Нас спасает периодический будильник из прошлого шага: следующий тик прервёт poll с EINTR, цикл в pump перечитает флаг и выйдет. Цена гонки ограничена десятью миллисекундами, а не вечностью. Правильных лекарств два. В Linux есть ppoll, который принимает маску сигналов аргументом и делает подмену атомарно, как sigsuspend; в macOS его нет. Переносимый приём называется self-pipe: обработчик SIGCHLD пишет байт в специальный пайп (write входит в список async-signal-safe), а poll ждёт этот пайп наравне с остальными, и сигнал превращается в обычные данные. Это одно из упражнений в конце урока.
report.zig: чужие байты в JSON
В ответе появились четыре поля: stdout, stderr и по флагу обрыва на каждый. До сих пор JSON собирался простым print, потому что в нём были только числа. Теперь внутри строка, которую написала чужая программа, и в ней может быть что угодно.
//! Итог запуска и его печать одной строкой JSON. Тоже чистый код:
//! от операционной системы здесь только знание о единицах `ru_maxrss`.
const std = @import("std");
const builtin = @import("builtin");
const memory = @import("memory.zig");
pub const Outcome = struct {
/// Код возврата, если программа завершилась сама (`exit` или `return` из `main`).
exit_code: ?u8 = null,
/// Номер сигнала, если программу убил сигнал. Ровно одно из двух полей не null.
signal: ?u32 = null,
/// Программа не уложилась в лимит времени, и zbox убил её группу.
timed_out: bool = false,
/// Время процессора в режиме пользователя и в режиме ядра.
cpu_user_ms: u64 = 0,
cpu_sys_ms: u64 = 0,
/// Пиковый размер резидентной памяти, килобайты.
max_rss_kb: u64 = 0,
/// Время по настенным часам от `fork` до конца `wait4`.
wall_ms: u64 = 0,
/// Пики из `/proc/<pid>/status`, килобайты: виртуальная память и
/// резидентная. Только Linux, и только если сторож успел снять показания.
vm_peak_kb: ?u64 = null,
vm_hwm_kb: ?u64 = null,
/// Пик памяти всей группы из `memory.peak`. Только с `--mem-mb`.
cgroup_peak_kb: ?u64 = null,
/// Сколько процессов убил OOM-убийца группы. Только с `--mem-mb`.
oom_kills: ?u64 = null,
/// Захваченный вывод программы, не больше лимита на каждый поток.
stdout: []const u8 = "",
stderr: []const u8 = "",
/// Поток упёрся в лимит: в поле лежит только начало вывода.
stdout_truncated: bool = false,
stderr_truncated: bool = false,
pub fn reason(outcome: Outcome) memory.Reason {
return memory.reason(.{
.signaled = outcome.signal != null,
.timed_out = outcome.timed_out,
.oom_kills = outcome.oom_kills,
.output_truncated = outcome.stdout_truncated or outcome.stderr_truncated,
});
}
};
/// `ru_maxrss` на Linux приходит в килобайтах, а на macOS в байтах.
/// man-страница `getrusage(2)` у каждой системы своя, и они расходятся.
pub fn maxRssKb(ru_maxrss: u64, os: std.Target.Os.Tag) u64 {
return switch (os) {
.macos => ru_maxrss / 1024,
else => ru_maxrss,
};
}
pub fn maxRssKbNative(ru_maxrss: u64) u64 {
return maxRssKb(ru_maxrss, builtin.os.tag);
}
/// Секунды и микросекунды из `timeval` в целые миллисекунды.
pub fn timevalMs(sec: i64, usec: i64) u64 {
return @intCast(sec * 1000 + @divTrunc(usec, 1000));
}
/// Числа и логические поля печатаем обычным `print`, экранировать в них
/// нечего. Вывод программы идёт через `writeJsonString`.
pub fn writeJson(out: *std.Io.Writer, outcome: Outcome) std.Io.Writer.Error!void {
try out.writeAll("{\"exit_code\":");
try writeOptional(out, outcome.exit_code);
try out.writeAll(",\"signal\":");
try writeOptional(out, outcome.signal);
try out.print(",\"timed_out\":{},\"cpu_user_ms\":{d},\"cpu_sys_ms\":{d},\"max_rss_kb\":{d},\"wall_ms\":{d}", .{
outcome.timed_out,
outcome.cpu_user_ms,
outcome.cpu_sys_ms,
outcome.max_rss_kb,
outcome.wall_ms,
});
// Имя тега enum это латинское слово без кавычек внутри, экранировать нечего.
try out.print(",\"reason\":\"{t}\",\"vm_peak_kb\":", .{outcome.reason()});
try writeOptional(out, outcome.vm_peak_kb);
try out.writeAll(",\"vm_hwm_kb\":");
try writeOptional(out, outcome.vm_hwm_kb);
try out.writeAll(",\"cgroup_peak_kb\":");
try writeOptional(out, outcome.cgroup_peak_kb);
try out.writeAll(",\"oom_kills\":");
try writeOptional(out, outcome.oom_kills);
try out.print(",\"stdout_truncated\":{},\"stderr_truncated\":{},\"stdout\":", .{
outcome.stdout_truncated,
outcome.stderr_truncated,
});
try writeJsonString(out, outcome.stdout);
try out.writeAll(",\"stderr\":");
try writeJsonString(out, outcome.stderr);
try out.writeAll("}\n");
}
/// Строка JSON из произвольных байтов. Кавычки, обратную косую и управляющие
/// символы экранирует std.json. Но вывод чужой программы не обязан быть
/// UTF-8, а обрыв по лимиту легко режет многобайтный символ пополам:
/// каждый негодный байт заменяем на U+FFFD, иначе JSON выйдет битым.
pub fn writeJsonString(out: *std.Io.Writer, bytes: []const u8) std.Io.Writer.Error!void {
try out.writeByte('"');
var rest = bytes;
while (rest.len > 0) {
const valid = validUtf8Prefix(rest);
try std.json.Stringify.encodeJsonStringChars(rest[0..valid], .{}, out);
rest = rest[valid..];
if (rest.len > 0) {
try out.writeAll("\\ufffd");
rest = rest[1..];
}
}
try out.writeByte('"');
}
/// Длина начала среза, которое состоит из целых правильных символов UTF-8.
fn validUtf8Prefix(bytes: []const u8) usize {
var i: usize = 0;
while (i < bytes.len) {
const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch break;
if (i + len > bytes.len) break;
_ = std.unicode.utf8Decode(bytes[i..][0..len]) catch break;
i += len;
}
return i;
}
fn writeOptional(out: *std.Io.Writer, value: anytype) std.Io.Writer.Error!void {
if (value) |number| {
try out.print("{d}", .{number});
} else {
try out.writeAll("null");
}
}
Кавычки, обратную косую и управляющие символы экранирует std.json.Stringify.encodeJsonStringChars. Но есть случай, о котором std не знает. JSON обязан быть правильным UTF-8, а вывод программы не обязан, и мы сами его портим: лимит в 1024 байта запросто приходится на середину двухбайтной русской буквы. validUtf8Prefix находит самое длинное начало из целых символов, оно уходит в std как есть, а негодный байт заменяется на U+FFFD, стандартный символ замены (в JSON он записан как обратная косая, u и fffd). Лимит в байтах и текст в символах это разные единицы, и на их стыке всегда нужен такой шов.
run.zig: куда встаёт захват
//! Запуск чужой программы: `fork`, `execvp`, `wait4`.
//! Сигналы и лимит времени живут рядом, в `watchdog.zig`.
const std = @import("std");
const c = std.c;
const args = @import("args.zig");
const capture_mod = @import("capture.zig");
const memlimit = @import("memlimit.zig");
const report = @import("report.zig");
const watchdog = @import("watchdog.zig");
// В std.c есть execve, но нет execvp. Нам нужен именно он: пусть libc
// сама найдёт `sleep` или `sh` по PATH, как это делает оболочка.
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
pub const Error = error{ ForkFailed, WaitFailed, OutOfMemory } || memlimit.CgroupError || capture_mod.Error;
/// Код возврата ребёнка, если `execvp` не удался. Так же поступает оболочка:
/// 127 значит, что команда не найдена.
pub const exec_failed_code = 127;
/// Ребёнок не смог поставить себе лимит памяти. Запускать программу
/// без заказанного лимита нельзя, поэтому до `execvp` дело не доходит.
pub const limit_failed_code = 126;
pub fn run(arena: std.mem.Allocator, command: args.Command) Error!report.Outcome {
const argv = command.argv;
// Массив для execvp собираем до fork. После fork в ребёнке безопасны
// только async-signal-safe функции, а аллокатор к ним не относится.
const c_argv = try arena.allocSentinel(?[*:0]const u8, argv.len, null);
for (argv, c_argv) |arg, *slot| slot.* = try arena.dupeZ(u8, arg);
// Группу cgroups тоже готовим до fork: каталог, лимит и путь к
// `cgroup.procs`. Ребёнку останется один open и один write.
var cgroup: memlimit.Cgroup = .{};
if (command.mem_mb) |mem_mb| try cgroup.create(command.cgroup_root, mem_mb);
defer if (command.mem_mb != null) cgroup.remove();
// Пайпы и файл для stdin открываем тоже до fork: после него у родителя
// и ребёнка окажутся копии одних и тех же дескрипторов.
var capture: capture_mod.Capture = try .open(arena, command.stdin_path, @as(usize, command.out_kb) * 1024);
const started_ms = watchdog.nowMs();
// Сигналы блокируем до fork. Иначе короткая программа вроде `true`
// завершится раньше, чем родитель приготовится ждать, SIGCHLD придёт
// в пустоту, и родитель уснёт навсегда.
const old_mask = watchdog.arm();
const pid = c.fork();
if (pid < 0) {
_ = watchdog.disarm(&old_mask);
capture.abandon();
return error.ForkFailed;
}
if (pid == 0) {
watchdog.childSetup(&old_mask);
// С этой строки дескриптор 2 это пайп: сообщения об ошибках ниже
// попадут в поле stderr ответа, а не на экран.
capture.childSetup();
// Сначала группа, потом execvp: память программы с первой страницы
// считается уже в группе с лимитом.
if (command.mem_mb != null and !memlimit.Cgroup.joinSelf(cgroup.procsPath())) childFail(limit_failed_code);
if (command.as_mb) |as_mb| {
if (!memlimit.limitAddressSpace(as_mb)) childFail(limit_failed_code);
}
// Мы в ребёнке. Успешный execvp не возвращается: образ процесса
// заменён, этого кода в памяти больше нет.
_ = execvp(c_argv[0].?, c_argv.ptr);
childFail(exec_failed_code);
}
// Мы в родителе.
var sampler: memlimit.Sampler = .{ .pid = pid };
watchdog.startTimer(pid, command.time_ms);
capture.parentSetup();
capture.pump(&old_mask, &sampler);
const alarm_fired = watchdog.disarm(&old_mask);
// Ребёнок завершился, но мог оставить в группе фоновых внуков. Пока он
// зомби, его pid и pgid заняты, так что чужую группу мы не заденем.
watchdog.killGroup();
// wait4 это waitpid, который заодно отдаёт rusage ребёнка. Ждать уже
// не придётся: ребёнок мёртв, вызов только забирает зомби.
var status: c_int = 0;
var usage: c.rusage = undefined;
while (true) {
const reaped = c.wait4(pid, &status, 0, &usage);
if (reaped == pid) break;
// Сигнал мог прервать ожидание, тогда просто ждём дальше.
if (std.posix.errno(reaped) == .INTR) continue;
return error.WaitFailed;
}
var outcome = decodeStatus(@bitCast(status));
outcome.cpu_user_ms = report.timevalMs(usage.utime.sec, usage.utime.usec);
outcome.cpu_sys_ms = report.timevalMs(usage.stime.sec, usage.stime.usec);
outcome.max_rss_kb = report.maxRssKbNative(@intCast(usage.maxrss));
outcome.wall_ms = watchdog.nowMs() - started_ms;
outcome.stdout = capture.out.sink.written();
outcome.stderr = capture.err.sink.written();
outcome.stdout_truncated = capture.out.sink.truncated;
outcome.stderr_truncated = capture.err.sink.truncated;
outcome.vm_peak_kb = sampler.last.vm_peak_kb;
outcome.vm_hwm_kb = sampler.last.vm_hwm_kb;
if (command.mem_mb != null) {
// Счётчики читаем после wait4: к этому моменту ядро их уже обновило.
const usage_now = cgroup.usage();
outcome.cgroup_peak_kb = usage_now.peak_kb;
outcome.oom_kills = usage_now.events.oom_kill;
}
// Будильник мог прозвенеть в тот же миг, когда программа закончила сама.
// Лимит превышен, только если её действительно убил наш SIGKILL.
outcome.timed_out = alarm_fired and outcome.signal == @intFromEnum(std.posix.SIG.KILL);
return outcome;
}
/// Слово состояния из wait4 упаковано по-разному на разных системах,
/// поэтому разбираем его макросами W*, а не сдвигами руками.
fn decodeStatus(status: u32) report.Outcome {
if (c.W.IFEXITED(status)) return .{ .exit_code = c.W.EXITSTATUS(status) };
if (c.W.IFSIGNALED(status)) return .{ .signal = @intFromEnum(c.W.TERMSIG(status)) };
// Без WUNTRACED остановленного ребёнка wait4 не вернёт.
unreachable;
}
/// Выход из ребёнка с сообщением. Только async-signal-safe вызовы.
fn childFail(code: u8) noreturn {
const message: []const u8 = if (code == exec_failed_code)
"zbox: не удалось запустить программу\n"
else
"zbox: не удалось поставить лимит памяти\n";
_ = c.write(2, message.ptr, message.len);
// Именно _exit, а не exit: обычный exit сбросил бы буферы stdio
// и выполнил atexit-обработчики, унаследованные от родителя.
c._exit(code);
}
Порядок строк здесь несёт смысл. Capture.open стоит до fork: после него у обоих процессов окажутся копии одних и тех же дескрипторов. В ребёнке capture.childSetup() идёт сразу после watchdog.childSetup и до всего, что может сломаться: с этой строки дескриптор 2 это пайп, и сообщение о неудачном execvp попадёт в поле stderr ответа, а не на экран пользователя zbox. В родителе parentSetup закрывает пишущие концы до первого poll, иначе родитель сам был бы тем писателем, из-за которого конец файла не наступает. И pump занимает ровно то место, где раньше стоял waitForChild.
Заметь, чего в ребёнке нет: ни Reader, ни Writer, ни аллокатора. Между fork и execvp можно звать только async-signal-safe функции, и dup2 с close в этот список входят. childFail пишет сообщение сырым write.
Мелкие правки
Причина завершения получила пятое значение, и у неё самый низкий приоритет среди лимитов: если программу убил будильник, а вывод при этом тоже оборван, виновато время.
-pub const Reason = enum { exited, signaled, time_limit, memory_limit };
+pub const Reason = enum { exited, signaled, time_limit, memory_limit, output_limit };
/// Факты о запуске, по которым выбирается причина завершения.
pub const Verdict = struct {
signaled: bool = false,
timed_out: bool = false,
oom_kills: ?u64 = null,
+ output_truncated: bool = false,
};
@@
if ((verdict.oom_kills orelse 0) > 0) return .memory_limit;
if (verdict.timed_out) return .time_limit;
+ if (verdict.output_truncated) return .output_limit;
return if (verdict.signaled) .signaled else .exited;
Два новых флага командной строки:
/// Каталог cgroups v2, внутри которого zbox заводит свою группу.
cgroup_root: []const u8 = "/sys/fs/cgroup",
+ /// Файл, который программа получит как stdin. Терминал ей не достаётся.
+ stdin_path: []const u8 = "/dev/null",
+ /// Лимит захваченного вывода, килобайты, на stdout и stderr по отдельности.
+ out_kb: u32 = 64,
/// Программа и её аргументы: то, что уйдёт в `execvp`.
argv: []const []const u8,
@@
} else if (std.mem.eql(u8, flag, "--cgroup-root")) {
if (value.len == 0) return error.BadUsage;
command.cgroup_root = value;
+ } else if (std.mem.eql(u8, flag, "--stdin")) {
+ if (value.len == 0) return error.BadUsage;
+ command.stdin_path = value;
+ } else if (std.mem.eql(u8, flag, "--out-kb")) {
+ command.out_kb = try positive(value);
} else return error.BadUsage;
В main.zig две строки справки и новая ветка ошибки с кодом выхода 3, тем же, что у недоступных cgroups:
\\ --cgroup-root DIR где заводить группу, по умолчанию /sys/fs/cgroup
+ \\ --stdin FILE файл вместо stdin программы, по умолчанию /dev/null
+ \\ --out-kb N лимит на stdout и на stderr, по умолчанию 64 КБ на каждый;
+ \\ программу, которая его превысила, zbox убивает
\\
\\Ответ: одна строка JSON на stdout. Код программы лежит в поле exit_code,
- \\сам zbox при этом возвращает 0.
+ \\её вывод в полях stdout и stderr, сам zbox при этом возвращает 0.
@@
std.process.exit(3);
},
+ error.StdinUnavailable => {
+ try out.print("zbox: не могу открыть файл для stdin: {s}\n", .{command.stdin_path});
+ try out.flush();
+ std.process.exit(3);
+ },
else => return err,
Посмотри на try out.flush() перед std.process.exit(3): это то самое правило из раздела про Writer, exit не выполняет defer и не знает про твой буфер.
В src/root.zig добавь строку pub const capture = @import("box/capture.zig");, в build.zig допиши шаг в список: const project_steps = [_]u8{ 48, 49, 54, 61 };.
Прогон
macOS 15, arm64:
$ zbox run sh -c 'echo раз; echo два >&2; exit 4'
{"exit_code":4,"signal":null,"timed_out":false,"cpu_user_ms":1,"cpu_sys_ms":1,"max_rss_kb":2016,"wall_ms":4,"reason":"exited","vm_peak_kb":null,"vm_hwm_kb":null,"cgroup_peak_kb":null,"oom_kills":null,"stdout_truncated":false,"stderr_truncated":false,"stdout":"раз\n","stderr":"два\n"}
$ printf '3 4\n' > in.txt
$ zbox run --stdin in.txt sh -c 'read a b; echo $((a+b))'
{"exit_code":0,...,"stdout":"7\n","stderr":""}
$ zbox run cat
{"exit_code":0,...,"stdout":"","stderr":""}
$ zbox run --out-kb 1 yes
{"exit_code":null,"signal":9,"timed_out":false,"cpu_user_ms":0,"cpu_sys_ms":1,"max_rss_kb":1232,"wall_ms":13,"reason":"output_limit",...,"stdout_truncated":true,"stderr_truncated":false,"stdout":"y\ny\ny\n...
$ zbox run sh -c 'sleep 30 & echo готово'
{"exit_code":0,...,"reason":"exited",...,"stdout":"готово\n","stderr":""}
$ zbox run --stdin /nope cat; echo "код $?"
zbox: не могу открыть файл для stdin: /nope
код 3
На stdout самого zbox теперь ровно одна строка, и это JSON. cat без файла не виснет: его stdin это /dev/null, первый же read возвращает ноль. yes убит через 13 мс с причиной output_limit, хотя лимита времени не было. Программа с фоновым sleep 30 отвечает мгновенно, а не через полминуты. Под Linux (Debian 12 в контейнере) выводы те же, только заполнены поля vm_peak_kb и vm_hwm_kb, которых на macOS нет.
Тесты шага
Вспомогательный модуль тестов изменился в одном месте, и на нём стоит задержаться.
//! Общее для интеграционных тестов: запустить настоящий zbox и разобрать ответ.
const std = @import("std");
const build_options = @import("build_options");
/// Поля ответа, которые проверяют тесты. Лишние поля JSON пропускаем:
/// поздние шаги добавляют свои, а ранние тесты обязаны остаться зелёными.
pub const Reply = struct {
exit_code: ?u8,
signal: ?u32,
cpu_user_ms: u64,
cpu_sys_ms: u64,
max_rss_kb: u64,
wall_ms: u64,
timed_out: bool = false,
reason: []const u8 = "",
vm_peak_kb: ?u64 = null,
vm_hwm_kb: ?u64 = null,
cgroup_peak_kb: ?u64 = null,
oom_kills: ?u64 = null,
stdout: []const u8 = "",
stderr: []const u8 = "",
stdout_truncated: bool = false,
stderr_truncated: bool = false,
};
pub const Run = struct {
reply: Reply,
/// Вывод самой программы. До захвата он шёл перед строкой с JSON,
/// теперь лежит в поле `stdout` ответа. Ранним тестам разницы не видно.
program_stdout: []const u8,
};
/// Путь к подопытной программе `hog`, которая занимает память по заказу.
pub const hog_exe = build_options.hog_exe;
/// Сырой запуск zbox: для тестов, которым важен код возврата самого zbox.
/// `zbox_args` это командная строка после имени zbox.
pub fn zboxRaw(arena: std.mem.Allocator, zbox_args: []const []const u8) !std.process.RunResult {
const argv = try std.mem.concat(arena, []const u8, &.{ &.{build_options.zbox_exe}, zbox_args });
return std.process.run(arena, std.testing.io, .{ .argv = argv });
}
pub fn zbox(arena: std.mem.Allocator, zbox_args: []const []const u8) !Run {
const result = try zboxRaw(arena, zbox_args);
try std.testing.expectEqual(std.process.Child.Term{ .exited = 0 }, result.term);
return parseReply(arena, result.stdout);
}
pub fn parseReply(arena: std.mem.Allocator, stdout: []const u8) !Run {
// Ответ zbox это последняя строка stdout.
const trimmed = std.mem.trimEnd(u8, stdout, "\n");
const json_start = if (std.mem.lastIndexOfScalar(u8, trimmed, '\n')) |newline| newline + 1 else 0;
const reply = try std.json.parseFromSliceLeaky(Reply, arena, trimmed[json_start..], .{
.ignore_unknown_fields = true,
});
return .{ .reply = reply, .program_stdout = reply.stdout };
}
Раньше program_stdout был куском stdout до строки с JSON. Теперь это поле stdout из ответа. Тесты прошлых шагов, которые проверяли вывод программы, остались зелёными без единой правки: они спрашивали “что напечатала программа”, а не “что стоит перед фигурной скобкой”. Это и есть польза от прослойки в тестах.
//! Шаг 61: захват stdout и stderr через пайпы, лимит вывода, stdin из файла.
const std = @import("std");
const zbox = @import("zbox");
const support = @import("support.zig");
const testing = std.testing;
const generous_ms = 4000;
test "Sink хранит начало и помнит про обрыв" {
var buf: [5]u8 = undefined;
var sink: zbox.capture.Sink = .{ .buf = &buf };
sink.append("abc");
try testing.expect(!sink.truncated);
sink.append("defg");
try testing.expect(sink.truncated);
try testing.expectEqualStrings("abcde", sink.written());
sink.append("ещё");
try testing.expectEqualStrings("abcde", sink.written());
}
test "Sink: ровно лимит это ещё не обрыв" {
var buf: [3]u8 = undefined;
var sink: zbox.capture.Sink = .{ .buf = &buf };
sink.append("abc");
try testing.expect(!sink.truncated);
}
test "writeJsonString экранирует кавычки, переводы строк и управляющие байты" {
var buf: [128]u8 = undefined;
var out: std.Io.Writer = .fixed(&buf);
try zbox.report.writeJsonString(&out, "он сказал \"да\"\n\\ \x01");
try testing.expectEqualStrings("\"он сказал \\\"да\\\"\\n\\\\ \\u0001\"", out.buffered());
}
test "writeJsonString: битый UTF-8 и разрезанный символ становятся U+FFFD" {
var buf: [128]u8 = undefined;
var out: std.Io.Writer = .fixed(&buf);
// 0xff не бывает в UTF-8, а 0xd0 это первая половина русской буквы.
try zbox.report.writeJsonString(&out, "a\xffb\xd0");
try testing.expectEqualStrings("\"a\\ufffdb\\ufffd\"", out.buffered());
}
test "parse: --stdin и --out-kb" {
const command = try zbox.args.parse(&.{ "run", "--stdin", "in.txt", "--out-kb", "8", "cat" });
try testing.expectEqualStrings("in.txt", command.stdin_path);
try testing.expectEqual(8, command.out_kb);
const plain = try zbox.args.parse(&.{ "run", "cat" });
try testing.expectEqualStrings("/dev/null", plain.stdin_path);
try testing.expectEqual(64, plain.out_kb);
try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "run", "--out-kb", "0", "cat" }));
}
test "reason: обрыв вывода слабее лимита времени" {
try testing.expectEqual(.output_limit, zbox.memory.reason(.{ .signaled = true, .output_truncated = true }));
try testing.expectEqual(.time_limit, zbox.memory.reason(.{ .signaled = true, .timed_out = true, .output_truncated = true }));
}
test "stdout и stderr приходят раздельно, а на stdout самого zbox только JSON" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const raw = try support.zboxRaw(arena, &.{ "run", "sh", "-c", "echo раз; echo \"два\" >&2; exit 4" });
try testing.expectEqual(1, std.mem.count(u8, raw.stdout, "\n"));
try testing.expectEqualStrings("", raw.stderr);
const reply = (try support.parseReply(arena, raw.stdout)).reply;
try testing.expectEqual(4, reply.exit_code);
try testing.expectEqualStrings("раз\n", reply.stdout);
try testing.expectEqualStrings("два\n", reply.stderr);
try testing.expect(!reply.stdout_truncated and !reply.stderr_truncated);
try testing.expectEqualStrings("exited", reply.reason);
}
test "неудачный execvp пишет в захваченный stderr" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const run = try support.zbox(arena_state.allocator(), &.{ "run", "zbox-no-such-program" });
try testing.expectEqual(zbox.run.exec_failed_code, run.reply.exit_code);
try testing.expect(std.mem.indexOf(u8, run.reply.stderr, "не удалось запустить") != null);
}
test "оба пайпа забиты сразу: без poll тут был бы тупик" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
// Сначала 200 КБ в stderr, потом 200 КБ в stdout, а буфер пайпа в ядре
// всего 64 КБ (на macOS от 16). Читай родитель сначала stdout до конца
// файла, программа уснула бы на записи в stderr, и оба ждали бы вечно.
const script = "head -c 204800 /dev/zero | tr '\\0' e >&2; head -c 204800 /dev/zero | tr '\\0' o";
const run = try support.zbox(arena_state.allocator(), &.{ "run", "--out-kb", "1024", "--time-ms", "30000", "sh", "-c", script });
try testing.expectEqual(0, run.reply.exit_code);
try testing.expectEqual(200 * 1024, run.reply.stdout.len);
try testing.expectEqual(200 * 1024, run.reply.stderr.len);
try testing.expect(!run.reply.stdout_truncated and !run.reply.stderr_truncated);
}
test "короткие счёты: вывод по кусочкам с паузами собран целиком" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const script = "printf нач; sleep 0.05; printf ало; sleep 0.05; printf ' конец'";
const run = try support.zbox(arena_state.allocator(), &.{ "run", "sh", "-c", script });
try testing.expectEqualStrings("начало конец", run.reply.stdout);
}
test "лимит вывода: yes убит, в ответе ровно лимит и флаг" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
// Лимита времени нет: остановить yes может только лимит вывода.
const run = try support.zbox(arena_state.allocator(), &.{ "run", "--out-kb", "4", "yes" });
try testing.expectEqual(9, run.reply.signal);
try testing.expect(run.reply.stdout_truncated);
try testing.expect(!run.reply.stderr_truncated);
try testing.expectEqual(4 * 1024, run.reply.stdout.len);
try testing.expectEqualStrings("output_limit", run.reply.reason);
try testing.expect(run.reply.wall_ms < generous_ms);
}
test "обрыв посреди русской буквы не ломает JSON" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
// Один байт латиницы сдвигает двухбайтные буквы на нечётную границу,
// и лимит в 1024 байта приходится ровно в середину буквы.
const script = "printf a; while true; do printf жжжжжжжжжжжжжжжж; done";
const run = try support.zbox(arena_state.allocator(), &.{ "run", "--out-kb", "1", "sh", "-c", script });
try testing.expect(run.reply.stdout_truncated);
try testing.expect(std.mem.endsWith(u8, run.reply.stdout, "\u{fffd}"));
}
test "stdin из файла, без флага программа читает /dev/null" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(testing.io, .{ .sub_path = "in.txt", .data = "3 4\n" });
const path = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}/in.txt", .{tmp.sub_path});
const fed = try support.zbox(arena, &.{ "run", "--stdin", path, "sh", "-c", "read a b; echo $((a + b))" });
try testing.expectEqualStrings("7\n", fed.reply.stdout);
// cat без файла не виснет в ожидании терминала: сразу конец файла.
const empty = try support.zbox(arena, &.{ "run", "--time-ms", "30000", "cat" });
try testing.expectEqual(0, empty.reply.exit_code);
try testing.expectEqualStrings("", empty.reply.stdout);
try testing.expect(!empty.reply.timed_out);
}
test "нет файла для stdin: zbox выходит с кодом 3" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const raw = try support.zboxRaw(arena_state.allocator(), &.{ "run", "--stdin", "/zbox/no/such/file", "cat" });
try testing.expectEqual(std.process.Child.Term{ .exited = 3 }, raw.term);
}
test "внук держит пайп открытым, но родителя не задерживает" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
// Фоновый sleep унаследовал пишущий конец stdout. Ждать конца файла
// пришлось бы 30 секунд, поэтому zbox ждёт SIGCHLD, а не закрытия пайпа.
const run = try support.zbox(arena_state.allocator(), &.{ "run", "sh", "-c", "sleep 30 & echo готово" });
try testing.expectEqual(0, run.reply.exit_code);
try testing.expectEqualStrings("готово\n", run.reply.stdout);
try testing.expect(run.reply.wall_ms < generous_ms);
}
Тесты делятся на две группы. Первые шесть чистые: Sink, writeJsonString, разбор флагов, приоритет причин. Они работают где угодно и не запускают ни одного процесса. Остальные запускают настоящий zbox, и каждый закрепляет одно решение шага.
- “оба пайпа забиты сразу” это первая ловушка: 200 КБ в stderr, потом 200 КБ в stdout. Закомментируй в
pumpчтениеerrи убедись, что тест не падает, а виснет до лимита времени. - “короткие счёты” пишет вывод тремя кусками с паузами: родитель получает три коротких
readи обязан склеить их в одну строку. - “лимит вывода” запускает
yesбез лимита времени: остановить его может только наш лимит. В ответе ровно 4096 байт. - “обрыв посреди русской буквы” сдвигает текст на один байт, чтобы граница лимита пришлась внутрь символа, и проверяет, что JSON разобрался и кончается на U+FFFD.
- “внук держит пайп открытым” это вторая ловушка: ответ обязан прийти быстрее четырёх секунд при живом
sleep 30.
$ zig build test --summary all
...
Build Summary: 12/12 steps succeeded; 46/49 tests passed (3 skipped)
В шаге 61 пятнадцать тестов, все зелёные. Три пропуска на macOS это тесты cgroups из урока про память; в привилегированном контейнере Linux проходят все 49.
Практика
Напиши rio_readlineb сам, только вместо файла возьми источник, который живёт прямо в задаче: структура Source с методом read. Он ведёт себя как системный вызов на медленном устройстве: отдаёт не больше, чем просили, размеры кусков идут по кругу из заданного списка, в конце данных возвращает ноль. И считает обращения к себе: поле reads это твой strace -c.
От тебя три метода LineReader: read (он же rio_read), readLine и readN. Память под буфер даёт вызывающий, как у std.Io.Reader. Тесты проверяют:
- строки HTTP-запроса при счётах по пять байт и при рваных счётах 1, 7, 2, 3;
- последнюю строку без
\nи пустой источник; - строку длиннее приёмника и строку длиннее внутреннего буфера;
- число обращений к источнику: тысяча байт строк буфером на 64 байта это 17 чтений, а не тысяча;
- что
readне ходит к источнику, пока в буфере что-то лежит, даже если лежит меньше просимого; - сценарий смешивания из урока, сделанный правильно: заголовок
LEN 5строкой, тело блоком, оба через один буфер.
Упражнения
Итоги
- Системный вызов стоит сотни наносекунд независимо от того, сколько байт он несёт. Чтение по байту это миллионы вызовов и секунды; буфер в памяти программы превращает их в тысячи вызовов и миллисекунды. На нашем файле: 10 280 579 вызовов и 3174 мс против 1256 вызовов и 22 мс.
- RIO из книги это буфер, две позиции и одна функция
rio_read: пополнить пустой буфер однимread, отдать не больше того, что лежит.rio_readlinebиrio_readnbэто циклы поверх неё. std.Io.Readerустроен так же (buffer,seek,end), но буфер даёт вызывающий, а строчные функции возвращают срез внутрь буфера без копирования. Срез живёт до следующего обращения к читателю.takeDelimiterInclusiveотдаёт строку с разделителем и на хвосте без\nвозвращаетEndOfStream;takeDelimiterотдаёт без разделителя иnullв конце;takeDelimiterExclusiveразделитель не съедает. Строка длиннее буфера этоerror.StreamTooLong. Конец потока в std это ошибка, а не ноль.strace -cсчитает системные вызовы независимо от программы. Наш счётчик и его таблица сошлись до единицы: 125 и 124 вызова на мегабайт при буфере 8192.- Буферизованное и небуферизованное чтение на одном дескрипторе смешивать нельзя: буфер уже утащил байты, которых ядро за тобой не числит. Результат зависит от размера буфера:
llowoпри 8 байтах, ноль байт при 64. Writerзовётwrite, когда порция не влезла в буфер, и наflush. Забытыйflushэто молча потерянный хвост вывода. Сбрасывай перед выходом, передstd.process.exit, передforkи перед ожиданием ввода.- Метаданные живут в inode и добываются через
stat, по имени или по дескриптору, без открытия файла. В Zig тип файла этоenumв полеkind, права это число.statпроходит по символической ссылке, если не попросить обратного. У файлов в/procразмер ноль. - Каталог это таблица “имя, номер inode”. Размера в записи нет, потому что он принадлежит inode.
ls -lэтоgetdents64пачкой и по одномуstatxна файл. Файловые операции в Zig идут относительно дескриптора каталога, и это защита от гонок с переименованием. - Копирование блоками: время падает вместе с числом вызовов до буфера примерно в 64 КБ, дальше полка. 4096 из
st_blksizeэто нижняя граница, а не оптимум.Reader.streamRemainingчитает прямо в буфер писателя, аDir.copyFileпросит ядро скопировать файл в файл без захода в память процесса. - Захват вывода в
zbox: два пайпа, неблокирующие читающие концы,pollна оба сразу, чтение циклом доEAGAIN. Чтение по очереди даёт тупик на ёмкости пайпа. Конец работы этоSIGCHLD, а не конец файла, потому что внук может держать пишущий конец сколько угодно. Вывод сверх лимита означаетSIGKILLгруппе и причинуoutput_limit. Обрыв посреди символа UTF-8 лечится заменой на U+FFFD.
Дальше
Сегодня мы дважды упёрлись в вещи, которые пришлось принять на веру. Почему после fork у родителя и ребёнка “копии одних и тех же дескрипторов” и что именно у них общее? Почему dup2(fd, 1) заставляет программу писать в пайп, хотя она про пайп не знает? Почему конец файла на пайпе зависит от того, сколько процессов держат его конец? За всем этим стоят три таблицы ядра: дескрипторы процесса, открытые файлы и v-node. В следующем уроке мы нарисуем их, проиграем на них open, fork, dup2 и pipe, разберём задачи книги, где один и тот же файл открыт дважды, соберём конвейер из двух команд и наконец ответим, когда брать Reader с буфером, а когда сырой системный вызов.
домашка