Swift Structured Concurrency
Introduction
Swift's structured concurrency model provides a safe and efficient way to write concurrent code. Introduced in Swift 5.5, it represents a significant improvement over traditional concurrency approaches like completion handlers and Grand Central Dispatch (GCD). Structured concurrency makes asynchronous code easier to read, write, and reason about by connecting the lifetimes of tasks to your program's structure.
In this tutorial, you'll learn:
- What structured concurrency is and why it matters
- How to work with async/await syntax
- Understanding tasks and task management
- Working with task groups for parallel execution
- Error handling in concurrent code
What is Structured Concurrency?
Structured concurrency is a programming paradigm that ensures async operations follow a clear hierarchy and lifecycle. The key principle is that tasks started in a function must complete before the function returns. This creates a parent-child relationship between tasks that helps manage resources and avoid common concurrency issues.
Key Benefits:
- Predictable Execution: Tasks follow a hierarchical structure matching your code
- Automatic Cancellation: Child tasks get cancelled when their parent task ends
- Simplified Error Handling: Errors propagate naturally through the task hierarchy
- Resource Management: Resources are properly released when tasks complete
Async/Await: The Foundation of Structured Concurrency
The async/await
pattern forms the basis of Swift structured concurrency.
Async Functions
An async
function can suspend execution while waiting for something to complete:
func fetchUserData() async -> User {
// Asynchronous code that can suspend
let data = await networkService.getData()
return User(from: data)
}
The Await Keyword
The await
keyword marks suspension points in your code where execution might pause:
func displayUserProfile() async {
let user = await fetchUserData() // Code might suspend here
updateUI(with: user) // Runs after fetchUserData completes
}
Example: Basic Async/Await
Let's see a complete example with simulated network calls:
// Simulating a network request with a delay
func fetchWeatherData(for city: String) async -> String {
// Simulate network delay
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
return "Sunny, 75°F in \(city)"
}
// Using the async function
func showWeatherReport() async {
print("Fetching weather data...")
let nyWeather = await fetchWeatherData(for: "New York")
print(nyWeather)
}
// Running the async function
Task {
await showWeatherReport()
}
// Output:
// Fetching weather data...
// Sunny, 75°F in New York
Understanding Tasks
Tasks are units of asynchronous work in Swift. They manage the execution of async code and provide a structured way to handle concurrency.
Types of Tasks
- Unstructured Tasks: Created with
Task { }
and can exist independently - Child Tasks: Created within a parent task and tied to its lifecycle
- Detached Tasks: Created with
Task.detached { }
and run independently from their creating context
Creating Tasks
Here's how to create and work with tasks:
// Unstructured task
let task = Task {
return await fetchWeatherData(for: "London")
}
let result = await task.value // Wait for the result
// Child task (created within a function)
func processWeatherData() async {
let childTask = Task {
return await fetchWeatherData(for: "Paris")
}
// childTask automatically gets cancelled if this function returns early
let parisWeather = await childTask.value
print(parisWeather)
}
// Detached task (independent lifecycle)
let detachedTask = Task.detached {
return await fetchWeatherData(for: "Tokyo")
}
Task Management
Tasks can be cancelled, checked for cancellation, and their priority adjusted:
// Creating a task
let task = Task {
// Check if cancelled
if Task.isCancelled {
return "Cancelled"
}
// Potentially long operation
return await fetchWeatherData(for: "Miami")
}
// Cancel the task
task.cancel()
// Check the result (might be cancelled)
do {
let result = try await task.value
print("Result: \(result)")
} catch {
print("Task failed: \(error)")
}
Task Groups for Parallel Execution
Task groups allow you to create and manage multiple child tasks that run in parallel.
Creating and Using Task Groups
func getWeatherForMultipleCities() async throws -> [String] {
try await withTaskGroup(of: String.self) { group in
let cities = ["New York", "London", "Tokyo", "Sydney"]
// Add tasks to the group
for city in cities {
group.addTask {
await fetchWeatherData(for: city)
}
}
// Collect results
var results = [String]()
for await result in group {
results.append(result)
}
return results
}
}
// Using the task group
Task {
do {
let weatherReports = try await getWeatherForMultipleCities()
print("Weather reports received: \(weatherReports.count)")
for report in weatherReports {
print(report)
}
} catch {
print("Failed to get weather data: \(error)")
}
}
// Possible output:
// Weather reports received: 4
// Sunny, 75°F in New York
// Rainy, 65°F in London
// Cloudy, 70°F in Tokyo
// Clear, 80°F in Sydney
Benefits of Task Groups
- Parallel Execution: All tasks in a group can run concurrently
- Structured Lifecycle: All child tasks complete (or are cancelled) before the group completes
- Automatic Cancellation: Cancelling the group cancels all child tasks
- Easy Result Collection: Use
for await
to collect results as they complete
Handling Errors in Structured Concurrency
Swift's structured concurrency makes error handling more natural by integrating with Swift's existing error handling mechanisms.
enum WeatherError: Error {
case networkFailure
case invalidCity
}
func fetchWeatherData(for city: String) async throws -> String {
// Simulate failure for a specific city
if city == "Unknown" {
throw WeatherError.invalidCity
}
// Simulate network delay
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
return "Sunny, 75°F in \(city)"
}
// Error handling with task groups
func getWeatherForCities(_ cities: [String]) async {
do {
try await withThrowingTaskGroup(of: String.self) { group in
for city in cities {
group.addTask {
try await fetchWeatherData(for: city)
}
}
// Process results as they complete
for try await result in group {
print("Weather report: \(result)")
}
}
print("All weather data fetched successfully")
} catch {
print("Error fetching weather: \(error)")
}
}
// Test with a mix of valid and invalid cities
Task {
await getWeatherForCities(["New York", "Unknown", "Tokyo"])
}
// Possible output:
// Weather report: Sunny, 75°F in New York
// Error fetching weather: invalidCity
Real-World Example: Concurrent Image Loading
Let's build a more practical example that loads multiple images concurrently and combines them:
actor ImageCache {
private var cache = [URL: UIImage]()
func image(for url: URL) -> UIImage? {
return cache[url]
}
func setImage(_ image: UIImage, for url: URL) {
cache[url] = image
}
}
class ImageLoader {
private let cache = ImageCache()
func loadImage(from url: URL) async throws -> UIImage {
// Check cache first
if let cachedImage = await cache.image(for: url) {
print("Cache hit for \(url.lastPathComponent)")
return cachedImage
}
print("Downloading \(url.lastPathComponent)...")
// Simulate network request
try await Task.sleep(nanoseconds: UInt64.random(in: 1...3) * 1_000_000_000)
// In a real app, you'd do:
// let (data, _) = try await URLSession.shared.data(from: url)
// guard let image = UIImage(data: data) else { throw ImageError.invalidData }
// Here we'll just create a placeholder image
let image = UIImage() // Placeholder
// Cache the result
await cache.setImage(image, for: url)
return image
}
func loadImages(from urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
// Start all downloads concurrently
for (index, url) in urls.enumerated() {
group.addTask {
let image = try await self.loadImage(from: url)
return (index, image)
}
}
// Collect results in the correct order
var images = Array<UIImage?>(repeating: nil, count: urls.count)
for try await (index, image) in group {
images[index] = image
}
return images.compactMap { $image }
}
}
}
// Example usage
Task {
let imageLoader = ImageLoader()
let urls = [
URL(string: "https://example.com/image1.jpg")!,
URL(string: "https://example.com/image2.jpg")!,
URL(string: "https://example.com/image3.jpg")!
]
do {
let startTime = Date()
let images = try await imageLoader.loadImages(from: urls)
let duration = Date().timeIntervalSince(startTime)
print("Loaded \(images.count) images in \(String(format: "%.2f", duration)) seconds")
} catch {
print("Failed to load images: \(error)")
}
}
// Possible output:
// Downloading image1.jpg...
// Downloading image2.jpg...
// Downloading image3.jpg...
// Loaded 3 images in 3.02 seconds
Priority and Cancellation
Swift tasks support different priorities and cancellation:
// Task with specific priority
let highPriorityTask = Task(priority: .high) {
await fetchWeatherData(for: "Important City")
}
// Task with cancellation check
func processingWithCancellationCheck() async throws -> String {
// Check for cancellation at various points
for i in 1...10 {
try Task.checkCancellation() // Throws if cancelled
// Or use this pattern:
if Task.isCancelled {
return "Task was cancelled at step \(i)"
}
// Do some work
try await Task.sleep(nanoseconds: 500_000_000)
}
return "Completed successfully"
}
// Using the cancellable task
let cancellableTask = Task {
do {
return try await processingWithCancellationCheck()
} catch is CancellationError {
return "Task was cancelled"
} catch {
return "Other error: \(error)"
}
}
// Cancel after a delay
Task {
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
cancellableTask.cancel()
print("Task cancelled")
}
let result = await cancellableTask.value
print("Result: \(result)")
// Possible output:
// Task cancelled
// Result: Task was cancelled at step 5
Summary
Swift's structured concurrency model provides a powerful way to write safe, efficient concurrent code. By organizing asynchronous work into a hierarchy of tasks, it helps prevent common concurrency issues like memory leaks, race conditions, and complex error handling.
Key takeaways:
async/await
provides clear syntax for asynchronous code- Tasks organize and manage concurrent work
- Task groups enable parallel execution with structured lifecycle
- Structured concurrency integrates with Swift's error handling
- Cancellation propagates automatically through the task hierarchy
With structured concurrency, Swift developers can write concurrent code that's easier to read, debug, and maintain.
Additional Resources
- Swift.org: Concurrency
- WWDC21: Meet async/await in Swift
- WWDC21: Explore structured concurrency in Swift
Exercises
-
Create a function that fetches data from multiple APIs in parallel using a task group and combines the results.
-
Implement a timeout mechanism for an async task that automatically cancels if it takes too long.
-
Build a simple image gallery app that loads thumbnails concurrently and displays them as they become available.
-
Implement a caching system using actors and structured concurrency to efficiently load and cache remote resources.
-
Create a task that can be paused, resumed and cancelled, and demonstrate its usage in a practical scenario.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)