The Evolution of Concurrency in Swift
iOS apps need to handle multiple tasks simultaneously: network requests, UI updates, data processing, and user interactions. Over the years, I've used various concurrency approaches in production apps, from NSOperationQueue to GCD to modern async/await.
In this comprehensive guide, I'll walk you through all the major concurrency tools available to iOS developers, with real examples from apps I've built and maintained.
1. Grand Central Dispatch (GCD)
GCD remains the foundation of iOS concurrency. Understanding queues and how they work is essential for any iOS developer.
Serial vs Concurrent Queues
// Serial Queue - tasks execute one after another
let serialQueue = DispatchQueue(label: "com.app.serial")
serialQueue.async {
print("Task 1 started")
Thread.sleep(forTimeInterval: 2)
print("Task 1 completed")
}
serialQueue.async {
print("Task 2 started") // Waits for Task 1 to complete
print("Task 2 completed")
}
// Concurrent Queue - tasks can execute simultaneously
let concurrentQueue = DispatchQueue(label: "com.app.concurrent",
attributes: .concurrent)
concurrentQueue.async {
print("Task A started")
Thread.sleep(forTimeInterval: 2)
print("Task A completed")
}
concurrentQueue.async {
print("Task B started") // Starts immediately
print("Task B completed")
}
Common GCD Patterns
// Background work with main thread UI update
DispatchQueue.global(qos: .userInitiated).async {
// Heavy computation on background thread
let result = performHeavyCalculation()
DispatchQueue.main.async {
// Update UI on main thread
self.updateUI(with: result)
}
}
// Delayed execution
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.showDelayedMessage()
}
// Quality of Service levels
DispatchQueue.global(qos: .userInteractive).async { /* High priority */ }
DispatchQueue.global(qos: .userInitiated).async { /* User initiated */ }
DispatchQueue.global(qos: .default).async { /* Default priority */ }
DispatchQueue.global(qos: .utility).async { /* Long-running tasks */ }
DispatchQueue.global(qos: .background).async { /* Background tasks */ }
2. Operations and OperationQueue
For complex task management with dependencies and cancellation, Operations provide more control than GCD.
Custom Operations
class ImageDownloadOperation: Operation {
private let url: URL
private let completion: (UIImage?) -> Void
init(url: URL, completion: @escaping (UIImage?) -> Void) {
self.url = url
self.completion = completion
super.init()
}
override func main() {
guard !isCancelled else { return }
do {
let data = try Data(contentsOf: url)
let image = UIImage(data: data)
DispatchQueue.main.async {
self.completion(image)
}
} catch {
DispatchQueue.main.async {
self.completion(nil)
}
}
}
}
// Using Operations with dependencies
let downloadQueue = OperationQueue()
downloadQueue.maxConcurrentOperationCount = 3
let downloadOp = ImageDownloadOperation(url: imageURL) { image in
// Handle downloaded image
}
let processOp = BlockOperation {
// Process the downloaded image
}
processOp.addDependency(downloadOp) // processOp waits for downloadOp
downloadQueue.addOperations([downloadOp, processOp], waitUntilFinished: false)
3. Modern Swift Concurrency (async/await)
Swift 5.5 introduced structured concurrency with async/await, making asynchronous code more readable and less error-prone. I've migrated several production apps to use this modern approach.
Basic async/await
// Traditional completion handler approach
func fetchUser(completion: @escaping (Result) -> Void) {
URLSession.shared.dataTask(with: userURL) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NetworkError.noData))
return
}
do {
let user = try JSONDecoder().decode(User.self, from: data)
completion(.success(user))
} catch {
completion(.failure(error))
}
}.resume()
}
// Modern async/await approach
func fetchUser() async throws -> User {
let (data, _) = try await URLSession.shared.data(from: userURL)
let user = try JSONDecoder().decode(User.self, from: data)
return user
}
// Usage
Task {
do {
let user = try await fetchUser()
await updateUI(with: user)
} catch {
await showError(error)
}
}
Concurrent Operations
// Sequential execution (slow)
func loadDataSequentially() async throws {
let user = try await fetchUser()
let posts = try await fetchPosts()
let comments = try await fetchComments()
// Process data...
}
// Concurrent execution (fast)
func loadDataConcurrently() async throws {
async let user = fetchUser()
async let posts = fetchPosts()
async let comments = fetchComments()
// All three requests happen simultaneously
let (userData, postsData, commentsData) = try await (user, posts, comments)
// Process data...
}
// Using TaskGroup for dynamic concurrency
func downloadImages(urls: [URL]) async -> [UIImage] {
await withTaskGroup(of: UIImage?.self) { group in
var images: [UIImage] = []
for url in urls {
group.addTask {
return try? await downloadImage(from: url)
}
}
for await image in group {
if let image = image {
images.append(image)
}
}
return images
}
}
4. Actors - Safe Concurrent Programming
Actors solve the problem of data races in concurrent code by ensuring that their mutable state can only be accessed from one task at a time.
Basic Actor Usage
actor Counter {
private var value = 0
func increment() {
value += 1
}
func getValue() -> Int {
return value
}
}
// Usage
let counter = Counter()
// All actor methods are async
Task {
await counter.increment()
let value = await counter.getValue()
print("Counter value: \(value)")
}
// Multiple concurrent accesses are safe
Task.detached {
for _ in 0..<1000 {
await counter.increment()
}
}
Task.detached {
for _ in 0..<1000 {
await counter.increment()
}
}
MainActor for UI Updates
@MainActor
class ViewModel: ObservableObject {
@Published var isLoading = false
@Published var data: [Item] = []
@Published var errorMessage: String?
func loadData() async {
isLoading = true
errorMessage = nil
do {
// This network call happens on a background thread
let newData = try await dataService.fetchData()
// UI updates happen on MainActor automatically
self.data = newData
self.isLoading = false
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
}
}
// Mixed actor usage
actor DataManager {
private var cache: [String: Data] = [:]
func getData(for key: String) -> Data? {
return cache[key]
}
func setData(_ data: Data, for key: String) {
cache[key] = data
}
@MainActor
func updateUI(with data: Data) {
// This method runs on the main actor
// Safe to update UI here
}
}
5. Real-World Patterns I Use
Network Layer with async/await
protocol NetworkServiceProtocol {
func request(_ endpoint: Endpoint) async throws -> T
}
actor NetworkService: NetworkServiceProtocol {
private let session: URLSession
private var cache: [String: Data] = [:]
init(session: URLSession = .shared) {
self.session = session
}
func request(_ endpoint: Endpoint) async throws -> T {
let cacheKey = endpoint.cacheKey
// Check cache first
if let cachedData = cache[cacheKey],
let cachedResponse = try? JSONDecoder().decode(T.self, from: cachedData) {
return cachedResponse
}
// Make network request
let request = try endpoint.urlRequest()
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
200...299 ~= httpResponse.statusCode else {
throw NetworkError.invalidResponse
}
// Cache successful response
cache[cacheKey] = data
let decodedResponse = try JSONDecoder().decode(T.self, from: data)
return decodedResponse
}
}
Image Loading with Cancellation
actor ImageLoader {
private var cache: [URL: UIImage] = [:]
private var ongoingTasks: [URL: Task] = [:]
func loadImage(from url: URL) async throws -> UIImage {
// Return cached image if available
if let cachedImage = cache[url] {
return cachedImage
}
// Return ongoing task if already loading
if let ongoingTask = ongoingTasks[url] {
return try await ongoingTask.value
}
// Create new loading task
let task = Task {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageError.invalidData
}
return image
}
ongoingTasks[url] = task
do {
let image = try await task.value
cache[url] = image
ongoingTasks.removeValue(forKey: url)
return image
} catch {
ongoingTasks.removeValue(forKey: url)
throw error
}
}
func cancelLoading(for url: URL) {
ongoingTasks[url]?.cancel()
ongoingTasks.removeValue(forKey: url)
}
}
Performance Considerations
Thread Pool Management
- Don't create too many threads: More threads ≠ better performance
- Use appropriate QoS levels: Help the system prioritize your work
- Avoid blocking the main thread: Keep UI responsive
Memory Management
// Avoid retain cycles in async contexts
class DataManager {
func loadData() async {
// ❌ Strong reference cycle
let data = await networkService.fetchData { [self] result in
self.processData(result)
}
// ✅ Weak reference
let data = await networkService.fetchData { [weak self] result in
self?.processData(result)
}
}
}
Testing Concurrent Code
class NetworkServiceTests: XCTestCase {
func testConcurrentRequests() async throws {
let networkService = NetworkService()
// Test multiple concurrent requests
await withTaskGroup(of: Void.self) { group in
for i in 0..<10 {
group.addTask {
do {
let result: TestResponse = try await networkService.request(.test(id: i))
XCTAssertEqual(result.id, i)
} catch {
XCTFail("Request \(i) failed: \(error)")
}
}
}
}
}
}
Migration Strategy
If you're working with existing code, here's how I approach migration to modern concurrency:
- Start with new features: Use async/await for all new code
- Create async wrappers: Wrap existing completion-based APIs
- Gradually migrate: Convert high-impact areas first
- Use @MainActor: Annotate UI-related classes
- Test thoroughly: Concurrent bugs can be subtle
// Wrapper for legacy completion-based API
extension LegacyNetworkManager {
func fetchData() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
self.fetchData { result in
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
Common Pitfalls
- Don't mix async/await with completion handlers unnecessarily
- Be careful with actor isolation - understand when code runs on which actor
- Avoid creating too many concurrent tasks - use TaskGroup for bounded concurrency
- Remember that actors are reference types - they can still have retain cycles
Production Tips
All these patterns and examples are available in my Concurrency folder on GitHub, including unit tests and performance benchmarks I've collected from real apps.