Чтобы отменить ожидание таймера только для одного потребителя, предоставьте каждому потребителю собственный AsyncStream и дескриптор задачи. Используйте общий таймер Foundation, а затем распределяйте его значения по отдельным потокам. Тогда отмена потребителя A завершит его ожидание, а потребитель B продолжит работу.
В этом руководстве создаётся небольшое приложение для Mac с двумя дисплеями синтетических чисел и кнопкой Cancel A. Оба дисплея получают значения от одного таймера. Задача — прекратить асинхронное получение данных потребителем A, не останавливая B и не аннулируя общий таймер.
Почему отмена одного ожидания может повлиять на других потребителей?
AsyncSequence описывает асинхронный перебор; поведение при отмене зависит от конкретной реализации последовательности. В текущем исходном коде стандартной библиотеки для AsyncStream указано, что отмена задачи, приостановленной в next(), завершает поток. Вызов может немедленно вернуть nil или сделать это при последующих вызовах. Новый итератор того же потока не создаёт независимую подписку. Исходный код стандартной библиотеки Swift: AsyncStream.swift.
Поэтому важно правильно определить владельцев ресурсов. Если две задачи получают данные из одного потока, считать отмену частной операцией над одним итератором — неподходящий контракт для этой задачи. Вместо этого создайте два потока с отдельными продолжениями и явно отправляйте каждое созданное значение в оба потока. Apple описывает AsyncStream как мост от обратных вызовов к асинхронному перебору: yield(_:) используется для передачи элементов, а finish() — для завершения потока. AsyncStream.
Отмена задач также работает по кооперативной модели: вызов cancel() помечает задачу как отменённую и запускает обработчики отмены, но не прерывает произвольный код. Перед применением полученного значения потребитель должен проверить состояние отмены. Отмена также распространяется на дочерние задачи, поэтому отмена общей родительской задачи может затронуть больше, чем только нужный дескриптор потребителя. Task.cancel().
В этом приложении используются следующие правила владения:
| Операция | Ожидаемый эффект |
|---|---|
| Отменить задачу A | Прекратить получение данных A и удалить подписку A |
| Завершить продолжение A | Нормально завершить поток A |
| Отменить задачу B | Независимо прекратить получение данных B |
| Остановить узел таймера | Аннулировать таймер и завершить все оставшиеся потоки |
Какие значения должны получать потребители таймера?
В примере используется намеренно простой контракт значений. При каждом обработанном срабатывании таймера целое число увеличивается от 1 до 1 000, а затем снова становится равным 1. Каждый дисплей показывает это число и его квадрат. Например, для иллюстративного значения 7 результатом будет n=7, square=49. Ограничение сохраняет вычисления небольшими при многократных циклах.
Каждая подписка буферизует только самое новое ожидающее значение. Это подходит для дисплея, задача которого — показывать последнее синтетическое состояние: медленный дисплей может пропускать промежуточные значения. Такой подход не подходит, если необходимо обработать каждое созданное событие. Apple описывает как ограниченную буферизацию, так и неограниченный буфер по умолчанию для AsyncStream. Буферизация AsyncStream.
Счётчик учитывает обработанные срабатывания, а не прошедшие секунды. Таймеры Foundation зависят от цикла обработки событий, могут срабатывать с опозданием и не воспроизводят каждое пропущенное срабатывание. Двухсекундный интервал ниже позволяет легко наблюдать за отменой; он не задаёт точные часы. Timer.
Как создать независимые подписки?
В Xcode создайте цель приложения macOS SwiftUI с инструментарием Swift 6 и минимальной версией macOS 14 или новее. Добавьте следующие три файла, заменив точку входа приложения и представление из шаблона. Вместе они образуют законченный пример; внешние пакеты, сетевой доступ и сохранение данных не требуются. Код проверен при чтении, но в этой исследовательской среде не компилировался.
Создайте общий узел таймера
Узел владеет таймером и словарём продолжений подписок. Каждый вызов subscribe() создаёт новый поток. Обработчик завершения удаляет только запись с соответствующим идентификатором. Apple указывает, что отмена активного перебора потока вызывает onTermination и завершает перебор с nil. AsyncStream.Continuation.onTermination.
Поместите этот код в TickHub.swift:
import Foundation
@MainActor
final class TickHub {
private var timer: Timer?
private var number = 0
private var continuations:
[UUID: AsyncStream<Int>.Continuation] = [:]
func subscribe() -> (id: UUID, stream: AsyncStream<Int>) {
let id = UUID()
let pair = AsyncStream<Int>.makeStream(
bufferingPolicy: .bufferingNewest(1)
)
pair.continuation.onTermination = { [weak self] _ in
Task { @MainActor [weak self] in
self?.unsubscribe(id)
}
}
continuations[id] = pair.continuation
return (id, pair.stream)
}
func start() {
guard timer == nil else { return }
let newTimer = Timer(
timeInterval: 2,
repeats: true
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.emit()
}
}
newTimer.tolerance = 0.2
timer = newTimer
RunLoop.main.add(newTimer, forMode: .common)
}
private func emit() {
guard timer != nil else { return }
number = number == 1_000 ? 1 : number + 1
for continuation in continuations.values {
_ = continuation.yield(number)
}
}
func unsubscribe(_ id: UUID) {
let continuation = continuations.removeValue(forKey: id)
continuation?.finish()
}
func stop() {
timer?.invalidate()
timer = nil
let remaining = Array(continuations.values)
continuations.removeAll()
for continuation in remaining {
continuation.finish()
}
}
}
Инициализатор таймера создаёт таймер без расписания, поэтому узел явно добавляет его в главный цикл обработки событий. Обратный вызов переходит в задачу главного актора, прежде чем обращаться к состоянию узла. Проверка timer != nil не позволяет уже поставленному в очередь обратному вызову отправить значение после завершения работы. Timer.init(timeInterval:repeats:block:) и RunLoop.add(_:forMode:).
Всё состояние узла изолировано главным актором MainActor, исполнитель которого эквивалентен главной очереди dispatch. Обработчик завершения планирует очистку там, а не обращается напрямую к словарю из контекста выполнения обратного вызова. MainActor.
Предоставьте каждому потребителю отдельный дескриптор задачи
Поместите следующий код в TimerDemoModel.swift. Два вызова subscribe() обязательны: они создают независимые жизненные циклы потоков. Блоки defer также отменяют подписку при выходе потребителя, в том числе при обычном выходе из цикла.
import Combine
@MainActor
final class TimerDemoModel: ObservableObject {
@Published private(set) var aText = "Waiting"
@Published private(set) var bText = "Waiting"
@Published private(set) var aCancelled = false
@Published private(set) var stopped = false
private let hub = TickHub()
private var aTask: Task<Void, Never>?
private var bTask: Task<Void, Never>?
private var started = false
func start() {
guard !started else { return }
started = true
let a = hub.subscribe()
let b = hub.subscribe()
let source = hub
aTask = Task { @MainActor [weak self] in
defer { source.unsubscribe(a.id) }
for await number in a.stream {
guard !Task.isCancelled else { break }
self?.aText = "n=\(number), square=\(number * number)"
}
}
bTask = Task { @MainActor [weak self] in
defer { source.unsubscribe(b.id) }
for await number in b.stream {
guard !Task.isCancelled else { break }
self?.bText = "n=\(number), square=\(number * number)"
}
}
hub.start()
}
func cancelA() {
guard !aCancelled, !stopped else { return }
aCancelled = true
aTask?.cancel()
}
func stopAll() {
guard !stopped else { return }
stopped = true
aTask?.cancel()
bTask?.cancel()
hub.stop()
aTask = nil
bTask = nil
}
}
Обратите внимание: cancelA() выполняет ровно одну операцию отмены. Он не вызывает hub.stop() и не обращается к задаче B. Цикл проверяет Task.isCancelled после получения числа и до изменения дисплея, следуя описанной в документации Swift кооперативной модели отмены. The Swift Programming Language — Concurrency.
Оба пути очистки могут попытаться удалить A: обработчик завершения и defer задачи. Здесь это сделано намеренно. unsubscribe(_:) сначала удаляет запись, а затем завершает возвращённое продолжение. При повторном удалении запись не найдётся, поэтому остальные подписки останутся нетронутыми.
Добавьте элементы управления для рабочего стола
Поместите этот код в TimerWaiterDemoApp.swift. В цели должно быть только одно объявление приложения @main.
import SwiftUI
@main
@MainActor
struct TimerWaiterDemoApp: App {
var body: some Scene {
WindowGroup {
TimerDemoView()
}
}
}
@MainActor
struct TimerDemoView: View {
@StateObject private var model = TimerDemoModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Independent timer consumers").font(.headline)
Text("A: \(model.aText)")
Text(model.aCancelled ? "A cancelled" : "A active")
Text("B: \(model.bText)")
HStack {
Button("Cancel A") { model.cancelA() }
.disabled(model.aCancelled || model.stopped)
Button("Stop all") { model.stopAll() }
.disabled(model.stopped)
}
}
.padding(24)
.frame(minWidth: 420)
.onAppear { model.start() }
.onDisappear { model.stopAll() }
}
}
@StateObject обеспечивает модели стабильное хранилище на протяжении жизни контейнера представления, а опубликованные изменения обновляют зависимые текстовые представления. Действие при исчезновении представления обеспечивает очистку на его уровне; точное время вызова зависит от представления. Кнопка Stop all также позволяет явно остановить работу. StateObject и onDisappear(perform:).
Как убедиться, что отмена затрагивает только нужного потребителя?
Запустите приложение и выполните эту последовательность приёмки. Приведённые ниже числа — иллюстративные наблюдения, на которые следует обратить внимание, а не результаты проведённого тестирования.
- Дождитесь, пока оба дисплея обновятся. Обычно на них должны отображаться одинаковые последние числа и их квадраты.
- Нажмите
Cancel Aмежду срабатываниями таймера. Статус A сменится наA cancelled, а отображаемое значение останется прежним. - Дождитесь как минимум ещё двух срабатываний. Значение B должно продолжать меняться, а значение A — оставаться прежним.
- Нажмите
Stop all. Значение B тоже должно перестать меняться. - Перезапустите приложение и сразу отмените A, до получения первого значения. У A должно остаться состояние
Waiting, а B должен начать получать числа.
Эта последовательность проверяет отмену как после начала получения данных, так и до появления первого полезного значения. В этой реализации действие отмены и обновления дисплея сериализованы на главном акторе. Обновление, завершённое до нажатия кнопки, останется видимым; проверка отмены не даст следующей возобновившейся итерации A применить новое значение.
Если поведение отличается, прежде чем менять интервал таймера, проверьте границы владения ресурсами:
| Симптом | Что проверить в первую очередь |
|---|---|
| При отмене A останавливается B | Проверьте, нет ли общего потока или очистки, вызывающей hub.stop() |
| Дисплеи получают разные наборы значений | Убедитесь, что каждый потребитель получает отдельный результат subscribe() |
| Значения не появляются | Убедитесь, что таймер запущен и добавлен в работающий главный цикл приложения |
| Значения иногда пропускаются | Проверьте, соответствует ли политика буферизации самых новых значений требованиям дисплея |
| Работа продолжается после исчезновения представления | Убедитесь, что остановка затрагивает оба дескриптора задач и узел |
Что меняется для ожидания одного значения или перезапуска?
Для ожидания одного значения оставьте подписку приватной, обработайте первое подходящее значение и выйдите из цикла. Сохраните операцию отписки defer: она явно задаёт желаемый срок жизни подписки, даже если цикл завершается через break. При обычном завершении ожидания одного значения эта подписка должна удалиться, а общий источник данных должен остаться доступен для B.
Чтобы перезапустить A, создайте новую подписку и новую задачу после завершения старой задачи. Не пытайтесь сбросить отменённую задачу или повторно использовать завершённый поток. В интерфейсе с возможностью перезапуска дождитесь завершения старой задачи, прежде чем устанавливать новую, либо как-то иначе различайте последовательные жизненные циклы подписок, чтобы очистка старой не могла удалить новое состояние.
В этом примере для каждого экземпляра представления предусмотрен только один запуск. Глобальная остановка аннулирует повторяющийся таймер на главном акторе, где он был установлен. Apple указывает, что аннулированные таймеры нельзя использовать повторно; для перезапуска нужно создать новый таймер. Жизненный цикл Timer.
Независимая отмена обеспечивается независимым владением подписками. Оставьте таймер в узле, предоставьте каждому потребителю отдельный поток и дескриптор задачи и удаляйте только его подписку после завершения. Так кнопка на настольном компьютере сможет остановить ожидание одного дисплея, а второй продолжит получать те же синтетические вычисления.
Источники
- AsyncStream.swift — проект Swift.
- AsyncStream — Apple.
- onTermination — Apple.
- cancel() — Apple.
- Concurrency — проект Swift.
- Timer — Apple.
- init(timeInterval:repeats:block:) — Apple.
- add(_:forMode:) — Apple.
- MainActor — Apple.
- StateObject — Apple.
- onDisappear(perform:) — Apple.