Advanced Swift Actors: Re-entrancy & Interleaving
Understand actor behaviour with a real-world use case
Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every two weeks.
Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.
Actors can be tricky to wrap your head around.
Conceptually, they’re pretty simple: actors are reference-typed entities that enforce serial access to their methods and state. However, without understanding their nuances, it’s hard to know whether an actor is the right tool for the job, or if you’re just adding complexity.
To help nurture your instinct for when to take an actor out of your toolbelt, I’m going to guide us through a use case that affects almost all apps: Authentication.
I’ll first explain the challenges of building an authentication service in your iOS app. We will use sequence diagrams to design exactly how we want our ideal system to behave. Next, I’ll briefly cover core Swift concurrency concepts, explain how actors work under the hood, and discuss what is meant by re-entrancy. Finally, I’ll bring these concepts together and demonstrate how we can use our knowledge of actors to implement an optimal authentication service in your iOS app.
If you want to code along, download the finished sample project here.
Building an Authentication System
Most mobile apps authenticate using the OAuth 2.0 standard:
Your users access resources on the server using an access token (sometimes called a bearer token).
This token is placed on URL request
Authorization
headers.The token expires after some time, and a refresh token is used to get a new set of valid tokens.
When your access token expires — after either a marathon user session or, more likely, when your user comes back the next day — your app needs to send a request to your backend authentication service to re-authenticate and get fresh credentials.
We’re going to look this refresh in-depth in our example.
Our simple sample app
Our sample app is a nascent social network, and couldn’t be more simple. We’re going to release an MVP with a single feature: fetch the user’s friends from the backend and display them in a list.
The structure is quite basic: the template ContentView
, a corresponding view model, and a service to fetch the user’s friends.
This FriendService
fetches a valid auth token from the authentication service and makes the authenticated API call to our backend.
The AuthService
has a dead-simple interface, with just one async method to retrieve the bearer token, which can then be added to our network request headers to authenticate them:
protocol AuthService {
/// Return a valid, refreshed, bearer token
///
func getBearerToken() async throws -> String
}
Our auth service is really doing a lot of work under the hood to fetch this:
Fetching credentials from the Keychain.
Checking whether they are expired.
If so, contacting our backend to refresh the credentials.
Storing fresh credentials in the Keychain anew.
Finally, returning the valid token to the friend service.
We can visualise the way our services are interacting by drawing a sequence diagram:
Your access token and refresh token are usually stored safely in the iOS secure enclave through the iOS Keychain. To keep the example simple, I’m leaving off any Keychain operations and treating our backend services as single monolith.
This is nice and straightforward. It looks like the launch of the next big social app will go off without a hitch!
Wait a minute.
Our product manager just called me into a meeting.
…Bad news.
Our CEO heard on a podcast that personalisation is the new big trend, and therefore we’re going to need to introduce a personal greeting to the user when they open the app.
I love scope creep!
Our complex sample app
Here’s the new structure we require, complete with designs:
The new design still has a single view and view model, but now calls two services:
FriendService
fetches an array of[Friend]
s to display to the user as a list of names.UserService
fetches theUser
in order to greet the user with their name when they enter the app.
Both of these fetches rely on making authenticated API calls — so both need to fetch a valid bearer token from our AuthService
.
The problematic real-world
Life becomes tricky when you have multiple simultaneous API requests.
In reality, most apps will be fetching data from several sources after launch, so if you aren’t careful, you’ll encounter a situation where your auth service is called multiple times unnecessarily.
Your app only needs one single valid auth token, and so concurrent refreshes is both hugely wasteful and a potential wellspring of nasty bugs.
Here’s the sequence diagram for a naïve first-pass implementation of this more realistic situation:
This is the world the single-threaded JavaScript crowd want you to live in. And we complain about the 30% Apple tax!
The problem here happens when the user’s access token is expired. Since UserService
and FriendService
simultaneously request a valid access token, the AuthService
will make two separate requests to refresh the credentials.
At best, this puts unnecessary load on our backend systems. At worst, we don’t know what will happen: we could be invalidating the new auth token by immediately refreshing again, or the second request might hit the backend rate limit with a 429
error and just fail.
This diagram doesn’t even show all the duplicate work: we aren’t just doubling-up on the refresh credentials call to the backend — wrapped in the auth service itself, we’re also duplicating a check to the Keychain, logic to check token expiry, and saving refreshed credentials back to the Keychain.
In our ideal system, concurrent network requests should all suspend execution and wait for the token to return from the original network request — that is, they are all waiting for the same token.
Let’s diagram out this dreamy scenario:
Sounds complicated?
Fear not — Actors are here to save the day.
How actors work under the hood
Before we implement our dreamy optimal auth service, it would help to understand the Swift concurrency model in more detail, and how this informs the behaviour of actors.
Multi-threading
Multi-threading allows work to be done concurrently, which can enable higher throughput and performance in your app. However, it isn’t a magic bullet. Implementing multi-threading without due care can lead to issues such as data races, deadlocks, and invalid application states (and subsequent crashes).
Threads themselves also incur memory overhead (512kB by default), may take many milliseconds to instantiate, and CPU cores can lose both time and L1-cache memory when they switch between threads. Swift concurrency therefore aims to get as close as possible to the optimal scenario of 1-thread-per-CPU-core.
Cooperative Threading
Swift uses a Cooperative Threading Model which essentially allows different “units of work” to run on the same thread.
When we use the await
keyword, work can be suspended and make space for other work to be done on the same thread. This is a huge performance boost compared to an expensive CPU context-switch between threads.
Swift concurrency introduces continuation
s as lightweight objects which track which work is to be resumed. This concept is analagous to the DispatchQueue
abstraction introduced in Grand Central Dispatch — a cheap and lightweight abstraction compared to expensive and memory-heavy threads.
Executors
Swift concurrency introduced the concept of Executor
s as a runtime feature of Swift to schedule work. This is comparable to the global system-aware thread pool introduced by Grand Central Dispatch — ensuring the low-level thread management is handled by the system rather than by application developers.
Actors each have a SerialExecutor
, which we can think of a bit like a serial queue. Any work done by an actor (such as a method call) gets queued up on its serial executor.
This allows actors to express a “single threaded illusion” backed by the Swift runtime — work isolated on an instance of an actor will never execute concurrently.
Re-entrancy
If your actor has only synchronous methods, then all the work done on it behaves as if they’re on a serial queue; and all the properties will behave atomically. But when you have async methods, actors display a less intuitive behaviour known as re-entrancy.
When an actor-isolated async
function suspends at an await
, then other work can be executed on the actor before the suspended function resumes. Hence, new work can be queued and executed while some other work is waiting on the same instance of an actor — a phenomenon called interleaving.
I’ll try to demonstrate the canonical example of interleaving:
Here, there’s some “isolated” state on an actor, cachedToken
, which starts as nil in the refreshToken()
method.
While waiting for the asynchronous call to callAuthenticationAPI()
to return, setCachedToken()
may be called. Therefore, the actor’s cachedToken
property might be different by the time we resume execution of the refreshToken()
method.
actor AuthService {
var cachedToken: String?
func refreshToken() async {
cachedToken = nil
// nil
print(cachedToken)
// suspension point - other work may run on the actor
await callAuthenticationAPI()
// not guaranteed to be nil
print(cachedToken)
}
// this method can be called while refreshToken() is `await`ing
func setCachedToken(to token: String) {
cachedToken = token
}
}
Alright, that was a lot of theory.
Let’s get back to implementing our authentication service!
Remember to check out the sample project on GitHub if you want to run this code yourself.
Building our authentication service
Now that we’ve understood how actors behave, we can start to develop the ideal auth service we described earlier.
Building the naïve auth service
First, to ensure we aren’t overcomplicating things, let’s build the simpler, naïve, version of our system.
AuthService
looks like this:
protocol AuthService: AnyActor {
/// Return a valid, refreshed, bearer token
///
func getBearerToken() async throws -> String
}
actor AuthServiceImpl: AuthService {
func getBearerToken() async throws -> String {
try await fetchValidAuthToken()
}
// Various print statements and waits to represent the
// work that would be done by a 'real' service
private func fetchValidAuthToken() async throws -> String {
print("Checking the keychain for credentials...")
print("Credentials found!")
print("Checking the expiry on the credentials...")
print("Credentials expired!")
print("Refreshing auth token...")
try await Task.sleep(for: .seconds(1))
print("Token refreshed!")
print("Storing fresh token on the keychain...")
print("Token stored!")
}
}
In the sample project, we’ve set up print
statements all around our important API calls to simulate the work that would be going on in a real-life app. I’m only including code that helps us to understand the nuances of actor re-entrancy.
When we run our app, the ContentViewModel
makes concurrent requests to the the User and Friends services, and we see the following log statements:
1 Fetching user info...
2 Fetching friends...
3 Checking the keychain for credentials...
4 Credentials found!
5 Checking the expiry on the credentials...
6 Credentials expired!
7 Refreshing auth token...
8 Checking the keychain for credentials...
9 Credentials found!
10 Checking the expiry on the credentials...
11 Credentials expired!
12 Refreshing auth token...
13 Token refreshed!
14 Storing fresh token on the keychain...
15 Token stored!
16 Token refreshed!
17 Storing fresh token on the keychain...
18 Token stored!
19 User Jacob found!
20 5 friends found!
Comparing this against our sequence diagram, it matches exactly what we expected: We make two API calls, which each request auth tokens simultaneously, and end up duplicating our work on the auth service:
Checking for credentials on the Keychain
Checking the expiry on the credentials
Refreshing the expired credentials
Storing these fresh credentials on the Keychain
To keep things simple in the sample code, I’m simply returning mock objects from the Friend and User services. The latency of the refresh API call is simulated using a 1-second delay.
Building the optimised auth service
We want to implement our ideal auth service, which forces all API calls to wait together for one token refresh to take place. To accomplish this, we’ll utilise the re-entrant design of actors to our advantage.
Let’s look back at the implementation of getBearerToken()
:
func getBearerToken() async throws -> String {
try await fetchValidAuthToken() // <-- this is a suspension point!
}
Since we’re await
ing this function call, we can interleave the getBearerToken()
function with other invocations if multiple services want an auth token concurrently.
That means, once our Friend Service’s request for a token lands on await
, then User Service’s request for a token can enter getBearerToken()
and start a new unit of work.
This souped-up version of the AuthService
actor sets a Task
as a property on the actor — like all mutable shared state, access to this Task
is protected serially.
protocol AuthService: AnyActor {
/// Return a valid, refreshed, bearer token.
///
func getBearerToken() async throws -> String
}
actor AuthServiceImpl: AuthService {
var tokenTask: Task<String, Error>?
func getBearerToken() async throws -> String {
if tokenTask == nil {
tokenTask = Task { try await fetchValidAuthToken() }
}
defer { tokenTask = nil }
return try await tokenTask!.value
}
// ...
}
Let’s dissect getBearerToken()
line-by-line here:
1 func getBearerToken() async throws -> String {
2
3 if tokenTask == nil {
4 tokenTask = Task { try await fetchValidAuthToken() }
5 }
6
7 defer { tokenTask = nil }
8
9 return try await tokenTask!.value
10 }
Line
3
: we check whether there is an existing token task on the actor.Line
4
: set a new Task — this synchronously creates a Task — a unit of work — as a property on the actor without actually starting the work.
Learn more about unstructured concurrency in my previous article which demonstrates how to write unit tests for code utilising Unstructured Concurrency.
Line
7
: We use our old friend, thedefer
statement, to ensure that thetokenTask
is cleaned up after we finish performing our work — this means that after we synchronise access to our token, we allow future invocations of the method to create a brand-newTask
.Line
9
: This final line is where the magic happens. Weawait
the value wrapped in thetokenTask
.
This function only has one suspension point: The final line where we await
the value of the refreshed token.
This in effect separates the first 8 lines of our method from the value returned by the task after the suspension point in the final line. The function is really two “units” of work, one before the await
and one after. These units of work are queued on the actor’s SerialExecutor
separately.
The method is therefore re-entrant, and other getBearerToken()
invocations can interleave and await
the token as well.
How async suspension really works
You might be curious as to how we can nil the tokenTask
at the end of the method without the app crashing on concurrent invocations when force-unwrapping it. But here’s what’s really happening when the functions interleave:
Call 1 sees a nil token task, and so sets it on the actor.
Call 1 then hits the
return try await tokenTask!.value
and suspends execution.Call 2 sees that token task is set, and so does not set a new task.
Call 2 also hits the suspension point.
Call 1 resumes after the token refresh has completed, returns the token, and sets the actor’s
tokenTask
property to nil.Call 2 resumes, and uses a cached state of the
Task
captured by the suspension point to return the value inside.
This is another critical concept to understand when it comes to Swift concurrency: When an async function suspends, the runtime captures all the necessary information it needs to continue executing once it resumes. This includes caching values it will rely on to resume execution. Therefore, the tokenTask
property on the actor is copied, since Task
is a value type.
The concurrent requests for the bearer token all complete successfully by accessing the value of the completed Task
. Once the task completes, subsequent reads of the value
property don’t re-run the task; they simply check the stored result.
Running the code, let’s check out the log statements for V2:
1 Fetching user info...
2 Fetching friends...
3 Checking the keychain for credentials...
4 Credentials found!
5 Checking the expiry on the credentials...
6 Credentials expired!
7 Refreshing auth token...
8 Token refreshed!
9 Storing fresh token on the keychain...
10 Token stored!
11 User Jacob found!
12 5 friends found!
As you can see, the errant duplicated work of checking the keychain, checking the expiry date, refreshing the token, and storing the new tokens are nixed.
The optimal system has been achieved.
Our infra team can stop paging us at 2am.
Conclusion
I hope you’ve learned a lot on this journey.
We began by discussing a common real-life use case where safe multi-threaded access is critical — authentication services. We took a deep-dive into the problems with a naïve implementation and designed an approach that minimised network overhead.
Next, we took a brief detour into theory to understand the Swift concurrency runtime, and learned about how actors utilise serial executors to enforce an “illusion of single-threadedness”. We also investigated what is meant by re-entrancy and how it can cause results which seem unintuitive to the uninitiated.
Finally, we built out our full authentication service. We created the naïve version, confirmed the problems we expected, and then used our knowledge of Tasks and re-entrancy to implement our optimal authentication system which converted our messy sequence of duplicated API calls into a beautifully interleaved ballet.
This is probably the best article on modern swift concurrency out there. Thanks for sharing!
Thank you for this, I was wondering about how a call could get the value (token or error) on the second call while the first one is running, the concept of "cache" was new for me with a little help from Claude to make it more sense (I'm new on this concept) I was able to grasp it better but your Article help me a lot.
Sometimes I find myself saving all this "demo projects" because I'm afraid I wont remember the concepts and I need to come back and remember so also thanks again for the code.