Async\Thread: запуск PHP у окремому потоці

Навіщо потрібні потоки

Корутини вирішують проблему конкурентності для I/O-навантажень — один процес може обробляти тисячі конкурентних мережевих запитів або очікувань диска. Але корутини мають обмеження: вони всі виконуються в одному PHP-процесі та по черзі отримують управління від планувальника. Якщо завдання CPU-bound — стиснення, парсинг, криптографія, важкі обчислення — одна така корутина заблокує планувальник, і всі інші корутини зупиняться, доки вона не завершиться.

Потоки вирішують це обмеження. Async\Thread запускає замикання в окремому паралельному потоці з власним ізольованим середовищем PHP: власним набором змінних, власним автозавантажувачем, власними класами та функціями. Між потоками нічого не передається напряму — будь-які дані передаються за значенням, через глибоке копіювання.

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;
use function Async\delay;

// Тікер у головній корутині — доводить, що паралельний потік
// не заважає головній програмі продовжувати роботу
spawn(function() {
    for ($i = 0; $i < 5; $i++) {
        echo "tick $i\n";
        delay(100);
    }
});

spawn(function() {
    $thread = spawn_thread(function() {
        // Важкі обчислення в окремому потоці
        $sum = 0;
        for ($i = 0; $i < 5_000_000; $i++) {
            $sum += sqrt($i);
        }
        return $sum;
    });

    $result = await($thread);
    echo "heavy done: ", (int) $result, "\n";
});
tick 0
tick 1
tick 2
tick 3
tick 4
heavy done: 7453558806

Тікер спокійно завершує свої 5 «тіків» конкурентно з важкою роботою потоку — головна програма не змушена чекати.

Коли використовувати потоки замість корутин

ЗавданняІнструмент
Багато конкурентних HTTP/DB/файлових запитівКорутини
Тривала CPU-bound робота (парсинг, криптографія)Потоки
Ізоляція нестабільного кодуПотоки
Паралельна робота на кількох CPU-ядрахПотоки
Обмін даними між завданнямиКорутини + канали

Потік — це відносно дорога сутність: запуск нового потоку на порядок важчий за запуск корутини. Тому не варто створювати тисячі потоків: типова модель — кілька довгоживучих робочих потоків (часто рівних кількості CPU-ядер) або один потік для конкретного важкого завдання.

Життєвий цикл

php
// Створення — потік запускається та починає виконання негайно
$thread = spawn_thread(fn() => compute());

// Очікування результату. Корутина, що викликає, чекає; інші продовжують роботу
$result = await($thread);

// Або неблокуюча перевірка
if ($thread->isCompleted()) {
    $result = $thread->getResult();
}

Async\Thread реалізує інтерфейс Completable, тому його можна передати до await(), await_all(), await_any() та Task\Group — так само, як і звичайну корутину.

Стани

МетодЩо перевіряє
isRunning()Потік ще виконується
isCompleted()Потік завершився (успішно або з винятком)
isCancelled()Потік було скасовано
getResult()Результат, якщо завершився успішно; інакше null
getException()Виняток, якщо завершився з помилкою; інакше null

Обробка винятків

Виняток, кинутий усередині потоку, перехоплюється та доставляється до батьківського потоку, загорнутий у Async\RemoteException:

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

spawn(function() {
    $thread = spawn_thread(function() {
        throw new RuntimeException('boom');
    });

    try {
        await($thread);
    } catch (Async\RemoteException $e) {
        echo "remote class: ", $e->getRemoteClass(), "\n";

        $original = $e->getRemoteException();
        if ($original !== null) {
            echo "original: ", $original->getMessage(), "\n";
        }
    }
});
remote class: RuntimeException
original: boom

getRemoteException() може повернути null, якщо клас винятку не вдалося завантажити в батьківському потоці (наприклад, це клас, визначений користувачем, який існує тільки в приймаючому потоці).

Передача даних між потоками

Це найважливіша частина моделі. Все передається копіюванням — спільних посилань немає.

Що можна передати

ТипПоведінка
Скалярні значення (int, float, string, bool, null)Копіюються
МасивиГлибока копія; вкладені об'єкти зберігають ідентичність
Об'єкти з оголошеними властивостями (public $x тощо)Глибока копія; відтворюються з нуля на приймаючій стороні
ClosureТіло функції передається разом з усіма змінними use(...)
WeakReferenceПередається разом з об'єктом-референтом (див. нижче)
WeakMapПередається з усіма ключами та значеннями (див. нижче)
Async\FutureStateЛише один раз, для запису результату з потоку (див. нижче)

Що не можна передати

ТипПричина
stdClass та будь-які об'єкти з динамічними властивостямиДинамічні властивості не мають оголошення на рівні класу і не можуть бути коректно відтворені в приймаючому потоці
PHP-посилання (&$var)Спільне посилання між потоками суперечить моделі
Ресурси (resource)Файлові дескриптори, curl-дескриптори, сокети прив'язані до конкретного потоку

