Learning Platform
Глоссарий Troubleshooting
Урок 09.01 · 22 мин
Начальный
IPCPipesFIFOShellLinux

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.

Pipe -- два дескриптора и kernel buffer
Process A: writerПроцесс-писатель. Пишет в pipe через write(fd_write, buf, size). Если буфер pipe заполнен -- write блокируется, пока reader не прочитает
kernel pipe bufferКольцевой буфер в памяти kernel. Размер по умолчанию 65536 байт (64 КБ) в современных Linux. Можно изменить через fcntl F_SETPIPE_SZ
Process B: readerПроцесс-читатель. Читает из pipe через read(fd_read, buf, size). Если буфер пуст -- read блокируется, пока writer не запишет (или не закроет свой конец)
FIFO orderДанные идут в строгом порядке поступления -- first in, first out. Pipe -- byte stream, без границ сообщений. Если writer пишет два write(10 байт) и два write(20 байт), reader может прочитать одним read(30) или read(60) -- байты не разделены
No seekPipe не поддерживает lseek. Это потоковая абстракция -- нельзя 'перемотать' назад, прочитанные байты уходят. Поэтому 'cat file | tee out' читает file один раз

Базовая программа на 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

Что произошло:

  1. pipe(fds) — системный вызов, создаёт два file descriptors: fds[0] для чтения, fds[1] для записи. Между ними kernel buffer.
  2. fork() — дочерний процесс наследует оба дескриптора.
  3. Родитель закрывает write-end (он не пишет), ребёнок закрывает read-end (он не читает). Это важно, иначе read зависнет.
  4. Ребёнок пишет, родитель читает. 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.

ls | grep -- shell wiring
ls processДочерний процесс bash, после exec стал ls. Его STDOUT (fd 1) перенаправлен в write-end pipe через dup2
pipe (kernel buffer)Kernel pipe buffer, типично 64 КБ. ls пишет байты сюда, grep читает с другого конца. Когда буфер заполнен и grep не успевает -- ls блокируется на write
grep processДочерний процесс bash, после exec стал grep. Его STDIN (fd 0) перенаправлен на read-end pipe. grep думает что читает из терминала -- на самом деле из pipe
ls stdout = pipe writeПосле dup2(fds[1], 1) дескриптор 1 (stdout) ссылается на запись pipe. printf автоматически уходит в pipe
grep stdin = pipe readПосле dup2(fds[0], 0) дескриптор 0 (stdin) ссылается на чтение pipe. read из stdin читает с 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.

Anonymous pipe vs FIFO
Anonymous pipeСоздаётся через pipe() syscall. Существует только пока живёт хотя бы один файловый дескриптор. Передаётся через fork() -- ребёнок наследует дескрипторы родителя. Не имеет имени в файловой системе
Used for: fork-based IPCПрименение: связь родитель-ребёнок, shell pipelines (bash создаёт перед fork-ом), команды coproc, fd-passing через unix sockets
Named pipe (FIFO)Создаётся через mkfifo. Файл в файловой системе с типом 'p'. Любой процесс может открыть его на чтение/запись. Существует пока файл существует (или после unlink, пока кто-то держит fd)
Used for: cross-process pipeПрименение: связь неродственных процессов, скрипты которые передают данные между запусками, простой broker-pattern

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 ...
WARNING

Это причина 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-философия: композиция команд через каналы
Проверка знанийKnowledge check
У вас log-парсер на Python: `tail -f /var/log/app.log | python parser.py`. После долгих логов парсер вдруг 'теряет' данные -- не доходит до него. Какие гипотезы и как лечить?
ОтветAnswer
Несколько возможных причин: 1) Buffering: tail -f по умолчанию line-buffered (так как pipe), но если кто-то использует -F или другие опции -- может стать block-buffered. Python также буферизует stdin. Лекарство: `PYTHONUNBUFFERED=1` или `python -u`, `stdbuf -oL tail -f ...`. 2) Pipe full -- если parser обрабатывает медленнее, чем tail пишет, kernel pipe buffer (64 КБ) заполнится, tail заблокируется на write. Это back pressure, но если процесс tail-а получает SIGPIPE и не обрабатывает -- может умереть. Лекарство: `signal.signal(SIGPIPE, SIG_DFL)` или ловить EPIPE. 3) Если файл /var/log/app.log ротируется (logrotate), tail -f может потерять трек: новые записи идут в новый inode, а tail держит fd на старый. Лекарство: `tail --follow=name` или `tail -F` (Big F) -- следит за именем, переоткрывает при ротации. 4) Если parser падает с exception на каком-то событии и не перезапускается -- tail продолжит писать в SIGPIPE'd pipe и затем тоже умрёт. Лекарство: try/except в parser, supervisor над всем пайплайном. 5) Высокая частота событий -- read из stdin блочной buffered семантикой может не успевать; перейти на iter(sys.stdin) или asyncio для нон-blocking чтения. Диагностика: проверить размер kernel pipe буфера через /proc/PID/fdinfo/N, проверить, не ротировался ли log (ls -li показывает inode), запустить с PYTHONUNBUFFERED=1 и smoke-тест.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Когда в shell вы пишете 'ls | grep .txt', что физически делает bash под капотом?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4