Перейти к содержимому

Дети

Тело может отдать работу другой задаче тремя способами:

  1. Тело запускает ребёнка через ctx.run и дожидается его или через ctx.start и оставляет его исход тому, кто его держит. Несколько детей, которых ждут вместе, составляют группу.
  2. Стрим обрабатывается по одному событию в ребёнке, которому принадлежит подписка.
  3. Продолжение, созданное через then, стартует, когда задача, за которой оно стоит, завершится успешно, и живёт дольше неё.

Слова для этих трёх похожи, и неверное компилируется так же, как верное. Каждое на своём месте:

final job = Job<void>((ctx) async {
// Ребёнок: тело его запускает, дожидается и отменяет вместе с собой.
final rows = await ctx.run(
Job.deferred<int>((c) => c.abandonable(loadRows)),
);
// Стрим: по одному событию, в собственном ребёнке.
final saving = ctx.each(events, (c, e) => c.join(() => save(e)));
// Цепочка: стартует сама, когда источник завершится успешно,
// и ребёнком её не может взять никакой родитель, поэтому тело ждёт её,
// как любой future.
final tail = saving.then<void>((c, _) => report(rows));
await ctx.abandonable(() => tail.value);
});

В разделах ниже строки под блоком кода — то, что этот код печатает:

  • cancel: пользователь отменяет родителя;
  • Job(…): сообщение, которое записало тело, или то, чем кончилась задача, как их печатает наблюдатель Log из раздела «Наблюдатель» страницы о наблюдении;
  • zone: ошибка, дошедшая до зоны неперехваченной.

Задача загружает строки в ребёнке и записывает в лог, сколько их. У задачи есть наблюдатель Log, а у ребёнка своего нет:

final parent = Job<void>(key: 'parent', observer: Log(), (ctx) async {
final child = Job.deferred<int>(key: 'rows', (c) => c.abandonable(load));
final rows = await ctx.run(child);
ctx.log('$rows rows');
});
Job(rows): Done(21)
Job(parent): 21 rows
Job(parent): Done(null)

ctx.run(child) сразу запускает ребёнка и возвращает future его значения. Future завершается, когда ребёнок закончился, вместе с его собственными детьми и уборкой. Ребёнок берёт наблюдателя родителя, поэтому наблюдатель печатает и исход ребёнка. Ребёнок со своим observer остаётся при нём. Родитель ждёт каждого ребёнка, которого запустил, и только потом кончается. Тело держит child, чтобы отменить его через child.cancel() или прочесть его исход из child.done.

Ребёнка создают через Job.deferred: такая задача оставляет свой старт тому, кто её запускает. ctx.run принимает ChildJob: задачу из Job.deferred или задачу движка, который его объявил. Job(body), then и Job.each создают задачи, которые стартуют сами. Такая задача могла бы стартовать раньше, чем родитель её примет, и работать вне его отмены, поэтому её не пропустит компилятор.

Код, у которого есть контекст, но который не знает стоящего за ним движка, делает ребёнка через ctx.child. Он принимает то же, что Job.deferred: тело, key, describe и timeout. В ядре это и есть Job.deferred. Движок, который сам владеет своими детьми, например solo, отвергает Job.deferred и делает там своего ребёнка, так что ctx.run(ctx.child((c) => c.abandonable(load))) работает одинаково под любым движком. Правила ребёнка — те, что у контекста, который его сделал, а родитель — задача, которая его запускает.

ctx.child ничего не запускает и ничего не проверяет: ctx.run, ctx.start и ctx.runAll отвергают то же, что отвергают для любого ребёнка. Пока ребёнок не запущен, он принадлежит тому, у кого он в руках. Никакая задача его не ждёт и не отменяет, его срок не идёт, его done не завершается, а сам он держит тело со всем, что тело захватило. Ребёнка, который не будет запущен, отменяет тот, у кого он в руках.

Пользователь уходит с экрана, пока строки грузятся, и родителя отменяют:

cancel
Job(rows): Cancelled(parent)
Job(parent): Cancelled(manual)

Родитель передаёт отмену ребёнку и кончается после него. Ребёнок ждёт load через ctx.abandonable, поэтому кончается сразу, а load работает дальше, и его никто не ждёт.

Ребёнок, созданный с cancellable: false, отклоняет отмену:

final child = Job.deferred<int>(
key: 'rows',
cancellable: false,
(c) => c.abandonable(load),
);
cancel
Job(rows): Done(21)
Job(parent): Cancelled(manual)

