Actors in Swift
2024-04-18
Introduction
With the version of Swift 5.5, a new type of objects has been introduced, which is an Actor. Actors in Swift are a type of reference objects (reference type) that are part of the advanced concurrency model. Its main function is to avoid data races (data race) and ensure safe access to shared mutable state in concurrent programming environments. Mainly it is a class that restricts access to its properties while still forming data races.
Actors in Swift are entities that encapsulate state and related behavior, ensuring that only one thread of execution can access that state at a time. They also provide implicit mutual exclusion, which means you don't need to worry about common concurrency problems like race conditions and locks.
Swift Actors
To define an actor in Swift, you need to use the keyword actor, followed by the name of the actor and its methods and properties. Here is a simple example of how to define an actor in Swift:
actor CounterActor {
private var count = 0
func getCount() -> Int {
return count
}
func increment() { count += 1 }
}
In this example, we have created an actor called CounterActor that encapsulates a private variable and two methods: increment() to increment the value and getCount() to get the current value.
The actors have the following characteristics:
- They are created using the actor keyword. This is a type of objects in Swift, such as structs, classes, and enums.
- Like classes, actors are reference types. This makes them useful for sharing status.
- They have many characteristics of classes: they can have properties, methods (asynchronous or not), initializers and subscripts, they can implement protocols and they can be generic.
- Actors do not support inheritance, so they cannot have convenience initializers, and they do not support keywords such as final or override.
- All actors automatically implement the Actor protocol, which no other type can use. This allows you to write restricted code to work only with actors.
Cross-actor reference
When we say that actors ensure data isolation, we mean that all mutable properties and functions inside an actor are isolated from direct access from the outside. This isolation is a core feature of actors and is crucial to ensuring data security in concurrent programming. But what does this isolation mean in practice?
Basically, if you want to read a property, change a value, or call a method of an actor, you can't do it directly like you would with a normal class or structure. Instead, you must "wait your turn." This is done by "sending a request" to the actor. Your request is then queued and processed in turn. Only when it is your turn to handle your request will you be able to read or modify the actor's properties or call its methods.
This process is known as cross-actor reference. When you reference or access something inside an actor from outside that actor, you are making a reference between actors. In practice, this means using asynchronous patterns, such as async and await, to interact with the actor. Here is an example of how to interact with an actor:
let counter = CounterActor()
// Asynchronous call to the actor's increment() method
await counter.increment()
// Asynchronous call to the actor's getCount() method
let currentCount = await counter.getCount()
One of the key advantages of actors in Swift is that they provide a secure and structured concurrency model. Because of implicit mutual exclusion, you don't need to worry about common concurrency problems like race conditions and locks.
SerialExecutor
We mentioned earlier that each actor has an internal serial queue, which is responsible for managing the actor's pending tasks, processing them one by one. This internal queue of an actor, known as Serial Executor, is somewhat similar to Serial DispatchQueue. However, there are crucial differences between these two, particularly in how they handle the order of task execution.
A significant difference is that tasks waiting in an actor's Serial Executor are not necessarily executed in the order in which they were submitted. This is a difference from the behavior of Serial DispatchQueue, which implements a strict FIFO (first in, first out) policy. With Serial DispatchQueue, tasks are executed exactly in the order they are received.
On the other hand, a Swift actor runtime employs a more lightweight and optimized queuing mechanism compared to a DispatchQueu, designed to take advantage of the capabilities of Swift's asynchronous functions. This difference arises 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 required to execute jobs strictly in the order in which they were submitted. Instead, runners are designed to prioritize tasks based on several factors, including task priority, rather than solely based on submission order.
There are several rules that we have to follow when working with actors:
- Accessing read-only properties in actors does not require async/await since their values are immutable and do not change.
- It is forbidden to modify mutable variables from outside (cross-actor reference), even with async/await. An actor can only change its state from within.
- All actor calls must be asynchronous (async/await)
actor Account {
let number: String = "IBAN---"
var balance: Int = 100
// ...
func withdraw(amount: Int) {
guard balance >= amount else { return }
self.balance = balance - amount
}
}
let account = Account()
///
let accountNumber = account.number
///
let balance = await account.balance
/// await is required here
let balance = account.balance // Error
/// there is no await, and the value cannot be changed with await either
account.balance = 1000 // Error
/// this is also invalid because it is a cross-actor reference from outside
await account.balance = 1000 // Error
/// the balance property can be changed from inside the actor
await account.withdraw(100)
But sometimes we have methods that don't change the state of our actor. Access to these methods does not actually require isolation. For these cases a non-isolated keyword can be used.
actor Account {
let number: String = "IBAN ---"
var balance: Int = 100
nonisolated func getMaskedNumber() -> String {
return String.init(repeating: "*", count: 12) + accountNumber.suffix(4)
}
// ...
}
let account = Account()
///
account.getMaskedNumber()
Members with the keyword (nonisolated) allow access to certain parts of an actor without requiring asynchronous calls. It is very important to know how to distinguish between mutable/immutable members in order to optimize access to actor members and our code.
Conclusions
In this tutorial, we have learned about actors in Swift, a powerful new feature for writing concurrent code safely and efficiently. Actors provide a structured model for managing access to shared state and eliminate many common concurrency problems. With actors in Swift, developers can write concurrent code much more easily and safely, improving the quality and robustness of their applications.