Understanding Swift Concurrency: Async/Await Explained

Swift 5.5 introduced a revolutionary approach to handling asynchronous code. The new async/await syntax makes concurrent code more readable and safer than traditional completion handlers.

The Problem with Completion Handlers

Before async/await, asynchronous code often led to callback hell:

func fetchUserData(completion: @escaping (Result<User, Error>) -> Void) {
    fetchUserID { result in
        switch result {
        case .success(let id):
            fetchUserProfile(id: id) { result in
                switch result {
                case .success(let profile):
                    fetchUserPosts(userID: id) { result in
                        // More nesting...
                    }
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

Enter Async/Await

The same code with async/await is dramatically simpler:

func fetchUserData() async throws -> User {
    let id = try await fetchUserID()
    let profile = try await fetchUserProfile(id: id)
    let posts = try await fetchUserPosts(userID: id)
    return User(id: id, profile: profile, posts: posts)
}

Key Concepts

Async Functions

Mark functions as async when they perform asynchronous work:

func downloadImage(from url: URL) async throws -> UIImage {
    let (data, _) = try await URLSession.shared.data(from: url)
    guard let image = UIImage(data: data) else {
        throw ImageError.invalidData
    }
    return image
}

Await Keyword

The await keyword marks suspension points where the function can pause:

let image = await downloadImage(from: imageURL)
// Execution pauses here until the image is downloaded
print("Image downloaded!")

Task and Structured Concurrency

Use Task to create concurrent work:

Task {
    do {
        let user = try await fetchUserData()
        print("User loaded: \(user.name)")
    } catch {
        print("Error: \(error)")
    }
}

Actors for Thread Safety

Actors protect shared mutable state:

actor UserCache {
    private var cache: [String: User] = [:]

    func user(for id: String) -> User? {
        return cache[id]
    }

    func setUser(_ user: User, for id: String) {
        cache[id] = user
    }
}

let cache = UserCache()
await cache.setUser(user, for: "123")

Parallel Execution

Run multiple operations concurrently:

async let profile = fetchProfile()
async let posts = fetchPosts()
async let followers = fetchFollowers()

let (userProfile, userPosts, userFollowers) = await (profile, posts, followers)

Task Groups

For dynamic concurrency:

func fetchAllImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask {
                try await downloadImage(from: url)
            }
        }

        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

Best Practices

  1. Don't block threads - Use async/await instead of semaphores
  2. Use actors for shared mutable state
  3. Structure your concurrency with task groups
  4. Handle cancellation properly with Task.isCancelled
  5. Use MainActor for UI updates
@MainActor
func updateUI(with data: String) {
    label.text = data
}

Conclusion

Swift's concurrency model makes asynchronous code safer and more maintainable. Start using async/await in your projects today!