Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Общие файлы, переадресация и пайпы
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Общие файлы, переадресация и пайпы
В уроке про системный ввод и вывод дескриптор был просто маленьким числом, которое вернул
open. В уроке про буферы мы научились читать через него строки. Сегодня вскроем само число. Что именно ядро хранит за цифрой 3? Почему дваopenодного файла читают независимо, а родитель и потомок послеforkотбирают байты друг у друга? Как оболочка устраиваетls > out.txt, если самаlsпро файл ничего не знает? Откудаwcвls | wc -lузнаёт, что данные кончились, и почемуyes | head -1не работает вечно? На все вопросы отвечает одна картинка из трёх таблиц. Мы нарисуем её, подвигаем руками в виджете и соберём на Zig переадресацию и конвейер теми же вызовами, которыми это делает оболочка из урока 52. Это последний урок блока про ввод и вывод: в конце дескриптор окажется ещё и сокетом, а это уже дверь в сети.
Цели урока
- Нарисовать три структуры ядра, которые стоят за открытым файлом: таблицу дескрипторов процесса, таблицу открытых файлов и таблицу v-node, и сказать, какое свойство в какой живёт.
- Предсказывать результат
readпосле двухopen, послеdupи послеfork, опираясь на то, общая запись или нет. - Объяснить
dup2(oldfd, newfd)как перестановку одной стрелки и собрать на нёмls > out.txt. - Собрать конвейер из двух команд на
pipe,fork,dup2иexecvpи объяснить, почему каждый лишний открытый конец пайпа это зависание. - Объяснить, почему
cmd | headзавершается:SIGPIPE,EPIPE, код 141, и что с этим делает стандартная библиотека Zig. - Знать, зачем нужен
O_CLOEXECи как дескриптор утекает в чужую программу. - Выбирать между буферизованным и системным вводом и выводом и понимать, почему на сокетах книга советует второе.
- Увидеть, что сокет это тоже дескриптор, и
dup2сreadработают на нём без изменений.
Идея: дескриптор это стрелка, а не файл
До сих пор мы говорили “дескриптор 3 это файл foobar.txt”. Это удобное упрощение, и оно перестаёт работать на первом же fork. Точнее так: дескриптор это номер ячейки в маленькой таблице процесса, а в ячейке лежит указатель на структуру ядра. Структур по дороге к байтам на диске три, и у каждой свой владелец.
Таблица дескрипторов своя у каждого процесса. Это массив, индекс в котором и есть то самое число. open ищет в массиве наименьшую свободную ячейку, поэтому первый открытый тобой файл почти всегда получает номер 3: ячейки 0, 1 и 2 оболочка заняла терминалом ещё до запуска программы.
Таблица открытых файлов одна на всё ядро. Каждый успешный open создаёт в ней новую запись. Самое важное в записи это текущая позиция в файле и счётчик ссылок: сколько ячеек из таблиц дескрипторов (любых процессов) на неё сейчас указывает. close уменьшает счётчик, и только когда он доходит до нуля, ядро удаляет запись. Стандарт POSIX называет эту запись open file description, и путать её с дескриптором (file descriptor) нельзя: вся сегодняшняя механика держится на разнице между ними.
Таблица v-node тоже общая. В ней по одной записи на файл, с которым ядро сейчас работает, сколько бы раз его ни открыли. Здесь лежит почти всё, что возвращает stat из прошлого урока: тип, права, размер, времена. В Linux первая структура называется struct fdtable, вторая struct file (поля f_pos, f_flags, f_mode, f_inode), третья struct inode. Названия из книги, v-node, пришли из BSD и SunOS, смысл тот же.
Полезно сразу разложить по полкам, что где хранится. На эту таблицу мы будем опираться весь урок.
| Свойство | Где живёт | Кто делит |
|---|---|---|
| номер дескриптора | таблица дескрипторов | никто, у каждого процесса своя нумерация |
флаг close-on-exec (FD_CLOEXEC) | ячейка таблицы дескрипторов | никто, даже копия после dup2 получает его сброшенным |
| текущая позиция | запись открытого файла | все дескрипторы, которые смотрят на запись |
| режим доступа (чтение, запись) | запись открытого файла | они же |
O_APPEND, O_NONBLOCK | запись открытого файла | они же, и это регулярный источник сюрпризов |
| счётчик ссылок | запись открытого файла | считает ячейки всех процессов |
| тип, права, размер, времена | v-node | все, кто открыл этот файл |
| данные | блоки на диске или буфер ядра | все |
Из таблицы следуют три ситуации, и книга разбирает именно их.
Обычный случай. Два дескриптора, два разных файла: две ячейки, две записи, два v-node. Ничего общего.
Один файл открыт дважды. Две ячейки, две записи, но v-node один. Позиции независимые: каждый дескриптор читает файл с начала, как будто другого нет. Данные при этом общие: если один запишет, другой прочитает новое.
Общая запись. Две ячейки указывают на одну запись. Так бывает после dup, dup2 и после fork. Позиция одна на всех: байт, прочитанный через один дескриптор, второй уже не увидит.
Всё остальное в уроке это комбинации третьего случая. Переадресация: заставить ячейку 1 указывать на запись файла. Пайп: две записи на один буфер ядра, и раздать их двум процессам через fork. Утечка дескриптора: ячейка, про которую забыли, дожила до чужой программы.
Демонстрация: два open против dup
Проверим второй и третий случаи одной программой. В файле foobar.txt шесть байт: foobar. Откроем его дважды, а первый дескриптор ещё и продублируем.
В этом уроке мы впервые в блоке зовём libc напрямую. В std.posix версии 0.16 нет fork, pipe, dup2 и execve: новая модель ввода и вывода через std.Io прячет их за std.process.spawn, а нам сегодня нужны именно голые вызовы. Они есть в std.c, это тонкие extern-объявления функций libc. На macOS libc линкуется всегда, на Linux добавляй -lc: zig run twoopens.zig -lc. Печатаем тоже напрямую, через write(1, ...) без буфера: в программах с fork это принципиально, и ниже ты увидишь почему.
const std = @import("std");
const c = std.c;
fn say(comptime fmt: []const u8, args: anytype) void {
var buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
_ = c.write(1, text.ptr, text.len);
}
fn readByte(fd: c.fd_t) u8 {
var byte: [1]u8 = undefined;
return if (c.read(fd, &byte, 1) == 1) byte[0] else '?';
}
pub fn main() void {
const first = c.open("foobar.txt", .{ .ACCMODE = .RDONLY });
const second = c.open("foobar.txt", .{ .ACCMODE = .RDONLY });
const copy = c.dup(first);
say("open вернул {d} и {d}, dup({d}) вернул {d}\n", .{ first, second, first, copy });
say("read({d}) = {c}\n", .{ first, readByte(first) });
say("read({d}) = {c} второй open: своя запись, своя позиция\n", .{ second, readByte(second) });
say("read({d}) = {c} dup: та же запись, позиция уже 1\n", .{ copy, readByte(copy) });
say("read({d}) = {c} и исходный дескриптор это видит\n", .{ first, readByte(first) });
const at = c.lseek(copy, 0, c.SEEK.CUR);
say("lseek({d}, 0, SEEK_CUR) = {d}\n", .{ copy, at });
}
$ printf foobar > foobar.txt
$ zig run twoopens.zig
open вернул 3 и 4, dup(3) вернул 5
read(3) = f
read(4) = f второй open: своя запись, своя позиция
read(5) = o dup: та же запись, позиция уже 1
read(3) = o и исходный дескриптор это видит
lseek(5, 0, SEEK_CUR) = 3
Три дескриптора, но записей в таблице открытых файлов две. Дескриптор 4 получил свою запись с позицией 0 и честно прочитал f. Дескриптор 5 это вторая стрелка на запись дескриптора 3: первый read(3) сдвинул позицию на 1, поэтому read(5) вернул o, и следующий read(3) продолжил с того места, где остановился read(5). Последняя строка добивает: lseek через дескриптор 5 сообщает позицию 3, хотя через сам 5 мы прочитали один байт. Позиция принадлежит записи, а не дескриптору.
dup(fd) берёт наименьший свободный номер, как open. Его брат dup2(oldfd, newfd) кладёт копию в ту ячейку, которую ты назвал, и к нему мы скоро вернёмся.
Теперь то же самое руками. В виджете три таблицы и кнопки системных вызовов. Сыграй сценарии упражнений 10.1 и 10.2 (сначала ответь на вопрос сам, потом жми шаги), а потом в свободном режиме повтори twoopens.zig: open, ещё один open, dup2(3, 5), и читай по байту из 3, 4 и 5. Следи за полем “позиция” и счётчиком ссылок.
На что обратить внимание:
- процесс стартует с тремя дескрипторами, но запись у них одна, с тремя ссылками: оболочка открыла терминал один раз и дважды продублировала;
closeне удаляет запись, пока на неё смотрит хоть одна стрелка; v-node уходит вместе с последней записью;- после
close(3)следующийopenснова вернёт 3. Правило наименьшего свободного номера выглядит мелочью, но на нём строили переадресацию до появленияdup2: закрыть 1 и сразу открыть файл, он и займёт место стандартного вывода.
fork: одна позиция на двоих
В уроке про процессы мы говорили, что потомок получает копию адресного пространства родителя и “те же открытые файлы”. Теперь можно сказать точно: fork копирует таблицу дескрипторов. Только её. Записи в таблице открытых файлов не копируются, у каждой просто растёт счётчик ссылок. Значит, родитель и потомок оказываются в третьем случае: общая запись, общая позиция.
const std = @import("std");
const c = std.c;
fn say(comptime fmt: []const u8, args: anytype) void {
var buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
_ = c.write(1, text.ptr, text.len);
}
fn readBytes(fd: c.fd_t, out: []u8) []u8 {
const n = c.read(fd, out.ptr, out.len);
return out[0..@intCast(@max(n, 0))];
}
pub fn main() void {
const fd = c.open("foobar.txt", .{ .ACCMODE = .RDONLY });
var buf: [2]u8 = undefined;
const pid = c.fork();
if (pid == 0) {
say("потомок: read({d}) = {s}\n", .{ fd, readBytes(fd, &buf) });
_ = c.close(fd);
c._exit(0);
}
_ = c.waitpid(pid, null, 0);
say("родитель: read({d}) = {s}\n", .{ fd, readBytes(fd, &buf) });
say("родитель: read({d}) = {s}\n", .{ fd, readBytes(fd, &buf) });
}
$ zig run forkshare.zig
потомок: read(3) = fo
родитель: read(3) = ob
родитель: read(3) = ar
Родитель ни разу не читал первые два байта и всё равно начал с третьего: позицию сдвинул потомок. Обрати внимание и на close(fd) в потомке: он закрыл свою стрелку, счётчик ссылок упал с 2 до 1, запись осталась жива, и родитель спокойно дочитал файл. Закрывать унаследованные дескрипторы в потомке безопасно, родителю это не вредит.
Это не побочный эффект, а задуманное поведение, и на нём держится обычная работа оболочки. Запусти скрипт с > log.txt: оболочка открывает файл один раз, а потом по очереди запускает команды скрипта, каждая наследует ту же запись и пишет с того места, где остановилась предыдущая. Будь у каждой команды своя позиция, все писали бы с нуля и затирали друг друга.
Оборотная сторона: позицию делят, а синхронизации нет. Если родитель и потомок пишут в общий дескриптор одновременно, каждый отдельный write попадёт в файл целиком и сдвинет общую позицию, но порядок кусков решит планировщик. Для журналов, куда пишут несколько процессов с разными записями (каждый сам сделал open), есть флаг O_APPEND: с ним ядро перед каждым write атомарно переставляет позицию в конец файла, и строки разных процессов не затирают друг друга.
И ещё одна ловушка из таблицы выше: O_NONBLOCK живёт в записи, а не в ячейке. Программа, которая переводит свой стандартный ввод в неблокирующий режим, меняет запись терминала, общую с оболочкой и всеми соседями по конвейеру. После её падения оболочка может внезапно получить EAGAIN на чтении команды. Поэтому аккуратные программы возвращают флаг на место перед выходом.
Сыграй в виджете сценарий упражнения 10.3, а потом продолжи его: сделай в потомке close(3) и посмотри на счётчик.
dup2 и переадресация
Программа ls пишет в дескриптор 1 и больше ничего не знает. Ей всё равно, что стоит за единицей: терминал, файл или пайп. В этом и состоит договорённость Unix о стандартных дескрипторах: программа использует номера 0, 1 и 2, а кто-то снаружи решает, куда они ведут. Этот кто-то обычно оболочка, а её инструмент это dup2.
int dup2(int oldfd, int newfd);
dup2 делает ячейку newfd копией ячейки oldfd. По шагам:
- если
newfdоткрыт, ядро его закрывает (минус ссылка у старой записи, возможно, с удалением); - в ячейку
newfdкладётся указатель на ту же запись, что уoldfd; - счётчик ссылок этой записи растёт на 1;
- флаг close-on-exec у
newfdсброшен, каким бы он ни был уoldfd.
Шаги 1 и 2 атомарны: между ними другой поток не успеет занять освободившийся номер своим open. Старый приём “закрыть 1 и сразу открыть файл” такой гарантии не даёт, и в многопоточной программе он ломается.
Порядок аргументов путают все. Запомни через присваивание: dup2(a, b) читается как fds[b] = fds[a]. Источник слева, цель справа. После вызова oldfd по-прежнему открыт, и если он больше не нужен, его закрывают отдельно. Частный случай: dup2(fd, fd) ничего не делает и возвращает fd. Он кажется бессмысленным, но в конце урока именно на нём ты споткнёшься в задаче.
Где оболочка зовёт dup2? В единственном месте, где это возможно: в потомке, после fork и до execvp. До fork нельзя, иначе оболочка переадресует собственный вывод. После execvp поздно, там уже работает чужой код. А в промежутке мы уже отдельный процесс со своей таблицей дескрипторов, но ещё выполняем свой код. И главное: execve заменяет адресное пространство, но таблицу дескрипторов не трогает (кроме ячеек с флагом close-on-exec). Новая программа просыпается с теми стрелками, которые ей расставили.
Соберём ls -1 /usr > out.txt руками:
const std = @import("std");
const c = std.c;
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
fn say(comptime fmt: []const u8, args: anytype) void {
var buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
_ = c.write(2, text.ptr, text.len);
}
pub fn main() void {
const pid = c.fork();
if (pid == 0) {
// Между fork и exec: мы уже отдельный процесс, но ещё наш код.
const fd = c.open(
"out.txt",
.{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true },
@as(c_uint, 0o644),
);
if (fd < 0) c._exit(126);
_ = c.dup2(fd, 1);
_ = c.close(fd);
const argv = [_:null]?[*:0]const u8{ "ls", "-1", "/usr" };
_ = execvp("ls", &argv);
c._exit(127);
}
var status: c_int = 0;
_ = c.waitpid(pid, &status, 0);
say("ls закончился, статус {d}\n", .{c.W.EXITSTATUS(@bitCast(status))});
}
$ zig run redirect.zig
ls закончился, статус 0
$ cat out.txt
X11
X11R6
bin
lib
libexec
local
sbin
share
standalone
Вывод снят на macOS, на Linux список каталогов в /usr будет другим, механика та же. На терминале от ls ни строчки: всё ушло в файл. Разберём потомка по таблицам:
openсоздал запись дляout.txtи вернул 3 (наименьший свободный);dup2(3, 1)закрыл старую единицу (у записи терминала стало на ссылку меньше) и направил ячейку 1 на запись файла, у которой теперь две ссылки;close(3)убрал лишнюю стрелку. Без негоlsполучила бы открытый дескриптор 3, о котором не просила. Ей это не повредит, но привычка закрывать всё лишнее передexecскоро окажется вопросом жизни и смерти конвейера;execvpзаменил код наls, таблица дескрипторов пережила замену.
Сообщение о статусе мы печатаем в дескриптор 2, а не в 1. Сейчас это неважно (единицу мы переставили только в потомке), но в следующем листинге стандартный вывод займёт конвейер, и диагностике там не место. Для этого дескриптор 2 и существует.
Флаги open здесь ровно те, что ставит оболочка для >: O_WRONLY | O_CREAT | O_TRUNC и права 0644. Для >> вместо O_TRUNC ставят O_APPEND. Для < file это O_RDONLY и dup2(fd, 0). В эталонной оболочке из урока 52 весь этот блок занимает два десятка строк, и теперь ты знаешь каждую.
Загадка 2>&1
Запись 2>&1 в оболочке означает dup2(1, 2): “пусть 2 смотрит туда же, куда сейчас смотрит 1”. Слово “сейчас” тут главное. Переадресации выполняются слева направо, и две похожие команды дают разный результат:
ls /usr /nope > out.txt 2>&1 # и список, и ошибка в файле
ls /usr /nope 2>&1 > out.txt # список в файле, ошибка на терминале
В первой строке оболочка сначала делает dup2(файл, 1), потом dup2(1, 2): единица уже смотрит на файл, двойка копирует эту стрелку. Во второй сначала dup2(1, 2): единица ещё смотрит на терминал, и двойка копирует стрелку на терминал (где и так была). Потом dup2(файл, 1) переставляет единицу, а двойку уже никто не трогает. dup2 копирует стрелку, а не связывает дескрипторы навсегда. Повтори обе последовательности в виджете (в свободном режиме: open, потом два dup2 в разном порядке), и загадка перестанет быть загадкой. В домашнем задании ты соберёшь обе на Zig.
Раз уж первая команда направила 1 и 2 на одну запись, у них общая позиция, и строки вывода и ошибок ложатся в файл друг за другом. Альтернатива > out.txt 2> out.txt открывает файл дважды: две записи, две позиции, обе с нуля, и потоки затирают друг друга. Это второй случай из начала урока, только в роли грабель.
Пайпы
Пайп это буфер в ядре с двумя концами. Вызов pipe(&fds) создаёт сразу две записи в таблице открытых файлов, обе смотрят на один v-node особого вида (за ним стоит не диск, а кольцевой буфер в памяти ядра), и возвращает два дескриптора: fds[0] только для чтения, fds[1] только для записи. Мнемоника та же, что у стандартных дескрипторов: 0 это ввод, 1 это вывод. Что записано в fds[1], то можно прочитать из fds[0], в том же порядке, ровно один раз.
У пайпа нет имени в файловой системе, открыть его через open нельзя. Получить его конец можно единственным способом: унаследовать дескриптор. Поэтому порядок всегда один: сначала pipe, потом fork. Потомок, созданный до pipe, про пайп не узнает никогда.
Четыре правила, по которым живёт пайп. Первое это тот самый короткий счёт из урока про системный ввод и вывод, остальные три объясняют всё поведение конвейеров.
readиз пустого пайпа засыпает, пока не появятся данные. Проснувшись, возвращает сколько есть, а не сколько просили: это короткий счёт.readвозвращает 0 (EOF), когда пайп пуст и не осталось ни одного открытого пишущего конца. Ни одного во всей системе: считаются все стрелки всех процессов на запись пишущего конца.writeв полный пайп засыпает, пока читатель не освободит место. Так медленный читатель притормаживает быстрого писателя, и конвейеру не нужна память под весь промежуточный результат.writeв пайп без читателей не засыпает, а получаетSIGPIPE, и если сигнал не убил процесс, возвращает -1 сerrno = EPIPE.
Какой ёмкости буфер? Измерим: переведём пишущий конец в неблокирующий режим и будем писать, пока ядро не откажет.
const std = @import("std");
const c = std.c;
fn say(comptime fmt: []const u8, args: anytype) void {
var buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
_ = c.write(1, text.ptr, text.len);
}
pub fn main() void {
var fds: [2]c.fd_t = undefined;
if (c.pipe(&fds) != 0) return;
// Неблокирующий режим: полный пайп вернёт EAGAIN, а не усыпит нас навсегда.
const nonblock: c.O = .{ .NONBLOCK = true };
_ = c.fcntl(fds[1], c.F.SETFL, @as(c_int, @bitCast(nonblock)));
const chunk: [1024]u8 = @splat('x');
var total: usize = 0;
while (true) {
const n = c.write(fds[1], &chunk, chunk.len);
if (n <= 0) break;
total += @intCast(n);
}
const failure: std.posix.E = @enumFromInt(c._errno().*);
say("в пайп влезло {d} байт, дальше write вернул -1, errno = {s}\n", .{ total, @tagName(failure) });
}
$ zig run pipecap.zig
в пайп влезло 65536 байт, дальше write вернул -1, errno = AGAIN
64 КиБ и на Linux 7.0 (16 страниц по 4 КиБ, значение по умолчанию с ядра 2.6.11), и на macOS 26. Это не константа стандарта: на Linux ёмкость меняют через fcntl(fd, F_SETPIPE_SZ, n) до предела из /proc/sys/fs/pipe-max-size (по умолчанию 1 МиБ), а macOS начинает с 16 КиБ и сама растит буфер до 64 КиБ под нагрузкой. Стандарт гарантирует другое число, PIPE_BUF: запись не длиннее PIPE_BUF байт атомарна, то есть не перемешается с записями других процессов в тот же пайп. На Linux это 4096, на macOS 512, POSIX требует не меньше 512. Строка журнала короче 512 байт, записанная одним write, дойдёт целой при любом числе пишущих процессов.
Конвейер из двух команд
Теперь всё готово, чтобы собрать ls -1 /usr | wc -l. План:
- родитель зовёт
pipe; forkпервого потомка: он направляет свою единицу на пишущий конец и становитсяls;forkвторого потомка: он направляет свой ноль на читающий конец и становитсяwc;- родитель закрывает оба своих конца и ждёт обоих потомков.
const std = @import("std");
const c = std.c;
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
const Argv = [*:null]const ?[*:0]const u8;
fn say(comptime fmt: []const u8, args: anytype) void {
var buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
_ = c.write(2, text.ptr, text.len);
}
/// Потомок: подставляет `input` и `output` на места 0 и 1 и становится командой.
fn spawn(argv: Argv, input: c.fd_t, output: c.fd_t, unused: c.fd_t) c.pid_t {
const pid = c.fork();
if (pid != 0) return pid;
if (unused >= 0) _ = c.close(unused);
if (input != 0) {
_ = c.dup2(input, 0);
_ = c.close(input);
}
if (output != 1) {
_ = c.dup2(output, 1);
_ = c.close(output);
}
_ = execvp(argv[0].?, argv);
c._exit(127);
}
pub fn main() void {
const left = [_:null]?[*:0]const u8{ "ls", "-1", "/usr" };
const right = [_:null]?[*:0]const u8{ "wc", "-l" };
var fds: [2]c.fd_t = undefined;
if (c.pipe(&fds) != 0) return;
say("pipe: читаем из {d}, пишем в {d}\n", .{ fds[0], fds[1] });
const writer = spawn(&left, 0, fds[1], fds[0]);
const reader = spawn(&right, fds[0], 1, fds[1]);
// Родителю пайп не нужен. Не закроешь fds[1], и wc никогда не увидит EOF.
_ = c.close(fds[0]);
_ = c.close(fds[1]);
_ = c.waitpid(writer, null, 0);
_ = c.waitpid(reader, null, 0);
say("оба потомка собраны\n", .{});
}
$ zig run pipeline.zig
pipe: читаем из 3, пишем в 4
9
оба потомка собраны
Девять строк, столько же, сколько было в out.txt. Ни ls, ни wc не знают, что работают в паре: одна пишет в 1, другая читает из 0.
Функция spawn общая для обоих потомков, и каждая её строка закрывает конкретную дыру. Посчитаем стрелки. Сразу после pipe на каждый конец смотрит одна ячейка родителя. После двух fork уже по три: родитель, будущая ls, будущая wc. А в работающем конвейере на каждый конец должна смотреть ровно одна: единица у ls и ноль у wc. Все остальные четыре стрелки лишние, и за каждую есть кому отвечать:
lsзакрывает читающий конец (параметрunused), а послеdup2и исходный номер пишущего;wcзакрывает пишущий конец, а послеdup2исходный номер читающего;- родитель закрывает оба.
Что будет, если забыть? Вспомни правило 2: EOF приходит, когда закрыты все пишущие концы. Оставь close(fds[1]) в родителе закомментированным, и wc дочитает всё, что написала ls, а потом заснёт в read навсегда: в системе есть ещё один открытый пишущий конец, вдруг в него что-то запишут. Держит его родитель, который сам стоит в waitpid и ждёт wc. Взаимная блокировка из двух процессов, и ни один из них не делает ничего плохого, просто лишняя стрелка. Ещё коварнее забыть close пишущего конца в самой wc: тогда она ждёт EOF от самой себя.
Проверка if (input != 0) в spawn выглядит перестраховкой, но без неё функция сломается в тот день, когда дескриптор уже стоит на своём месте: dup2(0, 0) ничего не сделает, а следующий close(0) закроет стандартный ввод. У ls здесь input равен 0 не случайно: это значит “оставь как есть”.
Сыграй сценарий про забытый пишущий конец: родитель сделал pipe и fork, потомок написал и закрыл свой конец, а read у родителя всё равно блокируется. Смотри на подпись под v-node пайпа: там счётчик пишущих концов.
На Linux расстановку видно снаружи через /proc. Запустим в контейнере конвейер из двух sleep и посмотрим на дескрипторы обоих процессов:
$ sleep 100 | sleep 101 &
$ ls -l /proc/<pid>/fd
== sleep 100
0 -> /dev/null
1 -> pipe:[2499602]
2 -> pipe:[2497570]
== sleep 101
0 -> pipe:[2499602]
1 -> pipe:[2497569]
2 -> pipe:[2497570]
Снято в Debian 12 под Docker (ядро 7.0, aarch64), лишние колонки ls убраны. Число в квадратных скобках это номер inode пайпа, то есть тот самый v-node. У первого sleep единица и у второго ноль показывают на один и тот же pipe:[2499602]: это наш конвейер. Остальные пайпы поставил сам Docker, который запустил контейнер без терминала и забирает его вывод через такие же пайпы. Весь Unix собран из одной детали.
Почему cmd | head завершается
Команда yes печатает y в бесконечном цикле. Команда yes | head -1 печатает одну строку и завершается. Кто останавливает yes?
head читает одну строку, печатает её и выходит. При выходе ядро закрывает все его дескрипторы, в том числе единственный читающий конец пайпа. yes в этот момент продолжает писать, и очередной write попадает под правило 4: читателей нет, ядро посылает писателю SIGPIPE. Действие по умолчанию для SIGPIPE это завершение процесса, и yes умирает, ничего не успев понять. Оболочка это видит:
$ yes | head -1; echo "${PIPESTATUS[@]}"
y
141 0
141 это 128 + 13, так bash кодирует “убит сигналом 13”, а 13 это SIGPIPE и на Linux, и на macOS. Сигнал здесь не авария, а штатный способ сказать “дальше можешь не стараться”: без него каждая утилита должна была бы проверять результат каждого write, а yes образца 1979 года этого не делала. Заодно экономится работа: cat huge.log | grep -m1 ERROR перестаёт читать гигабайтный файл, как только grep нашёл первое совпадение и вышел.
Посмотрим на обе ветки правила 4 в одной программе. Потомок пишет в пайп, у которого закрыты все читающие концы: один раз с действием по умолчанию, второй раз с игнорируемым SIGPIPE.
const std = @import("std");
const c = std.c;
const posix = std.posix;
fn say(comptime fmt: []const u8, args: anytype) void {
var buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
_ = c.write(2, text.ptr, text.len);
}
/// Потомок пишет в пайп, у которого не осталось ни одного читателя.
fn writeToDeadPipe(ignore: bool) void {
var fds: [2]c.fd_t = undefined;
if (c.pipe(&fds) != 0) return;
const pid = c.fork();
if (pid == 0) {
_ = c.close(fds[0]);
if (ignore) {
const action: posix.Sigaction = .{
.handler = .{ .handler = posix.SIG.IGN },
.mask = posix.sigemptyset(),
.flags = 0,
};
posix.sigaction(.PIPE, &action, null);
}
const n = c.write(fds[1], "y\n", 2);
const failure: posix.E = @enumFromInt(c._errno().*);
say(" write вернул {d}, errno = {s}\n", .{ n, @tagName(failure) });
c._exit(1);
}
_ = c.close(fds[0]);
_ = c.close(fds[1]);
var raw: c_int = 0;
_ = c.waitpid(pid, &raw, 0);
const status: u32 = @bitCast(raw);
if (c.W.IFSIGNALED(status)) {
say(" потомок убит сигналом {s}\n", .{@tagName(c.W.TERMSIG(status))});
} else {
say(" потомок вышел сам с кодом {d}\n", .{c.W.EXITSTATUS(status)});
}
}
pub fn main() void {
say("SIGPIPE по умолчанию:\n", .{});
writeToDeadPipe(false);
say("SIGPIPE игнорируется:\n", .{});
writeToDeadPipe(true);
}
$ zig run sigpipe.zig
SIGPIPE по умолчанию:
потомок убит сигналом PIPE
SIGPIPE игнорируется:
write вернул -1, errno = PIPE
потомок вышел сам с кодом 1
В первом случае строка после write не напечатана вовсе: до неё процесс не дожил. Во втором write вернул -1, и errno равен EPIPE (в Zig это значение .PIPE перечисления std.posix.E). Для утилиты командной строки годится первый вариант. Для сервера он смертелен: клиент закрыл соединение посреди ответа, сервер пишет в сокет, получает SIGPIPE и падает целиком, со всеми остальными клиентами. Поэтому любой сетевой сервер первым делом игнорирует SIGPIPE и разбирает EPIPE как обычную ошибку записи. К этому мы вернёмся, когда будем писать свой веб-сервер.
А что делает Zig? Проверим своим yes, уже на std.Io, как положено программе 0.16:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &w.interface;
var lines: usize = 0;
while (true) : (lines += 1) {
out.writeAll("y\n") catch |err| {
std.debug.print("после {d} строк: {s}, причина {?}\n", .{ lines, @errorName(err), w.err });
return;
};
}
}
$ zig build-exe myyes.zig
$ ./myyes | head -1; echo "${PIPESTATUS[@]}"
y
после 20489 строк: WriteFailed, причина error.BrokenPipe
0 0
Никакого 141: программа получила ошибку error.BrokenPipe, напечатала диагностику и вышла с кодом 0. Число строк от запуска к запуску разное: это сколько успело уйти в буфер, пока head собирался выходить. Объяснение лежит в lib/std/Io/Threaded.zig. Функция init, которая создаёт реализацию Io для обычной программы, ставит на SIGPIPE пустой обработчик, и сигнал перестаёт убивать процесс. Так что программа с pub fn main(init: std.process.Init) живёт по серверным правилам с первой строки, а sigpipe.zig с голым pub fn main() void и вызовами libc по правилам C. Тонкость для внимательных: стоит именно обработчик, а не SIG_IGN. Из урока про сигналы ты помнишь, что при execve обработчики сбрасываются в действие по умолчанию, а игнорирование наследуется. Поэтому утилита, запущенная из Zig-программы, получает нормальный смертельный SIGPIPE, и конвейеры из неё работают как обычно.
И заметь, как ошибка доходит до нас: writeAll возвращает обобщённую error.WriteFailed (интерфейс std.Io.Writer не знает, что под ним файл), а настоящая причина лежит в поле err конкретного писателя.
O_CLOEXEC: дескриптор, который утёк
То, что execve сохраняет таблицу дескрипторов, делает возможной переадресацию. Оно же делает возможной утечку: любой дескриптор, который ты открыл и не закрыл перед exec, достаётся новой программе. Она про него не знает, но он у неё есть.
const std = @import("std");
const c = std.c;
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
fn say(comptime fmt: []const u8, args: anytype) void {
var buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
_ = c.write(1, text.ptr, text.len);
}
/// Запускает `cat /dev/fd/3`: чужая программа читает дескриптор 3, которого не открывала.
fn catInheritedFd() void {
const pid = c.fork();
if (pid == 0) {
const argv = [_:null]?[*:0]const u8{ "cat", "/dev/fd/3" };
_ = execvp("cat", &argv);
c._exit(127);
}
_ = c.waitpid(pid, null, 0);
say("\n", .{});
}
pub fn main() void {
const leaky = c.open("foobar.txt", .{ .ACCMODE = .RDONLY });
say("open без флага вернул {d}, cat /dev/fd/3 после exec:\n", .{leaky});
catInheritedFd();
_ = c.close(leaky);
const tidy = c.open("foobar.txt", .{ .ACCMODE = .RDONLY, .CLOEXEC = true });
say("open с O_CLOEXEC вернул {d}, cat /dev/fd/3 после exec:\n", .{tidy});
catInheritedFd();
}
$ zig run cloexec.zig
open без флага вернул 3, cat /dev/fd/3 после exec:
foobar
open с O_CLOEXEC вернул 3, cat /dev/fd/3 после exec:
cat: /dev/fd/3: Bad file descriptor
Путь /dev/fd/3 означает “мой дескриптор номер 3” (на Linux это ссылка на /proc/self/fd/3, и сообщение об ошибке там другое: No such file or directory). В первом запуске cat, совершенно посторонняя программа, прочитала файл, который не открывала. Замени foobar.txt на файл с ключами, а cat на скрипт, который веб-сервер запускает по запросу пользователя, и получится настоящая уязвимость: CVE-2024-21626 в runc, на котором работает Docker, это ровно утёкший в контейнер дескриптор каталога хоста. Кроме безопасности страдает и логика: утёкший пишущий конец пайпа, как мы видели, отнимает у читателя EOF, а утёкший слушающий сокет не даёт перезапустить сервер на том же порту.
Лекарство это флаг close-on-exec на ячейке таблицы дескрипторов: ядро само закроет такую ячейку в момент execve. Ставить его надо сразу при создании дескриптора: open(..., O_CLOEXEC), на Linux ещё pipe2(fds, O_CLOEXEC), socket(..., SOCK_CLOEXEC), dup3, accept4. Двухшаговый вариант “сначала open, потом fcntl(fd, F_SETFD, FD_CLOEXEC)” в многопоточной программе дыряв: между двумя вызовами другой поток может успеть сделать fork и exec. Именно поэтому появились флаги, совмещающие создание и пометку: O_CLOEXEC в Linux 2.6.23, pipe2, dup3 и accept4 в 2.6.27 и 2.6.28.
Современное правило: каждый дескриптор создаётся с O_CLOEXEC, а потомку сознательно передаётся только то, что ему нужно. Передаётся через dup2: вспомни шаг 4 из описания dup2, копия получает сброшенный флаг. Пайп, открытый с O_CLOEXEC, после dup2(fds[1], 1) живёт в ячейке 1 без флага и переживает exec, а исходные ячейки закроются сами. Забытый close перестаёт быть ошибкой. Стандартная библиотека Zig так и поступает: всё, что открывает std.Io, открыто с CLOEXEC, а std.process.spawn расставляет потомку только 0, 1 и 2. В листингах этого урока флага нет нарочно, чтобы каждая стрелка была на виду и на твоей совести.
Стандартный ввод и вывод против системного
К концу блока у тебя три уровня инструментов. Системные вызовы read и write (в Zig: std.c, std.posix, на Linux std.os.linux). Устойчивые обёртки из урока про короткие счёты: цикл, который дочитывает и дописывает до конца. И буферизованные потоки из урока про буферы: в C это FILE* с printf и fgets, в Zig это std.Io.Reader и std.Io.Writer с явным буфером. Книга заканчивает главу советом, когда что брать, и совет стоит разобрать, потому что причины за ним те самые таблицы.
Правило первое: бери самый высокий уровень, который годится. Буфер экономит системные вызовы на порядки, а обработка коротких счётов и EINTR уже написана. Для файлов на диске и терминала буферизованный поток годится почти всегда.
Правило второе: буфер в пространстве пользователя и общая запись в ядре друг о друге не знают. Отсюда все неприятности. Вот самая известная:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &w.interface;
try out.print("строка напечатана до fork\n", .{});
// Строка пока лежит в buf, в ядро ничего не ушло.
const pid = std.c.fork();
try out.flush();
if (pid != 0) _ = std.c.waitpid(pid, null, 0);
}
$ zig run twice.zig
строка напечатана до fork
строка напечатана до fork
print вызван один раз, до fork, а строк две. Строка лежала в буфере buf, то есть в памяти процесса. fork скопировал память вместе с буфером, и каждый из двух процессов честно сбросил свою копию в общий дескриптор. В C ровно та же история с printf без \n или при выводе в файл. Лечение: flush перед fork. А лучше вообще не мешать в одной программе буферизованный вывод с fork, поэтому все листинги выше печатают через голый write.
Вторая неприятность зеркальная, на вводе: буферизованный читатель забирает из дескриптора больше, чем отдаёт тебе. Прочитал ты через него одну строку, а он вытащил из ядра 4096 байт. Общая позиция в записи уехала на 4096. Если теперь сделать fork и exec в расчёте, что потомок продолжит читать “со следующей строки”, он продолжит с байта 4096, а пропущенное осталось в буфере родителя. Это та же ошибка, что смешивание буферизованных и прямых чтений одного дескриптора, только разнесённая по двум процессам. Вывод тот же: на один дескриптор один буфер, и либо всё чтение идёт через него, либо буфера нет.
Правило третье, про сети, и оно чисто сишное. Поток FILE* полнодуплексный: один объект, один буфер и на ввод, и на вывод. Отсюда два ограничения стандарта C: нельзя читать сразу после записи без fflush (или fseek), и нельзя писать сразу после чтения без fseek. На файле это лишний вызов. На сокете второе ограничение невыполнимо: fseek зовёт lseek, а сокет позиции не имеет и отвечает ESPIPE (сейчас увидишь). Обходной путь это два потока на один сокет, fdopen(fd, "r") и fdopen(fd, "w"), но тогда закрыть надо оба, второй fclose закроет уже закрытый дескриптор, а в многопоточном сервере этот номер мог успеть достаться чужому соединению. Поэтому книга советует на сокетах обходиться без stdio: читать устойчивыми функциями со своим буфером, а форматировать ответ в строку в памяти и отправлять одним вызовом.
В Zig 0.16 этой ловушки нет по построению: Reader и Writer это два разных объекта, у каждого свой буфер, ты создаёшь их явно, и ни один не владеет дескриптором. Ограничение про чередование не возникает, двойного закрытия нет. Остаются две универсальные обязанности: flush перед тем, как ждать ответа собеседника (иначе запрос так и пролежит в буфере, а ты будешь ждать ответ на то, что не отправил), и один читающий буфер на дескриптор.
| Задача | Что брать | Почему |
|---|---|---|
| текстовый файл, терминал | буферизованный Reader и Writer | меньше системных вызовов, строки из коробки |
| большой двоичный файл целиком | прямое чтение большими кусками или mmap | двойное копирование через буфер ничего не даёт |
программа с fork | прямой write или flush перед каждым fork | иначе буфер размножится |
| обработчик сигнала | только прямой write | буферизованная печать не async-signal-safe |
| сокет, пайп | устойчивое чтение со своим буфером, ответ одним куском, flush перед ожиданием | короткие счёты это норма, позиции нет |
| дескриптор передаётся потомку | читать без буфера или не читать вовсе | буфер утащит чужие байты |
Сокеты как файлы
Последний кирпич блока. Всё, что мы делали сегодня, делалось с дескрипторами, и нигде не было сказано “файл на диске”. Пайп уже показал, что за v-node может стоять буфер ядра. Сокет, конец сетевого соединения, устроен так же: вызов socket (или accept, или socketpair) создаёт запись в таблице открытых файлов и возвращает обычный дескриптор. На нём работают read, write, close, dup2, наследование через fork и флаг close-on-exec. Не работает только то, чему нужна позиция: lseek.
Проверим без сети. socketpair создаёт два уже соединённых сокета, по сути двунаправленный пайп. Подставим один из них программе tr сразу на место ввода и вывода:
const std = @import("std");
const c = std.c;
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
fn say(comptime fmt: []const u8, args: anytype) void {
var buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
_ = c.write(1, text.ptr, text.len);
}
pub fn main() void {
// Два соединённых сокета: что записано в один, читается из другого, в обе стороны.
var pair: [2]c.fd_t = undefined;
if (c.socketpair(c.AF.UNIX, c.SOCK.STREAM, 0, &pair) != 0) return;
say("socketpair вернул дескрипторы {d} и {d}\n", .{ pair[0], pair[1] });
const pid = c.fork();
if (pid == 0) {
_ = c.close(pair[0]);
_ = c.dup2(pair[1], 0);
_ = c.dup2(pair[1], 1);
_ = c.close(pair[1]);
// tr ничего не знает про сокеты: читает 0, пишет в 1.
const argv = [_:null]?[*:0]const u8{ "tr", "a-z", "A-Z" };
_ = execvp("tr", &argv);
c._exit(127);
}
_ = c.close(pair[1]);
const request = "hello, sockets\n";
_ = c.write(pair[0], request, request.len);
// Закрыть только сторону записи: tr увидит EOF, а ответ мы ещё прочитаем.
_ = c.shutdown(pair[0], c.SHUT.WR);
var reply: [64]u8 = undefined;
const n = c.read(pair[0], &reply, reply.len);
say("ответ: {s}", .{reply[0..@intCast(@max(n, 0))]});
const at = c.lseek(pair[0], 0, c.SEEK.CUR);
const failure: std.posix.E = @enumFromInt(c._errno().*);
say("lseek на сокете: {d}, errno = {s}\n", .{ at, @tagName(failure) });
_ = c.waitpid(pid, null, 0);
}
$ zig run sockfile.zig
socketpair вернул дескрипторы 3 и 4
ответ: HELLO, SOCKETS
lseek на сокете: -1, errno = SPIPE
tr написана в семидесятых, про сокеты не знает ничего и только что отработала сетевым сервисом: приняла запрос, ответила, увидела EOF и вышла. Вся “сетевая” часть уместилась в два dup2. Ровно так работал inetd, суперсервер из BSD: принимал соединение, делал dup2(conn, 0), dup2(conn, 1) и exec нужной программы. Так же работают CGI-скрипты, и так же наш будущий веб-сервер будет отдавать динамические страницы: потомок направит единицу в сокет клиента и запустит программу, которая просто печатает.
Одно отличие от пайпа здесь принципиально. Соединение двунаправленное, и у него нет отдельного “пишущего конца”, который можно закрыть, чтобы собеседник увидел EOF. close закрыл бы обе стороны, и ответ мы бы уже не прочитали. Поэтому есть shutdown(fd, SHUT_WR): “я закончил писать, но ещё читаю”. В отличие от close, он действует на само соединение, а не на стрелку: сколько бы копий дескриптора ни наделал fork, собеседник получит EOF сразу.
Дальше останется узнать, откуда берутся сокеты, соединённые не с соседним процессом, а с машиной на другом конце планеты. Все сегодняшние правила (общая запись после fork, закрывай лишнее, игнорируй SIGPIPE, один буфер на дескриптор) переедут туда без изменений.
На macOS. Все листинги урока собраны и запущены на macOS 26 (Apple Silicon) напрямую, выводы в тексте сняты там же и перепроверены в Debian 12 под Docker: отличаются только список каталогов в
/usr(на Linux их 10) и текст ошибкиcat. На macOS libc линкуется всегда, на Linux не забудь-lc. Чего на macOS нет: каталога/proc(дескрипторы чужого процесса смотри черезlsof -p <pid>, свои черезls /dev/fd), вызововpipe2,dup3иaccept4(пайп сO_CLOEXECделается в два шага черезfcntl, гонку сforkзакрывают блокировкой илиposix_spawnс флагомPOSIX_SPAWN_CLOEXEC_DEFAULT),F_SETPIPE_SZ.PIPE_BUFна macOS равен 512 против 4096 на Linux: если рассчитываешь на атомарную запись строки в общий пайп, рассчитывай на 512. НомерSIGPIPEодинаковый, 13, и код 141 в оболочке тот же.
Практика
Настоящих fork и execve в песочнице нет, поэтому оболочка в задаче игрушечная, зато правила настоящие. В заготовке лежит модель: три процесса (оболочка, left, right), у каждого таблица на восемь дескрипторов, и методы pipe, fork, dup2, close с семантикой ядра. Ты пишешь runPipeline: последовательность вызовов, которая готовит left | right к двум execve. Тесты смотрят на итоговые таблицы: у left единица это пишущий конец, у right ноль это читающий, на каждый конец пайпа во всей системе смотрит ровно один дескриптор, и после выхода left у читателя будет EOF. Два последних теста запускают оболочку с закрытым стандартным вводом или выводом: pipe там вернёт не 3 и 4, и наивный план закроет то, что только что поставил.
Упражнения
Первые пять это упражнения и домашние задачи главы 10 книги в пересказе. Сначала ответь по картинке из трёх таблиц, потом проверь в виджете или программой.
Итоги
- За дескриптором стоят три структуры: ячейка в таблице дескрипторов процесса, запись в общей таблице открытых файлов (позиция, режим,
O_APPEND,O_NONBLOCK, счётчик ссылок) и v-node (то, что видитstat). Дескриптор это стрелка на запись, а не файл. - Два
openодного файла дают две записи и две независимые позиции.dup,dup2иforkдают вторую стрелку на ту же запись: позиция общая, запись живёт до последнегоclose. dup2(a, b)этоfds[b] = fds[a]: закрывает старыйb, копирует стрелку, сбрасывает close-on-exec. Переадресация делается в потомке междуforkиexecve, потому чтоexecveтаблицу дескрипторов сохраняет.2>&1копирует стрелку в момент выполнения, поэтому порядок переадресаций важен.- Пайп это две записи на один буфер ядра (64 КиБ на Linux и macOS по замеру), создаётся до
fork.readвидит EOF только когда закрыты все пишущие концы во всех процессах, поэтому каждый процесс конвейера закрывает всё, что не стоит у него на 0 и 1, а родитель закрывает оба конца. Запись доPIPE_BUFбайт атомарна. - Запись в пайп без читателей даёт
SIGPIPE, по умолчанию смертельный: так завершаетсяyes | head -1(код 141). При игнорируемом сигналеwriteвозвращаетEPIPE. СерверыSIGPIPEигнорируют;std.Io.Threadedв Zig 0.16 ставит на него пустой обработчик, и программа получаетerror.BrokenPipe. - Дескриптор без
O_CLOEXECутекает в любую программу послеexec: это дыра в безопасности и источник зависших конвейеров. Открывай всё сO_CLOEXEC, передавай потомку нужное черезdup2. - Буфер в памяти процесса не знает про общую запись в ядре: несброшенный буфер вывода удваивается при
fork, буфер ввода утаскивает байты, предназначенные потомку. Правило:flushпередforkи перед ожиданием ответа, один читающий буфер на дескриптор. - Сокет это тоже дескриптор с записью в таблице открытых файлов:
read,write,dup2,forkработают,lseekотвечаетESPIPE. Половину соединения закрываетshutdown, а неclose.
Дальше
Блок про ввод и вывод закрыт: ты знаешь, что такое дескриптор на самом деле, умеешь читать и писать устойчиво, с буфером и без, переставлять стрелки и соединять процессы пайпами. Следующий блок выводит всё это за пределы одной машины. Сначала разберёмся, как устроена сеть с точки зрения программиста: адреса, имена, порты, модель клиента и сервера. Потом откроем сокет, который соединён не с соседним процессом, а с другим компьютером, и увидим, что после connect и accept с ним работают уже знакомые read и write с короткими счётами. Оттуда один шаг до протокола HTTP и собственного маленького веб-сервера, который отдаёт файлы и запускает программы, направив их стандартный вывод прямо в сокет клиента. Тем самым dup2, которым ты сегодня делал ls > out.txt.
домашка