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

Жизненный цикл runner

Полный путь, который задача проходит от start() до join(): как родитель сериализует и подготавливает payload и вызывает proc_open для PHP, что делают bootstrap wRunner и AdaptiveRunner в дочернем процессе (включая fork в detached-режиме) и как процесс завершается. Концептуальную версию см. в модели работы.

Существует ровно два процесса, и они не разделяют ни одного объекта — только поток байтов на fd 0, секрет в окружении и горсть CLI-флагов. Родительская сторона управляется Engine; дочерняя — Runner. Эти двое никогда не ссылаются друг на друга.

Родитель: что делает start()

Thread::start() защищает от двойного старта, сериализует Runnable, упаковывает всё в LaunchSpec и передаёт его launcher’у engine.

  1. Сериализует Runnable. opis/closure — жёсткая зависимость, поэтому это всегда \Opis\Closure\serialize($runnable, $engine->security()) — никогда не нативный serialize(). Когда у engine настроен секрет, вывод HMAC-подписывается; без секрета это по-прежнему opis/closure, просто без подписи.
  2. Строит LaunchSpec — payload плюс namespace, name, tag, per-run arguments, флаг debug, цель output и флаг detached.
  3. 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, а не выжившего воркера.

text
  РОДИТЕЛЬ (ваше приложение)               ДОЧЕРНИЙ  (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:

  1. Автозагрузка — найти и подключить автозагрузчик Composer (путь установленного vendor’а, с откатом на локальный vendor/autoload.php).
  2. Настройки долгого выполненияset_time_limit(0), ob_implicit_flush(), ignore_user_abort(true).
  3. Чтение опций через getopt('', ['namespace::','name::','tag::','debug','detach','shmkey::']).
  4. Видимость ошибок--debug включает E_ALL + display_errors + display_startup_errors; иначе все три подавляются.
  5. Построение верификатора — прочитать WINTER_THREAD_SECRET из окружения; если присутствует, сконструировать DefaultSecurityProvider(secret: …), иначе null (без подписи).
  6. Запуск new AdaptiveRunner($security)->execute($options) и exit() с его кодом.

AdaptiveRunner::execute():

  1. Приём payload’а — задан --shmkeyShmTransport (читает сегмент, затем shmop_delete его); иначе PipeTransport читает STDIN. STDIN покрывает обе доставки — pipe и temp-file, которые побайтово идентичны для читателя. Пустой payload → сообщение в stderr + exit(1).
  2. Десериализациявсегда \Opis\Closure\unserialize($payload, $this->security). С секретом проверяет HMAC и отклоняет поддельные/изменённые payload’ы (Opis SecurityException) ещё до создания любого объекта; любой бросок здесь → stderr + exit(1).
  3. Освобождение строкиunset($payload) перед запуском (важно для больших payload’ов).
  4. Проверка типа — результат должен быть instanceof Runnable, иначе stderr + exit(1).
  5. Detached-режим — если присутствует --detach, daemonize(): pcntl_fork(); процесс launcher’а L делает exit(0), чтобы родитель дёшево его реапнул, тогда как воркер W вызывает posix_setsid(), чтобы получить новую сессию без управляющего терминала и быть переподключённым к init. Неудачный fork или setsid → stderr + exit(1). Это происходит до установки заголовка процесса и запуска задачи.
  6. Заголовок процесса — там, где существует cli_set_process_title(), устанавливается WinterThread <namespace> -> <name>@<tag>. Name откатывается на короткое имя класса Runnable, а tag откатывается на runnable, когда соответствующая опция отсутствует.
  7. Запуск — разобрать --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.

Связанное