
Представьте: вы прилетаете в аэропорт, бронируете автомобиль каршеринга, но авто на многоуровневой парковке. У вашего iPhone интернет есть, но у самой машины в этом месте связи нет — она не может достучаться до сервера. Возникает вопрос — как открыть авто?
На случай проблем с сетью в Ситидрайве есть оффлайн-сценарий — управление дверьми машины через Bluetooth. Для этого внутри автомобиля установлен специальный Bluetooth-модуль в блоке телеметрии. Именно с ним iPhone напрямую обменивается данными и позволяет открыть двери, даже если сама машина «отрезана» от сети. Так, Bluetooth становится страховкой, которая гарантирует доступ к авто в любых условиях.
Недавно мы с командой обновили этот механизм и значительно улучшили интеграцию различных модулей телеметрии. Я взял на себя часть по iOS и попробовал применить новый Swift Concurrency
поверх старого CoreBluetooth
. Как мы реализовали протокол доступа к 20 000 машин через Bluetooth можно прочесть в предыдущей статье моего коллеги. В этой статье расскажу, какие подводные камни вылезли при совмещении structured concurrency и callback-ориентированного API, как их обойти и на что стоит обратить внимание, если вы тоже решите «прикрутить» современные async/await к старому API.
Как работает Bluetooth-модуль в авто?
Не буду вдаваться в низкоуровневые подробности, как именно и через какие протоколы происходит передача данных, поэтому ограничимся упрощённым описанием:

Представим, что подключение к Bluetooth-модулю с iPhone установлено. Модуль раз в 5 секунд присылает данные, которые преобразуются в строки и используются в работе. Это похоже на трубу, из которой непрерывно поступают сообщения. Поток данных начинается с передачи Публичного ключа — он пригодится позднее. Если отправить команду в модуль, то вместо Публичного ключа придёт результат выполнения: успех, статус или ошибка.
Итак, модуль — это своего рода труба, которая потоком выдаёт новые значения и статусы.
CoreBluetooth
Для работы с BLE в iOS я использовал фреймворк CoreBluetooth
. Несмотря на то, что он довольно большой и универсальный, на деле пригодился только небольшой набор методов делегата. С их помощью я настроил реакцию на события от модуля: получение данных, статусы сканирования и подключения, а также другие изменения состояния.
Вот набор методов, с которыми я работал:
CBCentralManagerDelegate
// Отслеживает текущее состояние Bluetooth на устройстве
func centralManagerDidUpdateState(_ central: CBCentralManager)
// Вызывается при нахождении нового BLE-устройства
func centralManager(... didDiscover peripheral ...)
// Сообщает об успешном подключении к устройству
func centralManager(... didConnect peripheral ...)
CBPeripheralDelegate
// Приходит новое значение характеристики от устройства
func peripheral(... didUpdateValueFor characteristic ...)
// Получает список доступных сервисов устройства
func peripheral(... didDiscoverServices error ...)
// Получает список характеристик внутри конкретного сервиса
func peripheral(... didDiscoverCharacteristicsFor service ...)
А начинает сканирование инициализация CBCentralManager
. Всё это я вынес в отдельный сервис, чтобы инкапсулировать взаимодействие с BLE и не размазывать делегаты по коду. На данном этапе его основная логика проста: начинаем сканирование, ищем наш модуль, подключаемся, получаем сообщения в didUpdateValueFor
.
Требования к сервису
Сейчас мы уже перешли на Swift 6, активно используем новые возможности и ограничения языка — Swift Concurrency
и проверку потокобезопасности strict mode
. Так как взаимодействие с Bluetooth по своей природе асинхронное, хотелось иметь нативную поддержку языка, но без обращения к callback’ам. Кроме того, сервис должен быть потокобезопасным, поэтому для его реализации был выбран actor.
В итоге мы пришли к такой сигнатуре интерфейса:
protocol BluetoothServiceProtocol {
func connect() async throws
func disconnect() async
func write(_ message: String) async throws
}
actor BluetoothService: BluetoothServiceProtocol
Немного про логику метода connect()
— под капотом подразумевается сканирование, подключение и авторизация в модуле. Авторизация — это некоторые манипуляции с публичным и приватным ключом, которые происходят на сервере, мы получаем от него результат манипуляций и записываем в модуль, далее ожидается статус о том, что мы авторизовались. Только после всего этого считаем, что мы сделали connect()
. Ещё важно учитывать, что все эти операции проходят с таймером. Если не успеваем подключиться — выбрасываем timeout
.

