КомпонентиAsync\Thread

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: скрипти компілюються один раз для всього процесу, і кожен новий потік повторно використовує готовий байткод. Це прискорює запуск потоків.

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