Site icon AppTractor

Что же «структурированного» в Structured Concurrency?

Только спустя несколько лет работы со структурированным параллелизмом (Structured Concurrency) я наконец понял, к чему именно относится слово структурированный (structured). И раз уж для меня это оказалось неожиданностью, думаю, другим тоже может быть интересно.

Интуитивно мне казалось, что всё внутри современной модели параллелизма в Swift должно считаться structured, особенно Task. Ну правда: у задач есть хендлы, их можно отменять — по сравнению с dispatch_async или pthreads это выглядит довольно структурированно. Но, почитав документацию, я понял, что ошибался:

Выполняет переданную (не)выбрасывающую ошибку операцию асинхронно как часть новой неструктурированной задачи верхнего уровня.

Так что же тогда является структурированным? Ключ — во второй части термина: concurrency. В стандартной библиотеке есть всего три способа создать конкурентно выполняемую операцию, и Task — только первый из них. Два других — это async let и TaskGroup, и именно они как раз относятся к структурированному параллелизму.

Так что же делает параллелизм structured? Я бы сформулировал это так: это прямая и неизбежная зависимость. Task всегда можно запустить и потом просто о ней забыть — это уже не структурно. Но когда вы используете async let, вы обязаны дождаться результата через await до того, как функция завершится, либо отбросить его явно (об этом ниже). Аналогично, task groups можно создавать только через with(Throwing)TaskGroup, а это заставляет вызывающий код дождаться их завершения.

У этой зависимости есть практическое следствие: если вызывающий код для async let или task group отменяется, то и его подзадачи тоже будут отменены. Но для задач верхнего уровня это не так.

Чтобы это проиллюстрировать, давайте рассмотрим несколько примеров.

Обычный Task — это unstructured

Начнём с простого примера с двумя задачами, где вторая создаётся из первой. Затем мы отменяем первую и видим, что вторая при этом не отменяется:

let a = Task {
  let b = Task {
    …
  }
}
a.cancel()
// "b" is NOT cancelled!

Это происходит потому, что Task всегда является top-level и ни от чего не зависит. Граф зависимостей для этого примера выглядит так:

→ Task a
→ Task b

Появление структуры

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

func load() async -> Void {…}

let a = Task {
   async let b = load() // implicitly a subtask
   async let c = load() // implicitly a subtask
   _ = await (b, c)
}
a.cancel()
// "async let b", "async let c" cancelled

У нас есть асинхронная функция load, которая вызывается из таска. В отличие от обычных вызовов await, async let позволяет вычислять результаты параллельно, фактически создавая одну параллельную подзадачу для каждого вызова. Но поскольку вызывающая задача зависит от этих подзадач, ее отмена автоматически перенаправляется. Таким образом, структура зависимостей выглядит следующим образом:

→ Task a
  ↳ async let b
  ↳ async let c

Возможно, вам интересно, что произойдет, если вы отбросите ссылку на результат async let, вот так:

let a = Task {
   async let _ = load()
}

Хотя результат нигде не используется, функция всё равно вызывается — так же, как и при любом другом вызове с отброшенным результатом. И хотя задача не ожидает результата, при своём завершении она всё равно отменяет подзадачи. Зависимость здесь существует и соблюдается.

Пересечение терминологии

Если посмотреть на третий вариант — (Throwing)TaskGroup, — мы сталкиваемся с некоторым терминологическим пересечением. Группы тоже создают таски, но, в отличие от отдельных Task, они являются структурированными. Думаю, на практике это не такая уж большая проблема, если посмотреть, насколько по-разному задачи добавляются в группы:

let a = Task {
  await withTaskGroup { group in
    group.addTask {} // b
    group.addTask {} // c
  }
}
a.cancel()
// "group", "b" and "c" also cancelled!

Внешняя задача обязана дождаться завершения группы, а группа, в свою очередь, обязана дождаться завершения всех своих тасков. То есть и здесь существует зависимость, а отмена распространяется снаружи внутрь:

→ Task a
  ↳ TaskGroup
    ↳ Task b
    ↳ Task c

Ручная настройка зависимостей

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

let a = Task {
  let b = Task {}
  
  await withTaskCancellationHandler {
    await b.value
  } onCancel: {
    b.cancel()
  }
}
a.cancel()
// "b" also cancelled

Я подробнее разбирал обработчики отмены в другом месте, но здесь важен следующий момент: a.cancel() синхронно выполнит обработчик onCancel, а тот, в свою очередь, синхронно отменит b. Поэтому после завершения a.cancel() гарантируется, что b тоже уже отменена.

По возможности избегайте Task

Я бы сказал, что в большинстве случаев top-level таски вам, скорее всего, вообще не нужны. Да, Task — мощный инструмент. Но из-за своих нюансов с ним не так просто работать корректно. Обычно есть более простые решения со встроенными гарантиями — например, async let или TaskGroup. А иногда достаточно и обычных замыканий.

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

func subtaskUpdate() async {
  let subtask: Task<Void, Never> = …
  
  await withTaskCancellationHandler {
    await subtask.value
  } onCancel: {
    subtask.cancel()
  }
  
  if !Task.isCancelled {
    update()
  }
}

Но потом я понял: вместо того чтобы передавать полноценный Task, я могу просто передать его тело в виде замыкания. Код стал гораздо проще:

func subtaskUpdate() async {
  let subtask: () async -> Void = …
  
  await subtask()
  
  if !Task.isCancelled {
    update()
  }
}

В виде closure такая подзадача не требует ручной обработки отмены, потому что выполняется внутри вызывающего контекста. Дополнительный плюс в том, что клиент сам решает, запускать ли её и когда именно, тогда как Task всегда стартует независимо. Это важно и с точки зрения тестируемости: API, которое возвращает замыкание, тестировать гораздо проще, чем API, которое возвращает Task.

Итоги

На этом моя мини-серия о задачах и отменах заканчивается. Если я чему-то и научился, пока писал эти статьи, так это тому, что перед нами довольно сложная область с множеством нюансов, в которых легко ошибиться. Складывается ощущение, что здесь слишком легко “выстрелить себе в ногу”, например создав задачи, которые никогда не завершаются. Если вы хотите получить хорошо предсказуемый, тестируемый код с изоляцией между доменами, придётся быть очень внимательным к деталям.

Вот правила, к которому я пришёл для наших проектов на будущее:

Спасибо, что прочитали. И дайте знать, что думаете 😊

Источник

Exit mobile version