Задача
Главной задачей было связать все эти аспекты воедино:
Модуль-труба, постоянно отправляющий данные
Возможность получить ошибки в любой момент
Старый API
CoreBluetooth
со своими делегатамиПростой интерфейс и лаконичная
async throws
сигнатураСервис –
actor
Сomplete – самый высокий
strict mode
Делегаты
Для начала подпишем наш actor
под делегаты от CoreBluetooth
:
extension BluetoothService: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {}
...
}
И получим ошибки:
Actor-isolated instance method 'centralManagerDidUpdateState' cannot be @objc
Actor-isolated instance method 'centralManagerDidUpdateState' cannot be used to satisfy nonisolated requirement from protocol 'CBCentralManagerDelegate'
Non-'@objc' method 'centralManagerDidUpdateState' does not satisfy requirement of '@objc' protocol 'CBCentralManagerDelegate'
Методы актора или должны быть async
, что невозможно — нарушится сигнатура, следовательно, не реализуем протокол, или же должны быть nonisolated
. Итак, делаем методы делегата неизолированными:
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager)
Со Swift 6.1 можно
extension'ы
помечать какnonisolated
И вроде проблемы ушли, однако, мы не можем теперь из неизолированного метода ничего делать в акторе, даже через Task
, так как мы пытаемся захватить central
, а он доступен только внутри:
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
Task { [weak self] in // Error
await self?.handleBluetoothState(central.state)
}
}
Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure
Есть два решения этой проблемы:
1. Ослабить проверку на уровне импорта
@preconcurrency import CoreBluetooth
2. Объявить используемые типы Sendable вручную
extension CBPeripheral: @retroactive @unchecked Sendable {}
extension CBCharacteristic: @retroactive @unchecked Sendable {}
Было решено использовать первый вариант. Да, к сожалению, так мы не обеспечиваем полную потокобезопасность, но иначе нельзя. В своё оправдание могу сказать, что все библиотеки, которые предоставляют API для async/await
работы с Bluetooth, используют такой же способ избегать ошибок strict mode
. Самая популярная — AsyncBluetooth:

Реализуем connect
На этом этапе мы смогли подключиться к модулю и начать получать с него в наш актор поток сообщений. Как теперь обработать его и реализовать протокол, который я показал выше?
Вариант с Combine
Первая мысль — использовать Combine
. Записывать в паблишер всё, что приходит в didUpdateValueFor
, а потом из подписки доставать публичный ключ или статус. Выше я писал про метод протокола connect()
, и что под капотом кроется авторизация и таймаут. То есть нужно под async/await
синтаксис уместить подписку, в которой будут различные манипуляции (авторизация, таймер и т.п.):
func connect() async throws {
startCentralManager()
return try await withUnsafeThrowingContinuation { continuation in
publisher
.sink { value in
// Очень много кода
continuation.resume()
}
.store(in: &subscriptions)
}
}
Выглядит громоздко, да? Ещё надо учесть:
Ошибки, которые могут прилететь из методов делегата в любой момент
Как-то выкидывать timeout по таймеру
Потенциальные проблемы с потокобезопасностью
Время жизни подписки
Не стоит смешивать
Combine
соSwift Concurrency
...
Вариант с AsyncStream
На помощь пришёл AsyncStream
. Это удобный способ работать с асинхронными последовательностями данных. Значения в поток добавляются вручную с помощью метода yield()
, а завершить поток можно вызовом finish()
. На стороне потребителя данные читаются в цикле for await ... in
, который будет выполняться до тех пор, пока не придёт сигнал о завершении.
Если кратко, это упрощённый async/await
паблишер, как из Combine
, у которого максимум один подписчик. Или несколько, если запустить консюмеров в разных Task
, однако так делать не стоит — будет «неожиданное поведение».
Почти во всех гайдах (а их мало) он используется так, как в документации Apple:
extension QuakeMonitor {
static var quakes: AsyncStream<Quake> {
AsyncStream { continuation in
let monitor = QuakeMonitor()
monitor.quakeHandler = { quake in
continuation.yield(quake)
}
continuation.onTermination = { @Sendable _ in
monitor.stopMonitoring()
}
monitor.startMonitoring()
}
}
}
Тут continuation
только внутри замыкания.
Однако, мало где представлен другой вариант использования, который очень сильно расширяет возможности стрима — вынесение continuation наружу, чтобы можно было делать yield()
и finish()
в любом удобном месте объекта. Есть много интересных обсуждений на форуме. Об этом также написано в документации:

