Reentrancy on the Main Actor

Hello! WWDC 21 shows an example of a potential bug due to reentrancy on a method in a custom actor:

actor ImageDownloader {
    private var cache: [URL: Image] = [:]

    func image(from url: URL) async throws -> Image? {
        if let cached = cache[url] {
            return cached
        }

        let image = try await downloadImage(from: url)

        // Potential bug: `cache` may have changed.
        cache[url] = image
        return image
    }
}

Should we expect the same potential bug in a method marked @MainActor? E.g.:

class ViewModel {
	@MainActor func image(from url: URL) async throws -> Image? {
	    if let cached = cache[url] {
	        return cached
	    }
	
	    let image = try await downloadImage(from: url)
	
	    // Potential bug: `cache` may have changed.
	    cache[url] = image
	    return image
	}
}

In a word, yes.

Marking a function as @MainActor isolates it to MainActor.shared, which works very similar here to the actor case (isolated to self). You need to take extra care to make sure cache don’t get changed here.

Notice that the code you provide doesn’t show the definition of cache. I supposed it to be @MainActor private var cache: [URL: Image] = [:].

Yes.

When you await, the Task you are on is suspended and waits (hence "await") until it is resumed. In this case, while the image is downloading, your Task is waiting.

While that Task is waiting, the actor is able to service operations from other Tasks. By the time the original Task is resumed, any number of other Tasks may have touched and changed the actor's state.

awaits should be thought of as a kind of discontinuity. The world around you can change a lot before/after the await, even though it looks a lot like a normal, synchronous function call. Local variables, however, are preserved across awaits (in this case, the let cached = ... variable you declared just before the await is a local and thus preserved).

2 Likes

Great explanation!
A little addition, and I hope I am right in what I am saying:
If you use the following analogy: A task is to asynchronous functions what a thread is to synchronous functions. then you can avoid this issue as long as you keep whatever you are doing in the context of one Task.

In the example provided note that they leave the Task context with "detach".