Жизненный цикл runner
Полный путь, который задача проходит от start() до join(): как родитель
сериализует и подготавливает payload и вызывает proc_open для PHP, что делают bootstrap
wRunner и AdaptiveRunner в дочернем процессе (включая fork в detached-режиме)
и как процесс завершается. Концептуальную версию см. в
модели работы.
Существует ровно два процесса, и они не разделяют ни одного объекта — только поток байтов на
fd 0, секрет в окружении и горсть CLI-флагов. Родительская сторона управляется
Engine; дочерняя — Runner. Эти двое никогда не ссылаются
друг на друга.
Родитель: что делает start()
Thread::start() защищает от двойного старта, сериализует Runnable, упаковывает всё в
LaunchSpec и передаёт его launcher’у engine.
- Сериализует
Runnable.opis/closure— жёсткая зависимость, поэтому это всегда\Opis\Closure\serialize($runnable, $engine->security())— никогда не нативныйserialize(). Когда у engine настроен секрет, вывод HMAC-подписывается; без секрета это по-прежнему opis/closure, просто без подписи. - Строит
LaunchSpec— payload плюс namespace, name, tag, per-runarguments, флагdebug, цельoutputи флагdetached. Engine::launcher()->launch($spec)(по умолчаниюCliLauncher) делает всё остальное:stage()payload’а, сборка дескрипторов, построение команды,proc_open.
Как launcher подключает дескрипторы и команду
Спецификация fd 0 (stdin) для payload’а производится объектом transport, а не
каким-либо константой PAYLOAD_* — launcher просто подключает то, что возвращает stage():
| Transport | Спецификация fd-0 (stdinSpec) |
Доп. CLI-аргумент | Действие после запуска |
|---|---|---|---|
PipeTransport |
['pipe', 'r'] |
— | записать payload в pipe, закрыть его |
TempFileTransport |
['file', <0600 tmp>, 'r'] |
— | unlink файла (дочерний процесс держит fd) |
ShmTransport |
['file', '/dev/null', 'r'] |
--shmkey=<int> |
ничего — дочерний процесс читает по ключу |
- fd 1 / fd 2 (stdout/stderr) идут в файл
outputв режиме дописывания (режимa, по умолчанию/dev/null) или в неблокирующие pipe’ы обратно к родителю, когдаoutput === null. - Команда полностью экранируется через
escapeshellarg:php wRunner --namespace=… --name=… [--tag=…] [--debug] [--detach] [--shmkey=…] [--arg-…]. Флаг--tagвыдаётся только когда tag задан;--detach— только когда$detachedравно true; каждый per-run аргумент становится--arg-<key>[=<value>]. - Окружение дочернего процесса — пустой
childEnvозначает, что дочерний процесс наследует окружение родителя как есть; непустой (engine по умолчанию добавляетWINTER_THREAD_SECRETпри подписи) объединяется поверх унаследованного окружения. Секрет едет через окружение (доступный только владельцу/proc/<pid>/environ), никогда через argv.
После proc_open launcher записывает/удаляет payload как описано выше, переводит output-pipe’ы
в неблокирующий режим, затем вызывает proc_get_status, чтобы убедиться, что процесс действительно
запустился. Если proc_open завершился неудачей или дочерний процесс сразу умер, он очищает
подготовленный ресурс и бросает ThreadException; иначе он возвращает ProcessHandle,
и start() возвращает запущенный PID.
В detached-режиме возвращаемый PID принадлежит launcher'у
При detached: true дочерний процесс немедленно форкается, и видимый родителю
процесс (launcher, L) почти сразу завершается с кодом 0. Поэтому PID,
который вы получаете обратно, — это эфемерный PID L, а не выжившего воркера.
РОДИТЕЛЬ (ваше приложение) ДОЧЕРНИЙ (php wRunner)
───────────────── ────────────────────
Opis\Closure\serialize(runnable, sec)
transport->stage(payload) ── fd 0 ──►
построение экранированной команды
proc_open ─────────────────────────► autoload + set_time_limit(0)
запись pipe / unlink tmp getopt(namespace,name,tag,
proc_get_status → PID debug,detach,shmkey)
│ AdaptiveRunner::execute()
│ приём: shmkey? shm : STDIN
│ Opis\Closure\unserialize + проверка
│ instanceof Runnable?
│ if --detach: fork + setsid ─┐
│ cli_set_process_title │
│ runnable->run(parsedArgs) ◄─┘
join() ◄──────── код выхода ──────── return 0 | Throwable → 1Дочерний процесс: что делают wRunner + AdaptiveRunner
wRunner — это тонкий bootstrap-скрипт (упакованный bin); настоящая работа живёт в
дочернем Runner, чей вариант по умолчанию — AdaptiveRunner. Чтобы заменить runner, вы
заменяете bootstrap-скрипт — укажите engine на свой собственный через
AdaptiveEngine(runnerPath: …) или ManualEngine()->withRunnerPath(…), обычно
в паре с собственным Launcher. Никакого Thread::bindRunner() нет; дочерняя
сторона по замыслу независима от родительского Engine.
Bootstrap wRunner:
- Автозагрузка — найти и подключить автозагрузчик Composer (путь установленного vendor’а,
с откатом на локальный
vendor/autoload.php). - Настройки долгого выполнения —
set_time_limit(0),ob_implicit_flush(),ignore_user_abort(true). - Чтение опций через
getopt('', ['namespace::','name::','tag::','debug','detach','shmkey::']). - Видимость ошибок —
--debugвключаетE_ALL+display_errors+display_startup_errors; иначе все три подавляются. - Построение верификатора — прочитать
WINTER_THREAD_SECRETиз окружения; если присутствует, сконструироватьDefaultSecurityProvider(secret: …), иначеnull(без подписи). - Запуск
new AdaptiveRunner($security)->execute($options)иexit()с его кодом.
AdaptiveRunner::execute():
- Приём payload’а — задан
--shmkey→ShmTransport(читает сегмент, затемshmop_deleteего); иначеPipeTransportчитаетSTDIN. STDIN покрывает обе доставки — pipe и temp-file, которые побайтово идентичны для читателя. Пустой payload → сообщение в stderr +exit(1). - Десериализация — всегда
\Opis\Closure\unserialize($payload, $this->security). С секретом проверяет HMAC и отклоняет поддельные/изменённые payload’ы (OpisSecurityException) ещё до создания любого объекта; любой бросок здесь → stderr +exit(1). - Освобождение строки —
unset($payload)перед запуском (важно для больших payload’ов). - Проверка типа — результат должен быть
instanceof Runnable, иначе stderr +exit(1). - Detached-режим — если присутствует
--detach,daemonize():pcntl_fork(); процесс launcher’аLделаетexit(0), чтобы родитель дёшево его реапнул, тогда как воркерWвызываетposix_setsid(), чтобы получить новую сессию без управляющего терминала и быть переподключённым к init. Неудачный fork или setsid → stderr +exit(1). Это происходит до установки заголовка процесса и запуска задачи. - Заголовок процесса — там, где существует
cli_set_process_title(), устанавливаетсяWinterThread <namespace> -> <name>@<tag>. Name откатывается на короткое имя класса Runnable, а tag откатывается наrunnable, когда соответствующая опция отсутствует. - Запуск — разобрать
--arg-*из$_SERVER['argv'](--arg-k=v→$args['k']='v', голый--arg-k→$args['k']=true), вызватьrun($args)иreturn 0. ЛюбойThrowableперехватывается, его сообщение иgetTraceAsString()записываются в stderr, и онreturn-ит1.
detach() — это не detached-режим
Fork –detach выше (воркер выживает, переподключается к init) — это
дочерняя демонизация, запускаемая через start(detached: true).
Это не связано с Thread::detach() — родительским вызовом, который
лишь прекращает отслеживание всё ещё работающего handle. См.
справочник API по обоим.
Завершение: что делает join()
Thread::join() делегирует ProcessHandle::join(), который опрашивает
proc_get_status каждые 50 мс, пока дочерний процесс не перестанет работать, затем вызывает
finish($exitcode): он закрывает любые открытые pipe’ы, вызывает единственный блокирующий примитив —
proc_close — и наконец transport->cleanup($staged), который удаляет задержавшийся
временный файл или сегмент разделяемой памяти (запасной вариант при падении; обычно дочерний процесс
удаляет свой собственный). Он возвращает код выхода, либо null по истечении положительного
timeout (в секундах), либо -1, если thread никогда не запускался.
reap(), detach() и __destruct() неблокирующи на живом дочернем процессе — они
никогда не вызывают proc_close, пока воркер работает. reap() завершает-и-собирает только
уже мёртвый процесс (возвращая false, пока он ещё работает); detach() закрывает
pipe’ы родителя и сбрасывает handle без proc_close или очистки transport’а
(detached-дочерний процесс владеет своими собственными ресурсами); деструктор реапит завершённый
дочерний процесс или отсоединяет всё ещё работающий, поэтому он никогда не подвешивает родителя.
Именно это позволяет пулу собирать сотни handle в одном цикле — см.
безопасность и производительность.
Почему payload не может хранить ресурсы
Дочерний процесс — это свежий процесс PHP: он восстанавливает вашу задачу из байтов и
не разделяет память с родителем. В этом и есть вся причина, по которой живые ресурсы
(PDO/сокеты/потоки) должны открываться внутри run(), а не храниться в свойствах
Runnable.