DEV Community

ArshTechPro
ArshTechPro

Posted on

Structured Concurrency in Swift

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)")
    }
}
Enter fullscreen mode Exit fullscreen mode

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)")
    }
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Structured = Organized: Tasks form a hierarchy where parents manage children
  2. Automatic Cleanup: No need to manually cancel or track tasks
  3. Use TaskGroup: When processing collections or unknown number of tasks at compile time
  4. Use Async Let: When you have 2-5 known operations that should run in parallel
  5. 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)

Collapse
 
arshtechpro profile image
ArshTechPro

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.