Программирование SwiftКонкурентностьРазработчик приложений на Swift

Две задачи вызывают одну и ту же не изолированную async функцию: что гарантирует Swift об одновременности е...

Две задачи вызывают одну и ту же не изолированную async-функцию: что гарантирует Swift об одновременности её выполнения?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Swift не гарантирует последовательное выполнение вызовов одной и той же не изолированной async-функции. Две задачи могут выполнять её одновременно, поэтому доступ к общему изменяемому состоянию внутри функции должен быть защищён изоляцией: actor, глобальным актором, Mutex или другим корректным механизмом синхронизации.

Само наличие async означает возможность приостановки, но не последовательность, взаимное исключение и не привязку к одному потоку.

Исторический контекст

Модель async/await в Swift появилась как структурированный способ описывать приостанавливаемые операции вместо ручной работы с callback-цепочками и очередями GCD. Она разделяет две задачи: async описывает потенциальную приостановку, а tasks определяют конкурентное выполнение.

Для защиты состояния Swift предоставляет отдельные механизмы — actors, глобальные акторы и безопасные синхронные примитивы. Такое разделение не позволяет ошибочно считать любую асинхронную функцию автоматически сериализованной.

Постановка проблемы

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

Например, операция «прочитать значение, увеличить его и записать обратно» не становится атомарной только потому, что объявлена как async. Приостановка между шагами или параллельное выполнение двух вызовов может привести к гонке данных и потере обновления.

Подробное решение

Неизолированная async-функция выполняется в контексте вызывающей задачи. После await она может продолжить работу позже и, как правило, не обязана использовать тот же поток. Если функцию вызывают две задачи, Swift не создаёт вокруг неё общий замок и не ставит вызовы в очередь.

Пример показывает допустимое перекрытие двух вызовов:

func work() async { print("start") try? await Task.sleep(for: .milliseconds(100)) print("finish") } func launch() { Task { await work() } Task { await work() } }

Обе задачи могут вывести start до того, как какая-либо из них выведет finish. Порядок вывода не следует считать фиксированным: планировщик выбирает, когда запускать и возобновлять задачи.

Если состояние принадлежит actor, доступ к его изолированным свойствам сериализуется исполнителем этого actor. Если состояние должно изменяться синхронно, без await, может подойти Mutex. Для неизменяемых значений безопасная передача обычно достигается через Sendable, но Sendable не превращает произвольный общий изменяемый объект в потокобезопасный.

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

Ситуация из практики

Сервис обновления токена хранит срок действия и сам токен в обычном ссылочном объекте. Два параллельных сетевых запроса видят истёкший срок и одновременно запускают обновление, после чего один результат может затереть другой.

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

Практичное решение — поместить состояние сервиса в actor, а сетевой вызов организовать так, чтобы состояние не удерживалось в виде блокировки во время ожидания. Это сериализует критические переходы состояния и сохраняет возможность конкурентного выполнения независимых операций. Цена решения — асинхронные обращения к actor и необходимость учитывать его реентерабельность в местах с await.

Что кандидаты часто упускают

  1. Гарантирует ли async-функция последовательность операций внутри одного вызова?

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

  1. Достаточно ли сделать функцию async, чтобы устранить гонку при увеличении общего счётчика?

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

  1. Если два вызова выполняются на одном потоке, означает ли это отсутствие гонки данных?

Нет. Совпадение потоков в конкретном запуске не является контрактом Swift. Задачи могут приостанавливаться, возобновляться в другом месте и выполняться конкурентно; безопасность должна следовать из модели изоляции, а не из наблюдаемого поведения планировщика.