Обратно в блог
  • mobile

Будьте в курсе всех возможностей

Три неочевидные проблемы Swift Concurrency и как мы их решали в Яндекс Доставке

Привет! Меня зовут Ярослав Смирнов, я старший разработчик в iOS-SDK Яндекс Доставки. Я начинал писать ещё под первые iPhone и помню, что такое ручное управление памятью и отсутствие многозадачности. В нашей команде мы прошли тот же путь со всеми стадиями принятия Swift Concurrency, что и многие разработчики из комментариев на Stack Overflow и Reddit. Но в один момент мы собрались с силами и всё-таки адаптировали всю нашу кодовую базу для работы с новой технологией.

Сегодня я хочу поделиться самыми сложными и неочевидными моментами этой миграции. Мы поговорим о неизолированном deinit, вызовах из неадаптированного кода и работе с отложенными операциями — всё это на реальных примерах из нашего SDK. Цель этой статьи — не просто показать, какие классные решения мы придумали, а объяснить их логику, чтобы сэкономить вам время и сберечь нервы.

Почему мы решились на эти обновления?

01.png

Прежде чем переходить к описанию самого процесса, давайте коротко отвечу на вопрос, зачем мы вообще всё это начинали и стоило ли оно того в итоге? Несмотря на первоначальные трудности, мы в Яндекс Доставке верим, что structured concurrency — это будущее многопоточной разработки на платформах Apple. Для себя мы выделили четыре весомых аргумента, которые убедили нас, что игра стоит свеч.

  1. Контроль над data-races на этапе компиляции. Это фундаментальное изменение. Вместо того чтобы ловить трудновоспроизводимые крэши в рантайме, мы получаем союзника в лице компилятора. Он заранее подсвечивает места, где происходит небезопасное обращение к мутабельному состоянию, тем самым ловко превращая потенциальную ошибку в продакшене в поправимую ошибку сборки. А это, согласитесь, уже немало.
  2. Прозрачность контекста выполнения. С async/await и акторами в большинстве случаев вы можете с уверенностью сказать, где именно будет исполняться ваш код — на главном потоке или в бэкграунде. Эта предсказуемость избавляет от целого класса ошибок, связанных с неявным переключением очередей, и делает логику приложения более прямолинейной.
  3. Единая система отмены задач. Нам больше не нужно изобретать кастомные токены или пробрасывать флаги отмены через всё приложение. Встроенный API Task предоставляет унифицированный и надёжный механизм, который работает из коробки для всех асинхронных операций.
  4. Синхронная семантика асинхронного кода. Возможность писать асинхронный код так, будто он выполняется последовательно, — это огромный шаг вперёд. Вместо монструозных лестниц из вложенных колбэков или сложных конструкций с Promise и flatMap мы можем просто написать цикл for с await внутри. Код становится чище, понятнее и его гораздо проще поддерживать.

Шпаргалка по разметке типов

Чтобы миграция не превратилась в бесконечные споры на код-ревью, мы выработали простой алгоритм. Это наша внутренняя шпаргалка, которая помогает быстро принимать решения о разметке любого типа. Она состоит всего из двух ключевых вопросов.

02.png

Шаг 1: можно ли использовать тип из любого потока?

Это первый и главный вопрос, который мы себе задаём. Нужно ли нам работать с этим типом одновременно из разных concurrency-домейнов?

Если ответ «да» — используем Sendable.

Sendable — это протокол-маркер. У него нет никаких требований, он просто говорит компилятору: «Этот тип можно безопасно передавать между потоками». В первую очередь мы используем его для:

  • Простых моделей данных. Немутабельные структуры, которые описывают сущности.
  • DTO-структур. Объекты для маппинга JSON-ответов от сервера.
  • Фабрик и билдеров без внутреннего состояния. Типы, которые создают другие объекты, но сами не хранят изменяемых данных.

Если тип соответствует этим критериям, помечаем его Sendable и двигаемся дальше.

Шаг 2: если тип не Sendable, нужен ли ему фоновый поток?

Здесь важно озвучить главный принцип нашей миграции: мы не добавляем многопоточность и асинхронность там, где её не было раньше. Наша цель — использовать Swift Concurrency для более удобной и безопасной реализации уже существующих потребностей, а не создавать новые просто потому, что технология это позволяет.

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

  • Если фоновый поток не нужен — используем @MainActor.

