Swift Actors — in depth

Valentin Jahanmanesh
15 min readNov 23, 2023

--

Learn, don’t memorize.

I’m someone who loves to understand the inner workings of concepts. If I don’t grasp the underlying mechanics, everything about a concept appears unclear and feels like rote memorization rather than true understanding. Therefore, I’ve delved into several key swift concepts: actors, async/await, structured concurrency, and AsyncSequence. To make these concepts easier to understand, I plan to explain each one using practical, real-life examples. Let’s begin with a discussion on Actors.

What is an actor?

In Swift, an actor is a reference type introduced in Swift 5.5 as part of its advanced concurrency model. Its primary role is to prevent data races and ensure safe access to shared mutable state in concurrent programming environments. To understand this better, let’s use a simple example: a printer in an office.

Imagine you’re in an office with a shared printer, accessible to all staff. One day, you need to print a document, so you send the file to the printer and head over to collect it. But when you reach the printer, you find a surprise: the pages that have come out are not the ones from your file. Confused, you double-check and confirm you sent the correct file. It appears that someone else may have accidentally or intentionally canceled your print job and started their own. Faced with this mix-up, what steps would you take to resolve the issue?

Consider a situation where you print documents and, without verifying them, hand them directly to your manager, assuming they are correct. This could lead to problems if the printed pages are not what was intended. A similar predicament can arise in programming when you’re dealing with shared resources. In the context of Swift, this is especially relevant with classes, which are reference types. This characteristic implies that any modification made to an instance of a class will be reflected across all instances where that class is utilized in your application. It’s akin to the idea of one person’s actions at a shared office printer affecting everyone’s print jobs. Now, let’s look at a specific code example

class Account {
var balance: Int = 20// current user balance is 20
...
func withdraw(amount: Int) {
guard balance >= amount else {return}
self.balance = balance - amount
}
}

var myAccount = Account()
myAccount.withdraw(20$)

Our code is a simple operation: withdrawing money from an account. In this code, we first check if the account balance is sufficient before proceeding with the withdrawal. This scenario is akin to using a printer in an office. When you’re the only one in the office, using the printer (akin to the Account object) poses no issues, just like managing an account solo. Problems arise, however, when multiple people (or, in our programming analogy, multiple threads) try to use the printer simultaneously.

Now, back to our Account example in a multi-threaded environment, such as on modern mobile devices with multiple CPU cores. Picture two threads trying to execute withdraw(20$) function on the same Account object at the same time. The operating system juggles these threads, assigning CPU cores and managing their execution, but we can’t predict the exact order of operations.

Here’s the crux: Suppose the first thread checks the balance and finds it sufficient (balance >= amount). But before it can deduct the amount, a context switch happens, and the second thread starts executing. This second thread also finds the balance sufficient because the first thread hasn’t completed its withdrawal yet. So, it proceeds to withdraw the money, leaving the balance at zero. Then, when the first thread resumes, it continues from where it left off — which is to withdraw the money, even though the balance has already been depleted by the second thread.

This situation is similar to two people trying to use the same printer. If they don’t coordinate, they might end up sending print commands that interfere with each other, leading to mixed-up or missing printouts. In our account example, this lack of coordination leads to an overdraft, as the account is debited twice for the same amount, illustrating the challenges of concurrent access in programming.

Indeed, in our previous example, one of the withdrawal operations should have been declined due to insufficient funds, but due to concurrent access, both succeeded. This situation exemplifies a ‘Race Condition.’ A race condition occurs when multiple threads simultaneously access and modify a shared resource — in this case, the account balance — and the final result of these operations hinges on the timing of each thread’s execution.

In a race condition, the outcome is unpredictable because it depends on the sequence and timing of certain events which are outside the control of the program. The issue in our account example arose because both threads checked the balance and found it sufficient before either had deducted the amount. This led to both threads proceeding with the withdrawal, resulting in an incorrect account balance.

Race conditions like this are a serious concern in programming, especially in systems where data consistency and accuracy are critical. They highlight the need for careful management of shared resources in a multi-threaded environment, to ensure that operations are performed in a safe and predictable manner.

Deadlock: Occurs when two or more threads are blocked forever, each waiting for the other to release a resource.

Livelock: Threads are not blocked, but they are still unable to make progress as they keep responding to each other’s actions, leading to a state of constant change without progress.

Starvation: Happens when a thread is perpetually denied access to shared resources and is unable to make progress.

Priority Inversion: A lower-priority thread holds a resource needed by a higher-priority thread, but its execution is delayed, indirectly delaying the higher-priority thread.

Thread Leakage: Occurs when threads are not properly managed and are left running after they are no longer needed, consuming system resources.

