Вывод и правило Broken pipe
Почему start() по умолчанию задаёт outputTarget в
/dev/null и что на самом деле идёт не так, когда фоновый процесс пишет в pipe,
который никто не читает.
Какую поломку это предотвращает
Когда outputTarget равен null, start() направляет stdout и stderr дочернего процесса в
pipe’ы обратно к родителю. У pipe фиксированный буфер ОС (~64 КБ). Если pipe никто
не читает — и родитель ни разу не вызывает join() или reap() (оба сами вычитывают pipe):
- Дочерний процесс продолжает
echo; буфер заполняется. - Следующий
write()блокируется — дочерний процесс застревает и не делает работу. - Если родитель завершился или закрыл свой конец чтения, вместо этого запись вызывает
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 появлялись в выбранной вами цели.
Связанное
- Жизненный цикл runner — где подключаются дескрипторы
- Логирование и отладка — практическое руководство
- Справочник API — параметры
start()