Swift
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Optionals
-
Section 4: Object-Oriented and Value Types
-
Section 5: Memory Management
-
Section 6: Generics and Error Handling
-
Section 7: Concurrency
-
Section 8: Working with Collections
-
Section 9: Codable and Data Handling
-
Section 10: Protocol-Oriented Programming
-
Section 11: Testing and Tooling
-
Section 12: Practical Projects
-
Section 13: Interview Practice
-
Section 14: More Practice Exercises
-
Section 15: More Standard Library
-
Section 16: Advanced Concurrency
-
Section 17: Foundation Framework Deep Dive
-
Section 18: URLSession and Networking Deep Dive
-
Section 19: Combine Framework
-
Section 20: SwiftUI Fundamentals for Swift Developers
-
Section 21: Server-Side Swift with Vapor
-
Section 22: Swift Package Manager Deep Dive
-
Section 23: Swift Concurrency Deep Dive
-
Section 24: More Language Features
-
Section 25: Error Handling Deep Dive
-
Section 26: Testing Deep Dive
-
Section 27: Data Structures and Algorithms in Swift
-
Section 28: More Practice Exercises
-
Section 29: More Interview Practice
-
Section 30: Swift Macros (Swift 5.9+)
-
Section 31: Property Wrappers Ecosystem
-
Section 32: Swift Interop Deep Dive
-
Section 33: iOS App Architecture Patterns
-
Section 34: Performance and Debugging
-
Section 35: App Distribution and CI/CD
-
Section 36: More Practical Projects
-
Section 37: SwiftData and Persistence
-
Section 38: More Design Patterns
-
Section 39: More Review and Practice
-
Section 40: More Foundation Deep Dive
-
Section 41: Advanced Collections in Swift
-
Section 42: Advanced Generics Practice
-
Section 43: UIKit for Legacy and Hybrid Apps
-
Section 44: watchOS and visionOS Development Basics
-
Section 45: More Networking Patterns
-
Section 46: More Testing Practice
-
Section 47: Accessibility in Swift Apps
-
Section 48: Localization
-
Section 49: More Practical Projects Round 2
-
Section 50: Swift Charts Framework
-
Section 51: More Interview and Algorithm Practice
-
Section 52: Final Practice and Mastery
-
Section 53: Swift Compiler and Build System
-
Section 54: More Concurrency Practice
-
Section 55: App Store Guidelines and Review
-
Section 56: More Design and Architecture
103: Actors and Reentrancy
I remember the first time I started using Actors in Swift. I thought, "Great, these are like classes but they handle their own synchronization. I can just wrap my state in an actor and never worry about data races again." I was half right. While actors protect your state from simultaneous access, they don't protect you from interleaved access. This is the concept of reentrancy, and it's where most developers trip up.
Let's walk through a scenario I ran into recently while building an image downloader. I wanted a cache so I wouldn't download the same image twice.
The "Naive" Cache
I started with a simple actor to hold my cached images. It looks straightforward: check if the image exists; if not, download it and save it.
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func getImage(url: URL) async throws -> UIImage {
// 1. Check if it's already here
if let cached = cache[url] {
print("Returning cached image for \(url.lastPathComponent)")
return cached
}
// 2. If not, go fetch it
print("Downloading image for \(url.lastPathComponent)...")
let image = try await downloadImage(from: url)
// 3. Save it for next time
cache[url] = image
return image
}
private func downloadImage(from url: URL) async throws -> UIImage {
// Simulating a network delay
try await Task.sleep(nanoseconds: 1 * 1_000_000_000)
return UIImage() // Returning a dummy image
}
}
At first glance, this is perfectly safe. Since it's an actor, only one task can be inside `getImage` at a time, right? Let's test that theory by requesting the same image twice, almost simultaneously.
let cache = ImageCache()
let url = URL(string: "https://example.com/logo.png")!
await withTaskGroup(of: Void.self) { group in
group.addTask { _ = try? await cache.getImage(url: url) }
group.addTask { _ = try? await cache.getImage(url: url) }
}
The Surprise in the Console
I expected to see "Downloading..." once and "Returning cached..." once. But when I ran this, the console showed:
- Downloading image for logo.png...
- Downloading image for logo.png...
What happened? I'm using an actor! The state is isolated! I felt like I was losing my mind for a second, but then it clicked. The key is the await keyword.
When the first task hits try await downloadImage(from: url), it suspends. It basically tells the Swift runtime, "I'm going to be waiting on the network for a while; feel free to let other things happen on this actor." While that first task is suspended, the second task enters getImage. It checks the cache, sees that the image isn't there yet (because the first task hasn't saved it), and starts its own download.
This is actor reentrancy. Actors do not block the entire thread while waiting for an await; they allow other tasks to run to prevent deadlocks and keep the app responsive. But the cost is that your assumptions about state can be invalidated during that suspension point.
Thinking Around the Gap
To fix this, I need a way to track that a download is already in progress. If I just use a boolean, I'll have the same problem. I need to store the "work" itself. In Swift, the best way to do that is by caching the Task.
Let's adjust the code. Instead of caching the UIImage, I'll cache the Task that produces the image.
actor ImageCache {
// Notice we are caching the Task, not the result
private var cache: [URL: Task<UIImage, Error>] = [:]
func getImage(url: URL) async throws -> UIImage {
// If there's already a task (running or finished), just await it
if let existingTask = cache[url] {
return try await existingTask.value
}
// Otherwise, create a new task to do the work
let task = Task {
print("Downloading image for \(url.lastPathComponent)...")
try await downloadImage(from: url)
}
cache[url] = task
return try await task.value
}
private func downloadImage(from url: URL) async throws -> UIImage {
try await Task.sleep(nanoseconds: 1 * 1_000_000_000)
return UIImage()
}
}
Now, when I run the same test, the first task creates the Task and immediately stores it in the dictionary before hitting an await. When the second task arrives, it sees the existing Task in the cache and simply awaits its completion. The "Downloading..." message only prints once.
The rule of thumb I've adopted is this: Never assume that the state of your actor is the same after an await as it was before. If you have a requirement that must remain atomic across an asynchronous call, you have to explicitly track the "pending" state or use a pattern like Task caching to bridge the gap.
📋 Practical Task
Exercise: Solving the Redundant User Profile Fetch
You are building a User Profile Manager. The manager has a method fetchUserProfile(userId:). Currently, if five different UI components request the profile for the same user at the same time, the app makes five separate API calls to the server. This is wasting bandwidth and putting unnecessary load on the backend.
Below is the buggy implementation. Your task is to modify the UserProfileManager actor to ensure that if multiple requests for the same userId come in simultaneously, only one network request is actually performed, and all callers receive the same result.
import Foundation
struct UserProfile {
let id: String
let name: String
}
actor UserProfileManager {
private var profileCache: [String: UserProfile] = [:]
func fetchUserProfile(userId: String) async throws -> UserProfile {
if let profile = profileCache[userId] {
return profile
}
// Simulate API Call
let profile = try await simulateNetworkCall(userId: userId)
profileCache[userId] = profile
return profile
}
private func simulateNetworkCall(userId: String) async throws -> UserProfile {
print("📡 API Call for user: \(userId)")
try await Task.sleep(nanoseconds: 1 * 1_000_000_000)
return UserProfile(id: userId, name: "User \(userId)")
}
}
// --- Test Code ---
let manager = UserProfileManager()
await withTaskGroup(of: Void.self) { group in
for _ in 0...4 {
group.addTask {
_ = try? await manager.fetchUserProfile(userId: "123")
}
}
}
Requirements:
- Modify the
profileCacheto store the ongoing work (Tasks) rather than just the finalUserProfile. - Ensure the console prints "📡 API Call for user: 123" exactly once, even though five requests are made.
- Ensure that all five callers successfully receive the
UserProfileobject.
There are no comments for now.