Mutex Overhead: The excessive use of mutexes (mutual exclusions) can lead to performance bottlenecks, as threads spend more time waiting for locks than executing their tasks.

False Sharing: Happens when threads on different processors modify variables that reside close to each other in memory, causing unnecessary cache synchronization and reducing performance.

Resource Thrashing: Occurs when system resources are insufficient for the concurrent workload, leading to constant context switching and poor performance.

Actors

Actors in Swift provide an elegant solution to the concurrency problems we discussed earlier. Before actors were introduced, common practices to manage concurrent access included using DispatchQueue, Operations, and Locks. These methods were effective, but they required a fair amount of manual code and management.

Now, with the introduction of the Actor type in Swift, much of the complexity involved in managing concurrency is abstracted away. Think back to our printer example: How could we have ensured that no one overwrites someone else’s print job? The ideal solution is to have a queue where print jobs are lined up and processed one at a time. As each new job arrives, it joins the queue and waits its turn, and the printer handles each job sequentially.

This is precisely how Actors work. Actors have an internal mailbox that functions like a queue. Requests sent to an actor are placed in this mailbox, and they are processed one after the other, in a serial manner. This queuing mechanism ensures that operations are carried out in order, thus preventing race conditions. Moreover, since the internal queue and mailbox are managed by the actor itself, all the underlying complexity is hidden from the programmer. This makes for a more streamlined and error-resistant approach to handling concurrent tasks in Swift.

Amazing ha?

But how does this work in practice? Actors ensure what we call ‘data isolation.’

To understand data isolation, consider the analogy of the printer in an office. Imagine encapsulating this printer in a separate room and disconnecting it from Wi-Fi. Now, if anyone wants to print something, they must physically go to this room and wait for their turn. This setup effectively prevents any overlapping or concurrent use of the printer, ensuring that only one person can use it at a time.

In the context of Swift, actors work similarly. When you encapsulate data within an actor, you’re essentially isolating it from direct access by other parts of your program. Any code that wants to interact with the data must go through the actor, effectively ‘lining up’ and waiting its turn. This means that even in a concurrent environment, the actor serializes access to its data, ensuring that only one piece of code interacts with it at any given time.

Now, let’s look at some code to see how this concept is applied in practice…

In the revised Account example, the transition from a class to an actor is strikingly straightforward. By simply changing class to actor in our definition, we make the Account object thread-safe. This change, while minimal in syntax, has significant implications for how the object is accessed and manipulated in a concurrent environment.

actor Account {
var balance: Int = 20// current user balance is 20
// ...
func withdraw(amount: Int) {
guard balance >= amount else {return}
self.balance = balance - amount
}
}

Indeed, it’s that easy to switch to an actor. However, this change is not without its challenges. If you try to compile your code after converting a class to an actor, you might encounter some errors. This is due to the nature of actors and their data isolation properties, akin to our analogy of putting the printer in a separate room and disconnecting it from Wi-Fi.

When we transform our class into an actor, direct access to its properties and methods from outside its context is no longer possible as it was before. Actors enforce a strict access control to ensure thread safety. Therefore, all the points in your code where the Account object was accessed previously now require an update. This typically involves using asynchronous patterns like async and await to interact with the actor, ensuring that access to its methods and properties is properly serialized and safe from concurrent access issues.

var myAccount = Account()
myAccount.withdraw(20$) // this line is no longer valid
await myAccount.withdraw(20$) // good

Cross-actor reference

In Swift, when we say that actors guarantee data isolation, we mean that all mutable properties and functions within an actor are isolated from direct access from the outside. This isolation is a core feature of actors and is crucial for ensuring thread safety in concurrent programming. But what does this isolation imply for accessing and modifying these properties and functions?

Essentially, if you want to read a property, change a value, or call a function of an actor, you can’t do so directly as you might with a regular class or struct. Instead, you need to ‘wait your turn’ in a manner of speaking. This is done by sending a request to the actor’s mailbox. Your request then queues up and gets processed in turn. Only when it’s your request’s turn to be handled can you read or modify the actor’s properties or call its functions.

This process is known as a cross-actor reference. When you reference or access something within an actor from outside of that actor, you’re making a cross-actor reference. In practice, this means using asynchronous patterns, such as async and await, to interact with the actor. When you use these constructs, your code effectively says, 'I need to access or modify something within this actor. Here's my request. I'll wait asynchronously until it's safe and appropriate to proceed.'

cross-actor reference refers to any access or interaction with an actor’s internal state (like mutable properties or functions) from outside of that actor’s scope. This interaction could be from a different actor or from non-actor code.

