Пакет · thread · глубокое погружение

Вывод и правило Broken pipe

Почему start() по умолчанию задаёт outputTarget в /dev/null и что на самом деле идёт не так, когда фоновый процесс пишет в pipe, который никто не читает.

Какую поломку это предотвращает

Когда outputTarget равен null, start() направляет stdout и stderr дочернего процесса в pipe’ы обратно к родителю. У pipe фиксированный буфер ОС (~64 КБ). Если pipe никто не читает — и родитель ни разу не вызывает join() или reap() (оба сами вычитывают pipe):

  1. Дочерний процесс продолжает echo; буфер заполняется.
  2. Следующий write() блокируется — дочерний процесс застревает и не делает работу.
  3. Если родитель завершился или закрыл свой конец чтения, вместо этого запись вызывает SIGPIPE / «Broken pipe», убивая дочерний процесс.

Это срабатывает только в настоящем fire-and-forget: вы передали null и потом ни разу не читаете, не вызываете join() или reap(). Задача будто бы запускается, а потом застревает или умирает по причинам, никак не связанным с её логикой — поэтому для fire-and-forget оставляйте значение по умолчанию /dev/null.

Почему /dev/null — безопасное значение по умолчанию

При значении по умолчанию outputTarget = '/dev/null' start() вообще не открывает pipe — ОС отдаёт stdout/stderr прямо в null-устройство. Ничего не буферизуется, ничего не блокируется, а родителю не нужно управлять жизненным циклом. Можно запустить задачу и спокойно выбросить объект Thread.

Компромисс

/dev/null безопасен, потому что его по определению никто не читает. Как только вы хотите читать вывод, вы осознанно переходите на pipe (null) и берёте на себя обязанность его вычитывать.

Три режима вывода

outputTarget Подключение Когда
'/dev/null' (по умолчанию) Отбрасывается ОС, без pipe Фоновые fire-and-forget задачи
'/path/to/file.log' Дописывается в файл Хранить запись; staging/продакшн
null Направляется родителю через pipe (неблокирующий) Локальная разработка — вы активно читаете

При файловой цели и stdout, и stderr открываются в режиме дописывания, поэтому переиспользовать один лог-файл между запусками безопасно. При null pipe’ы переводятся в неблокирующий режим, и вы читаете их через readOutput() / readError().

Чтение вывода из pipe

readOutput() / readError() возвращают всё, что пришло в неблокирующие pipe’ы с момента последнего вызова. Они потребляющие (каждый вызов отдаёт только новые байты) и никогда не блокируются. Три вещи, которые стоит знать:

  • Голый join() безопасен. join() — как и reap() — вычитывает pipe’ы пока ждёт, поэтому дочерний процесс, пишущий гораздо больше буфера в ~64 КБ, не может зависнуть, даже если вы сами ничего не читаете.
  • readOutput() после join() возвращает весь вывод. Байты буферизуются во время ожидания, поэтому переживают закрытие pipe’ов при завершении — читать до join больше не обязательно.
  • Цикл нужен только для «живого» вывода. Если хотите вывод по мере появления (прогресс-бары, потоковые логи), читайте в цикле, пока isAlive(); иначе просто join(), затем читайте.

--debug (флаг debugMode) ортогонален: он включает E_ALL + display_errors в дочернем процессе, чтобы предупреждения и notices PHP появлялись в выбранной вами цели.

Связанное