MAATRIX / Блог / Как устроен пайп между процессами и где он упирается в потолок

Как устроен пайп между процессами и где он упирается в потолок

MAATRIX

Вы запускаете cat access.log | grep error | sort | uniq -c, и команда работает медленнее, чем вы ожидали, хотя каждая утилита сама по себе быстрая. Дело не в диске и не в процессоре — дело в том, как устроен символ | между процессами: это не магия шелла, а буфер в ядре фиксированного размера, и его поведение объясняет добрую половину странностей конвейеров.

Что физически происходит, когда вы ставите `|`

Когда шелл видит cmd1 | cmd2, он не превращает вывод одной команды в текстовый файл, который потом читает вторая. Вместо этого он просит ядро создать пайп — системный вызов pipe() возвращает пару файловых дескрипторов: один только для чтения, второй только для записи. Дальше шелл делает fork() для обоих процессов и с помощью dup2() подменяет стандартный вывод первого процесса на дескриптор записи, а стандартный ввод второго — на дескриптор чтения. Сами cmd1 и cmd2 могут вообще не знать, что данные идут не через терминал — они просто пишут в fd 1 и читают из fd 0, как обычно.

Посмотреть на это можно вживую:

$ sleep 100 | cat &
[1] 41213
$ ls -la /proc/41213/fd
lrwx------ 1 user user 64 sep  8 12:01 0 -> pipe:[184920]
lrwx------ 1 user user 64 sep  8 12:01 1 -> /dev/pts/3

У процесса cat файловый дескриптор 0 (stdin) указывает на объект pipe:[184920] — это и есть тот самый буфер в ядре, у которого с обеих сторон висят процессы. Пайп существует не как файл на диске и не в пространстве пользователя — это кольцевой буфер внутри ядра, который само ядро наполняет и опустошает по мере системных вызовов write() и read() от процессов по обе стороны.

Почему буфер пайпа не бесконечный

Ключевая деталь: буфер пайпа — это область памяти ядра фиксированного размера, а не резиновый склад, который растягивается под любой объём данных. На большинстве современных дистрибутивов Linux размер пайпа по умолчанию — 65536 байт (64 KiB), это задокументированное поведение, описанное в man 7 pipe. Узнать текущее значение можно так:

$ cat /proc/sys/fs/pipe-max-size
1048576
$ ulimit -p
8

pipe-max-size — верхний предел, до которого процесс может увеличить буфер конкретного пайпа вызовом fcntl(fd, F_SETPIPE_SZ, size). Сам дефолтный размер отдельного пайпа при создании обычно кратен размеру страницы памяти и относительно небольшой — именно поэтому он так быстро заполняется, если вы гоните через конвейер что-то тяжелее пары строк текста.

Почему ядро вообще ограничивает буфер, а не выделяет памяти по потребности? Потому что пайп — не файл: у него нет владельца-получателя, который явно попросил именно столько памяти, и нет способа узнать заранее, сколько данных писатель собирается отправить. Неограниченный буфер означал бы, что процесс, пишущий быстрее, чем читает партнёр, мог бы бесконтрольно съедать память ядра. Фиксированный потолок — осознанный компромисс: он режет скорость отдельного конвейера, но защищает систему в целом от неконтролируемого роста буферов.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Что происходит, когда буфер заполнен: блокировка писателя

Пока в буфере есть свободное место, write() от процесса-писателя завершается мгновенно: данные копируются в буфер ядра, и процесс продолжает работать, даже не зная, читает ли кто-то с другой стороны прямо сейчас. Но как только место заканчивается, следующий write() не возвращает управление — писатель переходит в состояние ожидания (в ps aux это обычно S, или кратковременно D, если ожидание завязано на ввод-вывод) и не выполняется дальше, пока читатель не заберёт часть данных и не освободит место.

Это не зависание и не ошибка — это штатный, документированный механизм: блокирующий write() в пайп с полным буфером ждёт, сколько нужно читателю, чтобы вычитать хотя бы часть накопленного. Как только в буфере появляется место, ядро будит писателя, тот дописывает очередную порцию — и снова блокируется или завершает write(), если данных было немного.

Проверить это можно почти без инструментов:

$ yes | pv > /dev/null

yes пишет бесконечный поток символов y\n настолько быстро, насколько может, а pv читает его с контролируемой скоростью и печатает счётчик прошедших байт. Если временно приостановить pv (kill -STOP), процесс yes почти сразу встанет в ожидание записи — буфер заполнится за доли секунды, и yes, несмотря на способность генерировать данные быстрее, не сможет продолжать, пока pv снова не начнёт читать.

Симметрично работает и обратная сторона: если читатель вызывает read(), а буфер пуст, читатель тоже блокируется — до появления хотя бы одного байта или до закрытия дескриптора записи писателем (тогда read() вернёт 0, то есть конец файла).

Backpressure: как блокировка превращается в синхронизацию скоростей

