Async\Thread: запуск PHP у окремому потоці
Навіщо потрібні потоки
Корутини вирішують проблему конкурентності для I/O-навантажень — один процес може обробляти тисячі конкурентних мережевих запитів або очікувань диска. Але корутини мають обмеження: вони всі виконуються в одному PHP-процесі та по черзі отримують управління від планувальника. Якщо завдання CPU-bound — стиснення, парсинг, криптографія, важкі обчислення — одна така корутина заблокує планувальник, і всі інші корутини зупиняться, доки вона не завершиться.
Потоки вирішують це обмеження. Async\Thread запускає замикання в окремому паралельному потоці з власним ізольованим середовищем 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-ядер) або один потік для конкретного важкого завдання.
Життєвий цикл
// Створення — потік запускається та починає виконання негайно
$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
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: boomgetRemoteException() може повернути null, якщо клас винятку не вдалося завантажити в батьківському потоці (наприклад, це клас, визначений користувачем, який існує тільки в приймаючому потоці).
Передача даних між потоками
Це найважливіша частина моделі. Все передається копіюванням — спільних посилань немає.
Що можна передати
| Тип | Поведінка |
|---|---|
Скалярні значення (int, float, string, bool, null) | Копіюються |
| Масиви | Глибока копія; вкладені об'єкти зберігають ідентичність |
Об'єкти з оголошеними властивостями (public $x тощо) | Глибока копія; відтворюються з нуля на приймаючій стороні |
Closure | Тіло функції передається разом з усіма змінними use(...) |
WeakReference | Передається разом з об'єктом-референтом (див. нижче) |
WeakMap | Передається з усіма ключами та значеннями (див. нижче) |
Async\FutureState | Лише один раз, для запису результату з потоку (див. нижче) |
Що не можна передати
| Тип | Причина |
|---|---|
stdClass та будь-які об'єкти з динамічними властивостями | Динамічні властивості не мають оголошення на рівні класу і не можуть бути коректно відтворені в приймаючому потоці |
PHP-посилання (&$var) | Спільне посилання між потоками суперечить моделі |
Ресурси (resource) | Файлові дескриптори, curl-дескриптори, сокети прив'язані до конкретного потоку |
Спроба передати будь-що з цього негайно кине Async\ThreadTransferException у джерелі:
<?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
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
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: без сильного власника об'єкт збирається збирачем сміття.
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
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буде порожнім у приймаючому потоці. Це не баг — це прямий наслідок слабкої семантики: без сильного власника ключ знищується одразу після завантаження, і відповідний запис зникає.
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
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Важливі обмеження:
FutureStateможна передати лише в один потік. Друга спроба передачі кине виняток.- Передача самого
Futureне дозволена — він належить батьківському потоку і може пробудити лише свого власника. - Після передачі
FutureStateоригінальний об'єкт у батьківському потоці залишається дійсним: коли потік викликаєcomplete(), ця зміна стає видимою черезFutureв батьківському потоці —await($future)розблоковується.
Це єдиний стандартний спосіб доставити один результат з потоку до викликача, окрім звичайного return з spawn_thread(). Якщо потрібно передавати багато значень потоком, використовуйте ThreadChannel.
Bootloader: підготовка середовища потоку
Потік має власне середовище та не успадковує визначення класів, функцій або констант, оголошених у батьківському скрипті. Якщо замикання використовує клас, визначений користувачем, цей клас повинен бути перевизначений або завантажений через autoload — для цього є параметр bootloader:
$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: скрипти компілюються один раз для всього процесу, і кожен новий потік повторно використовує готовий байткод. Це прискорює запуск потоків.
Дивіться також
spawn_thread()— запуск замикання в потоціAsync\ThreadChannel— канали між потокамиawait()— очікування результату потокуAsync\RemoteException— обгортка для помилок приймаючого потоку