Skip to main content

Swift Concurrency Basics

Introduction

Swift Concurrency is a modern approach to writing asynchronous and parallel code in Swift. Introduced in Swift 5.5, this model makes asynchronous programming more straightforward and less error-prone compared to traditional completion handler patterns. The concurrency features in Swift help you write code that can perform multiple operations simultaneously while maintaining code readability and safety.

In this guide, we'll explore the fundamental concepts of Swift Concurrency, providing you with the knowledge needed to begin writing concurrent code in your applications.

What is Concurrency?

Concurrency is the ability to perform multiple tasks at the same time. In programming, this means executing different parts of your code simultaneously to improve performance and responsiveness.

Before diving into Swift's specific concurrency features, let's understand why concurrency matters:

  • Performance: Utilize multi-core processors effectively
  • Responsiveness: Keep your app's UI responsive while performing heavy work
  • Resource Efficiency: Make better use of waiting time (like during network requests)

The Evolution of Asynchronous Code in Swift

The Old Way: Completion Handlers

Before Swift 5.5, developers typically used completion handlers (callbacks) for asynchronous operations:

swift
func fetchUserData(completion: @escaping (Result<User, Error>) -> Void) {
// Perform network request
// When complete, call the completion handler
if success {
completion(.success(user))
} else {
completion(.failure(error))
}
}

// Usage
fetchUserData { result in
switch result {
case .success(let user):
print("User fetched: \(user.name)")
case .failure(let error):
print("Error fetching user: \(error)")
}
}

This approach led to deeply nested callbacks, error handling complexity, and what's often called "callback hell."

The New Way: Async/Await

With Swift Concurrency, the same operation becomes more straightforward:

swift
func fetchUserData() async throws -> User {
// Perform network request
if success {
return user
} else {
throw SomeError.networkFailed
}
}

// Usage
do {
let user = try await fetchUserData()
print("User fetched: \(user.name)")
} catch {
print("Error fetching user: \(error)")
}

The code is now sequential, easier to read, and error handling is more intuitive using Swift's familiar try/catch mechanism.

Core Components of Swift Concurrency

1. Async/Await

The async and await keywords are the foundation of Swift Concurrency:

  • async: Declares that a function or method can be suspended during its execution
  • await: Marks the points in your code where a function might suspend execution

Here's a simple example:

swift
func fetchWeather(for city: String) async throws -> Weather {
// Simulate network request
try await Task.sleep(for: .seconds(1))
return Weather(temperature: 72, condition: "Sunny")
}

func displayWeather() async {
do {
let weather = try await fetchWeather(for: "San Francisco")
print("The temperature is \(weather.temperature)°F and it's \(weather.condition)")
// Output: The temperature is 72°F and it's Sunny
} catch {
print("Failed to fetch weather: \(error)")
}
}

2. Tasks

Tasks are units of asynchronous work in Swift. They provide a way to structure concurrent operations:

swift
// Creating a task
let task = Task {
return try await fetchWeather(for: "New York")
}

// Accessing the result
let weather = try await task.value
print("New York weather: \(weather.temperature)°F")

Task Cancellation

Tasks support built-in cancellation:

swift
let task = Task {
try await fetchWeather(for: "London")
}

// Later, cancel the task
task.cancel()

// Inside an async function, check for cancellation
func processSomething() async throws {
// Check if current task is cancelled
try Task.checkCancellation()

// Or cooperatively check while doing work
for item in items {
try Task.checkCancellation()
// process item
}
}

3. Task Groups

Task groups allow you to create and manage multiple child tasks:

swift
func fetchWeatherForMultipleCities() async throws -> [String: Weather] {
let cities = ["New York", "Los Angeles", "Chicago", "Miami"]

return try await withThrowingTaskGroup(of: (String, Weather).self) { group in
for city in cities {
group.addTask {
let weather = try await fetchWeather(for: city)
return (city, weather)
}
}

var results = [String: Weather]()
for try await (city, weather) in group {
results[city] = weather
}
return results
}
}

This code fetches weather data for multiple cities concurrently and collects the results.

4. Actors

Actors provide a way to isolate state and prevent data races:

swift
actor TemperatureLogger {
private var measurements: [Int] = []
private var max: Int = Int.min

func record(measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}

func getMax() -> Int {
return max
}
}

