Whenever you begin studying about actors in Swift, you’ll discover that explanations will all the time include one thing alongside the traces of “Actors defend shared mutable state by ensuring the actor solely does one factor at a time”. As a single sentence abstract of actors, that is nice but it surely misses an vital nuance. Whereas it’s true that actors do just one factor at a time, they don’t all the time execute operate calls atomically.
On this submit, we’ll discover the next:
- Exploring what actor reentrancy is
- Understanding why async capabilities in actors may be problematic
Usually talking, you’ll use actors for objects that should maintain mutable state whereas additionally being secure to move round in duties. In different phrases, objects that maintain mutable state, are handed by reference, and have a should be Sendable are nice candidates for being actors.
Implementing a easy actor
A quite simple instance of an actor is an object that caches knowledge. Right here’s how which may look:
actor DataCache {
var cache: [UUID: Data] = [:]
}
We are able to immediately entry the cache
property on this actor with out worrying about introducing knowledge races. We all know that the actor will guarantee that we gained’t run into knowledge races after we get and set values in our cache from a number of duties in parallel.
If wanted, we are able to make the cache
personal and write separate learn
and write
strategies for our cache:
actor DataCache {
personal var cache: [UUID: Data] = [:]
func learn(_ key: UUID) -> Knowledge? {
return cache[key]
}
func write(_ key: UUID, knowledge: Knowledge) {
cache[key] = knowledge
}
}
The whole lot nonetheless works completely advantageous within the code above. We’ve managed to restrict entry to our caching dictionary and customers of this actor can work together with the cache by means of a devoted learn
and write
technique.
Now let’s make issues slightly extra difficult.
Including a distant cache function to our actor
Let’s think about that our cached values can both exist within the cache
dictionary or remotely on a server. If we are able to’t discover a particular key regionally our plan is to ship a request to a server to see if the server has knowledge for the cache key that we’re in search of. Once we get knowledge again we cache it regionally and if we don’t we return nil
from our learn
operate.
Let’s replace the actor to have a learn
operate that’s async and makes an attempt to learn knowledge from a server:
actor DataCache {
personal var cache: [UUID: Data] = [:]
func learn(_ key: UUID) async -> Knowledge? {
print(" cache learn referred to as for (key)")
defer {
print(" cache learn completed for (key)")
}
if let knowledge = cache[key] {
return knowledge
}
do {
print(" try and learn distant cache for (key)")
let url = URL(string: "http://localhost:8080/(key)")!
let (knowledge, response) = attempt await URLSession.shared.knowledge(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
print(" distant cache MISS for (key)")
return nil
}
cache[key] = knowledge
print(" distant cache HIT for (key)")
return knowledge
} catch {
print(" distant cache MISS for (key)")
return nil
}
}
func write(_ key: UUID, knowledge: Knowledge) {
cache[key] = knowledge
}
}
Our operate is loads longer now but it surely does precisely what we got down to do; test if knowledge exists regionally, try and learn it from the server if wanted and cache the outcome.
For those who run and take a look at this code it would most definitely work precisely such as you’ve supposed, properly completed!
Nonetheless, when you introduce concurrent calls to your learn
and write
strategies you’ll discover that outcomes can get slightly unusual…
For this submit, I’m operating a quite simple webserver that I’ve pre-warmed with a few values. After I make a handful of concurrent requests to learn a price that’s cached remotely however not regionally, right here’s what I see within the console:
cache learn referred to as for DDFA2377-C10F-4324-BBA3-68126B49EB00
try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn referred to as for DDFA2377-C10F-4324-BBA3-68126B49EB00
try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn referred to as for DDFA2377-C10F-4324-BBA3-68126B49EB00
try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn referred to as for DDFA2377-C10F-4324-BBA3-68126B49EB00
try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn referred to as for DDFA2377-C10F-4324-BBA3-68126B49EB00
try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
As you possibly can see, executing a number of learn
operations leads to having numerous requests to the server, even when the information exists and also you anticipated to have the information cached after your first name.
Our code is written in a means that ensures that we all the time write a brand new worth to our native cache after we seize it from the distant so we actually shouldn’t anticipate to be going to the server this usually.
Moreover, we’ve made our cache an actor so why is it operating a number of calls to our learn
operate concurrently? Aren’t actors presupposed to solely do one factor at a time?
The issue with awaiting within an actor
The code that we’re utilizing to seize data from a distant knowledge supply really forces us right into a state of affairs the place actor reentrancy bites us.
Actors solely do one factor at a time, that’s a reality and we are able to belief that actors defend our mutable state by by no means having concurrent learn and write entry occur on mutable state that it owns.
That mentioned, actors don’t like to take a seat round and do nothing. Once we name a synchronous operate on an actor that operate will run begin to finish with no interruptions; the actor solely does one factor at a time.
Nonetheless, after we introduce an async operate that has a suspension level the actor won’t sit round and anticipate the suspension level to renew. As an alternative, the actor will seize the subsequent message in its “mailbox” and begin making progress on that as a substitute. When the factor we have been awaiting returns, the actor will proceed engaged on our authentic operate.
Actors don’t like to take a seat round and do nothing once they have messages of their mailbox. They’ll decide up the subsequent job to carry out at any time when an energetic job is suspended.
The truth that actors can do that is referred to as actor reentrancy and it may possibly trigger attention-grabbing bugs and challenges for us.
Fixing actor reentrancy is usually a difficult drawback. In our case, we are able to clear up the reentrancy concern by creating and retaining duties for every community name that we’re about to make. That means, reentrant calls to learn
can see that we have already got an in progress job that we’re awaiting and people calls may also await the identical job’s outcome. This ensures we solely make a single community name. The code under exhibits the complete DataCache
implementation. Discover how we’ve modified the cache
dictionary in order that it may possibly both maintain a fetch job or our Knowledge
object:
actor DataCache {
enum LoadingTask {
case inProgress(Process<Knowledge?, Error>)
case loaded(Knowledge)
}
personal var cache: [UUID: LoadingTask] = [:]
personal let remoteCache: RemoteCache
init(remoteCache: RemoteCache) {
self.remoteCache = remoteCache
}
func learn(_ key: UUID) async -> Knowledge? {
print(" cache learn referred to as for (key)")
defer {
print(" cache learn completed for (key)")
}
// now we have the information, no must go to the community
if case let .loaded(knowledge) = cache[key] {
return knowledge
}
// a earlier name began loading the information
if case let .inProgress(job) = cache[key] {
return attempt? await job.worth
}
// we do not have the information and we're not already loading it
do {
let job: Process<Knowledge?, Error> = Process {
guard let knowledge = attempt await remoteCache.learn(key) else {
return nil
}
return knowledge
}
cache[key] = .inProgress(job)
if let knowledge = attempt await job.worth {
cache[key] = .loaded(knowledge)
return knowledge
} else {
cache[key] = nil
return nil
}
} catch {
return nil
}
}
func write(_ key: UUID, knowledge: Knowledge) async {
print(" cache write referred to as for (key)")
defer {
print(" cache write completed for (key)")
}
do {
attempt await remoteCache.write(key, knowledge: knowledge)
} catch {
// did not retailer the information on the distant cache
}
cache[key] = .loaded(knowledge)
}
}
I clarify this strategy extra deeply in my submit on constructing a token refresh move with actors in addition to my submit on constructing a customized async picture loader so I gained’t go into an excessive amount of element right here.
Once we run the identical take a look at that we ran earlier than, the outcome seems like this:
cache learn referred to as for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn referred to as for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn referred to as for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn referred to as for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn referred to as for DDFA2377-C10F-4324-BBA3-68126B49EB00
try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
We begin a number of cache reads, that is actor reentrancy in motion. However as a result of we’ve retained the loading job so it may be reused, we solely make a single community name. As soon as that decision completes, all of our reentrant cache learn actions will obtain the identical output from the duty we created within the first name.
The purpose is that we are able to depend on actors doing one factor at a time to replace some mutable state earlier than we hit our await
. This state will then inform reentrant calls that we’re already engaged on a given job and that we don’t must make one other (on this case) community name.
Issues turn out to be trickier once you attempt to make your actor right into a serial queue that runs async duties. In a future submit I’d wish to dig into why that’s so difficult and discover doable options.
In Abstract
Actor reentrancy is a function of actors that may result in delicate bugs and surprising outcomes. Resulting from actor reentrancy we should be very cautious after we’re including async
strategies to an actor, and we have to guarantee that we take into consideration what can and will occur when now we have a number of, reentrant, calls to a particular operate on an actor.
Typically that is utterly advantageous, different occasions it’s wasteful however gained’t trigger issues. Different occasions, you’ll run into issues that come up as a result of sure state in your actor being modified whereas your operate was suspended. Each time you await one thing within an actor it’s vital that you simply ask your self whether or not you’ve made any state associated assumptions earlier than your await that you could reverify after your await.
The 1st step to avoiding reentrancy associated points is to know what it’s, and have a way of how one can clear up issues once they come up. Sadly there’s no single resolution that fixes each reentrancy associated concern. On this submit you noticed that holding on to a job that encapsulates work can forestall a number of community calls from being made.
Have you ever ever run right into a reentrancy associated drawback your self? And if that’s the case, did you handle to unravel it? I’d love to listen to from you on Twitter or Mastodon!