Swift Concurrency in Practice: async/await, Actors, and Structured Concurrency
Swift Concurrency, introduced in Swift 5.5 and significantly enhanced through Swift 6, provides a safe and expressive model for writing concurrent code. Gone are the days of callback pyramids and DispatchQueue juggling.
The Basics: async/await
Defining and Calling Async Functions
func fetchUser(id: String) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw APIError.invalidResponse
}
return try JSONDecoder().decode(User.self, from: data)
}
Bridging from Synchronous Code
override func viewDidLoad() {
super.viewDidLoad()
Task {
await loadProfile()
}
}
func fetchImageFromLegacyAPI(_ url: URL) async throws -> UIImage {
try await withCheckedThrowingContinuation { continuation in
legacyImageLoader.load(url: url) { result in
switch result {
case .success(let image): continuation.resume(returning: image)
case .failure(let error): continuation.resume(throwing: error)
}
}
}
}
Actor Model: Safe Shared State
Actors prevent data races at compile time by isolating mutable state.
actor UserCache {
private var cache: [String: User] = [:]
private var fetchTasks: [String: Task<User, Error>] = [:]
func user(for id: String) async throws -> User {
if let cached = cache[id] { return cached }
if let existingTask = fetchTasks[id] {
return try await existingTask.value
}
let task = Task<User, Error> {
try await APIClient.shared.fetchUser(id: id)
}
fetchTasks[id] = task
do {
let user = try await task.value
cache[id] = user
fetchTasks.removeValue(forKey: id)
return user
} catch {
fetchTasks.removeValue(forKey: id)
throw error
}
}
func invalidate(id: String) { cache.removeValue(forKey: id) }
}
@MainActor
@MainActor
class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var error: Error?
private let userCache = UserCache()
func load(userId: String) async {
isLoading = true
defer { isLoading = false }
do {
user = try await userCache.user(for: userId)
} catch {
self.error = error
}
}
}
Structured Concurrency
Concurrent Async Let
func fetchAllUserData(userId: String) async throws -> UserDashboard {
async let profile = APIClient.shared.fetchProfile(userId: userId)
async let posts = APIClient.shared.fetchPosts(userId: userId)
async let followers = APIClient.shared.fetchFollowers(userId: userId)
return try await UserDashboard(
profile: profile,
posts: posts,
followers: followers
)
}
TaskGroup for Dynamic Work
func downloadImages(urls: [URL]) async throws -> [URL: UIImage] {
try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in
for url in urls {
group.addTask {
let image = try await ImageLoader.shared.load(url: url)
return (url, image)
}
}
var results: [URL: UIImage] = [:]
for try await (url, image) in group {
results[url] = image
}
return results
}
}
Task Cancellation
Cooperative Cancellation
func processLargeDataset(_ items: [DataItem]) async throws -> [ProcessedItem] {
var results: [ProcessedItem] = []
for (index, item) in items.enumerated() {
try Task.checkCancellation()
let processed = try await processItem(item)
results.append(processed)
if index % 100 == 0 {
await Task.yield()
}
}
return results
}
Cancellable View Tasks in SwiftUI
struct SearchView: View {
@State private var query = ""
@State private var results: [SearchResult] = []
var body: some View {
VStack {
SearchBar(text: $query)
ResultsList(results: results)
}
.task(id: query) {
guard !query.isEmpty else { results = []; return }
do {
try await Task.sleep(for: .milliseconds(300))
results = try await SearchService.search(query: query)
} catch is CancellationError {
// Ignore cancellation
} catch {
print("Search error:", error)
}
}
}
}
AsyncSequence
func processEvents() async {
let stream = AsyncStream<WebSocketMessage> { continuation in
webSocket.onMessage = { message in
continuation.yield(message)
}
webSocket.onClose = {
continuation.finish()
}
}
for await message in stream {
await handleMessage(message)
}
}
Swift 6 Strict Concurrency Checking
// Sendable conformance
struct UserData: Sendable {
let id: String
let name: String
}
// @unchecked Sendable for manual safety guarantees
final class ThreadSafeCounter: @unchecked Sendable {
private var count = 0
private let lock = NSLock()
func increment() {
lock.withLock { count += 1 }
}
var value: Int { lock.withLock { count } }
}
Performance Patterns
Limit concurrency to avoid overwhelming the system:
func batchProcess<T>(items: [T], maxConcurrency: Int = 4) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
var inFlight = 0
for item in items {
if inFlight >= maxConcurrency {
try await group.next()
inFlight -= 1
}
group.addTask { try await processItem(item) }
inFlight += 1
}
try await group.waitForAll()
}
}
Conclusion
Swift Concurrency transforms iOS and macOS development. The async/await syntax eliminates callback pyramids, actors prevent data races at compile time, and structured concurrency makes parallel work safe and readable. With Swift 6's strict checking, the compiler becomes your concurrency safety net, leading to code that is easier to reason about, safer, and often faster.