Как это выглядит внутри BluetoothService
:
var continuation: AsyncThrowingStream<String, Error>.Continuation?
var pipe: AsyncThrowingStream<String, Error> {
AsyncStream { self.continuation = $0 }
}
...
func connect() async throws {
startCentralManager()
for try await message in pipe {
// Авторизация и тп...
// Когда всё сделали:
return
}
}
...
nonisolated func peripheral(... didUpdateValueFor characteristic ...) {
Task {
await continuation.yield(message)
}
}
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard central.state != .poweredOn else { return }
Task {
await continuation.finish(throwing: Error)
}
}
Инициализация стрима, continuation
которого вынесен как отдельное свойство сервиса. Метод connect()
считывает его, ждёт в for await loop
сообщение либо ошибку. Из didUpdateValueFor
метода идёт запись декодированного сообщения в стрим через .yield()
.
Для возможности завершать стрим с ошибкой я использовал AsyncThrowingStream
. Так, из метода centralManagerDidUpdateState
, если статус равен .unauthorized
, выполнится .finish(throwing:)
, соответственно, for await loop
с кейвордом try
.
Выглядит супер! Нужен async — вот тебе for await loop
, жди его выполнения. Прилетела ошибка — выкинул throw прям тут же.
Таймаут
Взаимодействие с BLE-модулем может «зависнуть» — например, если устройство находится далеко или модуль работает нестабильно. Напомню логику — если у нас не получается подключиться и авторизоваться на протяжении N-ого времени, то выкидываем у метода connect()
ошибку.
В Concurrency есть возможность распараллелить один await в скоупе функции на два с помощью withTaskGroup
, затем получившуюся группу задач использовать как AsyncSequence (от него наследуется и AsyncStream), чтобы получить значения выполнения этих тасок:
try await withThrowingTaskGroup { group in
group.addTask {
for try await message in pipe {
// Авторизация...
}
}
group.addTask {
try await Task.sleep(for: N)
throw BluetoothError.timeout
}
guard let result = try await group.next() else { throw BluetoothError }
group.cancelAll()
return result
}
Что тут происходит? Я перенёс for await loop
в одну из тасок этой группы. В другой таске мы просто «спим» N времени и выкидываем ошибку. Чтобы понять, что быстрее произойдёт, мы считываем с group
(где лежат таски) следующий элемент .next()
. В нашем случае либо выход из скоупа, либо ошибка timeout
.
Для удобства я обернул это в отдельную функцию
withTimeout
, куда достаточно передать async throws логику, которую мы хотим ждать с таймером.
Похожим образом реализуем остальные методы протокола.
Итоги проекта
В результате получилось решение, которое:
Полностью укладывается в правила
strict mode
.Даёт потокобезопасность за счёт actor без ручных локов и
DispatchQueue
.Обрабатывает входящий поток данных через
AsyncStream
, сохраняя последовательность и избавляясь от гонок.Нативно поддерживает таймауты на
async/await
, без коллбеков и громоздких таймеров.
По сравнению с «классическим» подходом на делегатах и очередях, код стал компактнее, читаемее и предсказуемее. Для разработчика это значит: меньше инфраструктурного кода и больше фокуса на бизнес-логику. Если завтра в проекте появится новый BLE-модуль или изменится протокол взаимодействия — адаптировать такой сервис гораздо проще.
И самое приятное: всё это построено на стандартных возможностях Swift Concurrency
, без сторонних библиотек и без обходных манёвров с потоками.
Комментарии (4)
Maxik12
12.09.2025 18:23А насколько вероятна ситуация, когда применённый подход к потокобезопасности может создать проблемы остальной программе?
ubahwin Автор
12.09.2025 18:23По сути самая проблемная часть находится тут:
1. Ослабить проверку на уровне импорта
Swift Concurrency в Swift 6 со strict mode гарантирует потокобезопасность на уровне компилятора для Sendable, акторов и тп. В случае с
@preconcurrency
мы фактически доверяем исходному фреймворку. Если CoreBluetooth был потокобезопасным и корректно использовался до Swift 6, то его использование с этим атрибутом не ухудшает ситуацию
iushakov
Я тоже делал сохранение continuation, но потом перешел на Swift Async Algorithms и AsyncChannel
ubahwin Автор
У нас в команде были мысли попробовать затянуть этот пакет, но пока ничего не решили. Каналы да – прикольная тема. Но даже сам AsyncStream у нас очень не часто в проекте встречается :)