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

Время

Пауза и срок — два места, где отмена встречается со временем: 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 1
step 2
migration stopped
onError: DatabaseStopped
database closed
outcome: 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 1
step 2
migration stopped
onError: DatabaseStopped
database closed
outcome: 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().