So, in a nutshell, data isolation in actors means that any interaction with an actor’s internal state from the outside world must be mediated through this controlled, asynchronous process, ensuring that the actor can safely manage its state without the risks of concurrent access conflicts.


actor Account {
var balance: Int = 20// current user balance is 20
// ...
func withdraw(amount: Int) {
guard balance >= amount else {return}
self.balance = balance - amount
}
}

actor TransactionManager {
let account: Account

init(account: Account) {
self.account = account
}

func performWithdrawal(amount: Int) async {
await account.withdraw(amount: amount)
}
}

// Usage
let account = Account()
let manager = TransactionManager(account: account)

// Perform a withdrawal from TransactionManager Actor
Task {
// cross-actor reference
await manager.performWithdrawal(amount: 10)
}

// Perform a withdrawal from outside any actor
Task {
// cross-actor reference
await myAccount.withdraw(amount: 5)
}

In Swift’s concurrency model, the await keyword is indeed a crucial component, particularly when working with actors. Interestingly, you might have noticed that we didn't need to explicitly mark the withdraw function in the actor with async. This is because, by default, any function in an actor is considered potentially asynchronous due to the nature of actors themselves. All interactions with an actor from the outside are inherently asynchronous, which means that any cross-actor references need to be preceded by await.

Using await is like signaling a potential pause in the execution, similar to waiting for your turn when someone else is using the office printer. It indicates to the Swift runtime that this point in the code may need to suspend execution until the actor is ready to handle the request. This suspension doesn't always occur — if the actor is not busy with other tasks, your code will proceed immediately. That's why we refer to it as a 'possible' suspension point.

Now, applying this to our withdraw scenario, the implementation becomes much safer and predictable. Imagine two threads trying to execute withdraw on the same Account actor simultaneously. With the actor and await in place, even if the operating system switches from the first thread to the second in the middle of execution, the second thread won't immediately enter the withdraw function. Its execution will pause at the await point, waiting for the first operation to complete. This ensures that the actions are serialized — the first thread completes its withdrawal, and only then does the actor process the second thread's request. At this point, the second thread will find that the balance is insufficient for another withdrawal, and the function will return without making any further changes to the balance.

In this way, the actor model, with its asynchronous access pattern and the await keyword, ensures that operations on shared resources like our Account object are handled safely, preventing race conditions and maintaining data integrity.

Serial Executer

In our discussion about Swift’s actors, we mentioned that each actor has an internal serial queue, which is responsible for handling tasks (or ‘mail’) in the actor’s mailbox, processing them one by one. This internal queue of an actor, known as the Serial Executor, is somewhat analogous to the Serial DispatchQueue. However, there are crucial differences between these two, particularly in how they handle task execution order and their underlying implementations.

One significant difference is that tasks waiting on an actor’s Serial Executor are not necessarily executed in the order they were awaited. This is a departure from the behavior of a Serial DispatchQueue, which adheres to a strict first-in-first-out (FIFO) policy. With a Serial DispatchQueue, tasks are executed exactly in the order they are received.

On the other hand, Swift’s actor runtime employs a lighter and more optimized queue mechanism compared to Dispatch, tailored to leverage the capabilities of Swift’s async functions. This difference stems from the fundamental nature of executors versus DispatchQueues. An executor is essentially a service that manages the submission and execution of tasks. Unlike DispatchQueues, executors are not bound to execute jobs strictly in the order they were submitted. Instead, executors are designed to prioritize tasks based on various factors, including task priority, rather than solely on submission order.

This nuanced difference in task scheduling and execution between Serial Executors and Serial DispatchQueues underpins the flexibility and efficiency of Swift’s actor model. Executors offer a more dynamic way to manage tasks, especially in a concurrent programming environment. I plan to explore Executors more deeply in a separate discussion to further elucidate their role and advantages in Swift’s concurrency model.

Rules

  1. Accessing read-only properties in actors doesn’t require await since their values are immutable and don't change.
actor Account {
**let accountNumber: String = "IBAN---"**
var balance: Int = 20// current user balance is 20
// ...
func withdraw(amount: Int) {
guard balance >= amount else {return}
self.balance = balance - amount
}
}

**/// This is fine ✅**
let accountNumber = account.accountNumber
Task {
**/// This is fine ✅**
let balance = await account.balance

**/// This is not good ❌**
let balance = ****account.balance // Error
}

2. Modifying mutable variables from a cross-actor reference is prohibited, even with await.

/// This is not good ❌
account.balance = 12 // Error

/// This is not good ❌
await account.balance = 12 // Error

