Skip to Content
Course content

103: Actors and Reentrancy

Click on the "Edit" button in the top corner of the screen to edit your slide content.

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 profileCache to store the ongoing work (Tasks) rather than just the final UserProfile.
  • 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 UserProfile object.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.