@MainActor — это глобальный актор, который гарантирует, что весь код внутри типа будет выполняться на главном потоке. Поскольку мобильное приложение в первую очередь работает с UI, реагирует на действия пользователя и отображает данные, около 80% нашего кода так или иначе связано с main thread. Поэтому @MainActor идеально подходит для большинства объектов бизнес-логики, не говоря уже о компонентах View-слоя.

  • Если фоновый поток необходим — используем кастомный actor.

Если основная задача объекта — выполнение работы в бэкграунде, мы создаём для него кастомный actor. Актор — это специальный тип, который изолирует свой мутабельный state и выполняет свои методы в собственном приватном контексте. Любое обращение к нему извне по умолчанию будет асинхронным.

Мы используем кастомные акторы в двух случаях:

  1. Когда объект по своей природе предназначен для фоновой работы. Например, PostcardThumbnailProvider, который генерирует превью для открыток.
  2. Когда к коду объекта всегда обращаются из бэкграундного потока. Отличный пример — RetryPolicy для сетевых запросов, который вызывается из контекста URLSession.
actor PostcardThumbnailProvider {
    func getThumbnail(for postcard: Postcard) -> UIImage {
        ...
    }
}

actor RetryPolicy {
    func nextRetryAttempt(httpResponse: HTTPURLResponse) -> Bool {
        ...
    }
    func nextRetryAttempt(error: Error) -> Bool {
        ...
    }
}

Такой алгоритм покрывает подавляющее большинство наших сценариев. Он и есть та самая основа, от которой мы отталкивались при адаптации всей своей кодовой базы.

Три самые коварные проблемы миграции

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

Проблема №1: нелогичный неизолированный deinit

Это, пожалуй, самая назойливая проблема, с которой мы столкнулись. Давайте разберём её на конкретном примере, который связан с загрузкой заказов в приложении Яндекс Go.

Представим упрощённую архитектуру. У нас есть ViewStore, который готовит данные для UI, и Provider, который эти данные загружает с сервера.

@MainActor
class ViewStore {
    let provider: Providing
    
    // ...
    
    deinit {
        provider.stopPolling()
        provider.removeObserver(self)
    }
}

@MainActor
protocol Providing {
    // ...
}

Согласно нашему алгоритму, оба типа помечены @MainActor, так как они непосредственно связаны с UI. Но как только мы это делаем, компилятор начинает ругаться на код в deinit:

Call to main actor-isolated instance method 'stopPolling()' in a synchronous nonisolated context

Проблема в том, что метод deinit в акторах не изолирован. Он может быть вызван на любом потоке. Казалось бы, странное решение, почему разработчики языка сделали всё именно так?

03.png

Всё дело в механизме деинициализации. Когда счётчик ссылок на объект обнуляется, рантайм вызывает swift_release, затем синхронно выполняется deinit, и сразу после этого освобождается память. Ключевое слово здесь — синхронно. Последняя ссылка на ваш объект может исчезнуть на любом потоке. Если бы deinit нужно было принудительно выполнять в контексте своего актора, этот вызов пришлось бы делать асинхронно. А это нарушило бы базовый контракт деинициализации, принятый как в Swift, так и в Objective-C.

Итак, deinit может прийти с любого потока. Что с этим делать? Мы нашли три разных подхода в зависимости от ситуации.

Решение А: простое, для отложенных действий — Task

Рассмотрим первый вызов — provider.stopPolling(). Если нам не критично, чтобы остановка поллинга произошла строго синхронно с деинициализацией, мы можем просто обернуть вызов в асинхронную задачу и перевести его в нужный контекст.

deinit {
    Task { @MainActor [provider] in
        provider.stopPolling()
    }
    provider.removeObserver(self)
}

Просто, эффективно, и в большинстве случаев этого достаточно. Но этот трюк не сработает со второй строкой.

Решение Б: когда нужно передать self — «нет вызова, нет проблем»

В вызове provider.removeObserver(self) мы передаём ссылку на сам объект. Если обернуть его в Task, мы попытаемся использовать self уже после того, как deinit завершится. Так делать категорически нельзя.

Когда deinit запускается, у счётчика ссылок уже выставлен флаг isDeiniting. Рантайм перестаёт учитывать новые сильные ссылки, и попытка воскресить объект ни к чему не приведёт — сразу за deinit последует освобождение памяти, и мы получим доступ к мусору.

Как же быть? Иногда лучший способ решить проблему — это её избежать. Давайте посмотрим, как устроен ObserversSet — класс-коллекция, который наш Provider использует для хранения обзёрверов.

class ObserversSet<T> {
    var observers = NSHashTable<AnyObject>.weakObjects()
    // ...
}

