Swift Concurrency — async/await, Task, Actors, and Structured Concurrency
In this tutorial, you will learn about Swift Concurrency. We cover key concepts, practical examples, and best practices to help you master this topic.
Swift concurrency introduces async/await syntax, actors, and structured concurrency to write safe, performant asynchronous code that eliminates callback nesting and data race conditions at compile time.
What You'll Learn
- async/await syntax and async functions
- Task and task priorities
- TaskGroup for parallel operations
- Actors for safe mutable state
- MainActor for UI thread dispatching
- Structured concurrency principles
Why It Matters
Before Swift concurrency, asynchronous code relied on completion handlers, delegate callbacks, and DispatchQueue. This led to deeply nested closures, forgotten error handling, and easy-to-miss thread-safety bugs. Swift's built-in concurrency model catches many issues at compile time and produces code that reads like synchronous code.
Real-World Use
A photo editing app downloads three images simultaneously, applies filters, and updates the UI. Using async/await with TaskGroup, the three downloads run in parallel. Each result is processed as it arrives. When all complete, the UI updates on the main actor. Error handling is centralized in a single do/catch block.
Learning Path
flowchart LR A[Data Persistence
Lesson 21] --> B[Concurrency
You are here] B --> C[Notifications
Lesson 23] B --> D[Combine Framework
Lesson 24] style B fill:#f90,color:#fff
async/await Syntax
An async function can suspend its execution without blocking the current thread.
import Foundation
func fetchUserName() async -> String {
try? await Task.sleep(nanoseconds: 1_000_000_000)
return "Alice"
}
func fetchUserAge() async -> Int {
try? await Task.sleep(nanoseconds: 500_000_000)
return 30
}
func fetchUserData() async {
async let name = fetchUserName()
async let age = fetchUserAge()
let result = await "User: \(name), Age: \(age)"
print(result)
}
Task {
await fetchUserData()
}
Output: User: Alice, Age: 30
The async keyword marks a function as asynchronous. The await keyword marks suspension points where the function may pause. async let runs multiple tasks in parallel and collects their results.
Throwing Async Functions
Async functions can throw errors, caught with standard do/catch.
enum NetworkError: Error {
case badURL
case requestFailed(String)
case invalidResponse
}
func fetchData(from urlString: String) async throws -> Data {
guard let url = URL(string: urlString) else {
throw NetworkError.badURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.requestFailed("Invalid status code")
}
return data
}
func loadContent() async {
do {
let data = try await fetchData(from: "https://api.example.com/data")
print("Received \(data.count) bytes")
} catch NetworkError.badURL {
print("Invalid URL provided")
} catch NetworkError.requestFailed(let message) {
print("Request failed: \(message)")
} catch {
print("Unexpected error: \(error.localizedDescription)")
}
}
Task {
await loadContent()
}
Task and Task Priority
A Task is a unit of asynchronous work. You create tasks to call async functions from synchronous contexts.
func performBackgroundWork() {
Task(priority: .background) {
print("Background task started (priority: background)")
try? await Task.sleep(nanoseconds: 1_000_000_000)
print("Background task completed")
}
Task(priority: .userInitiated) {
print("User-initiated task started (priority: high)")
try? await Task.sleep(nanoseconds: 500_000_000)
print("User-initiated task completed")
}
Task(priority: .utility) {
print("Utility task started (priority: low)")
try? await Task.sleep(nanoseconds: 700_000_000)
print("Utility task completed")
}
}
performBackgroundWork()
// Allow time for tasks to run
Thread.sleep(forTimeInterval: 2.0)
Tasks can be cancelled, and you should check for cancellation in long-running operations.
func processLargeDataset() async {
for i in 0..<1000 {
try? Task.checkCancellation()
// Process item i...
if i % 100 == 0 {
print("Processing... \(i)/1000")
}
}
print("Processing complete")
}
let task = Task {
await processLargeDataset()
}
// Cancel after a short time
Task {
try? await Task.sleep(nanoseconds: 200_000_000)
task.cancel()
print("Task cancelled")
}
TaskGroup for Parallel Operations
TaskGroup runs multiple tasks concurrently and collects their results.
import Foundation
func downloadFile(id: Int) async -> String {
let duration = UInt64.random(in: 500_000_000...1_500_000_000)
try? await Task.sleep(nanoseconds: duration)
return "File \(id) downloaded in \(duration / 1_000_000)ms"
}
func downloadAllFiles() async {
let results = await withTaskGroup(of: String.self) { group in
for i in 1...5 {
group.addTask {
return await downloadFile(id: i)
}
}
var collected: [String] = []
for await result in group {
collected.append(result)
print("Completed: \(result)")
}
return collected
}
print("All done: \(results.count) files downloaded")
}
Task {
await downloadAllFiles()
}
Output:
Completed: File 2 downloaded in 678ms
Completed: File 1 downloaded in 902ms
Completed: File 5 downloaded in 1100ms
Completed: File 3 downloaded in 1200ms
Completed: File 4 downloaded in 1450ms
All done: 5 files downloaded
All five downloads run simultaneously. Results are collected as they complete, not in order of submission.
Throwing TaskGroup
Use withThrowingTaskGroup when tasks can throw errors.
func fetchUser(id: Int) async throws -> String {
if id == 3 {
throw NetworkError.requestFailed("User not found")
}
try? await Task.sleep(nanoseconds: 500_000_000)
return "User_\(id)"
}
func fetchAllUsers() async {
do {
let users = try await withThrowingTaskGroup(of: String.self) { group in
for id in 1...5 {
group.addTask {
return try await fetchUser(id: id)
}
}
var results: [String] = []
for try await user in group {
results.append(user)
}
return results
}
print("Users: \(users)")
} catch {
print("Failed to fetch users: \(error)")
}
}
Task {
await fetchAllUsers()
}
Actors
Actors protect mutable state by ensuring only one task accesses the actor's isolated state at a time.
actor BankAccount {
private var balance: Double
init(initialBalance: Double) {
self.balance = initialBalance
}
func deposit(amount: Double) {
balance += amount
print("Deposited \(amount). Balance: \(balance)")
}
func withdraw(amount: Double) -> Bool {
guard balance >= amount else {
print("Insufficient funds. Balance: \(balance)")
return false
}
balance -= amount
print("Withdrew \(amount). Balance: \(balance)")
return true
}
func getBalance() -> Double {
return balance
}
}
let account = BankAccount(initialBalance: 1000.0)
Task {
await account.deposit(amount: 500.0)
}
Task {
let withdrew = await account.withdraw(amount: 200.0)
print("Withdrawal success: \(withdrew)")
}
Task {
let balance = await account.getBalance()
print("Final balance: \(balance)")
}
Output:
Deposited 500.0. Balance: 1500.0
Withdrew 200.0. Balance: 1300.0
Final balance: 1300.0
All calls to the actor use await because they cross the actor's isolation boundary. The actor ensures no two tasks modify balance simultaneously.
Sendable
Types passed between concurrency domains must conform to Sendable.
struct Transaction: Sendable {
let id: UUID
let amount: Double
let timestamp: Date
}
actor TransactionLogger {
private var transactions: [Transaction] = []
func log(_ transaction: Transaction) {
transactions.append(transaction)
}
func allTransactions() -> [Transaction] {
return transactions
}
}
let logger = TransactionLogger()
let transaction = Transaction(id: UUID(), amount: 99.99, timestamp: Date())
Task {
await logger.log(transaction)
let count = await logger.allTransactions().count
print("Logged \(count) transactions")
}
MainActor
@MainActor ensures code runs on the main thread, essential for UI updates.
import UIKit
@MainActor
class UserViewModel: ObservableObject {
@Published var userName: String = ""
@Published var isLoading = false
func loadUser() async {
isLoading = true
do {
let url = URL(string: "https://api.example.com/user")!
let (data, _) = try await URLSession.shared.data(from: url)
let user = try JSONDecoder().decode(User.self, from: data)
// These updates are on MainActor automatically
userName = user.name
isLoading = false
} catch {
userName = "Error loading"
isLoading = false
}
}
}
struct User: Decodable {
let name: String
}
// Usage:
// let viewModel = UserViewModel()
// Task { await viewModel.loadUser() }
Marking the entire class with @MainActor ensures all its properties and methods run on the main thread. Individual functions can also be marked with @MainActor.
Structured Concurrency
Structured concurrency means every task has a parent, and the parent waits for all children to complete before the scope exits.
func structuredConcurrencyDemo() async {
print("Starting parent task")
await withTaskGroup(of: Void.self) { group in
group.addTask {
print(" Child 1 starting")
try? await Task.sleep(nanoseconds: 1_000_000_000)
print(" Child 1 done")
}
group.addTask {
print(" Child 2 starting")
try? await Task.sleep(nanoseconds: 500_000_000)
print(" Child 2 done")
}
}
print("All children complete — parent continues")
}
Task {
await structuredConcurrencyDemo()
}
Output:
Starting parent task
Child 1 starting
Child 2 starting
Child 2 done
Child 1 done
All children complete — parent continues
If the parent task is cancelled, all child tasks are automatically cancelled. If a child throws an unhandled error in a throwing group, all siblings are cancelled.
Common Mistakes
Calling async functions without await: Swift enforces
awaitat compile time. Missingawaitproduces a compiler error, not a runtime issue.Not checking Task cancellation: Long-running tasks should periodically check
Task.isCancelledor callTask.checkCancellation()to respond to cancellation.Actor deadlocks: An actor calling its own method synchronously is safe, but calling another actor's method without await crosses an isolation boundary and must use await.
Using Task without storing the reference: A detached task (
Task.detached) runs independently. UseTask(structured) by default; use detached only when you need a separate parent context.UI updates off the main thread: Use
@MainActororawait MainActor.run { ... }to ensure UI updates happen on the main thread.
Practice Questions
- What is the difference between
TaskandTask.detached? - How do actors prevent data races?
- What is the purpose of
@MainActor? - How does TaskGroup enable parallel execution?
- Challenge: Build an async image cache that downloads images in parallel using a TaskGroup, stores them in an actor for thread-safe access, and provides a
@MainActorclass for UI binding.
Mini Project
Create a ConcurrentImageDownloader with:
- An
ImageDownloaderactor that tracks download progress and cached images - A
downloadImages(urls: [URL]) async -> [UIImage]function usingwithThrowingTaskGroup - A
@MainActorclass that observes download progress and updates a progress label - Error handling for invalid URLs and network failures
- Cancellation support: if the user navigates away, all downloads cancel
FAQ
What's Next
With concurrency mastered, explore Reactive Programming with Combine Framework for event streams, or learn about Notifications for push and local notifications.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro