Mastering Swift Actors: Navigating Re-entrancy and Interleaving
Written on
Chapter 1: Understanding Actors in Swift
Actors in Swift can be quite challenging to grasp. While the concept is fundamentally straightforward—actors are reference types that ensure serial access to their methods and state—their subtleties can complicate their effective use. To sharpen our intuition on when to utilize actors, we will explore a common scenario encountered in nearly all applications: authentication.
Initially, I’ll outline the obstacles involved in developing an authentication service for your iOS app. We will use sequence diagrams to illustrate the desired behavior of our system. Following that, I will briefly touch on core Swift concurrency principles, delve into the mechanics of actors, and clarify the concept of re-entrancy. Ultimately, we will integrate these ideas to create an efficient authentication service for your iOS application.
To follow along with the coding examples, feel free to download the completed sample project here.
Section 1.1: Building an Authentication System
Most mobile applications implement authentication via the OAuth 2.0 protocol. Users access server resources using an access token (commonly referred to as a bearer token), which is included in the Authorization headers of URL requests. This token has a limited lifespan, and a refresh token is utilized to obtain a new set of valid tokens.
When the access token expires—either after an extended user session or, more frequently, when the user returns the next day—your app must send a request to the backend authentication service to reauthenticate and acquire new credentials. We will examine this refresh process in detail in our example.
Our Basic Sample Application
Our sample application is a fledgling social network designed with simplicity in mind. The MVP will include a single feature: fetching the user's friends from the backend and displaying them in a list.
The application’s structure is basic, featuring a ContentView template, a corresponding view model, and a service to retrieve the user’s friends. The FriendService obtains a valid authentication token from the authentication service and executes the authenticated API call to our backend.
The AuthService has a straightforward interface with just one asynchronous method to obtain the bearer token, which can then be included in our network request headers for authentication:
protocol AuthService {
/// Return a valid, refreshed bearer token
func getBearerToken() async throws -> String
}
Our authentication service performs several tasks behind the scenes: fetching credentials from the Keychain, checking for expiration, contacting the backend to refresh credentials if needed, storing the new credentials in the Keychain, and finally returning the valid token to the FriendService.
We can visualize the interactions between our services through a sequence diagram, where the FriendService requests a bearer token, the AuthService refreshes credentials, and a new token is returned, enabling the FriendService to make a successful API call.
In practice, the access token and refresh token are securely stored in the iOS Keychain. For simplicity, we will omit Keychain operations and treat our backend services as a unified entity.
However, just as we’re preparing for a smooth launch of the next big social app, we receive some unexpected news from our product manager. It turns out our CEO has decided that personalization is the next big trend, and we must now implement a personalized greeting for users upon app entry.
Section 1.2: The Complexities of Our Sample Application
With this new requirement, the structure of our app becomes slightly more intricate, now needing to incorporate designs for a personalized greeting and a list of friends.
The updated design retains a single view and view model but now involves two services: FriendService, which fetches an array of [Friend] to display, and UserService, which retrieves the user’s information for greeting purposes. Both services need to make authenticated API calls, necessitating valid bearer tokens from our AuthService.
The Real-World Challenge
The situation becomes complicated when multiple API requests occur simultaneously. In reality, most applications will retrieve data from several sources after launch, and if not managed properly, it can lead to redundant calls to the authentication service.
Since your app requires only one valid auth token, concurrent refreshes can lead to significant resource waste and potential bugs.
The following sequence diagram illustrates a naive implementation of this more complex scenario: both the FriendService and UserService simultaneously request a bearer token, resulting in the AuthService refreshing credentials twice, returning two fresh tokens, and leading to undefined results for the services.
The issue arises when the user’s access token has expired. The UserService and FriendService both seek a valid access token simultaneously, prompting the AuthService to make two separate credential refresh requests. This creates unnecessary load on backend systems and can lead to potential errors, such as invalidating the new token by refreshing it again, or hitting the backend rate limit with a 429 error.
This diagram fails to illustrate all the duplicated processes involved. Not only are we redundantly refreshing the credentials, but we're also duplicating operations such as fetching credentials from the Keychain, checking expiration, and writing refreshed credentials back to the Keychain.
In our optimal system, all concurrent network requests should pause and wait for the token to be returned from the original request, meaning they would all wait for the same token.
Let's visualize this ideal scenario: when both the FriendService and UserService request an auth token, the AuthService refreshes credentials just once, returns the valid bearer token to both services, and they can then proceed with successful API calls.
Sounds complex? Don't worry—actors are here to help!
Chapter 2: How Actors Function Behind the Scenes
Before we implement our optimal authentication service, it’s essential to delve deeper into the Swift concurrency model and understand how it shapes actor behavior.
Multithreading
Swift's multithreading allows for concurrent execution, improving performance and throughput in your application. However, it isn’t a cure-all; improper implementation can lead to issues like data races, deadlocks, and unstable application states.
Threads require memory overhead and can take significant time to instantiate, causing potential inefficiencies. Swift's concurrency aims to optimize the scenario by allowing one thread per CPU core whenever feasible.
Cooperative Threading
Swift adopts a Cooperative Threading Model, enabling different "units of work" to operate on the same thread. When using the await keyword, work can be suspended, freeing up space for other tasks on that thread. This method offers a significant performance advantage over costly CPU context-switching.
Swift concurrency introduces continuations—lightweight objects that track which tasks are to be resumed—similar to the DispatchQueue abstraction in Grand Central Dispatch, providing a more economical alternative to resource-heavy threads.
Executors
Swift concurrency also introduces Executors, which manage work scheduling at runtime. This feature parallels the global system-aware thread pool of Grand Central Dispatch, allowing the system to manage low-level thread operations rather than developers.
Each actor is equipped with a SerialExecutor, functioning like a serial queue. An actor's tasks are queued on this executor, allowing actors to maintain a "single-threaded illusion," ensuring that work isolated to an actor instance will never execute concurrently.
Re-entrancy
If an actor contains only synchronous methods, all tasks behave as if they are executing on a serial queue, ensuring atomicity. However, with asynchronous methods, actors exhibit a less predictable behavior known as re-entrancy.
When an actor-isolated async function awaits, other tasks may execute on the actor before the original function resumes, allowing new tasks to be queued and executed—a phenomenon referred to as interleaving.
Let’s illustrate this with a classic example of interleaving:
actor AuthService {
var cachedToken: String?
func refreshToken() async {
cachedToken = "abc123"
print(cachedToken) // Outputs: 'abc123'
await callAuthenticationAPI() // Suspension point
print(cachedToken) // May not be 'abc123'
}
func setCachedToken(to token: String) {
cachedToken = token}
}
Now that we've covered the theory, let’s proceed to implement our authentication service!
Section 2.1: Building Our Authentication Service
With our understanding of actor behavior in place, we can begin constructing the ideal authentication service we envisioned earlier.
Creating the Naive Auth Service
To avoid unnecessary complexity, we will first build a simpler version of our system.
The AuthService protocol will look like this:
protocol AuthService: AnyActor {
func getBearerToken() async throws -> String
}
The implementation of AuthServiceImpl will be as follows:
actor AuthServiceImpl: AuthService {
func getBearerToken() async throws -> String {
try await fetchValidAuthToken()}
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, I included print statements around our critical API calls to simulate real-world operations. When running the app, the ContentViewModel will make concurrent requests to the User and Friends services, resulting in the following log statements:
- Fetching user info...
- Fetching friends...
- Checking the keychain for credentials...
- Credentials found!
- Checking the expiry on the credentials...
- Credentials expired!
- Refreshing auth token...
- Checking the keychain for credentials...
- Credentials found!
- Checking the expiry on the credentials...
- Credentials expired!
- Refreshing auth token...
- Token refreshed!
- Storing fresh token on the keychain...
- Token stored!
- Token refreshed!
- Storing fresh token on the keychain...
- Token stored!
- User Jacob found!
- 5 friends found!
This log reflects the anticipated outcome from our sequence diagram: we initiate two API calls, each requesting authentication tokens concurrently, leading to duplicated tasks within the auth service.
For simplicity in this sample code, I have returned mock objects from the Friend and User services, simulating latency with a one-second delay.
Creating the Optimized Auth Service
Our goal is to design an authentication service that ensures all API calls wait together for a single token refresh. We will leverage the re-entrant design of actors to our advantage.
Let’s revisit the implementation of getBearerToken():
func getBearerToken() async throws -> String {
try await fetchValidAuthToken() // <-- suspension point!
}
By awaiting this function call, we can interleave the getBearerToken() function with other invocations if multiple services request a token simultaneously.
This advanced version of the AuthService actor will include a Task as a property of the actor—ensuring that access to this mutable shared state is managed serially.
protocol AuthService: AnyActor {
func getBearerToken() async throws -> String
}
actor AuthServiceImpl: AuthService {
var tokenTask: Task?
func getBearerToken() async throws -> String {
if tokenTask == nil {
tokenTask = Task { try await fetchValidAuthToken() }}
defer { tokenTask = nil }
return try await tokenTask!.value
}
}
Let’s break down the getBearerToken() implementation line by line:
- We first check if there is an existing tokenTask on the actor.
- If not, we create a new Task—a unit of work—without initiating it yet.
- The defer statement ensures that we clear the tokenTask after completing the work.
- Finally, we await the value wrapped in the tokenTask.
This function features a single suspension point—the last line—allowing the first eight lines of the function to execute before awaiting the task's value. Consequently, the method is re-entrant, permitting other calls to getBearerToken() to interleave and await the token.
Understanding Async Suspension
You might wonder how we can set tokenTask to nil at the end of the method without causing crashes during concurrent invocations when force-unwrapping it. Here's how the interleaving functions:
- Call 1 sees a nil tokenTask and sets it.
- Call 1 then hits the suspension point.
- Call 2 observes that the tokenTask is set, so it does not create a new task.
- Call 2 also reaches the suspension point.
- Call 1 resumes after the token refresh, returns the token, and sets tokenTask to nil.
- Call 2 resumes, using the cached state of the Task captured during suspension.
Swift's concurrency model captures all necessary information during async function suspension, caching values needed for resumption. This ensures that the tokenTask property on the actor is copied since Task is a value type.
Consequently, concurrent requests for the bearer token complete successfully, accessing the value of the completed Task. Subsequent reads of the value do not rerun the task; they simply check the stored result.
Running the optimized code, we can observe the following log statements:
- Fetching user info...
- Fetching friends...
- Checking the keychain for credentials...
- Credentials found!
- Checking the expiry on the credentials...
- Credentials expired!
- Refreshing auth token...
- Token refreshed!
- Storing fresh token on the keychain...
- Token stored!
- User Jacob found!
- 5 friends found!
As illustrated, the redundant tasks—checking the keychain, verifying expiration, refreshing the token, and storing new tokens—are eliminated. We have successfully established an optimal system, alleviating the need for our infrastructure team to contact us at 2 a.m.
Conclusion
I hope this exploration has been insightful. We started by discussing a practical use case where safe multi-threaded access is vital—authentication services. We examined the challenges of a naive implementation and devised a solution that minimizes network overhead.
Next, we briefly ventured into the theory of Swift concurrency, learning how actors use serial executors to create an "illusion of single-threadedness." We also investigated re-entrancy and its potential to produce seemingly counterintuitive outcomes.
Finally, we constructed a complete authentication service, first developing a naive version, identifying the expected issues, and then applying our understanding of tasks and re-entrancy to create an optimal authentication system, transforming a chaotic series of duplicated API calls into a well-orchestrated process.
Thank you for reading Jacob's Tech Tavern. If you found this article helpful, please leave a comment or follow me for more of my work!
Actor Reentrancy in Swift explained - A deep dive into the concept of reentrancy in Swift actors and its implications.
The Swift Actor Pitfall: Understanding and Managing Reentrancy - Insights from iOS Conf SG 2024 on handling actor reentrancy effectively.