async/await in Swift
Eine Funktion, die warten muss, ist mit async markiert, und jeder Aufruf steht hinter await. An dieser Stelle darf das Programm pausieren, ohne einen Thread zu blockieren. Parallel arbeitest du mit async let oder einer Task-Gruppe. Abbrechen ist kooperativ: Der Code prüft selbst, ob er aufhören soll. Actors schützen veränderlichen Zustand, und Swift 6 erkennt viele Datenrennen schon beim Übersetzen.
Danach kannst du
- schreibst und rufst async-Funktionen mit await auf
- startest Arbeit parallel mit async let und withTaskGroup
- brichst Tasks ab und reagierst korrekt darauf
- schützt gemeinsamen Zustand mit einem Actor
- liest die Datenrennen-Meldung des Swift-6-Compilers
Voraussetzungen
Umgebung
Swift 6.4.0, Sprachmodus 6 (vollständige Concurrency-Prüfung)
plattformunabhängig, geprüft unter Linux x86-64
Die Beispiele sind Skripte mit Code auf oberster Ebene. In Apps startet dieser Code meist in einer async-Funktion oder einem Task.
Auf dieser Seite
async und await
Manche Arbeit dauert, weil auf etwas gewartet wird: auf eine Antwort aus dem Netz, auf eine Datei, auf einen Timer. Früher hat man dafür Callbacks verschachtelt. Seit Swift 5.5 schreibst du solchen Code von oben nach unten, wie normalen Code (SE-0296):
func ladeProfil(id: Int) async -> String {
// Simuliert eine langsame Quelle, zum Beispiel ein Netzwerk
try? await Task.sleep(for: .milliseconds(100))
return "Profil \(id)"
}
func ladeBeitraege(fuer id: Int) async -> [String] {
try? await Task.sleep(for: .milliseconds(150))
return ["Beitrag A von \(id)", "Beitrag B von \(id)"]
}
// Nacheinander: die zweite Anfrage startet erst nach der ersten
let start = ContinuousClock.now
let profil = await ladeProfil(id: 7)
let beitraege = await ladeBeitraege(fuer: 7)
let nacheinander = ContinuousClock.now - start
print(profil, beitraege.count)
// Gleichzeitig mit async let: beide laufen parallel
let start2 = ContinuousClock.now
async let p = ladeProfil(id: 8)
async let b = ladeBeitraege(fuer: 8)
let (profil2, beitraege2) = await (p, b)
let parallel = ContinuousClock.now - start2
print(profil2, beitraege2.count)
print("parallel schneller:", parallel < nacheinander)
Ausgabe
Profil 7 2 Profil 8 2 parallel schneller: true
async in der Signatur heißt: Diese Funktion darf pausieren. await beim Aufruf markiert die Stelle, an der das passieren kann. Während die Funktion wartet, ist der Thread frei für andere Arbeit. Wie try bei Fehlern ist await ein Hinweis für Leser: Hier kann Zeit vergehen, und danach kann sich die Welt verändert haben.
Task.sleep(for:) simuliert hier eine langsame Quelle. Es kann einen Fehler werfen, wenn die Arbeit abgebrochen wird, deshalb steht try? davor. Mehr dazu weiter unten.
Nacheinander oder gleichzeitig
Die ersten beiden Aufrufe laufen nacheinander: Das Programm wartet etwa 100 Millisekunden auf das Profil und erst dann 150 auf die Beiträge. Hängen die beiden nicht voneinander ab, ist das verschenkte Zeit.
Mit async let startest du beide sofort und wartest erst später gemeinsam auf die Ergebnisse. Die Gesamtzeit ist dann ungefähr die der langsameren Aufgabe. Die letzte Zeile der Ausgabe bestätigt, dass die parallele Variante schneller war. Die genauen Zeiten schwanken von Lauf zu Lauf, deshalb gibt das Beispiel nur den Vergleich aus.
Task-Gruppen
async let passt, wenn du die Anzahl der Aufgaben beim Schreiben kennst. Für eine Liste unbekannter Länge gibt es Task-Gruppen:
func pruefsumme(von datei: String) async -> Int {
try? await Task.sleep(for: .milliseconds(datei.count * 10))
return datei.unicodeScalars.reduce(0) { $0 + Int($1.value) }
}
let dateien = ["a.swift", "Package.swift", "README.md", "main.swift"]
// Eine Task-Gruppe startet beliebig viele Kind-Tasks und sammelt die Ergebnisse ein
let ergebnisse = await withTaskGroup(of: (String, Int).self) { gruppe in
for datei in dateien {
gruppe.addTask { (datei, await pruefsumme(von: datei)) }
}
var gesammelt: [String: Int] = [:]
for await (datei, summe) in gruppe {
gesammelt[datei] = summe
}
return gesammelt
}
// Ergebnisse kommen in beliebiger Reihenfolge an, daher in fester Reihenfolge ausgeben
for datei in dateien {
print(datei, ergebnisse[datei]!)
}
Ausgabe
a.swift 700 Package.swift 1287 README.md 685 main.swift 1024
withTaskGroup startet für jede Datei eine Kind-Task. Mit for await sammelst du die Ergebnisse ein, in der Reihenfolge, in der sie fertig werden, nicht in der, in der du sie gestartet hast. Deshalb landen sie im Dictionary und werden in der Reihenfolge der Eingabe ausgegeben.
Das ist strukturierte Nebenläufigkeit (SE-0304): Keine Kind-Task überlebt ihre Gruppe. Wenn withTaskGroup zurückkehrt, sind alle fertig. Wird die umgebende Task abgebrochen, werden auch alle Kinder abgebrochen. Du kannst keine Arbeit „vergessen“, die im Hintergrund weiterläuft.
Abbrechen
Abbrechen ist in Swift kooperativ: cancel() setzt nur eine Markierung. Die Task selbst entscheidet, wann sie darauf reagiert.
func verarbeite(_ anzahl: Int) async throws -> Int {
var erledigt = 0
for _ in 1...anzahl {
try Task.checkCancellation() // wirft CancellationError, wenn abgebrochen wurde
try await Task.sleep(for: .milliseconds(20))
erledigt += 1
}
return erledigt
}
let aufgabe = Task {
try await verarbeite(1_000)
}
try? await Task.sleep(for: .milliseconds(100))
aufgabe.cancel()
do {
let n = try await aufgabe.value
print("fertig:", n)
} catch is CancellationError {
print("abgebrochen, bevor alle 1000 Schritte erledigt waren")
}
Ausgabe
abgebrochen, bevor alle 1000 Schritte erledigt waren
Task.checkCancellation() wirft einen CancellationError, sobald die Task abgebrochen wurde. Auch Task.sleep wirft sofort, wenn die Task während des Schlafens abgebrochen wird. Die Schleife endet also nach wenigen Durchläufen statt nach 1000. Der Fehler wandert über try await aufgabe.value nach außen und wird dort gezielt mit catch is CancellationError behandelt.
Prüfe auf Abbruch vor jedem teuren Schritt. Code, der nie prüft, läuft nach cancel() einfach weiter bis zum Ende. Wenn du statt eines Fehlers lieber einen Teilwert zurückgibst, fragst du Task.isCancelled ab.
Actors: Zustand schützen
Sobald mehrere Tasks gleichzeitig denselben veränderlichen Zustand ändern, droht ein Datenrennen: Zwei Zugriffe überschneiden sich, und das Ergebnis hängt vom Zufall ab. Swift 6 lehnt solchen Code beim Übersetzen ab:
final class Zaehler {
var stand = 0
}
func zaehleParallel() async {
let zaehler = Zaehler()
await withTaskGroup(of: Void.self) { gruppe in
for _ in 1...10 {
gruppe.addTask { zaehler.stand += 1 }
}
}
print(zaehler.stand)
}
Compiler-Meldung
datenrennen.swift:9:20: error: passing closure as a 'sending' parameter risks causing data races between code in the current isolation context and concurrent execution of the closure [#RegionIsolation::SendingClosureRisksDataRace]
7 | await withTaskGroup(of: Void.self) { gruppe in
8 | for _ in 1...10 {
9 | gruppe.addTask { zaehler.stand += 1 }
| | `- note: closure captures 'zaehler' which is accessible to code in the current isolation context
| `- error: passing closure as a 'sending' parameter risks causing data races between code in the current isolation context and concurrent execution of the closure [#RegionIsolation::SendingClosureRisksDataRace]
10 | }
11 | }
[#RegionIsolation]: <https://docs.swift.org/compiler/documentation/diagnostics/region-isolation>
[#SendingClosureRisksDataRace]: <https://docs.swift.org/compiler/documentation/diagnostics/sending-closure-risks-data-race>Die Meldung ist lang, sagt aber genau das: Das Closure, das parallel laufen soll, fängt zaehler ein, ein Objekt, das auch der aufrufende Code sieht. Zehn Tasks könnten stand += 1 gleichzeitig ausführen. Am Ende der Meldung verlinkt der Compiler eine Erklärung auf swift.org.
Die Lösung ist ein Actor (SE-0306). Ein Actor ist wie eine Klasse, lässt aber immer nur eine Task zur Zeit an seinen Zustand:
actor Konto {
private(set) var stand = 0
func einzahlen(_ betrag: Int) {
stand += betrag
}
}
let konto = Konto()
// 1000 gleichzeitige Einzahlungen: der Actor lässt immer nur eine zur Zeit an seinen Zustand
await withTaskGroup(of: Void.self) { gruppe in
for _ in 1...1_000 {
gruppe.addTask { await konto.einzahlen(1) }
}
}
print(await konto.stand)
Ausgabe
1000
Tausend gleichzeitige Einzahlungen, und das Ergebnis ist zuverlässig 1000. Von außen ist jeder Zugriff auf einen Actor async, deshalb steht await vor konto.einzahlen(1) und vor konto.stand: Wenn der Actor gerade beschäftigt ist, wartet der Aufrufer, ohne zu blockieren.
Ein Struct hätte das Problem gar nicht erst: Werttypen werden kopiert statt geteilt (siehe Structs, Klassen und Enums). Typen, die sich sicher zwischen Tasks weitergeben lassen, erfüllen das Protokoll Sendable; für viele Structs leitet der Compiler das selbst ab.
Was das für Apps bedeutet
In Apps läuft die Oberfläche auf dem Main Actor, einem besonderen Actor für alles, was den Bildschirm verändert. SwiftUI-Views sind ihm zugeordnet. Lange Arbeit startest du mit await von dort aus, und das Ergebnis kommt automatisch wieder auf dem Main Actor an. Wie Xcode neue Projekte dafür voreinstellt, hat sich mit den letzten Swift-Versionen geändert; den aktuellen Stand und die Einstellungen beschreibt der Migrationsleitfaden auf swift.org.
Häufige Fehler
expression is 'async' but is not marked with 'await'. Vor einem async-Aufruf fehlt await:
func ladeZahl() async -> Int { 42 }
func zeige() async {
let zahl = ladeZahl()
print(zahl)
}
Compiler-Meldung
await-fehlt.swift:4:16: error: expression is 'async' but is not marked with 'await'
2 |
3 | func zeige() async {
4 | let zahl = ladeZahl()
| |- error: expression is 'async' but is not marked with 'await'
| `- note: call is 'async'
5 | print(zahl)
6 | }'async' call in a function that does not support concurrency. Du rufst async-Code aus einer normalen Funktion auf. Mach sie async oder starte einen Task { }.
passing closure as a 'sending' parameter risks causing data races …. Ein paralleles Closure teilt veränderlichen Zustand. Nutze einen Actor, einen Werttyp oder gib das Ergebnis aus der Task zurück, statt es von innen zu schreiben.
Übungen
- Schreibe
func quadratLangsam(_ n: Int) async -> Int, die 50 Millisekunden schläft undn * nliefert. Berechne mit einer Task-Gruppe die Quadrate von 1 bis 10 und gib ihre Summe aus. - Ändere das Konto-Beispiel: Ergänze eine Methode
abheben(_ betrag: Int) throws -> Int, die bei zu geringem Stand einen Fehler wirft. Führe 10 Einzahlungen und danach eine zu große Abhebung aus und gib Stand und Fehler aus.
Lösung zu Übung 1
func quadratLangsam(_ n: Int) async -> Int {
try? await Task.sleep(for: .milliseconds(50))
return n * n
}
let summe = await withTaskGroup(of: Int.self) { gruppe in
for n in 1...10 {
gruppe.addTask { await quadratLangsam(n) }
}
var gesamt = 0
for await quadrat in gruppe {
gesamt += quadrat
}
return gesamt
}
print(summe)
Ausgabe
385
Die Summe hängt nicht von der Reihenfolge ab, deshalb kannst du die Ergebnisse direkt aufaddieren, sobald sie ankommen.
Lösung zu Übung 2
enum KontoFehler: Error {
case nichtGenugGuthaben(verfuegbar: Int)
}
actor Konto {
private(set) var stand = 0
func einzahlen(_ betrag: Int) {
stand += betrag
}
func abheben(_ betrag: Int) throws -> Int {
guard betrag <= stand else {
throw KontoFehler.nichtGenugGuthaben(verfuegbar: stand)
}
stand -= betrag
return betrag
}
}
let konto = Konto()
for _ in 1...10 {
await konto.einzahlen(5)
}
do {
_ = try await konto.abheben(80)
} catch KontoFehler.nichtGenugGuthaben(let verfuegbar) {
print("Abgelehnt: nur \(verfuegbar) verfügbar")
}
print("Stand:", await konto.stand)
Ausgabe
Abgelehnt: nur 50 verfügbar Stand: 50
Aufrufe von außen brauchen hier try await: await, weil es ein Actor ist, try, weil die Methode werfen kann.