Ребёнок загружает строки, а родитель его ждёт. Прежде чем отдать значение телу, ctx.run проверяет отмену родителя. Родитель отменён, поэтому ctx.run бросает Cancelled родителя, и строка, которая пишет число строк в лог, не выполняется. Если тело уже кончилось, не дождавшись future, поздний успех возвращает значение без этой проверки, и ctx.runAll возвращает значения так же.

Что родитель делает со своими детьми, зависит от того, как он кончается:

Что происходит с родителем Его дети Родитель кончается
Его отменяют Отменяются, кроме созданных с cancellable: false Cancelled, когда они закончатся
Его тело бросает Cancelled Отменяются так же Cancelled, когда они закончатся
Его тело бросает другую ошибку Работают до конца Failed, когда они закончатся
Его тело возвращается Работают до конца Done, когда они закончатся

Ошибка ребёнка приходит через future, которую вернул ctx.run, со стеком ребёнка, и его Cancelled тоже. Cancelled ребёнка, который тело выпускает из await ctx.run(child) или child.value, отменяет родителя. Тогда родитель кончается с BodyCancelReason, в описании которого назван ребёнок.

Задача загружает строки и одновременно прогревает кэш. Кэш здесь никто не читает, а родитель всё равно должен кончиться только после него.

// Первая попытка: ignore() теряет провал кэша.
final parent = Job<void>(key: 'parent', observer: Log(), (ctx) async {
final rows = Job.deferred<int>(key: 'rows', (c) => c.abandonable(loadRows));
final cache = Job.deferred<void>(
key: 'cache',
(c) => c.abandonable(warmCache),
);
ctx.run(cache).ignore();
final count = ctx.run(rows);
ctx.log('${await count} rows');
});

Оба ребёнка запускаются сразу, и родитель ждёт обоих, прежде чем кончиться, дождалось их тело или нет. Когда кэш падает, лог это показывает, и только:

Job(rows): Done(21)
Job(parent): 21 rows
Job(cache): Failed(Bad state: cold)
Job(parent): Done(null)

Future от ctx.run — обычная future Dart. Она бросает ошибку ребёнка, если ребёнок упал, и его Cancelled, если ребёнка отменили. Чтобы её завершить, ctx.run наблюдает исход ребёнка, поэтому ядро за провал не отвечает: его несёт future. ignore() выбрасывает future, а с ней и ошибку. Наблюдатель всё равно слышит её в onError, но никто за неё не отвечает. cache.ignoreFailure() ничего бы не изменил: он снимает ответ за провал, на который никто не посмотрел, а ctx.run посмотрел.

final parent = Job<void>(key: 'parent', observer: Log(), (ctx) async {
final rows = Job.deferred<int>(key: 'rows', (c) => c.abandonable(loadRows));
final cache = Job.deferred<void>(
key: 'cache',
(c) => c.abandonable(warmCache),
);
ctx.start(cache);
final count = ctx.run(rows);
ctx.log('${await count} rows');
});
Job(rows): Done(21)
Job(parent): 21 rows
Job(cache): Failed(Bad state: cold)
Job(parent): Done(null)
zone: Bad state: cold

ctx.start(child) запускает ребёнка сразу, как ctx.run, и ничего не возвращает. Исход ребёнка никто не наблюдает, поэтому его провал идёт путём провала, который никто не наблюдал: в onUnanswered его наблюдателя, если тот подмешивает JobAnswerer, а иначе в зону, как здесь. Отмена ребёнка не уходит никуда, и ни то ни другое не меняет исход родителя. Если провал никому не нужен, это говорит cache.ignoreFailure(), и наблюдатель всё равно слышит его в onError, как описано в разделе «Сказать ядру, что она обработана».

Ребёнок мигрирует базу, и отмена останавливает миграцию на её токене, как в разделе «Токен через onCancel» страницы об отмене. Его запускает родитель вроде того, что в разделе «Запуск ребёнка». Приложение не назвало DatabaseStopped в Job.isCancellationError, и пользователь уходит с экрана, пока идёт миграция:

final child = Job.deferred<void>(key: 'migration', (ctx) async {
final stop = CancelToken();
ctx.onCancel(stop.cancel);
await ctx.join(() => database.migrate(stop));
});
await ctx.run(child);
cancel
Job(migration): Cancelled(parent)
zone: DatabaseStopped
Job(parent): Cancelled(manual)

Миграция останавливается, бросая свою DatabaseStopped, и тело ребёнка падает с ней. Ребёнок всё равно кончается Cancelled, и await ctx.run(child) бросает этот Cancelled. Ошибка тела с ним не приходит. Она уходит туда, куда уходит ошибка, которую не несёт ни один исход: к наблюдателю ребёнка и, если он не перехватит её в onUnanswered, в зону. Весь путь — в разделе «Куда уходят ошибки» страницы об ошибках.

Не пустить эту ошибку в зону могут три вещи:

  • Job.isCancellationError, где приложение один раз называет эту ошибку. Тогда она не идёт дальше наблюдателя ребёнка, как в разделе «Ошибки отмены».
  • Наблюдатель ребёнка, который перехватывает ошибку: JobAnswerer с переопределённым onUnanswered.
  • child.ignoreFailure(), который гасит ошибку, какой бы она ни была.

ctx.run(child).ignore() здесь не помогает. Он гасит то, что бросает future, а future бросает отмену.

Чего не видно по типу ctx.run, он отклоняет синхронно, до того как ребёнок начнёт работать:

Ребёнок Что делает ctx.run Что происходит с ребёнком
Задача другой реализации Бросает ArgumentError Ничего
Задача, которая уже запущена или уже закончилась Бросает StateError Ничего
Любая задача после того, как тело родителя завершилось Бросает StateError Ничего
Любая задача, когда родитель уже отменён Бросает Cancelled родителя Её отменяют до старта

Ребёнок, отклонённый с ArgumentError или StateError, остаётся у кода, который его создал. Job.deferred остаётся незапущенной, а её done ждёт, пока этот код её не запустит или не отменит.

ctx.runAll до первого старта делает те же проверки для каждой задачи своего списка. Ещё он отклоняет одну и ту же задачу дважды с ArgumentError. Отказ с ArgumentError или StateError оставляет все задачи списка как были: группа не запускает ни одну из них, исправные тоже, и done каждой ждёт, пока создавший её код её не запустит или не отменит. Тело, которое поймало отказ и ждёт задачу из списка, ждёт вечно. Под отменённым родителем каждую задачу списка отменяют до старта. Так же отменяют и остаток списка, когда ветка отменяет родителя до своего первого await.

Список читается один раз, копией, до первого старта. Некоторые отказы приходят, только когда задачу принимают: от движка, который отклоняет родителя, или от правила движка, которое бросает исключение. Такой отказ может прийти, когда часть веток уже запущена: группа останавливает их и ждёт, а задачи после отклонённой так и не стартуют. Пустой список возвращается пустым, после проверок, которые делает ctx.run.

Дерево детей может быть настолько глубоким, насколько позволяет стек. Каждый уровень здесь запускает следующий как ребёнка:

ChildJob<void> level(int depth) {
return Job.deferred<void>((ctx) async {
// Без этого await вся цепочка строится на одном стеке.
await null;
if (depth > 0) await ctx.run(level(depth - 1));
});
}

Стек расходуют два обхода дерева, и глубину может ограничить любой из них.

Первый обход строит дерево. Запуск ребёнка выполняет его тело до первого await. Без await null выше каждый уровень создаёт своего ребёнка раньше, чем приостанавливается, и вся цепочка строится на одном стеке. В Dart VM на десктопе этот стек кончается примерно на тысяче уровней, когда дерево ещё строится. Такое дерево всё равно кончается: ctx.run бросает StackOverflowError в тело, которое его вызвало. Задачи, чей старт оборвало переполнение, кончаются, когда кончится это тело.

С await null каждый уровень строится в своей микротаске, и предел переходит ко второму обходу, к отмене. Отмена спускается по дереву рекурсией, и стек у неё кончается примерно на трёх тысячах уровней. Ни то ни другое число не обещание: оба следуют из размера кадра тела.

Отмена, у которой кончился стек, останавливается там, где он кончился, а задачи ниже этой глубины продолжают работать. Переполнение выходит из самого cancel(), раньше, чем тот вернёт future, поэтому cancel().ignore() его не ловит. Рекурсии, измеряемой тысячами вложенных задач, нужен не стек поглубже, а плоская структура.

ctx.each(stream, onData) подписывается на стрим и обрабатывает события по одному в дочерней Job<void>, которой принадлежит подписка. Родитель ждёт этого ребёнка, даже если его тело вернулось, не ожидая ребёнка. Остальное на странице о стримах: что вместо этого делают await for и listen в теле, как колбэк останавливается по отмене, чья уборка у одного события и Job.each для задачи, вся работа которой в стриме.