То, что мы только что описали — блокировка писателя при заполненном буфере и блокировка читателя при пустом — на практике работает не как баг, а как встроенный механизм обратного давления, backpressure. Он решает задачу, для которой иначе понадобился бы отдельный протокол координации: как заставить два независимых процесса с разной скоростью работы обмениваться данными, не теряя ни байта и не требуя от одного из них бесконечной памяти про запас.

Без этого механизма были бы только плохие варианты: либо писатель копит непрочитанные данные в своей памяти, пока не упрётся в лимиты процесса или своп, либо данные, не поместившиеся из-за отставания читателя, просто теряются. Пайп с ограниченным буфером и блокирующим write() даёт третий вариант — писатель сам, естественно, притормаживает ровно настолько, насколько отстаёт читатель: ни байта не теряется, а память ядра не растёт бесконтрольно.

По сути это та же идея, что лежит в основе TCP-окна между сетевыми хостами или ограниченной очереди между потоками в многопоточном приложении: скорость связки регулируется самым медленным звеном, а не задаётся вручную заранее. Разница в том, что для пайпа этот механизм не нужно проектировать отдельно — он встроен в семантику блокирующего чтения и записи на уровне системных вызовов, и любая программа с обычным read()/write() автоматически в нём участвует.

Стоит понимать границу: backpressure в пайпе — это давление на скорость, а не гарантия против дедлока. Если два процесса одновременно пишут и читают друг у друга через два разных пайпа и оба буфера заполнились в неудачный момент, возможна взаимная блокировка — каждый ждёт места для записи, которое должен освободить другой, находящийся в том же состоянии. Для простых shell-конвейеров (данные текут в одну сторону) это не проблема, но реально для двунаправленного общения процессов через пару пайпов.

Почему медленное звено тормозит весь конвейер

Из механизма backpressure прямо следует то, с чего мы начали: в цепочке cmd1 | cmd2 | cmd3 | cmd4 скорость всей связки ограничена самым медленным её участником, а не средней или максимальной скоростью остальных. Если cmd3 обрабатывает данные заметно медленнее, чем cmd1 и cmd2 их генерируют, происходит следующее: буфер между cmd2 и cmd3 заполняется, cmd2 блокируется на записи, из-за этого перестаёт вычитывать буфер между cmd1 и cmd2, тот тоже заполняется, и уже cmd1 блокируется на своей записи — хотя сам cmd1 может быть предельно быстрым и не иметь никакого отношения к тому, что тормозит cmd3.

Это распространяется по цепочке практически мгновенно после заполнения первого буфера. Итоговая пропускная способность всего конвейера не может превысить пропускную способность самого узкого места — это справедливо для любой цепочки команд, где данные передаются через пайпы, будь то два звена или десять.

Практический вывод: если конвейер работает медленнее, чем вы ожидали, искать причину нужно не в первой команде (часто самой заметной — например, cat большого файла), а в самом медленном звене. Типичные кандидаты на «узкое место»:

  • команды с тяжёлой построчной обработкой — регулярные выражения в grep -E/sed, сложная сортировка в sort на большом объёме данных;
  • команды, которым нужна вся входная последовательность целиком, прежде чем отдать хоть что-то на выход, — классический пример sort: он не может начать писать в следующий пайп, пока не получит и не отсортирует всё, а значит весь конвейер до него простаивает;
  • процессы, упирающиеся не в CPU, а в ожидание собственного ввода-вывода — например, чтение исходных данных с медленного сетевого диска: именно это ожидание, а не сама обработка, держит буфер пайпа полным на входе в неё.

Чтобы отличить «медленный из-за CPU» от «медленный из-за ожидания», полезно посмотреть на состояние процесса и на разбивку загрузки процессора — подробнее в статьях про состояние D и почему kill -9 в нём бессилен и про то, куда уходит процессор между user, system и iowait.

Как увидеть узкое место конвейера на практике

Диагностика начинается с вопроса: какой из процессов в цепочке сейчас ждёт, а какой реально работает. Первый инструмент — ps с флагом состояния:

$ ps -eo pid,ppid,stat,cmd | grep -E 'cat|grep|sort|uniq'
41501  41498 S    cat access.log
41502  41498 D    grep error
41503  41498 R    sort
41504  41498 S    uniq -c

Здесь sort в состоянии R — реально работает на процессоре (или ждёт его), а grep в D — скорее всего ждёт ввода-вывода (не обязательно от пайпа, может быть диск). Процессы в S, которые долго не меняют состояние, часто просто ждут данных от соседа по конвейеру: cat может ждать записи, если следующий grep не успевает читать, а uniq — ждать данных от sort, который ещё не закончил сортировку всего входа.

Второй инструмент — заглянуть в файловые дескрипторы и увидеть, какие пайпы связывают процессы:

$ ls -la /proc/41502/fd
lrwx------ 1 user user 64 sep  8 12:10 0 -> pipe:[185011]
lrwx------ 1 user user 64 sep  8 12:10 1 -> pipe:[185012]

