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

Правила уборки

«Уборка» выбирает между dispose и discard и говорит, что может колбэк. Эта страница ведёт правила дальше: ресурс, который передают из задачи в задачу, колбэк, зарегистрированный без вызова, и то, что встречает уборка задачи после возврата из тела.

Строки под кодом — то, что он печатает при запуске. База, её кэш и курсор сообщают, что с ними происходит, cancel — момент, когда пользователь отменяет задачу, а outcome: — то, чем задача кончилась.

Ресурс может открыть одна задача, а получить другая: connect открывает базу, ready делает на ней миграцию и передаёт базу дальше. У того, кто держит базу последним, должна остаться возможность её закрыть.

connect зарегистрировала освобождение, поэтому база выглядит прикрытой, куда бы она ни попала. Миграция падает:

// Первая попытка: discard не уходит со значением, и база не закрывается.
final connect = Job.deferred<Database>(
key: 'connect',
(ctx) =>
ctx.abandonable(Database.open, discard: (database) => database.close()),
);
final ready = Job.deferred<Database>((ctx) async {
final database = await ctx.run(connect);
await ctx.join(() => database.migrate());
return database;
});
database opened
database migration failed
outcome: Failed(MigrationFailed)

База остаётся открытой, а закрыть её некому. Вот что происходит:

  1. connect кончается Done: она передала своё значение, поэтому её discard отбрасывается вместе со значением.
  2. discard регистрирует условно. Такую регистрацию решает исход той задачи, которая её сделала, а не той, к которой уходит значение, и получателю её не несёт ничто.
  3. Миграция падает, и ready кончается с базой на руках и без собственной регистрации.

Единственное исключение из второго пункта, ветка ctx.runAll, описано в разделе «Отмена после возврата из тела».

Отладочный канал, который включается строкой Job.debug = print;, называет каждую передачу, которая отбросила регистрацию, зарегистрировал получатель что-нибудь или нет, так что места, где действует это правило, читаются прямо из прогона. Для connect выше строка такая:

Job(connect) handed its value over: 1 conditional cleanup dropped

Получатель регистрирует полученное на той строке, где он его получает:

// Вторая попытка: контрольная точка в run может потерять значение раньше.
final ready = Job.deferred<Database>((ctx) async {
final database = await ctx.run(connect);
ctx.onDiscard(database.close);
await ctx.join(() => database.migrate());
return database;
});

Упавшая миграция теперь закрывает базу. Но пользователь отменяет ready как раз тогда, когда connect кончилась, и база снова теряется:

database opened
cancel
outcome: Cancelled(manual)

Вот что происходит:

  1. connect кончается Done и по дороге отбрасывает свою регистрацию.
  2. Со значением ребёнка на руках run проверяет родителя: не отменён ли он, а когда задачей управляет движок, построенный на ядре, например solo, то и правила этого движка. Проверка бросает исключение, здесь это отмена, и значение уходит вместе с ним.
  3. До строки с onDiscard дело не доходит, и базу не закрывает никто.
final ready = Job.deferred<Database>((ctx) async {
final database = await ctx.run(
connect,
discard: (database) => database.close(),
);
await ctx.join(() => database.migrate());
return database;
});

Та же отмена:

database opened
cancel
database closed
outcome: Cancelled(manual)

Переданная в run, уборка забирает значение в момент его прихода, и отнять его контрольная точка уже не может. Если точка бросает, run закрывает базу на месте и ждёт закрытия, прежде чем тело услышит отмену; если проходит, регистрация ложится на стек ready. Теперь ready закрывает базу, когда заканчивается чем угодно, кроме значения, а когда завершается успешно, передаёт её нетронутой получателю, который регистрирует её так же. Кто закроет базу, решает момент отмены:

Когда отменяют ready Вторая попытка Регистрация по приходу
Пока connect открывает базу connect, отменённая каскадом connect, отменённая каскадом
После того как connect кончилась Done, но до того, как run отдаст значение телу никто ready
Пока базу открывает connect с cancellable: false никто: connect всё равно кончается Done ready

