Задача (Task) — это единица асинхронной работы в Swift Concurrency. Создание задачи — это самый простой способ внедрить асинхронный контекст в ваш код:
func setupView() { Task { self.data = await fetchData() } }
Используя Task, мы можем вызывать await и получать данные асинхронно, даже если сама функция setupView()
не помечена как async
.
Поскольку мы инициализируем их с помощью замыкания, стандартное* поведение при создании Task не сразу становится очевидным: они будут выполняться в том же потоке, в котором были созданы.
Давайте рассмотрим, как это повлияет на наш код.
Блокировка синхронными задачами
Недавно я помогал отлаживать странное зависание — замирание пользовательского интерфейса — которое проявлялось в методе viewDidLoad
(помните такой, детки?).
Виновником был синхронный вызов базы данных, который выполнял сложный запрос на чтение и обрабатывал результаты. Это блокировало наш пользовательский интерфейс, поскольку обработка должна была завершиться до появления экрана.
@MainActor override func viewDidLoad() { super.viewDidLoad() self.data = fetchDataFromDatabase() }
Попробуем первое очевидное решение: вывести этот код из главного потока. Наивный подход — создать Task и на этом закончить.
@MainActor override func viewDidLoad() { super.viewDidLoad() Task { self.data = fetchDataFromDatabase() } }
Помните, что Task обычно выполняется в потоке, из которого он был инстанцирован: если вы явно не разрешите переключение контекста с помощью await
, выполнение будет привязано к потоку, из которого он был вызван.
Поэтому, поскольку эта функция находится на @MainActor
, содержимое Task по-прежнему будет выполняться в главном потоке — первоначальное зависание UI не будет исправлено.
Мы можем создать собственный пример кода, чтобы увидеть этот принцип в действии.
Используя точки останова, мы можем просмотреть активные потоки во время работы нашей функции в навигаторе отладки Xcode. Задача выполняется в Thread 1, главном потоке, в котором происходит вся работа с пользовательским интерфейсом.
Очевидно, что Task не является серебряной пулей — он не сможет волшебным образом заставить медленную синхронизируемую нагрузку работать в фоновом потоке.
Переход между потоками
Кооперативный пул потоков (подобный Grand Central Dispatch) используется для оптимального распараллеливания — один поток на одно ядро процессора. Это означает, что рабочие нагрузки, выполняемые внутри Task, могут быть распределены на разные потоки в пуле.
Хотя Task позволяет нам создать этот асинхронный контекст, среда выполнения Swift Concurrency не будет переходить между потоками, пока код внутри Task не будет разбит на await
.
Await
обозначает точку приостановки. Код, вызывающий async
метод, сохраняется в куче в асинхронном фрейме и после этого может быть возобновлен для продолжения работы.
Мы можем проиллюстрировать эту приостановку с помощью навигатора отладки — давайте запустим реальную асинхронную рабочую нагрузку, получив некоторые данные из интернета.
Эта задача начинается в основном потоке, но задача получения данных URLSession
переходит в фоновый Thread 9, чтобы дождаться ответа сети. После получения ответа оставшаяся часть работы выполняется в Thread 1.
При использовании таска от главного актора она все еще может перескакивать между различными потоками для выполнения асинхронной работы и избегать блокировки потока пользовательского интерфейса ожиданиями.
Для функций, не помеченных @MainActor
, вы можете обеспечить выполнение Task в главном потоке, инициализировав его следующим образом:
Task { @MainActor in // ... }
Вернемся к нашей блокирующей синхронной нагрузке в viewDidLoad()
: мы можем преобразовать запрос к базе данных и трудоемкую обработку в асинхронную функцию. Это позволит вывести обработку из основного потока, пока задача ожидает результатов.
Отделенные задачи
Есть еще один подход, который помогает проиллюстрировать, что происходит с реальными потоками. Давайте посмотрим, что произойдет, если мы создадим отделенную (Detached) задачу в функции @MainActor
.
Отделенная задача не привязана к какой-либо существующей иерархии задач или потоку. Когда мы установим точку останова, то обнаружим, что она выполняет свое содержимое в Thread 11, фоновом потоке.
Чтобы заполнить пользовательский интерфейс, нам также нужно вернуть результаты на главный агент с помощью await MainActor.run { }
, чтобы избежать обновления пользовательского интерфейса из фонового потока:
Task.detached { [weak self] in let data = self?.fetchDataSynchronously() await MainActor.run { self?.data = data } }
Отделенные задачи добавляются в глобальный исполнитель — немного похоже на глобальную очередь в Grand Central Dispatch. Хотя они не блокируют поток пользовательского интерфейса, рабочая нагрузка может быть выполнена не сразу — мы можем установить уровень приоритета (например, .low
, .medium
или .high
), чтобы подтолкнуть систему в нужном направлении.
Почему в самом начале мы говорили о «стандартном» поведении?
Я рад, что вы заметили.
Итак, вероятно, в 99.9% случаев, когда вы инстанцируете задачу, она будет выполняться в том потоке, в котором была создана.
Но при использовании пула потоков система сама определяет, какая работа где запланирована, и технически работа над задачей может быть запланирована в другом потоке. Вам просто нужно создать огромную нагрузку на систему.
В разделе «Высокопроизводительные Swift приложения» я вносил улучшения в свое приложение 2FA. Оно рассчитывало миллионы кодов TOTP на будущее, чтобы отправлять push-уведомления, когда вы получали крутые номера вроде 012345. Благодаря использованию более эффективных алгоритмов сопоставления чисел и внедрению параллелизма в вычисления, криптографическая обработка чисел стала в 20 раз быстрее.
Это также идеальное место для демонстрации того, как рантайм НЕ использует стандартное поведение.
Здесь, как и в исходном примере, мы запускаем функцию в главном потоке. Как и ожидалось, наш исходный оператор печати выполняется в Thread 1.
Но на самом деле наша задача выполняется в Thread 14. Рантайм запланировал ее запуск в фоновом потоке, потому что было выполнено очень много работы.
Контекст?
Блокировка процессора множеством параллельных задач, вычисляющих 2FA-коды.
Но если вы не планируете одновременно тонну работы, то вполне можете ожидать стандартного поведения.
Это может быть лучшей эвристикой:
Моя задача, вероятно, будет выполняться в потоке, в котором она создана, однако я не должен использовать какие-либо опасные операции, такие как
assumeIsolated
, основываясь на этом предположении.
Заключение
Задачи чрезвычайно полезны для выполнения асинхронной работы, но они могут иметь странные ограничения, если мы не до конца понимаем их поведение.
Task
обычно начинает выполняться сразу в том потоке, в котором он определен. Это означает, что если он запущен в главном потоке, то и выполняться он должен в главном потоке.- Функции
await
выступают в качестве точки приостановки, когда работа может покинуть главного агента и выполняться в другом потоке. Task.detached
передает работу глобальному исполнителю для планирования на любом доступном глобальном потоке.
Использование навигатора отладки с точками останова помогает нам увидеть сквозь высокоуровневые абстракции среды выполнения Swift Concurrency базовые POSIX-потоки, которые на самом деле выполняют нашу работу.
Хотя такое поведение Task является стандартным, можно перегрузить нашу систему и наблюдать другую ситуацию. Поэтому, вероятно, нам следует избегать выполнения небезопасных операций, основанных на этом предположении.
Надеюсь, теперь вы лучше понимаете, как работают задачи в Swift Concurrency, и что на самом деле происходит под капотом в ваших асинхронных рабочих нагрузках.