Дети
Тело может отдать работу другой задаче тремя способами:
- Тело запускает ребёнка через
ctx.runи дожидается его или черезctx.startи оставляет его исход тому, кто его держит. Несколько детей, которых ждут вместе, составляют группу. - Стрим обрабатывается по одному событию в ребёнке, которому принадлежит подписка.
- Продолжение, созданное через
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 rowsJob(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 не завершается, а сам он держит
тело со всем, что тело захватило. Ребёнка, который не будет запущен, отменяет
тот, у кого он в руках.
Когда отменяют родителя
Заголовок раздела «Когда отменяют родителя»Пользователь уходит с экрана, пока строки грузятся, и родителя отменяют:
cancelJob(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),);cancelJob(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, в описании которого назван ребёнок.
Ребёнок без await
Заголовок раздела «Ребёнок без await»Задача загружает строки и одновременно прогревает кэш. Кэш здесь никто не читает, а родитель всё равно должен кончиться только после него.
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: 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 rowsJob(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 посмотрел.
Запуск без future
Заголовок раздела «Запуск без future»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 rowsJob(cache): Failed(Bad state: cold)Job(parent): Done(null)zone: Bad state: coldctx.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);cancelJob(migration): Cancelled(parent)zone: DatabaseStoppedJob(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 отклоняет ребёнка»Чего не видно по типу 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 для задачи, вся работа которой в стриме.