What is Structured Concurrency?
Structured concurrency is Swift's way of organizing concurrent tasks in a hierarchy, like a family tree. When you start a parent task, any child tasks it creates are tied to its lifetime.
Key benefits:
- Child tasks can't outlive their parent
- When a parent is cancelled, all children are cancelled
- Errors from children bubble up to the parent
- No manual cleanup needed
Think of it like this: If you're cooking dinner (parent task) and ask helpers to chop vegetables and boil water (child tasks), when dinner is cancelled, everyone stops cooking automatically.
How It's Achieved in Swift
Swift provides two main tools for structured concurrency:
1. TaskGroup - Dynamic Concurrency
TaskGroup is perfect when you don't know how many tasks you'll need at compile time. It allows you to:
- Add tasks dynamically in a loop
- Process results as they complete (not in order)
- Handle varying amounts of work
- Limit concurrency if needed
Think of it as a pool where you can keep adding swimmers (tasks) and collect them as they finish.
2. Async Let - Static Concurrency
Async let is ideal when you know exactly what parallel operations you need at compile time. It:
- Starts tasks immediately upon declaration
- Provides cleaner syntax for 2-5 concurrent operations
- Requires you to await all declared tasks
- Makes the code read more linearly
Think of it as starting a race where all runners (tasks) begin at the same time and you wait at the finish line.
Example 1: Downloading Files with TaskGroup
Here's a simple example of downloading multiple files concurrently:
import Foundation
struct DownloadResult {
let filename: String
let data: Data
}
func downloadFiles(_ urls: [URL]) async throws -> [DownloadResult] {
// Create a task group that returns DownloadResult
return try await withThrowingTaskGroup(of: DownloadResult.self) { group in
// Add a task for each URL
for url in urls {
group.addTask {
// Each task downloads one file
let (data, _) = try await URLSession.shared.data(from: url)
let filename = url.lastPathComponent
return DownloadResult(filename: filename, data: data)
}
}
// Collect all results
var results: [DownloadResult] = []
for try await result in group {
results.append(result)
}
return results
}
}
// Usage
Task {
let urls = [
URL(string: "https://example.com/file1.json")!,
URL(string: "https://example.com/file2.json")!,
URL(string: "https://example.com/file3.json")!
]
do {
let files = try await downloadFiles(urls)
print("Downloaded \(files.count) files")
// If any download fails, all others are automatically cancelled
// No need to manually manage tasks!
} catch {
print("Download failed: \(error)")
}
}
What's happening:
-
withThrowingTaskGroup
creates a scope for child tasks - Each file downloads in parallel
- If one fails, the error is thrown and remaining downloads are cancelled
- The parent task waits for all children to complete
Example 2: Fetching User Data with Async Let
When you need to fetch different types of data in parallel:
import Foundation
// Simple data models
struct User {
let name: String
let age: Int
}
struct Posts {
let count: Int
let recent: [String]
}
// Fetch functions (simulating API calls)
func fetchUser(id: Int) async throws -> User {
// Simulate network delay
try await Task.sleep(for: .seconds(1))
return User(name: "Alice", age: 30)
}
func fetchUserPosts(userId: Int) async throws -> Posts {
// Simulate network delay
try await Task.sleep(for: .seconds(1))
return Posts(count: 45, recent: ["Hello World", "Swift is awesome"])
}
// Fetch both in parallel using async let
func loadUserProfile(userId: Int) async throws -> (user: User, posts: Posts) {
// Start both operations at the same time
async let user = fetchUser(id: userId)
async let posts = fetchUserPosts(userId: userId)
// Wait for both to complete
// Total time: ~1 second (not 2!) because they run in parallel
return try await (user, posts)
}
// Usage
Task {
do {
let (user, posts) = try await loadUserProfile(userId: 123)
print("\(user.name) has \(posts.count) posts")
// Both requests run in parallel
// If either fails, the other is cancelled automatically
} catch {
print("Failed to load profile: \(error)")
}
}
What's happening:
-
async let
starts tasks immediately - Both API calls run in parallel
- The parent waits for both to complete
- If one fails, the other is automatically cancelled
Key Takeaways
- Structured = Organized: Tasks form a hierarchy where parents manage children
- Automatic Cleanup: No need to manually cancel or track tasks
- Use TaskGroup: When processing collections or unknown number of tasks at compile time
- Use Async Let: When you have 2-5 known operations that should run in parallel
- Cancellation Propagates: Cancel a parent, all children stop automatically
Simple Rule of Thumb
- Processing an array of similar items? → Use
TaskGroup
- Need 2-4 different things at once? → Use
async let
- Tasks automatically clean up when their scope ends
Conclusion
Structured concurrency in Swift transforms how we write concurrent code. Instead of manually managing threads, queues, and callbacks, we get a clean, hierarchical system where:
- Parent tasks manage their children automatically
- Cancellation and errors propagate naturally
- Resources are cleaned up without manual intervention
- Code reads linearly despite running concurrently
Top comments (1)
Structured concurrency is Swift's way of organizing concurrent tasks in a hierarchy, like a family tree. When you start a parent task, any child tasks it creates are tied to its lifetime.