Спроба передати будь-що з цього негайно кине Async\ThreadTransferException у джерелі:

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

spawn(function() {
    $obj = new stdClass();   // динамічні властивості
    $obj->x = 1;

    try {
        $thread = spawn_thread(function() use ($obj) {
            return 'unreachable';
        });
        await($thread);
    } catch (Async\ThreadTransferException $e) {
        echo $e->getMessage(), "\n";
    }
});
Cannot transfer object with dynamic properties between threads (class stdClass). Use arrays instead

Ідентичність об'єктів зберігається

Один і той самий об'єкт, на який посилаються кілька разів у графі даних, створюється лише один раз в приймаючому потоці, і всі посилання вказують на нього. У межах однієї операції передачі (усі змінні з use(...) одного замикання, одне надсилання в канал, один результат потоку) ідентичність зберігається:

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

class Config {
    public function __construct(public string $name = '') {}
}

// Клас повинен бути оголошений у середовищі приймаючого потоку — робимо це через bootloader
$boot = function() {
    eval('class Config { public function __construct(public string $name = "") {} }');
};

spawn(function() use ($boot) {
    $obj  = new Config('prod');
    $meta = ['ref' => $obj];

    $thread = spawn_thread(function() use ($obj, $meta) {
        // Один і той самий екземпляр у двох різних змінних
        echo "same: ", ($obj === $meta['ref'] ? "yes" : "no"), "\n";

        // Мутація через одне посилання видима через інше
        $obj->name = 'staging';
        echo "meta: ", $meta['ref']->name, "\n";

        return 'ok';
    }, bootloader: $boot);

    echo await($thread), "\n";
});
same: yes
meta: staging
ok

Те саме стосується зв'язаних об'єктів у межах одного графа: масив з посиланнями на спільні вкладені об'єкти збереже ідентичність після передачі.

Цикли

Граф із циклом через звичайні об'єкти може бути переданий. Обмеження полягає в тому, що дуже глибоко вкладені цикли можуть досягти внутрішнього ліміту глибини передачі (сотні рівнів). На практиці це майже ніколи не трапляється. Цикли виду $node->weakParent = WeakReference::create($node) — тобто об'єкт, що посилається на себе через WeakReference — наразі стикаються з тим самим обмеженням, тому краще не використовувати їх у межах одного переданого графа.

WeakReference між потоками

WeakReference має спеціальну логіку передачі. Поведінка залежить від того, що ще передається разом з ним.

Референт також передається — ідентичність зберігається

Якщо об'єкт передається разом з WeakReference (безпосередньо, всередині масиву або як властивість іншого об'єкта), то на приймаючій стороні $wr->get() повертає саме той екземпляр, що потрапив в інші посилання:

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

class Config { public function __construct(public string $name = '') {} }
$boot = function() { eval('class Config { public function __construct(public string $name = "") {} }'); };

spawn(function() use ($boot) {
    $obj = new Config('prod');
    $wr  = WeakReference::create($obj);

    $thread = spawn_thread(function() use ($obj, $wr) {
        echo "wr === obj: ", ($wr->get() === $obj ? "yes" : "no"), "\n";
        return 'ok';
    }, bootloader: $boot);

    await($thread);
});
wr === obj: yes

Референт не передається — WeakReference стає мертвим

Якщо передається тільки WeakReference, але не сам об'єкт, то в приймаючому потоці ніхто не тримає сильне посилання на цей об'єкт. За правилами PHP це означає, що об'єкт негайно знищується, а WeakReference стає мертвим ($wr->get() === null). Це рівно та сама поведінка, що й в однопотоковому PHP: без сильного власника об'єкт збирається збирачем сміття.

php
spawn(function() use ($boot) {
    $obj = new Config('prod');
    $wr  = WeakReference::create($obj);

    $thread = spawn_thread(function() use ($wr) {   // $obj НЕ передається
        echo "dead: ", ($wr->get() === null ? "yes" : "no"), "\n";
        return 'ok';
    }, bootloader: $boot);

    await($thread);
});
dead: yes

Джерело вже мертве

Якщо WeakReference вже був мертвим у джерелі на момент передачі ($wr->get() === null), він прибуде в приймаючий потік також мертвим.

Одиночний екземпляр

WeakReference::create($obj) повертає одиночний екземпляр (singleton): два виклики для одного об'єкта дають той самий екземпляр WeakReference. Ця властивість зберігається під час передачі — в приймаючому потоці також буде рівно один екземпляр WeakReference на об'єкт.

WeakMap між потоками

WeakMap передається з усіма своїми записами. Але діє те саме правило, що й в однопотоковому PHP: ключ WeakMap живе лише доти, доки хтось тримає сильне посилання на нього.

Ключі є в графі — записи виживають

Якщо ключі передаються окремо (або доступні через інші передані об'єкти), WeakMap у приймаючому потоці містить усі записи:

php
<?php

use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

class Key { public function __construct(public string $name = '') {} }
$boot = function() { eval('class Key { public function __construct(public string $name = "") {} }'); };

spawn(function() use ($boot) {
    $k1 = new Key('alpha');
    $k2 = new Key('beta');
    $wm = new WeakMap();
    $wm[$k1] = 'v1';
    $wm[$k2] = 'v2';

    $thread = spawn_thread(function() use ($wm, $k1, $k2) {
        echo "count: ", count($wm), "\n";
        echo "k1: ", $wm[$k1], "\n";
        echo "k2: ", $wm[$k2], "\n";
        return 'ok';
    }, bootloader: $boot);

    await($thread);
});
count: 2
k1: v1
k2: v2

Тільки WeakMap — записи зникають

Якщо передається тільки WeakMap, а його ключі не з'являються більше ніде в графі, WeakMapбуде порожнім у приймаючому потоці. Це не баг — це прямий наслідок слабкої семантики: без сильного власника ключ знищується одразу після завантаження, і відповідний запис зникає.

php
spawn(function() use ($boot) {
    $ghost = new Key('ghost');
    $wm = new WeakMap();
    $wm[$ghost] = 'value';

    $thread = spawn_thread(function() use ($wm) {  // $ghost не передається
        echo "count: ", count($wm), "\n";
        return 'ok';
    }, bootloader: $boot);

    await($thread);
});
count: 0

Щоб запис «пережив» передачу, його ключ повинен бути переданий окремо (або як частина якогось іншого об'єкта, що сам входить до графа).

Вкладені структури

WeakMap може містити інші WeakMap, WeakReference, масиви та звичайні об'єкти як значення — все передається рекурсивно. Цикли виду $wm[$obj] = $wm обробляються коректно.

Future між потоками

Безпосередня передача Async\Future між потоками неможлива: Future — це об'єкт очікувача, події якого прив'язані до планувальника потоку, в якому він був створений. Натомість можна передати «сторону запису» — Async\FutureState — і лише один раз.

Типовий патерн: батьківський потік створює пару FutureState + Future, передає FutureState до потоку через змінну use(...), потік викликає complete() або error(), а батьківський потік отримує результат через свій Future:

php
<?php

use Async\FutureState;
use Async\Future;
use function Async\spawn;
use function Async\spawn_thread;
use function Async\await;

spawn(function() {
    $state  = new FutureState();
    $future = new Future($state);

    $thread = spawn_thread(function() use ($state) {
        // Імітація важкої роботи
        $data = "computed in thread";
        $state->complete($data);
    });

    // Батьківський потік чекає через свій Future — подія надходить сюди,
    // коли потік викликає $state->complete()
    $result = await($future);
    echo "got: ", $result, "\n";

    await($thread);
    echo "thread done\n";
});
got: computed in thread
thread done

Важливі обмеження:

  1. FutureState можна передати лише в один потік. Друга спроба передачі кине виняток.
  2. Передача самого Future не дозволена — він належить батьківському потоку і може пробудити лише свого власника.
  3. Після передачі FutureState оригінальний об'єкт у батьківському потоці залишається дійсним: коли потік викликає complete(), ця зміна стає видимою через Future в батьківському потоці — await($future) розблоковується.

Це єдиний стандартний спосіб доставити один результат з потоку до викликача, окрім звичайного return з spawn_thread(). Якщо потрібно передавати багато значень потоком, використовуйте ThreadChannel.

Bootloader: підготовка середовища потоку

Потік має власне середовище та не успадковує визначення класів, функцій або констант, оголошених у батьківському скрипті. Якщо замикання використовує клас, визначений користувачем, цей клас повинен бути перевизначений або завантажений через autoload — для цього є параметр bootloader:

php
$thread = spawn_thread(
    task: function() {
        $config = new Config('prod');  // Config повинен існувати в потоці
        return $config->name;
    },
    bootloader: function() {
        // Виконується в приймаючому потоці ДО основного замикання
        require_once __DIR__ . '/src/autoload.php';
    },
);

Bootloader гарантовано виконується в приймаючому потоці перед завантаженням змінних use(...) та перед викликом основного замикання. Типові завдання bootloader: реєстрація автозавантажувача, оголошення класів через eval, встановлення ini-параметрів, завантаження бібліотек.

Граничні випадки

Суперглобальні змінні

$_GET, $_POST, $_SERVER, $_ENV є власними в потоці — вони ініціалізуються заново, як у новому запиті. У поточній версії TrueAsync їх заповнення в приймаючих потоках тимчасово вимкнено (планується ввімкнути пізніше) — стежте за CHANGELOG.

Статичні змінні функцій

Кожен потік має власний набір статичних змінних функцій і класів. Зміни в одному потоці не видимі в інших — це частина загальної ізоляції.

Opcache

Opcache спільно використовує скомпільований кеш байткоду між потоками як read-only: скрипти компілюються один раз для всього процесу, і кожен новий потік повторно використовує готовий байткод. Це прискорює запуск потоків.

Дивіться також