Pipes — anonymous, named FIFO и магия shell |
Когда вы пишете в shell ls | grep .txt | wc -l, происходит фундаментальная вещь Unix-философии: три отдельные программы, ничего не знающие друг о друге, обмениваются данными через pipe — механизм межпроцессной коммуникации, заложенный в основу системы. ls ничего не пишет в файл и не вызывает grep — она просто пишет в свой stdout, который kernel перенаправил так, чтобы это попало в stdin grep-а. И всё это работает на уровне byte stream без копирования через диск, в памяти, эффективно.
Pipes — старейший IPC механизм Unix, появился в 1973 году и до сих пор остаётся одним из самых используемых. Любой docker-контейнер, любой стартап-скрипт, любая систем-d unit с ExecStartPre — они все где-то внутри используют pipes. Понимание, как они работают физически, делает вас на порядок эффективнее в отладке.
В этом уроке: kernel ring buffer внутри pipe, два вида pipe (anonymous и named/FIFO), что физически делает shell-оператор |, размер pipe buffer-а, и почему tail -f log | grep error иногда буферизируется.
Что такое pipe — одностронний byte stream
Pipe — это однонаправленный byte stream, который kernel поддерживает между двумя файловыми дескрипторами. Один процесс пишет в дескриптор-writer, другой читает из дескриптора-reader. Между ними — buffer внутри kernel.
Базовая программа на C:
cat > pipe_demo.c << 'CEOF'
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int main(void) {
int fds[2]; // [0] = read end, [1] = write end
pipe(fds);
if (fork() == 0) {
// child: writer
close(fds[0]); // не нужен read end
write(fds[1], "Hello from child\n", 17);
close(fds[1]);
return 0;
}
// parent: reader
close(fds[1]); // не нужен write end
char buf[100];
int n = read(fds[0], buf, sizeof(buf));
write(STDOUT_FILENO, buf, n);
close(fds[0]);
wait(NULL);
return 0;
}
CEOF
gcc pipe_demo.c -o pipe_demo
./pipe_demo
# Hello from child
Что произошло:
pipe(fds)— системный вызов, создаёт два file descriptors:fds[0]для чтения,fds[1]для записи. Между ними kernel buffer.fork()— дочерний процесс наследует оба дескриптора.- Родитель закрывает write-end (он не пишет), ребёнок закрывает read-end (он не читает). Это важно, иначе read зависнет.
- Ребёнок пишет, родитель читает. Kernel-buffer держит данные между моментом записи и чтения.
Магия shell: что физически делает |
Когда вы пишете ls | grep .txt, shell делает следующее:
# Псевдокод того, что делает bash:
# 1. pipe(fds)
# 2. fork() -- child1 (для ls)
# в child1:
# dup2(fds[1], STDOUT_FILENO) // stdout child1 -> write end pipe
# close(fds[0]); close(fds[1]) // закрыли оригинальные дескрипторы
# execve("/usr/bin/ls", ...) // exec ls
# 3. fork() -- child2 (для grep)
# в child2:
# dup2(fds[0], STDIN_FILENO) // stdin child2 -> read end pipe
# close(fds[0]); close(fds[1])
# execve("/usr/bin/grep", ".txt")
# 4. родитель закрывает fds[0] и fds[1]
# 5. wait для обоих детей
Ключевая операция — dup2. Она дублирует один file descriptor в другой. dup2(fds[1], 1) означает «дескриптор номер 1 (stdout) теперь указывает туда же, куда указывает fds[1]». Когда ls пишет в stdout (fd=1), это автоматически попадает в pipe.
Посмотреть это в действии через strace:
# Запустить ls | grep и проследить вызовы:
strace -ff -e pipe,pipe2,fork,clone,dup2,execve,write,read \
-o trace.log -- bash -c 'ls /etc | grep host'
# В файлах trace.log.PID видны:
# - pipe2([3,4], 0) -- создан pipe
# - clone() -- fork (clone-вариант)
# - dup2(4, 1) в дочернем процессе -- stdout = pipe write
# - execve("/bin/ls") -- запуск ls
# - write(1, "hosts\n...", N) -- ls пишет в свой stdout, который теперь pipe
# - в другом процессе: dup2(3, 0) -- stdin = pipe read
# - execve("/bin/grep")
# - read(0, ...) в grep -- читает из pipe
Файл со схожим строением (например trace.log.12345) покажет конкретно одну сторону цепи. Это и есть весь shell pipeline.
Buffer size и SIGPIPE
Pipe buffer в Linux по умолчанию 65536 байт (64 КБ). Можно проверить:
# Размер pipe-буфера системы:
cat /proc/sys/fs/pipe-max-size
# 1048576 -- максимум на pipe (1 МБ)
# Размер конкретного pipe можно увидеть в /proc/PID/fdinfo:
ls -la /proc/self/fd/
# 0 -> /dev/pts/0
# 1 -> /dev/pts/0
# 2 -> /dev/pts/0
# При создании pipe в программе:
# fcntl(fds[1], F_SETPIPE_SZ, 1024*1024) -- увеличить до 1 МБ
# fcntl(fds[1], F_GETPIPE_SZ) -- узнать текущий
Если writer пишет быстрее, чем reader читает — buffer заполняется. write блокируется до тех пор, пока reader не прочитает. Это back pressure — естественный механизм flow control. В производственном пайплайне это часто спасает: медленный consumer заставляет producer замедлиться, а не накапливать данные в RAM.
Если reader умер раньше времени:
# Reader умер раньше writer-а:
yes | head -1
# y
# yes продолжит работать ещё долю секунды,
# потом получит SIGPIPE (когда попытается write в закрытый pipe)
# и умрёт молча
SIGPIPE — сигнал, который ядро отправляет процессу при попытке write в pipe, у которого reader уже закрыт. По умолчанию SIGPIPE убивает процесс. В долго работающих сервисах часто игнорируют этот сигнал (signal(SIGPIPE, SIG_IGN)), и тогда write возвращает -1 с errno=EPIPE — более elegant.
Anonymous vs named pipes
То, что мы видели — anonymous pipe. Существует пока живут оба end. Передаётся между процессами только через fork (родитель -> ребёнок).
Named pipe (FIFO) — pipe, у которого есть имя в файловой системе. Создаётся через mkfifo. Может быть открыт независимыми процессами без родительских связей.
# Создать FIFO:
mkfifo /tmp/my_fifo
ls -la /tmp/my_fifo
# prw-r--r-- 1 myuser myuser 0 May 18 10:00 /tmp/my_fifo
# ^^
# тип 'p' -- pipe
# Терминал 1 (writer):
echo "Hello via FIFO" > /tmp/my_fifo
# Зависнет, ждёт reader
# Терминал 2 (reader):
cat /tmp/my_fifo
# Hello via FIFO
# (после этого писатель в терминале 1 разблокируется)
# Удалить:
rm /tmp/my_fifo
Заметьте: echo > /tmp/my_fifo блокируется, пока не появится reader. Это нормальное поведение FIFO — открытие на запись ждёт открытия на чтение, и наоборот. Можно открыть в неблокирующем режиме (O_NONBLOCK), тогда open вернёт сразу.
FIFO живёт в файловой системе, но как и обычный pipe — данные не пишутся на диск. Сам файл просто маркер, через который kernel связывает процессы. Размер файла FIFO всегда 0.
FIFO — старая магия для скриптов:
# Простой 'журнал событий' через FIFO:
mkfifo /tmp/events.fifo
# Терминал 1 (background reader):
while true; do
cat /tmp/events.fifo # blocking, ждёт писателей
done &
# Терминал 2 (writer):
echo "Event A occurred" > /tmp/events.fifo
echo "Event B occurred" > /tmp/events.fifo
Один reader получает оба events. Если несколько reader-ов — они «гонятся» за байтами, никаких гарантий, кто что получит. Для нормального fan-out нужен брокер.
Буферизация stdio — скрытая ловушка
Программы часто используют stdio (printf, fwrite) — библиотечный buffer поверх syscall write. Этот буфер ведёт себя по-разному:
- Line-buffered, если stdout — терминал. Flush при ‘\n’.
- Block-buffered, если stdout — pipe или file. Flush при заполнении буфера (~8 КБ) или явном fflush.
Это создаёт классический gotcha:
# Прямой запуск -- видим вывод сразу:
python3 -c "
import time
for i in range(5):
print(f'tick {i}')
time.sleep(1)
"
# tick 0
# tick 1
# (видно по одной строке в секунду)
# То же через pipe:
python3 -c "
import time
for i in range(5):
print(f'tick {i}')
time.sleep(1)
" | cat
# (тишина 5 секунд, потом все 5 строк сразу!)
Причина: когда stdout = pipe, Python переключился на block buffering. Лекарство:
# В Python:
python3 -u -c "..." # -u = unbuffered
# или PYTHONUNBUFFERED=1 в env
# или sys.stdout.flush() явно
# Универсально через stdbuf:
stdbuf -o0 some_command | other_command # 0 = no buffer
stdbuf -oL some_command | other_command # L = line buffer
# Пример с tail -f:
tail -f /var/log/app.log | grep ERROR
# может буферизоваться. Лекарство:
tail -f /var/log/app.log | grep --line-buffered ERROR
# или: stdbuf -oL tail -f ... | stdbuf -oL grep ...
Это причина 80% жалоб ‘мой grep в pipe не показывает live-обновления’. Любая программа в середине pipe буферизуется по дефолту. Решение — —line-buffered у grep, sed -u, stdbuf для остальных.
Попробуй сам
Создайте FIFO и поиграйте с одновременным чтением/записью:
# Терминал 1:
mkfifo /tmp/demo
echo "Writer started, waiting reader"
echo "Hello via FIFO" > /tmp/demo
echo "Done"
# Терминал 2 (запустить пока висит echo в Terminal 1):
echo "Reader started"
cat /tmp/demo
echo "Read done"
# Terminal 1 должен разблокироваться сразу после того, как Terminal 2 прочитает
rm /tmp/demo
Измерьте throughput pipe-а:
# Минимальный pipe throughput тест (1 ГБ через pipe):
time dd if=/dev/zero bs=1M count=1024 2>/dev/null | dd of=/dev/null bs=1M
# Типично 3-10 GB/s -- очень быстро,
# потому что данные в kernel-buffer, нет копирований на диск
И сравните с file-based:
# Через файл:
time { dd if=/dev/zero bs=1M count=1024 of=/tmp/big.bin 2>/dev/null;
dd if=/tmp/big.bin of=/dev/null bs=1M 2>/dev/null; }
rm /tmp/big.bin
# Гораздо медленнее, потому что fsync на диск
Pipe побеждает — он не пишет на диск. Это и есть ценность pipe: zero-disk данный поток между процессами.
Pipes и UNIX-философия: композиция команд через каналы