Хранилищем для обзёрверов служит NSHashTable, инициализированный для хранения слабых ссылок. И это ключ к решению. После deinit память не освобождается мгновенно, если на объект есть слабые ссылки. Swift позаботится о том, чтобы эти ссылки не указывали на мусор. Это происходит с помощью очистки SideTable — места хранения слабых ссылок на объект. Важно то, что операции чтения слабой ссылки и уничтожения объекта взаимно потокобезопасны. Это значит, что если мы попытаемся прочитать слабую ссылку в момент деинициализации, мы либо получим nil, либо валидный объект, но никогда не получим крэш.

04.png

Поэтому в данном конкретном случае вызов removeObserver(self) можно просто удалить. NSHashTable сам позаботится об очистке мёртвой ссылки.

Решение В: сложное, но универсальное — выводим метод из изоляции

Хорошо, а что, если вызов в deinit всё-таки необходим? Представим, что наш Provider устроен так, что он запускает поллинг сети при появлении первого подписчика и останавливает его, когда уходит последний. Если мы не отпишемся, приложение продолжит генерировать ненужные сетевые запросы.

Удалить вызов нельзя. Перенести в Task тоже. Что остаётся?

Раз deinit может вызываться с разных потоков, давайте поступим с компилятором честно и признаем, что метод removeObserver тоже должен быть доступен с любого потока. Для этого помечаем его в протоколе как nonisolated.

@MainActor
protocol Providing {
    nonisolated func addObserver(_ observer: ProviderObserver)
    nonisolated func removeObserver(_ observer: ProviderObserver)
}

Теперь ошибка в deinit ушла, но появилась в реализации Provider. Компилятор справедливо ругается, что nonisolated-метод пытается изменить observers — свойство, изолированное на @MainActor.

05.png

Чтобы это исправить, нам придётся вернуться к истокам и вспомнить про старую добрую многопоточность. Мы превратим наш ObserversSet в потокобезопасный класс, используя NSLock.

  1. Делаем коллекцию @unchecked Sendable. Мы говорим компилятору, что берём потокобезопасность на себя.
  2. Добавляем NSLock. Создаём обычный мьютекс.
  3. Оборачиваем мутирующие операции в lock.withLock { ... }. Это защитит хранилище от одновременного доступа.
class ObserversSet<Observer>: @unchecked Sendable {
    var observers: NSHashTable<AnyObject> = .weakObjects()
    let lock = NSLock()

    func add(_ observer: Observer) {
        lock.withLock {
            observers.add(observer as AnyObject)
        }
    }

    func remove(_ observer: Observer) {
        lock.withLock {
            observers.remove(observer as AnyObject)
        }
    }
}

Теперь наш Provider может безопасно работать с ObserversSet из nonisolated-метода.

Казалось бы, всё? Почти. Компилятор выдаёт последнюю ошибку: Cannot access property 'provider' with a non-sendable type 'any Providing' from nonisolated deinit. Проблема в том, что пометив протокол @MainActor, мы гарантировали изоляцию только для его методов. Компилятор не знает, как реализован сам тип, который подчиняется протоколу, и не считает его потокобезопасным.

Чтобы это исправить, мы должны добавить констрейнт Sendable к самому протоколу. Так мы сообщим компилятору, что любой тип, реализующий Providing, будет потокобезопасным для передачи между домейнами. После этого все ошибки наконец уходят.

Проблема №2: обращение к актору из неадаптированного кода

Наше SDK — не отдельное приложение, оно встраивается в другие, и главный потребитель — Яндекс Go. Это огромный проект, и миграция на Swift Concurrency в нём идёт постепенно. И неизбежно возникают ситуации, когда наш новый, полностью адаптированный код должен взаимодействовать со старым, неадаптированным.

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

06.png

Проблема в том, что события в метрику могут отправляться откуда угодно:

  • С главного потока — например, при тапе на кнопку или скролле экрана.
  • С фонового потока — например, при обработке сетевой ошибки.

А наш объект Order, хранящий статус, изолирован на главном акторе.

@MainActor
class Order {
    var status: String
}

struct Reporter {
    let order: DeliveryOrder

    func report() -> String {
        order.status
    }
}

Компилятор тут же выдаёт ошибку: Main actor-isolated property 'status' can not be referenced from a nonisolated context. И он прав. Как нам безопасно и синхронно получить доступ к свойству из кода, который ничего не знает про async/await?