Если номер пайпа на входе процесса совпадает с номером на выходе предыдущего в цепочке — вы явно видите топологию конвейера, а не гадаете по командной строке.

Третий и самый наглядный способ — утилита pv (pipe viewer), которую можно временно вставить между звеньями конвейера, чтобы увидеть скорость прохождения данных через конкретную точку:

$ cat access.log | pv | grep error | pv | sort | uniq -c

Каждый pv покажет отдельный счётчик — по разнице скоростей на разных участках сразу видно, где данные начинают накапливаться. Участок, где скорость видимо просаживается относительно соседних, и есть узкое место.

Стоит учитывать и то, что каждый процесс в конвейере — это отдельный fork() со своими накладными расходами на запуск; если цепочка короткая, а данных мало, часть замедления может быть стоимостью создания процессов, а не работой пайпов — этот аспект жизни процесса разобран в материале про fork() и copy-on-write. А если конвейер упирается в дисковый ввод-вывод, а не в CPU, стоит заодно проверить лимиты на количество открытых файловых дескрипторов — см. статью про ulimit и лимиты открытых файлов.

Что можно сделать с узким местом и куда пайп не годится

Если узкое место найдено, вариантов обычно несколько, и они не взаимоисключающие.

Первый — убрать из цепочки лишние звенья. Каждая дополнительная команда — это ещё один процесс, ещё один пайп и ещё одна точка потенциальной блокировки. Классический пример избыточности — cat file | grep pattern, где grep pattern file делает то же самое без лишнего процесса.

Второй — заменить звено, которое требует весь вход целиком, на потоковый аналог, если такой есть для задачи. sort в общем случае не может отдавать результат, пока не получит все данные — это архитектурное свойство алгоритма сортировки, а не недостаток реализации, с этим ничего не поделать. Но полезно сокращать объём данных до этого звена — например, фильтровать через grep до sort, а не после.

Третий — увеличить буфер пайпа, если узкое место именно в частых блокировках на маленьком буфере, а не в реальной скорости обработки. Это делается вызовом fcntl(fd, F_SETPIPE_SZ, ...) из кода, который создаёт пайп, — обычным shell-конвейером | управлять размером буфера напрямую нельзя, но утилиты вроде mbuffer можно вставить промежуточным звеном именно для этого — дать буферу больше пространства и сгладить кратковременные всплески скорости.

Четвёртый — если нужен обмен данными не по иерархии родитель-потомок, а между произвольными процессами по имени, обычный анонимный пайп не подходит: он существует, только пока живы оба конца, созданные общим предком. Для этого в Linux есть именованные пайпы (FIFO) — создаются командой mkfifo, видны в файловой системе как отдельный объект, но по механизму буфера и блокировок работают точно так же, просто с постоянным именем вместо пары дескрипторов у родственных процессов.

И отдельно — где пайпы не работают как решение: это не сетевой транспорт. Пайп связывает процессы на одной машине через общее ядро; для обмена данными между процессами на разных серверах нужны сокеты с уже другой моделью буферизации, завязанной на сетевой стек и протокол.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Пайп в шелле (|) и именованный пайп (FIFO) — это одно и то же?

По механизму буферизации и блокировок — да, оба используют один и тот же объект ядра. Разница в доступе к дескрипторам: обычный пайп передаётся только через наследование от общего родителя при fork(), а именованный пайп имеет путь в файловой системе, и любые процессы могут открыть его по имени, даже не будучи родственниками.

Можно ли увеличить буфер пайпа для конкретного конвейера в bash без правки кода утилит?

Напрямую средствами | — нет, размер буфера меняется только вызовом fcntl из кода процесса. На практике для сглаживания скачков скорости в конвейер вставляют утилиты вроде mbuffer, которые сами держат внутренний буфер большего размера поверх обычного пайпа.

Почему sort в середине конвейера как будто останавливает весь поток данных?

Для полной сортировки алгоритму нужен весь набор данных целиком — sort не отдаст ни строки, пока не прочитает и не отсортирует всё на входе. Это не блокировка из-за переполненного буфера, а свойство самой задачи: до sort конвейер может продолжать писать, но после него данные не появятся, пока не закончится ввод.

Если процесс-писатель в пайп упал, что происходит с читателем?

Как только последний дескриптор записи закрывается, следующий read() у читателя вернёт 0 — конец файла, и читатель обычно завершается штатно. Обратная ситуация опаснее: если закрылся читатель, а писатель продолжает write(), процесс получает сигнал SIGPIPE, который по умолчанию его завершает — стандартный способ сообщить писателю, что писать больше некому.

Ограничение буфера — специфика Linux или общее для всех Unix-подобных систем?

Сам принцип — фиксированный буфер и блокирующая семантика при заполнении — общий для POSIX-совместимых систем. Конкретные цифры и наличие настройки через fcntl(F_SETPIPE_SZ) — деталь реализации ядра, и в других системах, например BSD или macOS, значения и доступные ручки настройки могут отличаться от Linux.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →