Время
Пауза и срок — два места, где отмена встречается со временем: ctx.pause ждёт
и кончается в момент отмены задачи, а timeout отменяет задачу, когда выходит
её предел. Остальное об отмене — на странице «Отмена».
Код работает с базой страницы об отмене: database.readAll читает строки за 10
мс, а миграция идёт в три шага по 10 мс. Перед каждым шагом миграция читает
CancelToken, сигнал остановки клиента базы, а не тип этого пакета; найдя его
отменённым, она останавливается, бросая DatabaseStopped. Приложение один раз,
в main, называет эту остановку, как показывает раздел
«Токен через onCancel», и каждая
задача на этой странице идёт с этой строкой.
Строки под кодом показывают, что он печатает при запуске. База говорит, что
пишет, а printing, наблюдатель задач с миграцией, печатает то, что до него
дошло: onError: для ошибки. outcome: показывает, чем завершается
job.done. Провал, исход которого никто не наблюдает, уходит дальше к ответу,
а у printing ответ — это зона, как показывает раздел
«Ошибка, которую никто не ждёт»
страницы об исходах.
Дать времени пройти
Заголовок раздела «Дать времени пройти»Задача читает строки раз в секунду.
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: обычный await досиживает задержку.final job = Job<void>((ctx) async { while (true) { use(await ctx.abandonable(database.readAll)); await Future<void>.delayed(const Duration(seconds: 1)); }});Пользователь отменяет через 500 мс после старта, на середине задержки, и задача
кончается на 510 мс позже. Обычный await контрольной точкой не служит: тело
досиживает задержку и узнаёт об отмене на ctx.abandonable следующего оборота.
До тех пор cancel() не возвращается, и всё, что ждёт задачу, ждёт вместе
с ней.
Вторая попытка
Заголовок раздела «Вторая попытка»// Вторая попытка: таймер задержки переживает задачу.await ctx.abandonable( () => Future<void>.delayed(const Duration(seconds: 1)),);Теперь задержка стоит за контрольной точкой, и задача кончается в момент
отмены. Но abandonable прекращает ожидание, а не работу, а работой здесь
служит таймер. Future.delayed отменить нельзя, и его таймер идёт оставшиеся
510 мс, хотя его никто не ждёт. Программа, которой больше нечего делать,
не выйдет, пока он не сработает, а тест, который ищет незавершённые таймеры,
найдёт один.
Пауза самой задачи
Заголовок раздела «Пауза самой задачи»await ctx.pause(const Duration(seconds: 1));ctx.pause служит контрольной точкой, которая занимает время. Она сразу
бросает Cancelled, когда приходит отмена, как ctx.abandonable, и её таймер
снимается вместе с ней:
| Как тело ждёт | Задача кончается | Оставшийся таймер |
|---|---|---|
await Future.delayed(...) |
через 510 мс после отмены | нет |
ctx.abandonable(() => Future.delayed(...)) |
сразу | ещё 510 мс |
ctx.pause(...) |
сразу | нет |
Без длительности ctx.pause() возвращается на следующем обороте цикла событий:
в этом месте долгое вычисление пропускает другую работу и может услышать
отмену. Внутри ctx.uncancellable отмена придержана, как любая другая, и пауза
идёт целиком. Отрицательная длительность считается нулевой, как
у Future.delayed: в отличие от timeout, ctx.pause из-за неё не бросает.
Миграции дано 15 мс. Когда они вышли, задача должна остановиться так же, как
при отмене пользователем: токен останавливает миграцию, а discard закрывает
базу. Вручную здесь никто не отменяет.
Первая попытка
Заголовок раздела «Первая попытка»Задачу останавливает cancel, поэтому таймер вызывает его, когда предел вышел:
// Первая попытка: таймер лимита переживает задачу.const limit = Duration(milliseconds: 15);
final job = Job<Database>(observer: printing, (ctx) async { final database = await ctx.join( Database.open, discard: (database) => database.close(), ); final stop = CancelToken(); ctx.onCancel(stop.cancel);
await ctx.join(() => database.migrate(stop));
return database;});Timer(limit, job.cancel);step 1step 2migration stoppedonError: DatabaseStoppeddatabase closedoutcome: Cancelled(manual)Задача останавливается так же, как у пользователя в разделе
«Токен через onCancel» страницы
об отмене, а задачу, которая уже кончилась, вызов не трогает: cancel() с ней
ничего не делает. Но сам таймер живёт до предела, кончилась задача или нет.
Миграция, которая падает на плохой схеме, сразу кончает задачу Failed,
а таймер идёт оставшиеся 15 мс, хотя отменять ему нечего. Программа, которой
больше нечего делать, не выйдет, пока он не сработает, а тест, который ищет
незавершённые таймеры, найдёт один.
Вторая попытка
Заголовок раздела «Вторая попытка»Таймер снимается, когда задача кончается:
// Вторая попытка: чтение done не пускает сбой в зону.final timer = Timer(limit, job.cancel);job.done.whenComplete(timer.cancel);Оставшегося таймера больше нет, но чтение job.done наблюдает исход. Провал
на плохой схеме по-прежнему доходит до наблюдателя как onError, а до зоны
больше не доходит. Без наблюдателя его никто не слышит, и сообщать о нём
приходится коду, который снимает таймер.
Срок самой задачи
Заголовок раздела «Срок самой задачи»final job = Job<Database>(observer: printing, timeout: limit, (ctx) async { final database = await ctx.join( Database.open, discard: (database) => database.close(), ); final stop = CancelToken(); ctx.onCancel(stop.cancel);
await ctx.join(() => database.migrate(stop));
return database;});step 1step 2migration stoppedonError: DatabaseStoppeddatabase closedoutcome: Cancelled(timeout)timeout даёт задаче собственный срок. Когда срок выходит, задача отменяется
с причиной TimeoutCancelReason, у которой timeout равен пределу. Исход
говорит об этом: Cancelled(timeout), тогда как таймер первой попытки оставлял
Cancelled(manual), как у пользователя.
Срок действует как отмена через cancel(), а не как TimeoutException
из Future.timeout:
- Тело останавливается на следующей контрольной точке.
- Секция
uncancellableпридерживает запрос, пока не закроется, и тогда задача его принимает, раньше чем тело вернёт значение: тело, которое кончаетсяreturn ctx.uncancellable(...), кончаетсяCancelled(timeout), значение секции не доходит ни до исхода, ни доvalue, и регистрацииonDiscardсрабатывают. Кончиться, пока секция ещё придерживает запрос, может только тело, которое пошло дальше, не дождавшись секции: тогда запрос уходит вместе с таймером, и задача кончается тем, что вернуло тело. См. «Придержать отмену». - Уже отменённая задача сохраняет свою причину.
- Дети получают срок в
causeсвоейParentCancelReason.
Срок ограничивает тело и детей, которых задача ждёт после него, но не стек уборки. Как ядро обращается с таймером:
- Ядро ставит таймер, когда стартует тело, поэтому время до старта
не считается: например, ожидание
start()уJob.deferredилиctx.runу родителя. - Ядро снимает таймер до того, как стек уборки начнёт разматываться: отмена,
пришедшая туда, превратила бы задачу, которая сделала свою работу,
в отменённую и закрыла бы её значение через
discard. - Ядро снимает таймер и при любом другом конце задачи.
Снимая таймер, ядро ничего не наблюдает, поэтому с timeout провал задачи
по-прежнему доходит до зоны:
| Как задача получает предел | Оставшийся таймер | Провал задачи |
|---|---|---|
Timer(limit, job.cancel) |
ещё 15 мс | доходит до зоны |
job.done.whenComplete(timer.cancel) |
нет | до зоны не доходит |
timeout: limit |
нет | доходит до зоны |
Задача, которая ещё идёт, когда кончается тест, держит свой таймер, как любой
другой. Тест под fakeAsync или testWidgets кончает такую задачу, отменив её
или дождавшись, раньше, чем кончится сам.
Нулевой или отрицательный срок бросает ArgumentError, и так же бросает срок
вместе с cancellable: false: отмену по сроку задача вправе отклонить,
а задача с cancellable: false отклоняла бы её всегда.
Срок для одного шага
Заголовок раздела «Срок для одного шага»Страница уходит без миниатюры, если миниатюра рисуется дольше 15 мс,
а renderThumbnail рисует её 20. Отдельный шаг получает срок как собственный
ребёнок, сделанный через ctx.child, которого примет движок задачи:
final thumbnail = ctx.child<String>( renderThumbnail, key: 'thumbnail', timeout: const Duration(milliseconds: 15),);try { return 'page with ${await ctx.run(thumbnail)}';} on Cancelled catch (cancelled) { ctx.check(); if (cancelled.reason case TimeoutCancelReason(:final timeout)) { return 'page without a thumbnail after ${timeout.inMilliseconds} ms'; } else { rethrow; }}outcome: Done(page without a thumbnail after 15 ms)Ребёнок кончается Cancelled(timeout), и ctx.run бросает эту отмену в тело
родителя. Сам родитель не отменён: срок вышел у ребёнка. Ветка catch в Dart
отбирает только по типу, без шаблона и без when, поэтому она ловит любой
Cancelled. Первым в ней стоит ctx.check() и бросает отмену самой задачи,
потому что причина срока не говорит, чей это срок: задача, у которой
собственный timeout вышел до ctx.run, сразу получает от него свой
Cancelled(timeout) и без проверки приняла бы его за срок миниатюры и пошла бы
дальше. Шаблон стоит в if после неё: он узнаёт причину срока и достаёт его
длительность. else бросает дальше миниатюру, которую отменил кто-то другой.
Если ветки нет, срок ребёнка кончает родителя
Cancelled(body: Job(thumbnail): Cancelled(timeout)). Ключ нужен ради этой
строки: ребёнок без ключа печатается как Job().