
Представьте: вы прилетаете в аэропорт, бронируете автомобиль каршеринга, но авто на многоуровневой парковке. У вашего 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 CoreBluetooth2. Объявить используемые типы 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)
 - Maxik1212.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 у нас очень не часто в проекте встречается :)