TaskSet
В прошлой главе выяснилось, что TaskGroup помнит всё. Это не случайность, а свойство, на которое можно опереться: сколько ни спрашивай группу о результатах, ответ будет тем же. Такое поведение называется идемпотентностью, и мы с ним уже встречались, когда повторный await корутины возвращал одно и то же.
Но у памяти есть цена. Прогоните через группу сто тысяч задач, и все сто тысяч результатов останутся лежать внутри, даже если каждый был нужен ровно один раз, в момент готовности. Для конвейера, который работает часами, это не хранилище, а утечка.
Нужен близнец TaskGroup с противоположным характером: отдал результат и забыл. Он называется TaskSet.
Потребление вместо хранения
Внешне всё похоже: spawn, close, лимит конкурентности. Различие в том, что происходит с результатом после доставки:
use Async\TaskSet;
$set = new TaskSet();
$set->spawn(fn() => 'alpha');
$set->spawn(fn() => 'beta');
$set->spawn(fn() => 'gamma');
echo $set->joinNext()->await(); // alpha
echo $set->joinNext()->await(); // beta, уже другой!
echo $set->joinNext()->await(); // gamma
echo $set->count(); // 0, набор пустКаждый вызов joinNext() отдаёт следующий готовый результат и удаляет запись из набора. Сравните с race() у TaskGroup, который сколько угодно раз возвращает одного и того же первого победителя. TaskSet ведёт себя не как хранилище, а как очередь: прочитал — забрал. Да, это та же семантика, что у recv из главы про каналы, только в очереди стоят не значения, а завершающиеся задачи.
Методы ожидания у близнецов рифмуются:
joinNext()— какrace(): первый завершившийся, запись удаляется.joinAny()— какany(): первый успешный, запись удаляется.joinAll()— какall(): все результаты разом, набор пустеет.
Приставка join и подсказывает разницу: результат не просто прочитан, а изъят.
Конвейер без утечки
Перепишем стотысячный импорт с учётом новых знаний:
$set = new TaskSet(concurrency: 10);
spawn(function () use ($set, $handle, $addressIndex) {
while (($row = fgetcsv($handle)) !== false) {
$set->spawn(fn() => checkAddress($row[$addressIndex]));
}
$set->close();
});
foreach ($set as $key => [$result, $error]) {
if ($error !== null) {
error_log("Адрес не проверен: {$error->getMessage()}");
continue;
}
saveAddress($pdo, $result);
}Одна корутина читает файл и подбрасывает задачи, главный поток разбирает результаты по мере готовности. Каждая обработанная запись немедленно удаляется из набора, поэтому память занимают только задачи в полёте: десять работающих плюс очередь. Файл может быть любого размера, потребление памяти от него не зависит.
Заметьте, как знакомые детали сложились в новую картину: лимит конкурентности вместо ручного пула воркеров, close() как сигнал «задач больше не будет», пара [$result, $error] вместо молчаливой потери исключений, и PDO из девятой главы, которому не страшны конкурентные saveAddress.
Супервизор
Есть и второй сценарий, где потребляющая семантика незаменима: код, который следит за долгоживущими задачами и реагирует на их завершение.
$set = new TaskSet();
$set->spawnWithKey('mailer', runMailer(...));
$set->spawnWithKey('metrics', runMetrics(...));
$set->spawnWithKey('cleaner', runCleaner(...));
foreach ($set as $key => [$result, $error]) {
error_log("Служба $key остановилась" . ($error ? ": {$error->getMessage()}" : ''));
// перезапускаем упавшую службу
$set->spawnWithKey($key, restartService($key));
}Набор не закрыт, поэтому foreach не завершается, а ждёт следующее событие. Каждая обработанная запись удаляется, перезапущенная служба добавляется на её место, и цикл живёт вечно. Супервизору не нужна история всех завершений с начала времён, ему нужно следующее: кто-то из подопечных остановился, надо разобраться и перезапустить. С TaskGroup такой цикл не написать: его foreach каждый раз начинал бы с первой остановившейся службы.
Вот, собственно, и вся разница между близнецами: память. Группа хранит результаты и отвечает на любой вопрос повторно, поэтому она хороша там, где набор задач фиксирован, а результаты ценны как единое целое. Набор отдаёт каждый результат один раз и сразу освобождает память, поэтому ему по плечу бесконечный поток задач. Есть простой способ выбрать: если ваш вопрос к задачам звучит как «что насчитали?», берите TaskGroup, а если «что там дальше?», берите TaskSet.
За четыре главы мы трижды построили одно и то же: обойти коллекцию, выполнив для каждого элемента конкурентную работу с ограничением. Через канал с воркерами, через TaskGroup, через TaskSet. Не пора ли этому паттерну получить собственное имя и уместиться в одну строчку?