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

Диаграмма метода connect
Диаграмма метода connect

Задача

Главной задачей было связать все эти аспекты воедино:

  • Модуль-труба, постоянно отправляющий данные

  • Возможность получить ошибки в любой момент

  • Старый 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)


  1. iushakov
    12.09.2025 18:23

    Я тоже делал сохранение continuation, но потом перешел на Swift Async Algorithms и AsyncChannel


    1. ubahwin Автор
      12.09.2025 18:23

      У нас в команде были мысли попробовать затянуть этот пакет, но пока ничего не решили. Каналы да – прикольная тема. Но даже сам AsyncStream у нас очень не часто в проекте встречается :)


  1. Maxik12
    12.09.2025 18:23

    А насколько вероятна ситуация, когда применённый подход к потокобезопасности может создать проблемы остальной программе?


    1. ubahwin Автор
      12.09.2025 18:23

      По сути самая проблемная часть находится тут:

      1. Ослабить проверку на уровне импорта

      Swift Concurrency в Swift 6 со strict mode гарантирует потокобезопасность на уровне компилятора для Sendable, акторов и тп. В случае с @preconcurrency мы фактически доверяем исходному фреймворку. Если CoreBluetooth был потокобезопасным и корректно использовался до Swift 6, то его использование с этим атрибутом не ухудшает ситуацию