Swift async/await Leitfaden: Task, Actor und @MainActor
7 Min. Lesezeit

Fast jeder Bildschirm einer iOS-App erledigt etwas, worauf er warten muss: Daten aus dem Netz laden, eine Datei lesen, ein Bild verarbeiten. Läuft diese Arbeit auf dem Main-Thread, friert die Oberfläche ein; verlagern Sie sie in den Hintergrund, müssen Sie das Ergebnis sicher zur Oberfläche zurückbringen. Das Nebenläufigkeitsmodell von Swift auf Basis von async/await holt beide Probleme in die Sprache: Wartender Code liest sich wie eine gewöhnliche Funktion, und der Compiler prüft, welche Daten aus welchem Kontext geändert werden dürfen.
Dieser Leitfaden beginnt bei Completion Handlern, geht der Reihe nach durch Task, async let, TaskGroup, @MainActor, Actors und Abbruch und führt am Ende alles in einem SwiftUI-Bildschirm zusammen.
Vom Completion Handler zu async/await
Ein Netzwerkaufruf im alten Stil sieht so aus:
import Foundation
struct User: Codable, Identifiable, Sendable {
let id: Int
let name: String
let email: String
}
func fetchUser(id: Int, completion: @escaping @Sendable (Result<User, Error>) -> Void) {
let url = URL(string: "https://example.com/api/users/\(id)")!
URLSession.shared.dataTask(with: url) { data, _, error in
if let error {
completion(.failure(error))
return
}
guard let data else {
completion(.failure(URLError(.badServerResponse)))
return
}
do {
let user = try JSONDecoder().decode(User.self, from: data)
completion(.success(user))
} catch {
completion(.failure(error))
}
}.resume()
}Die Probleme sind bekannt. Dass completion in jedem Zweig aufgerufen wird, liegt in Ihrer Verantwortung; der Compiler prüft es nicht. Zwei Aufrufe hintereinander erzeugen verschachtelte Einrückungen. Fehler laufen über Result statt über throws. @escaping und das Capture-Verhalten habe ich im Beitrag zu Closures behandelt; der größte Teil dieser Komplexität entfällt hier.
Dieselbe Aufgabe als async, zusammen mit einem generischen get-Helfer:
enum APIError: Error {
case invalidResponse
case badStatus(Int)
}
struct UserService: Sendable {
let baseURL = URL(string: "https://example.com/api")!
func fetchUser(id: Int) async throws -> User {
try await get("users/\(id)")
}
func fetchUsers() async throws -> [User] {
try await get("users")
}
private func get<T: Decodable>(_ path: String) async throws -> T {
let url = baseURL.appendingPathComponent(path)
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw APIError.badStatus(http.statusCode)
}
return try JSONDecoder().decode(T.self, from: data)
}
}await ist ein Unterbrechungspunkt: Die Funktion pausiert dort, der Thread wird für andere Arbeit freigegeben, und die Ausführung läuft weiter, sobald das Ergebnis da ist. Kein Thread wird blockiert. Weil die Funktion entweder einen Wert zurückgeben oder einen Fehler werfen muss, werden Fehler der Art „completion wurde nie aufgerufen“ unmöglich. Falls Ihnen das Muster guard let neu ist, lesen Sie Optionals und guard let.
Haben Sie eine Callback-basierte API, die Sie nicht ändern können, verpacken Sie sie in eine Continuation. Eine Regel gilt: Die Continuation muss auf jedem Pfad genau einmal fortgesetzt werden.
func fetchUserAsync(id: Int) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
fetchUser(id: id) { result in
continuation.resume(with: result)
}
}
}Task: aus synchronem Code in die async-Welt
Eine async-Funktion lässt sich nur aus einem anderen async-Kontext aufrufen. Um sie von einer synchronen Stelle wie einer Button-Aktion aus zu starten, verwenden Sie Task:
Button("Aktualisieren") {
Task { await model.load() }
}Task { } erbt den Actor des Kontexts, in dem er erzeugt wird; starten Sie ihn aus einer @MainActor-View, beginnt auch der Code darin auf dem Main Actor. Bewahren Sie den Rückgabewert auf, können Sie ihn mit cancel() abbrechen. Solche Tasks sind unstrukturiert: Ihre Lebensdauer verwalten Sie selbst.
async let und TaskGroup: Arbeit parallel ausführen
Schreiben Sie zwei unabhängige Anfragen als aufeinanderfolgende awaits, wartet die zweite auf das Ende der ersten. async let startet beide gleichzeitig:
struct Post: Codable, Identifiable, Sendable {
let id: Int
let title: String
}
struct Dashboard: Sendable {
let user: User
let posts: [Post]
}
func loadDashboard(service: UserService, userID: Int) async throws -> Dashboard {
async let user = service.fetchUser(id: userID)
async let posts = service.fetchPosts(userID: userID)
return try await Dashboard(user: user, posts: posts)
}(fetchPosts ist in UserService ein Einzeiler: try await get("users/\(userID)/posts").)
Steht die Anzahl der Aufgaben zur Kompilierzeit nicht fest, verwenden Sie eine TaskGroup:
func fetchUsers(ids: [Int], service: UserService) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask {
try await service.fetchUser(id: id)
}
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users.sorted { $0.id < $1.id }
}
}Die Ergebnisse kommen in der Reihenfolge der Fertigstellung, nicht in der des Hinzufügens; sortieren Sie nachträglich, wenn die Reihenfolge zählt. Beide Konstrukte sind strukturierte Nebenläufigkeit: Kind-Tasks können den Gültigkeitsbereich der Funktion nicht überleben, und wirft einer einen Fehler, werden die anderen abgebrochen.
@MainActor: die Oberfläche sicher aktualisieren
UI-Zustand darf nur vom Main-Thread aus geändert werden. Statt DispatchQueue.main.async zu schreiben, kennzeichnen Sie den Typ mit @MainActor, und der Compiler setzt die Regel durch:
import Observation
@MainActor
@Observable
final class UserListModel {
private(set) var users: [User] = []
private(set) var errorMessage: String?
private(set) var isLoading = false
private let service = UserService()
func load() async {
isLoading = true
defer { isLoading = false }
do {
users = try await service.fetchUsers()
errorMessage = nil
} catch is CancellationError {
// Der Bildschirm wurde geschlossen, keine Fehlermeldung nötig
} catch let error as URLError where error.code == .cancelled {
// URLSession meldet einen Abbruch mit diesem Fehler
} catch {
errorMessage = "Die Liste konnte nicht geladen werden: \(error.localizedDescription)"
}
}
}In der Zeile try await service.fetchUsers() läuft die Netzwerkarbeit außerhalb des Main Actors; wenn await zurückkehrt, ist der Code wieder auf dem Main Actor, und die Zuweisung an users ist sicher. @Observable und die Anbindung des Modells an die View sind Thema des Beitrags zum Datenfluss in SwiftUI.
Actors: gemeinsam genutzten veränderlichen Zustand schützen
Mehrere Tasks, die in dasselbe Dictionary schreiben, sind ein Data Race wie aus dem Lehrbuch. Ein actor ist ein Referenztyp, der garantiert, dass immer nur ein Task auf seinen Zustand zugreift; Locks schreiben Sie keine.
actor ImageCache {
private var storage: [URL: Data] = [:]
func data(for url: URL) -> Data? {
storage[url]
}
func store(_ data: Data, for url: URL) {
storage[url] = data
}
}
func loadImageData(from url: URL, cache: ImageCache) async throws -> Data {
if let cached = await cache.data(for: url) {
return cached
}
let (data, _) = try await URLSession.shared.data(from: url)
await cache.store(data, for: url)
return data
}Der Zugriff von außen verlangt await, weil Sie unter Umständen warten müssen, bis Sie an der Reihe sind. Ein Punkt verdient Aufmerksamkeit: An jedem await innerhalb einer Actor-Methode können sich andere Aufrufe dazwischenschieben. Gehen Sie nicht davon aus, dass ein vor dem await gelesener Wert danach noch derselbe ist.
Abbruch ist kooperativ
task.cancel() stoppt einen Task nicht mit Gewalt, es setzt nur ein Flag. System-APIs wie URLSession und Task.sleep reagieren von selbst auf dieses Flag und werfen einen Fehler. In eigenen langen Schleifen bauen Sie die Prüfung selbst ein:
func exportAll(_ users: [User]) async throws {
for user in users {
try Task.checkCancellation()
try await upload(user)
}
}.task in SwiftUI
Der Modifier .task startet die Arbeit, wenn die View erscheint, und bricht den Task ab, wenn sie verschwindet. .task(id:) bricht bei jeder Wertänderung den vorherigen Task ab und startet einen neuen, genau das, was ein Suchfeld braucht:
struct SearchScreen: View {
@State private var query = ""
@State private var results: [User] = []
var body: some View {
List(results) { user in
Text(user.name)
}
.searchable(text: $query)
.task(id: query) {
do {
try await Task.sleep(for: .milliseconds(300))
results = try await search(query)
} catch {
// Ein neues Zeichen wurde getippt: der vorherige Task wurde abgebrochen
}
}
}
private func search(_ text: String) async throws -> [User] {
let all = try await UserService().fetchUsers()
return text.isEmpty ? all : all.filter { $0.name.localizedCaseInsensitiveContains(text) }
}
}Solange der Nutzer weitertippt, wird der wartende Task abgebrochen; nur die Anfrage, die beim Innehalten vorliegt, geht ins Netz.
Swift 6 und Sicherheit vor Data Races
Im Sprachmodus Swift 6 wird die Sicherheit vor Data Races zur Kompilierzeit geprüft. Das zentrale Konzept heißt Sendable: Es besagt, dass ein Wert Nebenläufigkeitsgrenzen sicher überqueren kann, von einem Actor zum anderen oder von einem Task zum nächsten. Structs und Enums, die nur aus Sendable-Feldern bestehen, Actors und unveränderliche finale Klassen erfüllen das; eine gewöhnliche Klasse mit veränderlichen Feldern nicht, und der Compiler widerspricht, wenn Sie sie an einen anderen Task übergeben wollen. Deshalb sind die Modelle oben als Sendable gekennzeichnet. In bestehenden Projekten ist es deutlich weniger schmerzhaft, die Prüfungen schrittweise zu aktivieren und die Warnungen Modul für Modul abzuarbeiten, als auf einen Schlag umzustellen. Welche Typen Werte und welche Referenzen sind, spielt hier unmittelbar eine Rolle; die Details stehen im Beitrag zu struct und class.
Wann verwenden – und wann nicht?
async/await sollte der Standard für jede asynchrone Arbeit sein, die Sie heute schreiben: Netzwerk, Dateien, Datenbank, alles, was wartet. Einen Completion Handler verwenden Sie nur, wenn eine alte, nicht änderbare API Sie dazu zwingt, und den verpacken Sie in eine Continuation.
Werte, die über die Zeit fließen (Änderungen eines Textfelds, Standortaktualisierungen), lassen sich nicht mit einem einzelnen await ausdrücken; dafür eignen sich AsyncSequence oder weiterhin Combine. Eine synchrone, rein rechenintensive Funktion wird nicht schneller, wenn Sie sie async machen; async heißt nicht „läuft im Hintergrund“. Und gemeinsamer Zustand verlangt nicht immer einen Actor: Wird er nur von der Oberfläche genutzt, genügt @MainActor.
Häufige Fehler
1. UI-Zustand von außerhalb des Actors ändern
Symptom: Der Compiler meldet main actor-isolated property 'users' can not be mutated from a nonisolated context; in älterem ObservableObject-Code erscheint stattdessen zur Laufzeit die violette Warnung, dass Änderungen von einem Hintergrund-Thread veröffentlicht werden. Lösung: Kennzeichnen Sie das Modell mit @MainActor und nehmen Sie die Änderung in der eigenen async-Methode des Modells vor; flicken Sie nicht mit DispatchQueue.main.async.
2. Unabhängige Anfragen nacheinander abwarten
let user = try await service.fetchUser(id: 1)
let posts = try await service.fetchPosts(userID: 1) // wartet auf die vorige ZeileSymptom: Der Bildschirm öffnet sich so spät wie die Summe der Anfragedauern. Lösung: Braucht die zweite Anfrage das Ergebnis der ersten nicht, verwenden Sie async let.
3. Einen Task in onAppear starten und vergessen
Symptom: Die Anfrage läuft weiter, nachdem Sie den Bildschirm verlassen haben, bei jeder Rückkehr startet dieselbe Anfrage erneut, und manchmal überschreibt eine alte Antwort eine neuere. Lösung: Verwenden Sie .task { }; die Lebensdauer des Tasks ist an die der View gebunden.
4. Einen Abbruch als Fehler anzeigen
Symptom: Eine Fehlermeldung mit „cancelled“ blitzt auf, wenn der Nutzer den Bildschirm verlässt oder im Suchfeld tippt. Lösung: Fangen Sie CancellationError und URLError.cancelled getrennt ab, wie im Beispiel load() oben, und übergehen Sie sie stillschweigend.
Mini-Szenario: ein Bildschirm mit Nutzerliste
Setzen wir die Teile zusammen: UserService erledigt das Netzwerk, UserListModel hält den Zustand auf dem Main Actor, die View zeigt nur an.
import SwiftUI
struct UserListScreen: View {
@State private var model = UserListModel()
var body: some View {
List(model.users) { user in
VStack(alignment: .leading) {
Text(user.name)
Text(user.email).font(.caption)
}
}
.overlay {
if model.isLoading && model.users.isEmpty {
ProgressView()
} else if let message = model.errorMessage {
Text(message)
}
}
.task {
await model.load()
}
.refreshable {
await model.load()
}
}
}Öffnet sich der Bildschirm, startet .task das Laden; navigiert der Nutzer zurück, wird der Task abgebrochen, und dank der Abbruch-Zweige in load() erscheint keine Fehlermeldung. .refreshable ruft dieselbe async-Methode auf, und die Pull-Anzeige bleibt von selbst sichtbar, bis das await endet. In der View gibt es keine einzige DispatchQueue und keinen Callback; der Ablauf liest sich von oben nach unten.
Häufig gestellte Fragen
Läuft async/await im Hintergrund?
Nicht von selbst. await sagt nur, dass die Funktion an dieser Stelle unterbrochen werden kann. Wo Code läuft, bestimmt die Actor-Isolation: Eine Methode eines @MainActor-Typs läuft auf dem Main Actor, außer während sie an einem await unterbrochen ist.
Was ist der Unterschied zwischen Task und Task.detached?
Task { } erbt Actor und Priorität des Kontexts, in dem er erzeugt wird. Task.detached erbt nichts. Im Alltag reicht fast immer Task { } oder besser noch strukturierte Nebenläufigkeit (async let, TaskGroup, .task).
Was ist der Unterschied zwischen einem Actor und einer Klasse?
Beide sind Referenztypen. Ein Actor verhindert Data Races, indem er den Zugriff auf seinen veränderlichen Zustand serialisiert; deshalb verlangt der Zugriff von außen await. Actors unterstützen keine Vererbung.
Sollte man GCD und Completion Handler nicht mehr verwenden?
Sie funktionieren weiterhin und begegnen Ihnen in älterem Code. Bevorzugen Sie in neuem Code async/await; ältere APIs lassen sich in Continuations verpacken, sodass Sie schrittweise umstellen können.
Verwandte Artikel
SwiftUI @State, @Binding und @Observable: Datenfluss erklärt
Wann @State, @Binding, @Observable und @Environment in SwiftUI passen, wie sie zu ObservableObject stehen, mit Entscheidungstabelle und typischen Fehlern.
Swift Closures erklärt: $0, @escaping und [weak self]
Die Closure-Syntax in Swift Schritt für Schritt von der Langform bis $0: Trailing Closures, Werte einfangen, @escaping, Retain Cycles und [weak self].
SwiftUI: Erste App entwickeln – Schritt für Schritt
Die erste SwiftUI-App vom leeren Xcode-Projekt bis zum Simulator: App, View, VStack, @State, List und TextField in einer funktionierenden To-do-Liste.