Зачем вообще нужен Combine в 2026 году
Когда Apple представила Combine на WWDC 2019, все были уверены: вот оно, будущее асинхронного программирования на платформах Apple. А потом, буквально через пару лет, появились async/await в Swift 5.5, акторы, структурированная конкурентность — и разработчики начали массово списывать Combine со счетов.
Но рано.
Combine по-прежнему остаётся фундаментальной частью экосистемы Apple. SwiftUI использует его под капотом для привязки данных (да, даже если вы об этом не задумываетесь). Многие системные API — Notification Center, KVO, Timer — имеют нативные Combine-интерфейсы. А для определённых задач вроде обработки потоков данных, дебаунсинга пользовательского ввода или комбинирования нескольких асинхронных источников — Combine остаётся, пожалуй, самым элегантным решением.
В этом руководстве пройдём весь путь: от базовых концепций Publisher и Subscriber до продвинутых паттернов, которые реально пригождаются в повседневной iOS-разработке. Все примеры — рабочий код, который можно вставить в проект и сразу использовать.
Основные концепции: Publisher, Subscriber, Operator
Combine построен на трёх ключевых абстракциях. Честно говоря, если вы разберётесь в них — считайте, что поняли весь фреймворк.
Publisher — источник данных
Publisher — это протокол, описывающий тип, который умеет генерировать последовательность значений во времени. Каждый Publisher определяет два ассоциированных типа: Output (тип значений) и Failure (тип ошибки, которую он может выбросить).
import Combine
// Простейший Publisher — Just отправляет одно значение и завершается
let justPublisher = Just(42)
// Output = Int, Failure = Never
// CurrentValueSubject — хранит текущее значение и отправляет обновления
let currentValue = CurrentValueSubject<String, Never>("Начальное значение")
// PassthroughSubject — просто передаёт значения подписчикам
let passthrough = PassthroughSubject<Int, Error>()
Subscriber — потребитель данных
Subscriber подписывается на Publisher и получает значения. На практике вы почти всегда будете использовать встроенные sink и assign, а не писать свои. И это нормально — они покрывают 95% случаев.
let publisher = [1, 2, 3, 4, 5].publisher
// sink — самый универсальный способ подписки
let cancellable = publisher.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
print("Поток завершён")
case .failure(let error):
print("Ошибка: \(error)")
}
},
receiveValue: { value in
print("Получено: \(value)")
}
)
// assign — привязка значений к свойству объекта
class ViewModel: ObservableObject {
@Published var text: String = ""
}
let viewModel = ViewModel()
let textCancellable = Just("Привет, Combine!")
.assign(to: \.text, on: viewModel)
Operator — трансформация данных
Операторы — это методы на Publisher, которые возвращают новый Publisher. Именно они делают Combine таким мощным. По сути, вы строите цепочки преобразований данных — что-то вроде конвейера на заводе, где каждый этап делает одну конкретную вещь.
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].publisher
let cancellable = numbers
.filter { $0 % 2 == 0 } // Только чётные: 2, 4, 6, 8, 10
.map { $0 * $0 } // Квадрат: 4, 16, 36, 64, 100
.prefix(3) // Первые три: 4, 16, 36
.sink { value in
print(value)
}
AnyCancellable и управление памятью
Каждая подписка возвращает объект AnyCancellable. Если вы его не сохраните — подписка мгновенно отменяется. Это, наверное, самая частая ошибка новичков в Combine (и я тоже на неё попадался).
class SearchViewModel: ObservableObject {
@Published var query: String = ""
@Published var results: [String] = []
// Хранилище для подписок — обязательно!
private var cancellables = Set<AnyCancellable>()
init() {
// Подписка живёт столько же, сколько и ViewModel
$query
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] query in
self?.performSearch(query)
}
.store(in: &cancellables) // Сохраняем подписку
}
private func performSearch(_ query: String) {
// Логика поиска
}
}
Правило простое: всегда вызывайте .store(in: &cancellables) в конце цепочки, если подписка должна жить дольше текущей области видимости. Используйте Set<AnyCancellable> для хранения нескольких подписок — при деинициализации объекта все они автоматически отменятся. Удобно.
Ключевые операторы Combine: шпаргалка с примерами
В Combine десятки операторов, но давайте будем реалистами — в повседневной работе вы будете использовать примерно полтора десятка. Разберём самые важные по категориям.
Операторы трансформации
let publisher = PassthroughSubject<String, Never>()
// map — преобразование каждого значения
publisher
.map { $0.uppercased() }
.sink { print($0) } // "HELLO"
// flatMap — преобразование в новый Publisher (критично для сетевых запросов)
func fetchUser(id: Int) -> AnyPublisher<User, Error> {
URLSession.shared.dataTaskPublisher(for: URL(string: "https://api.example.com/users/\(id)")!)
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
let userIds = [1, 2, 3].publisher
userIds
.flatMap(maxPublishers: .max(3)) { id in
fetchUser(id: id)
}
.sink(
receiveCompletion: { _ in },
receiveValue: { user in print(user.name) }
)
.store(in: &cancellables)
// compactMap — как map, но отбрасывает nil
["1", "два", "3", "четыре", "5"].publisher
.compactMap { Int($0) }
.sink { print($0) } // 1, 3, 5
Операторы фильтрации
let numbers = (1...20).publisher
// filter — пропускает только подходящие значения
numbers
.filter { $0.isMultiple(of: 3) }
.sink { print($0) } // 3, 6, 9, 12, 15, 18
// removeDuplicates — убирает последовательные дубликаты
[1, 1, 2, 2, 2, 3, 3, 1, 1].publisher
.removeDuplicates()
.sink { print($0) } // 1, 2, 3, 1
// debounce — ждёт паузу перед отправкой (идеально для поиска)
$searchText
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { query in
// Выполняется через 500мс после последнего изменения
}
// throttle — ограничивает частоту (идеально для скролла)
scrollOffsetPublisher
.throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true)
.sink { offset in
// Максимум 10 обновлений в секунду
}
Операторы комбинирования
Вот тут Combine по-настоящему раскрывается. Когда нужно свести данные из нескольких источников — это его территория.
let username = PassthroughSubject<String, Never>()
let password = PassthroughSubject<String, Never>()
// combineLatest — комбинирует последние значения нескольких Publisher
Publishers.CombineLatest(username, password)
.map { user, pass in
!user.isEmpty && pass.count >= 8
}
.assign(to: &$isFormValid)
// merge — объединяет потоки одного типа в один
let localNotifications = NotificationCenter.default.publisher(for: .localUpdate)
let remoteNotifications = NotificationCenter.default.publisher(for: .remoteUpdate)
Publishers.Merge(localNotifications, remoteNotifications)
.sink { notification in
// Обрабатываем обновления из обоих источников
}
.store(in: &cancellables)
// zip — ждёт значения от каждого Publisher попарно
let firstName = Just("Иван")
let lastName = Just("Петров")
Publishers.Zip(firstName, lastName)
.map { "\($0) \($1)" }
.sink { print($0) } // "Иван Петров"
Combine и сетевые запросы: практический пример
Итак, одна из самых частых задач на практике — сетевые запросы. Combine интегрирован с URLSession напрямую, и это делает работу с сетью удивительно лаконичной.
struct Article: Codable {
let id: Int
let title: String
let body: String
}
class ArticleService {
private let baseURL = URL(string: "https://api.example.com")!
private let decoder = JSONDecoder()
func fetchArticles() -> AnyPublisher<[Article], Error> {
let url = baseURL.appendingPathComponent("articles")
return URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: [Article].self, decoder: decoder)
.receive(on: DispatchQueue.main) // Переключаемся на главный поток
.eraseToAnyPublisher()
}
func fetchArticle(id: Int) -> AnyPublisher<Article, Error> {
let url = baseURL.appendingPathComponent("articles/\(id)")
return URLSession.shared.dataTaskPublisher(for: url)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
return data
}
.decode(type: Article.self, decoder: decoder)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
Обработка ошибок в сетевых запросах
Combine предоставляет несколько стратегий работы с ошибками. Какую выбрать — зависит от того, что должно произойти, когда что-то пошло не так.
class ResilientArticleService {
private var cancellables = Set<AnyCancellable>()
func fetchWithRetry() -> AnyPublisher<[Article], Error> {
URLSession.shared.dataTaskPublisher(for: URL(string: "https://api.example.com/articles")!)
.map(\.data)
.decode(type: [Article].self, decoder: JSONDecoder())
.retry(3) // Повторить до 3 раз при ошибке
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
func fetchWithFallback() -> AnyPublisher<[Article], Never> {
URLSession.shared.dataTaskPublisher(for: URL(string: "https://api.example.com/articles")!)
.map(\.data)
.decode(type: [Article].self, decoder: JSONDecoder())
.replaceError(with: []) // При ошибке — пустой массив
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
func fetchWithCatch() -> AnyPublisher<[Article], Never> {
URLSession.shared.dataTaskPublisher(for: URL(string: "https://api.example.com/articles")!)
.map(\.data)
.decode(type: [Article].self, decoder: JSONDecoder())
.catch { error -> Just<[Article]> in
print("Ошибка загрузки: \(error.localizedDescription)")
return Just([]) // Возвращаем резервное значение
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
Combine + SwiftUI: привязка данных на практике
SwiftUI и Combine были спроектированы вместе — и это чувствуется буквально на каждом шагу. Свойство @Published автоматически создаёт Publisher, а SwiftUI подписывается на него через протокол ObservableObject. Магия? Нет, просто хорошая архитектура.
class LoginViewModel: ObservableObject {
@Published var email: String = ""
@Published var password: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var isFormValid: Bool = false
private var cancellables = Set<AnyCancellable>()
init() {
// Валидация формы в реальном времени
Publishers.CombineLatest($email, $password)
.map { email, password in
let emailValid = email.contains("@") && email.contains(".")
let passwordValid = password.count >= 8
return emailValid && passwordValid
}
.assign(to: &$isFormValid)
}
func login() {
isLoading = true
errorMessage = nil
AuthService.shared.login(email: email, password: password)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
},
receiveValue: { [weak self] token in
self?.handleLoginSuccess(token: token)
}
)
.store(in: &cancellables)
}
private func handleLoginSuccess(token: String) {
// Сохраняем токен и переходим на главный экран
}
}
struct LoginView: View {
@StateObject private var viewModel = LoginViewModel()
var body: some View {
Form {
TextField("Email", text: $viewModel.email)
.textContentType(.emailAddress)
.autocapitalization(.none)
SecureField("Пароль", text: $viewModel.password)
.textContentType(.password)
if let error = viewModel.errorMessage {
Text(error)
.foregroundColor(.red)
.font(.caption)
}
Button("Войти") {
viewModel.login()
}
.disabled(!viewModel.isFormValid || viewModel.isLoading)
}
}
}
Реактивный поиск с дебаунсингом: полный пример
Это, пожалуй, самый классический пример использования Combine — и мой любимый. Пользователь вводит текст, после небольшой паузы отправляется запрос на сервер. Без Combine это потребовало бы таймеров, флагов, ручной отмены предыдущих запросов... В общем, много кода. С Combine — несколько строк.
class SearchViewModel: ObservableObject {
@Published var searchText: String = ""
@Published var results: [SearchResult] = []
@Published var isSearching: Bool = false
private var cancellables = Set<AnyCancellable>()
private let searchService: SearchService
init(searchService: SearchService = .shared) {
self.searchService = searchService
setupSearchPipeline()
}
private func setupSearchPipeline() {
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
.handleEvents(receiveOutput: { [weak self] _ in
self?.isSearching = true
})
.flatMap { [weak self] query -> AnyPublisher<[SearchResult], Never> in
guard let self = self else {
return Just([]).eraseToAnyPublisher()
}
return self.searchService.search(query: query)
.replaceError(with: [])
.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink { [weak self] results in
self?.results = results
self?.isSearching = false
}
.store(in: &cancellables)
// Очищаем результаты при пустом вводе
$searchText
.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }
.sink { [weak self] _ in
self?.results = []
self?.isSearching = false
}
.store(in: &cancellables)
}
}
struct SearchView: View {
@StateObject private var viewModel = SearchViewModel()
var body: some View {
NavigationStack {
List {
if viewModel.isSearching {
ProgressView("Поиск...")
}
ForEach(viewModel.results) { result in
VStack(alignment: .leading) {
Text(result.title)
.font(.headline)
Text(result.description)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
.searchable(text: $viewModel.searchText, prompt: "Поиск")
.navigationTitle("Поиск")
}
}
}
Combine vs async/await: когда что использовать
Этот вопрос задают чаще всего, и я понимаю почему — границы действительно размыты. Вот мои критерии, которые неплохо работают на практике.
Используйте async/await когда:
- Одноразовые операции — загрузить данные, сохранить файл, выполнить запрос. Одно действие, один результат.
- Последовательные операции — сначала загрузить профиль, потом заказы, потом детали. Линейный поток, который легко читается.
- Простая параллельность —
async letилиTaskGroupдля параллельных запросов. - Обработка ошибок —
try/catchгораздо понятнее, чемreceiveCompletion(это сложно оспорить).
Используйте Combine когда:
- Потоки данных во времени — пользовательский ввод, обновления позиции, уведомления. Множество значений в течение жизни объекта.
- Комбинирование нескольких источников — валидация формы из нескольких полей, объединение данных из разных сервисов.
- Управление временем — debounce, throttle, delay, timeout. Async/await этого не умеет из коробки (по крайней мере, не так изящно).
- Декларативная трансформация данных — сложные цепочки filter → map → reduce.
- Привязка к SwiftUI —
@Publishedсвойства иObservableObject.
Пример: одна и та же задача двумя способами
// Async/await — просто и понятно для одноразовых запросов
func fetchUser() async throws -> User {
let (data, _) = try await URLSession.shared.data(from: userURL)
return try JSONDecoder().decode(User.self, from: data)
}
// Combine — мощнее для потоков данных
func observeUserUpdates() -> AnyPublisher<User, Never> {
NotificationCenter.default
.publisher(for: .userDidUpdate)
.compactMap { $0.userInfo?["user"] as? User }
.removeDuplicates()
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
На практике оба подхода прекрасно уживаются в одном проекте. Не нужно выбирать что-то одно — используйте каждый для тех задач, где он сильнее.
Мост между Combine и async/await
Начиная со Swift 5.5, Apple добавила возможность конвертировать между двумя моделями. И это, кстати, очень правильное решение — позволяет использовать преимущества обоих подходов без костылей.
// Combine Publisher → async/await через свойство .values
class DataManager {
let dataPublisher = PassthroughSubject<Data, Never>()
func processUpdates() async {
for await data in dataPublisher.values {
// Обрабатываем каждое значение из Publisher
await handleData(data)
}
}
private func handleData(_ data: Data) async {
// Обработка данных
}
}
// async/await → Combine через Future
func fetchUserPublisher(id: Int) -> AnyPublisher<User, Error> {
Future { promise in
Task {
do {
let user = try await APIClient.shared.fetchUser(id: id)
promise(.success(user))
} catch {
promise(.failure(error))
}
}
}
.eraseToAnyPublisher()
}
Продвинутый паттерн: кэширование с Combine
Реальные приложения редко просто загружают данные и показывают их. Обычно нужна стратегия кэширования: показать кэш моментально, обновить с сервера в фоне, обновить кэш. Combine делает этот паттерн на удивление элегантным.
class CachingArticleRepository {
private let networkService: ArticleService
private let cache = CurrentValueSubject<[Article], Never>([])
private var cancellables = Set<AnyCancellable>()
var articles: AnyPublisher<[Article], Never> {
cache.eraseToAnyPublisher()
}
init(networkService: ArticleService = ArticleService()) {
self.networkService = networkService
}
func refresh() {
networkService.fetchArticles()
.replaceError(with: cache.value) // При ошибке оставляем кэш
.sink { [weak self] articles in
self?.cache.send(articles)
}
.store(in: &cancellables)
}
func observeArticles() -> AnyPublisher<[Article], Never> {
// Сначала отдаём кэш, потом обновляем с сервера
let cached = cache.first()
let fresh = networkService.fetchArticles()
.replaceError(with: [])
.handleEvents(receiveOutput: { [weak self] articles in
self?.cache.send(articles)
})
return Publishers.Concatenate(prefix: cached, suffix: fresh)
.removeDuplicates()
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
Создание собственных Publisher
Иногда встроенных Publisher не хватает. Например, нужно обернуть callback-based API (а таких в iOS до сих пор немало). Вот как это делается на примере CoreLocation.
import CoreLocation
class LocationPublisher: NSObject, CLLocationManagerDelegate {
private let locationManager = CLLocationManager()
private let subject = PassthroughSubject<CLLocation, Error>()
var publisher: AnyPublisher<CLLocation, Error> {
subject.eraseToAnyPublisher()
}
override init() {
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyBest
}
func startTracking() {
locationManager.requestWhenInUseAuthorization()
locationManager.startUpdatingLocation()
}
func stopTracking() {
locationManager.stopUpdatingLocation()
subject.send(completion: .finished)
}
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
locations.forEach { subject.send($0) }
}
func locationManager(_ manager: CLLocationManager,
didFailWithError error: Error) {
subject.send(completion: .failure(error))
}
}
// Использование
class MapViewModel: ObservableObject {
@Published var userLocation: CLLocation?
@Published var distance: Double = 0
private let locationPublisher = LocationPublisher()
private var cancellables = Set<AnyCancellable>()
func startTracking() {
locationPublisher.publisher
.throttle(for: .seconds(1), scheduler: RunLoop.main, latest: true)
.sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] location in
self?.userLocation = location
}
)
.store(in: &cancellables)
locationPublisher.startTracking()
}
}
Тестирование кода с Combine
Тестируемость — одно из неочевидных, но важных преимуществ Combine. Потоки данных можно имитировать, а результаты — проверять предсказуемо.
import Testing
import Combine
// Вспомогательный тип для сбора значений из Publisher
class TestSubscriber<T> {
var values: [T] = []
var completion: Subscribers.Completion<any Error>?
private var cancellable: AnyCancellable?
func subscribe<P: Publisher>(to publisher: P) where P.Output == T {
cancellable = publisher
.sink(
receiveCompletion: { [weak self] in
self?.completion = $0.mapError { $0 }
},
receiveValue: { [weak self] value in
self?.values.append(value)
}
)
}
}
@Test func testSearchDebouncing() async throws {
let viewModel = SearchViewModel(
searchService: MockSearchService()
)
// Меняем текст быстро — должен отправиться только последний запрос
viewModel.searchText = "S"
viewModel.searchText = "Sw"
viewModel.searchText = "Swi"
viewModel.searchText = "Swift"
// Ждём debounce
try await Task.sleep(for: .milliseconds(500))
#expect(viewModel.results.count > 0)
// Убеждаемся, что поиск выполнился только один раз
}
@Test func testFormValidation() {
let viewModel = LoginViewModel()
#expect(viewModel.isFormValid == false)
viewModel.email = "[email protected]"
#expect(viewModel.isFormValid == false) // пароль ещё пустой
viewModel.password = "12345678"
#expect(viewModel.isFormValid == true)
}
Типичные ошибки и как их избежать
За годы работы с Combine я видел одни и те же грабли снова и снова. Вот четвёрка самых популярных.
1. Забытый AnyCancellable
// ❌ Подписка мгновенно отменяется — cancellable никуда не сохранён
func badExample() {
somePublisher
.sink { value in
print(value) // Никогда не вызовется
}
}
// ✅ Сохраняем подписку
private var cancellables = Set<AnyCancellable>()
func goodExample() {
somePublisher
.sink { value in
print(value)
}
.store(in: &cancellables)
}
2. Утечка памяти из-за сильных ссылок
// ❌ Retain cycle: self → cancellables → sink closure → self
somePublisher
.sink { value in
self.process(value)
}
.store(in: &cancellables)
// ✅ Слабая ссылка разрывает цикл
somePublisher
.sink { [weak self] value in
self?.process(value)
}
.store(in: &cancellables)
3. Обновление UI не на главном потоке
// ❌ dataTaskPublisher работает на фоновом потоке
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: [Article].self, decoder: JSONDecoder())
.sink(
receiveCompletion: { _ in },
receiveValue: { articles in
self.articles = articles // ⚠️ Обновление UI с фонового потока!
}
)
// ✅ Переключаемся на главный поток
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: [Article].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main) // Переключение потока
.sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] articles in
self?.articles = articles
}
)
.store(in: &cancellables)
4. Неправильное использование flatMap
// ❌ Без maxPublishers может создать сотни одновременных запросов
ids.publisher
.flatMap { id in
fetchItem(id: id)
}
// ✅ Ограничиваем параллелизм
ids.publisher
.flatMap(maxPublishers: .max(3)) { id in
fetchItem(id: id)
}
Часто задаваемые вопросы
Будет ли Apple убирать Combine из фреймворков?
Нет, и в ближайшем будущем точно не планируется. Combine глубоко интегрирован в SwiftUI, Foundation и другие системные фреймворки. Даже с появлением async/await он остаётся лучшим инструментом для работы с потоками данных — особенно в контексте SwiftUI и @Published свойств. Он не устарел, он просто занял свою нишу.
Можно ли использовать Combine вместе с async/await в одном проекте?
Не просто можно — это рекомендуемый подход. Apple предоставляет мосты между двумя моделями: свойство .values на Publisher для итерации через for await, а также Future для оборачивания async-функций. Используйте каждый инструмент там, где он наиболее уместен.
Чем Combine отличается от RxSwift?
Концептуально они очень похожи — оба реализуют паттерн реактивного программирования. Но есть важные различия: Combine — нативный фреймворк Apple, не требует сторонних зависимостей, интегрирован с SwiftUI и использует строгую типизацию ошибок. RxSwift — кроссплатформенный, с более обширной экосистемой операторов. Для новых iOS-проектов я бы выбирал Combine.
Как отладить сложную цепочку Combine?
Оператор .print() — ваш лучший друг. Он логирует все события в цепочке. Также полезен .handleEvents() для отслеживания конкретных событий и .breakpoint() для остановки в отладчике. Ну и классический breakpoint внутри замыкания sink тоже никто не отменял.
Работает ли Combine на Linux или других платформах?
Нет, Combine — проприетарный фреймворк Apple, доступный только на платформах Apple (iOS, macOS, watchOS, tvOS, visionOS). Для кроссплатформенного серверного Swift есть OpenCombine — open-source реализация, повторяющая API Combine. Но для серверного Swift обычно рекомендуют async/await и AsyncSequence — они более нативны для этого контекста.