Please share here in this topic what you think is essential to know for employing actors
wisely.
Here is one, transactionality, which I discovered today, posted by @nkbelov.
I will update this topic whenever I discover more.
Here is more...
Check assumptions after an await
Remember that await is a potential suspension point.
If your code gets suspended, the program and world will move on before your code gets resumed.Any assumptions you've made about global state, clocks, timers, or your actor will need to be checked after the await.
Details
From: Protect mutable state with Swift actors - wwdc2021
Synchronous code on the actor always runs to completion without being interrupted.
So we can reason about synchronous code sequentially, without needing to consider the effects of concurrency on our actor state.
We have stressed that our synchronous code runs uninterrupted, but actors often interact with each other or with other asynchronous code in the system.Let's take a few minutes to talk about asynchronous code and actors.
But first, we need a better example.
Here we are building an image downloader actor.
It is responsible for downloading an image from another service.
It also stores downloaded images in a cache to avoid downloading the same image multiple times.The logical flow is straightforward: check the cache, download the image, then record the image in the cache before returning.
Because we are in an actor, this code is free from low-level data races; any number of images can be downloaded concurrently.The actor's synchronization mechanisms guarantee that only one task can execute code that accesses the cache instance property at a time, so there is no way that the cache can be corrupted.
That said, the await keyword here is communicating something very important.
Whenever an await occurs, it means that the function can be suspended at this point.
It gives up its CPU so other code in the program can execute, which affects the overall program state.
At the point where your function resumes, the overall program state will have changed.
It is important to ensure that you haven't made assumptions about that state prior to the await that may not hold after the await.Imagine we have two different concurrent tasks trying to fetch the same image at the same time.
The first sees that there is no cache entry, proceeds to start downloading the image from the server, and then gets suspended because the download will take a while.While the first task is downloading the image, a new image might be deployed to the server under the same URL.
Now, a second concurrent task tries to fetch the image under that URL.
It also sees no cache entry because the first download has not finished yet, then starts a second download of the image.
It also gets suspended while its download completes.
After a while, one of the downloads -- let's assume it's the first -- will complete and its task will resume execution on the actor.
It populates the cache and returns the resulting image of a cat.Now the second task has its download complete, so it wakes up.
It overwrites the same entry in the cache with the image of the sad cat that it got.
So even though the cache was already populated with an image, we now get a different image for the same URL.That's a bit of a surprise.
We expected that once we cache an image, we always get that same image back for the same URL so our user interface remains consistent, at least until we go and manually clear out of the cache.
But here, the cached image changed unexpectedly.
We don't have any low-level data races, but because we carried assumptions about state across an await, we ended up with a potential bug.The fix here is to check our assumptions after the await.
If there's already an entry in the cache when we resume, we keep that original version and throw away the new one.
A better solution would be to avoid redundant downloads entirely.
[See the code in the next section]Actor reentrancy prevents deadlocks and guarantees forward progress, but it requires you to check your assumptions across each await.
To design well for reentrancy, perform mutation of actor state within synchronous code.
Ideally, do it within a synchronous function so all state changes are well-encapsulated.State changes can involve temporarily putting our actor into an inconsistent state.
Make sure to restore consistency before an await.And remember that await is a potential suspension point.
If your code gets suspended, the program and world will move on before your code gets resumed.Any assumptions you've made about global state, clocks, timers, or your actor will need to be checked after the await.
Code
From: Protect mutable state with Swift actors - wwdc2021
One solution
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)
// Replace the image only if it is still missing from the cache.
cache[url] = cache[url, default: image]
return cache[url]
}
}
// Dummies
struct Image {}
func downloadImage (from url: URL) async throws -> Image {
Image ()
}
A better solution
actor ImageDownloader {
private enum CacheEntry {
case inProgress (Task <Image, Error>)
case ready (Image)
}
private var cache: [URL: CacheEntry] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
switch cached {
case .ready(let image):
return image
case .inProgress(let task):
return try await task.value
}
}
let task = Task {
try await downloadImage(from: url)
}
cache[url] = .inProgress(task)
do {
let image = try await task.value
cache[url] = .ready(image)
return image
} catch {
cache[url] = nil
throw error
}
}
}
// Dummies
struct Image {}
func downloadImage (from url: URL) async throws -> Image {
Image ()
}