Reentrance and assumptions after suspension point

At 12:48 the presenter says "Any assumptions you've made about global state, clocks, timers, or your actor will need to be checked after the await." and shows that with the improved example of the image downloader where

let image = try await downloadImage(from: url)

// Replace the image only if it is still missing from the cache.
cache[url] = cache[url, default: image]

the cache is checked before it is updated.

The presenter then says the even better solution avoids redundant downloads altogether and refers to the accompanied code example.

In the attached code example:

do {
            let image = try await handle.get()
            cache[url] = .ready(image)
            return image
        } catch {
            cache[url] = nil
            throw error
        }

there is no check about the assumptions although it is after the suspension point try await handle.get().

Why can cache[url] = .ready(image) or cache[url] = nil be set without any checks? Why is that no assumption about global state? Why can this be ignored in this case?

Replies

Assuming you're referring to the code from this post: https://developer.apple.com/forums/thread/682032

This code works because of the checks at the beginning that are omitted in your quote. Only a call to this function that has read cache[url] as nil can start down the code path to write to the internal state. While a call is suspended at let image = try await handle.get(), any other call for the same URL will read .inProgress from the cache and return/throw the task handle's result without writing to the internal state.

There's no suspension points between reading the current state and writing something based on that state.

  • reading .inProgress or .ready will not cause that call to write to internal state.
  • reading nil and writing .inProgress happen within the same uninterrupted execution. After suspending, this context will overwrite its own earlier value of .inProgress with either .ready or nil. This is valid for the code as it is written, if we wanted to be extra-safe we could re-read and make sure the state for that URL is still .inProgress with the same handle. That's not necessary for this code, but adding other functions that manipulate the actor's state (e.g. adding a deleteCache(for: URL) method) would require careful updates to make sure this stays correct.

Thank you for your help. Yes I was referring to that code that was posted to https://developer.apple.com/forums/thread/682032 (which was taken from the code section from the video of the "Developer" app where I was seeing it)

You say "There's no suspension points between reading the current state and writing something based on that state."

But:

Reading state: if let cached = cache[url] { … }

Suspension Point let handle = async { try await downloadImage(from: url) }

Writing state: cache[url] = .inProgress(handle)

Suspension Point: let image = try await handle.get()

Writing state: Either cache[url] = .ready(image) or cache[url] = nil

What if task 2 enters while task 1 is at the first suspension point, and therefore reads the cache still as nil? Is this some wrong thinking on my part? (Sorry) Does the task handle not count as suspension point?

let handle = async {} isn't a suspension point (notice that you don't await the result) so the read and write are not interrupted.

It's a little confusing because that usage of async is actually a function call that has the same name as the async keyword.

Yes, thank you for clearing that confusion. I think I get it now.

ps: async/await makes concurrency less complicated but it is still not trivial. 😢 also thank you for your remark about that adding a delete cache function requires a check.

No problem. 🙌 Yes, concurrency will always be complex and devs will need to take care when using it. The real awesome benefits of async/await and actors are better syntax and compile time checks against the lower-level problems of data corruption that are super-hard to find. Trying to reason about the behavior you asked about would be 10x harder in a callback-based implementation. 😵