Это верно для любой передачи, включая группу: ветка ctx.runAll регистрирует по приходу каждое значение, полученное от своих детей, как и все. Контрольная точка ctx.join после действия так же освобождает значение, что бы она ни бросила, включая правило движка, которое бросило исключение.

Закрывает базу получатель, тот, кто зарегистрировал её по приходу. То же значение остаётся доступным через Job.value ребёнка, пока кто-нибудь держит хэндл, но если закрыть его оттуда, оно закроется второй раз. Когда значение не доходит ни до какого получателя, закрывает тот, у кого хэндл. Один такой случай — ветка, отказавшаяся от остановки группы (раздел «Что группа отдаёт обратно»); другой — цепочка, чей then отменён в ожидании (страница «Цепочки»).

Там, где оборачивать нечего, колбэк регистрируют напрямую: ctx.onDispose и ctx.onDiscard. Правила исхода у них те же, а колбэк может делать и остальную завершающую работу, например сбрасывать буфер на завершении задачи:

final buffer = StringBuffer();
ctx.onDispose(() => sink.add(buffer.toString()));

Оба метода возвращают функцию, снимающую регистрацию. Пользуйтесь ей, когда ресурс уже освобождён или передан; вызвать её повторно или после выполнения уборки безопасно. abandonable, join и run возвращают сам ресурс, а не функцию снятия, и сделанную ими регистрацию снимает ctx.disown(value). Он ищет значение по идентичности и возвращает, нашёл ли его. Поэтому передавайте ровно тот объект, который вернул вызов.

readAll закрывает курсор, когда прочитает всё. Задача регистрирует закрытие, чтобы курсор был прикрыт, пока его не закроет чтение, и снимает регистрацию после чтения. Пользователь отменяет задачу во время чтения:

// Первая попытка: отмена во время readAll закрывает курсор дважды.
final removeDisposer = ctx.onDispose(cursor.close);
await ctx.join(() => cursor.readAll()); // в конце курсор закрывается
removeDisposer();
cancel
cursor read
cursor closed
cursor closed
outcome: Cancelled(manual)

Курсор закрывается дважды. Вот что происходит:

  1. join ждёт readAll, и тот читает и закрывает курсор.
  2. Задачу отменяют во время чтения, и join бросает отмену вместо значения, так что строка, которая снимает регистрацию, не выполняется.
  3. Регистрация остаётся на месте, и уборка закрывает курсор во второй раз.
final removeDisposer = ctx.onDispose(cursor.close);
await ctx.join(() async {
await cursor.readAll(); // в конце курсор закрывается
removeDisposer();
});
cancel
cursor read
cursor closed
outcome: Cancelled(manual)

Внутри действия эти два шага не разнести: что бы вызов ни бросил после, освобождение и снятие регистрации уже случились вместе. То же верно для значения, зарегистрированного самим вызовом: ctx.disown(value) стоит там, где меняется владение, а не строкой ниже.

Асинхронная передача и её ctx.disown идут вместе внутри одной ожидаемой секции ctx.uncancellable:

final transaction = await ctx.join(
Transaction.begin,
dispose: (transaction) => transaction.rollback(),
);
await ctx.uncancellable(() async {
await transaction.commit(); // голый await, а не ctx.join
ctx.disown(transaction);
});

Шаг — голый await, а не ctx.join, и между ним и ctx.disown не стоит ничего, что бросает отмену задачи: ни члена контекста, ни await child.value. Ведь отмену, которую создают правила движка, секция не придерживает.

Регистрация работает и после обычного await. Обычный await, в отличие от вызова ctx.run из раздела «Вторая попытка», контрольной точкой не служит: между вызовом и строкой под ним бросить нечему. Пользователь отменяет задачу, пока база открывается:

final job = Job<Database>((ctx) async {
final database = await Database.open();
ctx.onDiscard(database.close);
return database;
});
cancel
database opened
database closed
outcome: Cancelled(manual)

