Асинхронність (Fibers)
Lift\Async\Concurrent — це крихітний помічник кооперативної конкурентності, побудований на Fibers PHP 8.1. Він дозволяє запускати кілька блокувальних викликів вводу-виводу паралельно в межах одного процесу PHP, без ext-amphp / ext-react / ext-swoole.
Ментальна модель:
Fiber— це функція, яка може призупинити себе (suspend) і відновитися пізніше.Concurrent::all([...])запускає багато задач як fibers, циклічно перемикає їх, доки всі не завершаться, і збирає результати.
Важливо: це не справжній паралелізм — PHP усе ще виконує один оператор за раз. Fibers допомагають, коли задачі витрачають більшу частину часу в очікуванні вводу-виводу (HTTP-виклики, запити до БД, очікування). Для CPU-навантаженої роботи вони не допомагають; використовуйте пул воркерів черги.
Коли (і коли ні) використовувати
✅ Добре пасує
- Звернутися до 5 сторонніх API й об’єднати їхні результати — послідовно = 5×затримка, конкурентно ≈ 1×затримка.
- Пакетувати безліч викликів
curl_multiза чистим інтерфейсом. - Попередньо прогрівати кеші, випускаючи кілька читань одразу.
- Запустити кілька операцій «за можливості» після запису, не блокуючи відповідь.
❌ Погано пасує
- CPU-навантажена робота (зміна розміру зображень, парсинг) — fibers не дають більше ядер.
- Усе, що можна перенести в чергу — асинхронність-у-запиті складніше осмислити, ніж асинхронність-через-воркер.
- Довгоживучі потоки — використовуйте Server-Sent Events або справжній цикл подій.
Приклад за 30 секунд
use Lift\Async\Concurrent;
use Lift\Http\HttpClient;
[$github, $weather, $stocks] = Concurrent::all([
fn() => HttpClient::new()->get('https://api.github.com/repos/malinichevvv/lift-php')->json(),
fn() => HttpClient::new()->get('https://api.weather.gov/...')->json(),
fn() => HttpClient::new()->get('https://api.iexcloud.io/...')->json(),
]);
return Response::json([
'github' => $github,
'weather' => $weather,
'stocks' => $stocks,
]);
Якщо кожен виклик займає ~200 мс блокування, послідовна версія займає ~600 мс, а конкурентна — ~200 мс — якщо нижчележний клієнт поступається керуванням під час вводу-виводу. Інакше це все ще еквівалентно Concurrent::sequential(...). Див. «Коли fibers справді допомагають» нижче.
API
Concurrent::all(array $tasks): array
Запускає кожен callable як fiber, циклічно перемикає, доки всі не завершаться, повертає результати в тому самому порядку. Перекидає перший виняток від будь-якої задачі.
$results = Concurrent::all([
'github' => fn() => fetchGithub(),
'weather' => fn() => fetchWeather(),
]);
// $results['github'], $results['weather']
Ключі зберігаються.
Concurrent::suspend(): void
Усередині callable задачі поступіться керуванням наступному fiber:
Concurrent::all([
function () {
$data = openSocketRead(); // умовно-блокувальне
Concurrent::suspend(); // дати іншим задачам виконатися
$more = openSocketRead();
return $data . $more;
},
function () { /* … */ },
]);
Виклик suspend() поза fiber — це no-op — безпечно використовувати всюди.
Concurrent::sequential(array $tasks): array
Ідентична сигнатура all(), але виконує задачі одну за одною. Корисно як заміна для оточень, що забороняють fibers (наприклад, PHP < 8.1 чи набори тестів, що не терплять особливостей життєвого циклу fiber):
$tasks = [/* … */];
$results = $useFibers ? Concurrent::all($tasks) : Concurrent::sequential($tasks);
Concurrent::run(callable $task): mixed
Загортає один callable у fiber і виконує його до кінця. Здебільшого корисно для стрес-тестування fiber-безпеки функції.
Коли fibers справді допомагають
Fiber поступається керуванням, лише коли щось усередині нього викликає Fiber::suspend(). PHP не поступається автоматично під час нативного вводу-виводу. Тож:
| Бібліотека / виклик | Авто-поступається? |
|---|---|
curl-виклик, звичайний file_get_contents |
❌ блокує весь процес |
curl_multi_* із ручним select |
✅ якщо обернути select у suspend() |
Асинхронні клієнти ReactPHP / amphp |
✅ — вони інтегруються з планувальником fiber |
sleep() / usleep() |
❌ — блокують |
Явна поступка Concurrent::suspend() |
✅ |
Коротко: Concurrent::all([HttpClient::new()->get(...), ...]) не пришвидшує, бо cURL блокує весь процес PHP. Використовуйте його з бібліотеками, що інтегруються з fibers PHP, або з curl_multi_*, де можна вкраплювати suspend() між select.
Для реального пришвидшення з HTTP опустіться до curl_multi:
function parallelGet(array $urls): array
{
$mh = curl_multi_init();
$handles = [];
foreach ($urls as $i => $url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($mh, $ch);
$handles[$i] = $ch;
}
$running = null;
do {
curl_multi_exec($mh, $running);
curl_multi_select($mh, 0.1);
} while ($running > 0);
$out = [];
foreach ($handles as $i => $ch) {
$out[$i] = curl_multi_getcontent($ch);
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);
return $out;
}
$pages = parallelGet([
'https://api.example.com/a',
'https://api.example.com/b',
'https://api.example.com/c',
]);
curl_multi_select($mh, 0.1) — природне місце для Concurrent::suspend(), якщо хочете вплести це в більшу групу fibers.
Реальний патерн — fan-out з таймаутом
use Lift\Async\Concurrent;
$timeout = 2.0;
$start = microtime(true);
$results = Concurrent::all([
'inventory' => fn() => $this->safe(fn() => $this->inventory->lookup($sku), $timeout, $start),
'pricing' => fn() => $this->safe(fn() => $this->pricing->fetch($sku), $timeout, $start),
'reviews' => fn() => $this->safe(fn() => $this->reviews->latest($sku), $timeout, $start),
]);
return Response::json($results);
private function safe(callable $work, float $timeout, float $start): mixed
{
if (microtime(true) - $start > $timeout) {
return null; // вийти — ми за дедлайном
}
try {
return $work();
} catch (\Throwable) {
return null; // за можливості: деградувати витончено
}
}
Кожна задача відстежує глобальний дедлайн і витончено виходить — відповідь ніколи не займає довше ~timeout секунд, навіть якщо один із вищележних сервісів повільний.
Обробка помилок
Concurrent::all() перекидає перший виняток від будь-якої задачі після того, як усі fibers завершаться. Якщо потрібна ізоляція помилок на задачу, загорніть кожен callable у власний try/catch:
$results = Concurrent::all([
fn() => safelyCall(fn() => $a->fetch()),
fn() => safelyCall(fn() => $b->fetch()),
]);
function safelyCall(callable $work): array
{
try { return ['ok' => true, 'value' => $work()]; }
catch (\Throwable $e) { return ['ok' => false, 'error' => $e->getMessage()]; }
}
Так виглядає більшість продакшен-коду — виводьте окремі невдачі нагору, ніколи не давайте одній поганій задачі вбити пакет.
Тестування
Fibers детерміновані, коли всі задачі чисті. Поводьтеся з ними як зі звичайними функціями в тестах:
public function testParallelFetchMergesResults(): void
{
$service = new ProductPage(/* замокані клієнти */);
$result = $service->show('SKU-1');
self::assertSame('Widget', $result['name']);
self::assertArrayHasKey('reviews', $result);
}
Уникайте тверджень «вони справді виконувалися паралельно» — це деталь реалізації. Стверджуйте результат.
Обмеження
- Немає циклу подій —
Concurrent::all— це цикл активного відновлення. Реальна асинхронність потребує ext-event / ReactPHP / amphp. - Глобалі витікають між fibers.
$_SERVERPHP, обробники помилок і багато розширень не очікують перемикань fiber. Залишайтеся в чистих callable, не перетинайте межу fiber через сторонній код, що вовтузиться з глобальним станом. - Задачі розділяють запит — усі вони бачать той самий
Request,Connection, контейнер. Жодної ізоляції. Стежте за своїми транзакціями. - Скасування не підтримується. Щойно задача запущена, вона виконується до кінця (або викидає виняток). Використовуйте дедлайни всередині задачі.
Для серйозніших асинхронних навантажень беріть ReactPHP, amphp, FrankenPHP або RoadRunner. Concurrent — це відповідь у 50 рядків для простого випадку «розіслати N HTTP-викликів віялом».
Часті підводні камені
| Симптом | Причина | Виправлення |
|---|---|---|
Concurrent::all([...]) тієї самої швидкості, що послідовно |
Задачі не поступаються в точках очікування вводу-виводу | Використовуйте бібліотеки, що інтегруються з fibers PHP, або curl_multi_*. |
Fiber::suspend викликано поза fiber |
suspend() із не-fiber контексту |
Помічник робить це no-op; перевірте, що ви всередині callable задачі. |
| Одна погана задача викинула виняток, а інші були відкинуті | all() перекидає першу помилку |
Загорніть кожну задачу у try/catch для ізоляції на задачу. |
| Пам’ять зростає лінійно з кількістю fibers | Ви створили N fibers, але ніколи не дали їм завершитися | Не створюйте тисячі fibers на запит — пакетуйте групами. |
| Транзакції переплітаються дивно | Два fiber розділяють одне з’єднання з БД | Не відкривайте транзакцію всередині fiber, якщо тільки не відкриваєте окреме з’єднання на fiber. |
Записи $_SESSION втрачаються |
Обробники збереження сесій PHP не fiber-aware | Використовуйте Сесії (на основі драйверів) замість цього. |
Шпаргалка
use Lift\Async\Concurrent;
// Fan-out
$results = Concurrent::all([
'a' => fn() => callA(),
'b' => fn() => callB(),
'c' => fn() => callC(),
]);
// Поступитися всередині задачі
Concurrent::suspend();
// Послідовний запасний варіант (оточення без fiber)
$results = Concurrent::sequential($tasks);
// Обгортка одного fiber
$value = Concurrent::run(fn() => doSomething());
// Семантика першої помилки
try {
Concurrent::all($tasks);
} catch (\Throwable $e) {
// …перша задача, що викинула, спливає сюди
}