Rationale: it is possible to support cross-actor property sets. However, cross-actor inout operations cannot be reasonably supported because there would be an implicit suspension point between the “get” and the “set” that could introduce what would effectively be race conditions. Moreover, setting properties asynchronously may make it easier to break invariants unintentionally if, e.g., two properties need to be updated at once to maintain an invariant.

3. All functions isolated to an actor must be called with the await keyword

Non-isolated

In Swift’s concurrency model, the concept of non-isolated members within an actor plays a crucial role. Non-isolated members allow certain parts of an actor to be accessed without the need for asynchronous calls or awaiting their turn in the actor’s task queue. This is particularly useful for properties or methods that do not modify the actor’s state and therefore do not contribute to race conditions or other concurrency issues.

actor Account {
let accountNumber: String = "IBAN..." // A constant, non-isolated property
var balance: Int = 20 // Current user balance is 20
// A non-isolated function
nonisolated func getMaskedAccountNumber() -> String {
return String.init(repeating: "*", count: 12) + accountNumber.suffix(4)
}
func withdraw(amount: Int) {
guard balance >= amount else { return }
self.balance = balance - amount
}
}
let accountNumber = account.**getAccountNumber()**

In your example,accountNumber is a constant property (let) in the Account actor and is immutable. This immutability makes it thread-safe and eliminates the need for isolation. As a result, accountNumber can be accessed synchronously, without the await keyword, despite being part of an actor. Conversely, balance is a mutable property and requires isolation within the actor. Any interaction with balance, like in the withdraw function, must adhere to the actor’s isolation protocols, often necessitating asynchronous access.

The distinction between isolated and non-isolated members in an actor is crucial. It helps optimize performance and streamline code in scenarios where strict isolation isn’t required, while still upholding the safety and concurrency management inherent to the actor model.

Takeaways

  1. Actors for Concurrency Management: We introduced Swift Actors as a pivotal part of the concurrency model in Swift 5.5, designed specifically to handle shared mutable state safely and to prevent data races.
  2. Addressing Concurrency Issues: We discussed common concurrency issues like race conditions, deadlocks, and livelocks, and demonstrated how actors can mitigate these problems. Traditionally, such issues were managed using DispatchQueue, Operations, and Locks.
  3. Thread Safety and Serial Execution: Actors enhance thread safety in Swift applications. They process tasks serially within an internal queue, functioning like a queue manager, ensuring that tasks are handled one at a time and thus avoiding concurrency conflicts.
  4. Understanding Cross-actor Reference: The concept of cross-actor reference is crucial. It necessitates the use of the await keyword for accessing actor properties or methods, marking potential suspension points for efficient task management.
  5. Serial Executors Versus DispatchQueues: We highlighted a key difference between the actor’s Serial Executor and Serial DispatchQueue. Actors are not bound to a strict first-in-first-out order and can prioritize tasks based on various factors, unlike DispatchQueues.
  6. Rules for Interacting with Actors: We outlined specific rules for interacting with actors, emphasizing the asynchronous access of mutable properties and the necessity of awaiting isolated functions.
  7. Non-isolated Members in Actors: We introduced the concept of non-isolated members within an actor. This is particularly useful for accessing certain properties or methods synchronously, without compromising thread safety.
  8. Practical Examples for Better Understanding: Through the Account actor example, we provided a practical understanding of how actors can be effectively utilized to maintain state safely in concurrent programming environments.
  9. Future Insights into Executors: We promised a deeper dive into Executors in future discussions, anticipating a more detailed exploration of this aspect of Swift’s concurrency model.

Conclusion

In our comprehensive journey through the world of Swift Actors, we’ve uncovered the depth and intricacies of this powerful feature in Swift’s concurrency model. Actors, as a fundamental part of Swift 5.5, have redefined how we approach shared mutable state and concurrency issues, offering a more robust and safer way to handle concurrent programming challenges.

We’ve seen how actors serve as a safeguard against common concurrency problems like race conditions, deadlocks, and livelocks, providing a more streamlined and efficient approach compared to traditional methods like DispatchQueue, Operations, and Locks. The introduction of actors represents a significant leap in simplifying concurrency management, making it more accessible and less error-prone for developers.

Looking forward, the promise of diving deeper into Executors in future discussions opens the door to even more advanced topics in Swift’s concurrency model. This exploration is not just about understanding a programming feature but about embracing a new paradigm in writing safer, more reliable, and efficient Swift code.

In conclusion, Swift Actors are not just a new tool in the toolbox for Swift developers; they represent a fundamental shift in how we think about and handle concurrency, making our code safer, more predictable, and easier to reason about. This exploration into Swift Actors is a testament to the ongoing evolution of Swift as a language that continually adapts and improves to meet the needs of modern software development.

--

--