Первое, что приходит в голову, — асинхронный рефакторинг. Можно переделать метод report() так, чтобы он возвращал значение через completion-блок.

func report(completion: @escaping @Sendable (String) -> Void) {
    Task { @MainActor in
        completion(order.status)
    }
}

Это хороший и правильный подход. Мы сами им часто пользуемся. Но есть нюанс: если ваш метод используется в десятках, а то и сотнях мест по всему приложению, такой рефакторинг превращается в каскадное изменение огромного количества кода. Это не всегда приемлемо.

Ловушка №1: MainActor.assumeIsolated

В API Swift Concurrency есть функция MainActor.assumeIsolated. Она говорит компилятору: «Поверь мне на слово, этот код уже выполняется на главном потоке».

func report() -> String {
    MainActor.assumeIsolated {
        order.status
    }
}

Этот код скомпилируется, но упадёт в рантайме, как только событие метрики будет отправлено из фонового потока. assumeIsolated — это не механизм переключения потока, а лишь способ обмануть компилятор, когда вы на 100% уверены в контексте выполнения. Здесь это не так.

Ловушка №2: DispatchQueue.main.sync и потенциальный дедлок

Хорошо, а что, если проверять поток вручную? Если мы на главном — используем assumeIsolated, если нет — синхронно переключаемся на main через DispatchQueue.

func report() -> String {
    if Thread.isMainThread {
        MainActor.assumeIsolated {
            order.status
        }
    } else {
        DispatchQueue.main.sync {
            order.status
        }
    }
}

На первый взгляд, это выглядит надёжным решением. Но оно скрывает в себе бомбу замедленного действия — дедлок. Представьте себе такой сценарий:

  1. Некий код на главном потоке вызывает синхронную операцию на своей приватной очереди (queue.sync). Это один из часто применяемых способов синхронизации. Главный поток блокируется и ждёт.
  2. Код на этой приватной очереди выполняет какую-то логику и в какой-то момент отправляет событие в метрику, вызывая наш метод report().
  3. Наш код видит, что текущий поток — не главный, и пытается синхронно выполнить блок на main очереди (DispatchQueue.main.sync).
  4. Всё. Приватная очередь заблокирована в ожидании главного потока, а главный поток заблокирован в ожидании приватной очереди. Мы получили классический дедлок.
07.png

Я не говорю, что этот паттерн — абсолютное зло. Но использовать его можно только в очень локальных участках кода, где вы полностью контролируете всю цепочку вызовов и гарантируете, что такая ситуация никогда не возникнет. В общем случае это слишком рискованно.

Надёжное решение: снова nonisolated и NSLock

Если мы не можем гарантировать поток выполнения, то честнее всего — как и в случае с deinit — признать этот факт. Мы можем вывести свойство status из-под изоляции актора, разрешив доступ к нему с любого потока.

@MainActor
class Order {
    nonisolated(unsafe) var status: String
}

Теперь компилятор не ругается, но мы открыли ящик Пандоры — потенциальный data race. Решение, как вы уже догадались, кроется в старой доброй plain old cocoa style concurrency. Мы создаём приватное хранилище для нашего свойства и защищаем доступ к нему с помощью NSLock.

@MainActor
class Order {
    nonisolated(unsafe) var status: String {
        set { statusLock.withLock { self.unsafeStatus = newValue } }
        get { statusLock.withLock { self.unsafeStatus } }
    }
    nonisolated(unsafe) private var unsafeStatus: String
    let statusLock = NSLock()
}

Здесь мы превратили status в вычисляемое nonisolated-свойство. Оно служит потокобезопасной точкой доступа к приватному хранилищу unsafeStatus, а все операции чтения и записи защищены мьютексом statusLock. Теперь доступ к свойству безопасен как извне, из неадаптированного кода, так и изнутри самого актора. Это надёжное и предсказуемое решение, которое не боится дедлоков и работает в любой ситуации.

Проблема №3: опасный Timer

Последний кейс, который мы разберём, связан с задачей, знакомой каждому iOS-разработчику, — отложенными операциями. Чтобы своевременно обновлять статус заказа, мы используем поллинг: раз в некий промежуток времени, для примера возьмём 15 секунд, мы опрашиваем сервер. Классический инструмент для этого — Timer.

08.png

Давайте посмотрим на среднестатистический код для такой задачи, но уже в контексте нашего @MainActor-провайдера.

@MainActor
class Provider {
    var timer: Timer?

    deinit {
        timer?.invalidate()
    }

