Потоки
Усі наші інструменти досі жили всередині одного потоку операційної системи. Корутини створювали враження, що все відбувається водночас, але в будь-який окремий момент насправді виконувалася рівно одна з них. Для задачі імпорту і запитів до GeoDirectory цього було більш ніж досить: задачі здебільшого чекали, а очікування чудово ділиться.
А ось задача іншого штибу. Раз на день ProfileService будує річний звіт: цілу хвилину чистих обчислень, без жодного мережевого чи базового виклику. Запустимо його в корутині, поруч зі знайомим цокотуном:
spawn(function () {
while (true) {
echo "tick\n";
delay(1000);
}
});
spawn(function () {
$report = buildYearlyReport($rows); // хвилина чистих обчислень
echo "report ready\n";
});Цокотун замовкає на цілу хвилину. Згадай першу главу: корутини перемикаються в точках await, на sleep, delay, введенні-виведенні. Але buildYearlyReport не має жодної точки await, лише цикли й арифметику. Планувальник так і не отримує керування назад, і весь процес, цокотун, воркери, обробка запитів, усе це просто сидить і дивиться, як одна корутина хрумкає числа.
Корутини дають тобі конкурентність, а не паралелізм. Коли задача обмежена процесором, а не очікуванням, потрібен другий процесор. Точніше кажучи, другий потік операційної системи.
spawn_thread
use function Async\spawn_thread;
use function Async\await;
$thread = spawn_thread(fn() => buildYearlyReport($rows));
$report = await($thread);spawn_thread запускає замикання в окремому потоці операційної системи, на іншому ядрі процесора. Звіт тепер обчислюється по-справжньому паралельно: цокотун далі цокає, воркери далі працюють, а планувальник гадки не має, що прямо поруч відбувається важка робота.
Ззовні потік виглядає майже як корутина: його можна передати знайомому await, який призупиняє лише корутину, що чекає. Виняток із потоку теж досягає await, загорнутий у Async\RemoteException.
Нічого спільного
Майже як корутина, але з однією фундаментальною різницею. У главі про канали ми казали, що корутини живуть у спільній пам'яті, об'єкт можна просто передати через use, а гонки даних не трапляються в межах одного потоку. Що ж, цей трюк не працює з потоками. Кожен потік має власне середовище PHP: власні змінні, власні класи, власні статичні властивості. Спільної пам'яті немає зовсім.
Ось чому все, що передається в потік, копіюється повністю:
$rows = loadRows();
$thread = spawn_thread(function () use ($rows) {
// це КОПІЯ $rows: зміни тут не видно ззовні
return buildYearlyReport($rows);
});Це правило приходить і з обмеженнями: у потік не можна передати посилання PHP (&$var), ресурс на кшталт відкритого файлу чи об'єкт із динамічними властивостями. Спроба зробити це одразу кидає ThreadTransferException, на самому початку, замість того щоб спричинити загадкову поведінку пізніше.
Правила суворі, але чесні: без спільного стану немає ні гонок, ні блокувань, ні м'ютексів, ні будь-яких інших жахіть класичної багатопотоковості. Потоки TrueAsync спілкуються так само, як корутини: передаючи значення, а не ділячи пам'ять.
До речі, один старий знайомий уміє осмислено перетинати межу потоку. FutureState із шостої глави можна передати в потік, тоді як його Future залишається позаду:
$state = new FutureState();
$future = new Future($state);
spawn_thread(function () use ($state) {
$state->complete(buildYearlyReport(loadRows()));
});
$report = await($future);Виробник в одному потоці, споживач в іншому, а контракт рівно той самий. Ось чому Future спершу й розділили на два окремі об'єкти: межа між ними виявилася достатньо міцною, щоб провести рівно по ній межу потоку.
ThreadPool
Потік це дорога сутність: його власне середовище PHP треба створити, ініціалізувати і прогріти. Одна задача на хвилину, звісно, немає проблем, але піднімати потік на кожну дрібну задачу так само марнотратно, як відкривати з'єднання з базою на кожен запит.
Ти вже знаєш, що робити з дорогими ресурсами. Саме так, пулити:
use Async\ThreadPool;
$pool = new ThreadPool(workers: 8);
$futures = [];
foreach ($uploads as $path) {
$futures[] = $pool->submit(makeThumbnail(...), $path);
}
foreach ($futures as $future) {
echo await($future), "\n";
}
$pool->close();Вісім потоків-воркерів створюються один раз і живуть, доки живе пул. submit кладе задачу в чергу, вільний воркер підхоплює її і повертає результат. І поглянь, що повертає submit: Future, обіцянку результату, який хтось інший вироблятиме. У шостій главі тим «кимось іншим» була корутина; тепер це цілий окремий потік, а контракт анітрохи не змінився.
Черга пулу має ліміт, і щойно вона заповнюється, submit призупиняє корутину, що викликала. Впізнаєш? Це зворотний тиск із глави про канали: виробник задач автоматично підлаштовує свій темп під швидкість воркерів.
Тримай кількість воркерів близькою до кількості ядер процесора: на відміну від очікування, обчислення потребують реальних фізичних ядер, і двадцять потоків на восьми ядрах лише розштовхуватимуть одне одного ліктями. За замовчуванням, без параметра workers, пул сам визначає доступний паралелізм, ураховуючи навіть квоти контейнера.
А для найпоширенішого випадку, «обробити список паралельно», є однорядкова форма, яку ти вже знаєш із iterate:
$thumbs = $pool->map($uploads, makeThumbnail(...));map розподіляє елементи між воркерами, чекає на всіх і повертає результати в їхньому початковому порядку.
Тож виявляється, що конкурентність має два виміри. Корутини стискають очікування: тисячі задач ділять один потік і не заважають одна одній, поки чекають. Потоки додають справжній паралелізм: обчислення розтікаються по ядрах процесора. Ці інструменти не змагаються між собою, вони доповнюють одне одного: де код чекає, бери корутину; де він обчислює, бери потік; а щойно того чи іншого стає багато, на допомогу приходить пул.
Наостанок поглянь, на що перетворився наш процес: сотні корутин, усі перемішані, обслуговують різних користувачів, задачі стрибають між воркерами і потоками. І серед цього натовпу просте запитання виявляється напрочуд складним: де зберігати «поточне»? Поточного користувача, поточну мову, ідентифікатор запиту? Є одна глобальна змінна на всіх, а корутин тисячі. Про це і йдеться в останній главі.