ОсновиКонкурентний ітератор

Конкурентний ітератор

Порахуймо, скільки разів ми будували один і той самий патерн. У главі про канали: канал, десять воркерів, цикл recv. У главі про TaskGroup: concurrency: 10, цикл spawn, close(). У главі про TaskSet: те саме, але зі споживанням результатів. Щоразу задача звучала однаково: обійти колекцію, зробити конкурентну роботу над кожним елементом і ніколи не перевищувати ліміт.

Патерн настільки поширений заслуговує на власну функцію:

php
use function Async\iterate;

iterate($addresses, checkAddress(...), concurrency: 10);

І це весь пул воркерів. iterate викликає функцію для кожного елемента колекції у власній корутині, стежить, щоб конкурентно виконувалося не більше десяти, і повертає керування, щойно все оброблено.

Генератор замість масиву

Стривай, а як же читання файлу? Зібрати сто тисяч адрес у масив тільки заради виклику iterate було б кроком назад: саме зворотний тиск із глави про канали заощаджував нам пам'ять. Немає проблеми: iterate приймає не лише масив, а будь-який Traversable, включно з генераторами:

php
function addresses(string $path): Generator
{
    $handle = fopen($path, 'r');
    $header = fgetcsv($handle);
    $addressIndex = array_search('address', $header);

    while (($row = fgetcsv($handle)) !== false) {
        yield $row[$addressIndex];
    }

    fclose($handle);
}

iterate(addresses('users.csv'), checkAddress(...), concurrency: 10);

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

Ранній вихід

Функція-обробник може зупинити весь обхід, повернувши false:

php
$broken = 0;

iterate(addresses('users.csv'), function (string $address) use (&$broken) {
    if (!checkAddress($address)) {
        $broken++;
    }

    if ($broken >= 100) {
        return false; // файл пошкоджений, продовжувати немає сенсу
    }
}, concurrency: 10);

Сотня недійсних адрес поспіль це надійна ознака того, що нам підсунули не той файл. Жоден новий елемент після false не запускається, корутини, що вже працюють, доробляють свою справу, і iterate повертається.

Винятки суворіші, і ти вже знаєш правило зі Scope: помилка в будь-якому обробнику зупиняє обхід, скасовує решту корутин і поширюється назовні. Падаємо разом за замовчуванням:

php
try {
    iterate(addresses('users.csv'), checkAddress(...), concurrency: 10);
} catch (RemoteApiException $e) {
    echo "Імпорт перервано: {$e->getMessage()}\n";
}

За цим немає ніякої магії: усередині iterate живе звичайний дочірній scope з восьмої глави, і саме він підтримує лад.

Драбина абстракцій

За шість глав склалася ціла драбина, і варто ще раз оглянути її, знизу вгору:

  • Канал + Scope — примітиви. Будь-яка топологія: пули, конвеєри, рандеву, супервізори. Максимум контролю, максимум коду.
  • TaskGroup / TaskSet — готові збірки для набору задач, коли потрібні результати: усі, перший, перший успішний або по одному в міру готовності.
  • iterate() — один рядок, коли результат не потрібен і треба лише конкурентно обійти колекцію.

Що вище драбиною, то менше коду і вужчий сценарій. Починай згори: якщо iterate вистачає, немає сенсу будувати групу; якщо групи замало, спускайся до каналів. Унизу можна зібрати будь-яку конструкцію, яку заманеться; згори все вже зібрано за тебе.

Зверни увагу, що сталося з нашим імпортом: глава за главою він усе стискався, доки нарешті не вмістився в один рядок. Саме так і має бути. Конкурентність перестає бути подією і стає звичайним інструментом, як foreach.

Але одне запитання з дев'ятої глави досі висить у повітрі. Для PDO пул з'єднань вбудований у ядро, тоді як checkAddress спілкується з GeoDirectory по HTTP. Його з'єднання теж варто перевикористовувати, і не лише його: сокети, клієнти, важкі об'єкти взагалі. Невже нам справді доведеться щоразу власноруч будувати пул із каналу? На щастя, ні, і наступна глава покаже чому.