    func scheduleFetch() {
        timer = Timer.scheduledTimer(
            withTimeInterval: 15,
            repeats: false
        ) { [weak self] _ in
            self?.fetchDeliveries()
        }
    }

    func fetchDeliveries() { ... }
}

Что здесь может пойти не так? На самом деле почти всё.

Колбэк таймера

Первая ошибка, с которой мы сталкиваемся, — в замыкании таймера: Call to main actor-isolated instance method 'fetchDeliveries()' in a synchronous nonisolated context.

Мы-то с вами знаем, что Timer, добавленный в RunLoop главного потока, будет вызывать свой колбэк на том же главном потоке. Но проблема в том, что текущее API Timer никак не сообщает об этом системе типов Swift Concurrency. Для компилятора это замыкание — неизолированный контекст.

Apple предлагает решение: использовать MainActor.assumeIsolated, чтобы заверить компилятор, что мы знаем, что делаем.

// ...
) { [weak self] _ in
    MainActor.assumeIsolated {
        self?.fetchDeliveries()
    }
}

Это сработает. Но это не решение, а скорее временная заплатка. Мы полагаемся на неявный контракт, а не на гарантии, встроенные в язык. Но это не единственная и даже не главная проблема.

Инвалидация в deinit

Как мы уже выяснили, deinit может быть вызван с любого потока. А документация Apple прямо говорит: инвалидировать Timer критически важно на том же потоке, на котором он был создан. В противном случае Input Source, связанный с таймером, может не удалиться из RunLoop, что приведёт к утечке ресурсов. Наша реализация в deinit напрямую нарушает это правило.

Можно ли сделать лучше? Да. С приходом Swift Concurrency от Timer для таких задач можно — и нужно — отказаться. Вместо Timer мы можем использовать асинхронную Task со статическим методом sleep. Вот как преображается наш код:

@MainActor
class Provider {
    var task: Task<Void, Error>?

    deinit {
        task?.cancel()
    }

    func scheduleFetch() {
        task = Task { [weak self] in
            try await Task.sleep(for: .seconds(15))
            if !Task.isCancelled {
                self?.fetchDeliveries()
            }
        }
    }

    func fetchDeliveries() { ... }
}

Этот подход решает обе проблемы Timer весьма элегантно и на уровне системы типов.

  1. Наследование контекста изоляции. Task по умолчанию наследует контекст, в котором была создана. Так как scheduleFetch помечен @MainActor, код внутри Task тоже будет выполняться на главном акторе. Нам больше не нужны никакие assumeIsolated.
  2. Потокобезопасная отмена. Task — это Sendable-тип. Мы можем безопасно вызывать метод 'cancel()' из любого потока, включая неизолированный deinit. Никаких рисков утечки ресурсов или крэшей.

Более того, Task.sleep не привязан к RunLoop, поэтому его можно без проблем использовать для отложенных операций на фоновых потоках. По сравнению с громоздким API DispatchSourceTimer из GCD, Task.sleep — это простой, удобный и всепрощающий инструмент, который идеально вписывается в новую парадигму многопоточности.

09.png

Заключение: когда же станет легко?

В процессе адаптации языка разработчики Swift выпустили документ, в котором признали: Swift Concurrency задумывалась как технология, лёгкая для внедрения, но что-то пошло не так. И это правда. Миграция может фрустрировать. Но в Apple работают умные люди, и в Swift 6.2 нас ждёт isolated deinit — одна из многих попыток сгладить самые острые углы. Это облегчит переход, но, как мы выяснили, асинхронный deinit — это свой набор компромиссов, о которых нужно будет помнить.

Так какие выводы мы сделали для себя, пройдя этот путь?

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

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

И в-третьих, при всём при этом Swift Concurrency — это реально мощный инструмент. Он даёт нам новые, более элегантные способы решения старых задач, как мы увидели на примере с Task.sleep.

Когда мы только начинали адаптацию, это было не просто сложно, это было… очень сложно. Попытка что-то исправить в одном месте порождала ошибки в десяти других. Но по мере того, как мы преодолели рубеж в 60–70% адаптированного кода, мы заметили, что просто создаём акторы, изолируем объекты на @MainActor и больше не испытываем почти никаких трудностей.

Сложность применения Swift Concurrency нелинейна. В начале пути она кажется запредельной, но с каждым адаптированным модулем она экспоненциально падает. И в тот момент, когда кривая идёт вниз, приходит понимание, насколько это крутая и продуманная технология. Надеюсь, наш опыт вам поможет.

  • mobile

Будьте в курсе всех возможностей

Ещё по этой теме