Отмена, пришедшая во время Database.open(), прервать его не может. Тело дожидается вызова, регистрирует открытое, и задача заканчивается Cancelled; onDiscard закрывает базу, потому что значение не дошло ни до какого вызывающего. Именно такое ожидание называет правило bare_await пакета async_job_lints: регистрация после него надёжна, но задача кончается не раньше вызова. Где тело ждёт через ctx.abandonable, ctx.join или ctx.run, отдайте колбэк этому вызову, как в разделе «Регистрация по приходу». Каждый из них служит контрольной точкой, и строка под ним может не выполниться никогда.

Тело вернулось, а дети и уборка у задачи ещё впереди. Она проходит их в таком порядке:

Шаг Что делает задача
Дети ждёт их всех: они могут пользоваться её ресурсами
Стек уборки разматывает его с последней регистрации, ожидая каждый колбэк
Вторая волна выполняет discard, который отмена сделала нужным задним числом
Конец исход и освобождение того, что держит движок

Что может колбэк, пока выполняется, — в конце раздела «dispose для того, что задача оставляет себе».

Задача может ещё ждать детей или выполнять уборку после return. Тело возвращает базу, пока ребёнок ещё прогревает кэш, и в этот момент пользователь отменяет задачу:

final job = Job<Database>((ctx) async {
final database = await ctx.join(
Database.open,
discard: (database) => database.close(),
);
// Кэш ждёт не тело, а задача.
ctx.start(Job.deferred<void>((c) => c.join(database.warmCache)));
return database;
});
database opened
cancel
cache warmed
database closed
outcome: Cancelled(manual)

Отмена, пришедшая в это время, меняет исход на Cancelled, и база, зарегистрированная через discard, закрывается вместо того, чтобы вернуться вызывающему. Здесь отмена приходит, пока задача ждёт кэш, поэтому discard выполняется на своём месте в стеке. Если же отмена приходит в размотку, когда стек уже прошёл мимо discard, тот выполняется второй волной. Вторая волна ставит этот discard после колбэков, зарегистрированных раньше него, а не в строгом обратном порядке регистрации.

Единственное исключение составляет ветка ctx.runAll. После того как группа отдала значения вызывающему, discard этой ветки больше не выполняется: ни второй волной, ни на отмене, пришедшей в саму размотку, ни как-либо ещё. Значение в руках вызывающего, а discard означает, что значение не досталось никому. Ради этого группа и держит ветку, тело которой вернуло значение. Держит она её, пока все ветки не вернут значения и не размотают свои стеки уборки или пока тело одной из них не кончится чем-то другим. Исхода до тех пор у ветки нет. Пока группа держит ветку, всё сказанное выше верно как написано: отмена, дошедшая до ветки, закрывает то, что ветка взяла.

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

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

Размотки этот колбэк не ждёт. Ребёнку, которого задача ещё ждёт, может понадобиться та же блокировка или тот же слот пула, и значение, отложенное до размотки, не отдало бы их ребёнку никогда. Освобождение, начатое до конца задачи, не даёт ей кончиться, пока не закончится само.

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

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

После стека ветки, в третьей строке, освобождение тоже идёт сразу. Ветка ctx.runAll ждёт группу дважды: перед своим стеком уборки и после него, пока свои стеки не размотают все ветки. Ко второму ожиданию стек уже размотан, включая закрытие пула, и освобождения ждёт только конец ветки. Если бы значение ждало второй волны, оно держало бы слот, нужный уборке другой ветки, которую ждёт группа, и группа ждала бы вечно.

В последней строке значение не позднее: тело бросило вызов без отмены, поэтому значение всё равно достаётся тому, кто держит future вызова, а его освобождение ждёт размотки на стеке. Ядро считает значение поздним с того момента, как узнаёт о конце тела, а не с его return, поэтому вызов, который выдаёт ресурс, бросать не стоит.

У вызова, который нарочно оставляют без await, тип пишут явно:

unawaited(
ctx.abandonable<Database>(
Database.open,
dispose: (database) => database.close(),
),
);

Без <Database> тип выводится из unawaited как void, и dispose приходится принимать void.