// Usage
let logger = TemperatureLogger()

// Must use await when accessing actor methods from outside
await logger.record(measurement: 25)
let max = await logger.getMax()
print("Maximum temperature: \(max)°C")

Practical Examples

Example 1: Fetching and Processing Images

Let's create a simple image downloader that processes images concurrently:

swift
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
}

func applyFilter(to image: UIImage) async -> UIImage {
// Simulate processing time
try? await Task.sleep(for: .seconds(0.5))
// Actual filter would go here
return image
}

func loadProfilePicture() async throws {
let profileURL = URL(string: "https://example.com/profile.jpg")!

let image = try await downloadImage(from: profileURL)
let filteredImage = await applyFilter(to: image)

// Update UI on the main actor
await MainActor.run {
profileImageView.image = filteredImage
}
}

Example 2: Parallel API Requests

Here's how to make multiple API requests in parallel and combine their results:

swift
func fetchUserProfile(id: String) async throws -> Profile {
let userURL = URL(string: "https://api.example.com/users/\(id)")!
let (userData, _) = try await URLSession.shared.data(from: userURL)
return try JSONDecoder().decode(Profile.self, from: userData)
}

func fetchUserFriends(id: String) async throws -> [Friend] {
let friendsURL = URL(string: "https://api.example.com/users/\(id)/friends")!
let (friendsData, _) = try await URLSession.shared.data(from: friendsURL)
return try JSONDecoder().decode([Friend].self, from: friendsData)
}

func loadUserData(id: String) async throws {
async let profile = fetchUserProfile(id: id)
async let friends = fetchUserFriends(id: id)

// Both requests run in parallel, then we wait for both to complete
let userData = try await (profile, friends)

print("User \(userData.0.name) has \(userData.1.count) friends")
}

Example 3: Task Prioritization

Different tasks can be given different priorities:

swift
// High priority task
let highPriorityTask = Task(priority: .high) {
try await fetchImportantData()
}

// Default priority task
let regularTask = Task {
try await fetchRegularData()
}

// Low priority task for background work
let backgroundTask = Task(priority: .low) {
try await performBackgroundProcessing()
}

Common Pitfalls and Best Practices

1. Avoiding Deadlocks

When using actors and tasks, be careful about potential deadlocks:

swift
// Potential deadlock pattern
actor MyActor {
func task1() async {
await task2() // Waiting for another method on the same actor
}

func task2() async {
await task1() // Circular dependency
}
}

2. MainActor for UI Updates

Always use MainActor for UI updates:

swift
func fetchAndUpdateUI() async throws {
let data = try await fetchData()

// Switch to the main thread for UI updates
await MainActor.run {
label.text = data.title
imageView.image = data.image
}
}

3. Structured Concurrency

Follow structured concurrency principles by ensuring tasks have proper lifetimes:

swift
// Good: Task is bounded by the function scope
func processData() async {
let task = Task {
try await heavyComputation()
}

// Task is guaranteed to be completed or cancelled when function exits
_ = try? await task.value
}

// Bad: Task may outlive its need
var globalTask: Task<Void, Never>?

func startBackgroundWork() {
globalTask = Task {
await infiniteLoop()
}
}

func stopBackgroundWork() {
globalTask?.cancel()
}

Summary

Swift Concurrency provides a powerful, modern approach to writing concurrent code. Its key components include:

  • async/await: A more readable way to write asynchronous code
  • Tasks: Units of asynchronous work with built-in cancellation support
  • Task Groups: For managing multiple concurrent operations
  • Actors: For safe state isolation in concurrent environments

By embracing these tools, you can write safer, more maintainable concurrent code that effectively leverages modern hardware capabilities while avoiding common pitfalls like race conditions and callback hell.

Further Learning

Practice Exercises

  1. Task Creation: Write a function that downloads multiple images concurrently using a task group, then combines them into a collage.

  2. Actor Practice: Create an actor to manage a thread-safe cache system that stores and retrieves data.

  3. Error Handling: Implement proper error handling in an async function that makes multiple network requests, ensuring errors are properly propagated.

Additional Resources

By building a solid foundation in Swift Concurrency basics, you'll be well-prepared to tackle more advanced concurrency patterns and create responsive, efficient applications.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)