Swift Actors are new in Swift 5.5 and are part of the big concurrency changes at WWDC 2021. Before actors, data races were a common exception to run into. So before we dive into Actors with isolated and nonisolated access, it’s good to understand what Data Races are and to understand how you can solve them today.
Actors in Swift aim to solve data races completely, but it’s important to understand that it’s likely to still run into data races. This article will cover how Actors work and how you can use them in your projects.
What are Actors?
Actors in Swift are not new: they’re inspired by the Actor Model that treats actors as the universal primitives of concurrent computation. However, proposal SE-0306 introduced Actors and explain which problem they solve: Data Races.
Data races occur when the same memory is accessed from multiple threads without synchronization, and at least one access is a write. Data Races can lead to unpredictable behavior, memory corruption, flaky tests, and weird crashes. You might have crashes that you’re unable to solve today as you have no clue when they happen, how you can reproduce them, or how you can fix them based on theory. My article Thread Sanitizer explained: Data Races in Swift explains in-depth how you can solve, find, and fix Data Races.
Actors in Swift protect their state from data races, and using them allows the compiler to give us helpful feedback while writing applications. In addition, the Swift compiler can statically enforce the limitations that come with actors and prevents concurrent access to mutable data.
You can define an Actor using the actor
keyword, just like you would with a class or a struct:
actor ChickenFeeder {
let food = "worms"
var numberOfEatingChickens: Int = 0
}
Actors are like other Swift types as they can also have initializers, methods, properties, and subscripts, while you can also use them with protocols and generics. Furthermore, unlike structs, an actor requires defining initializers when your defined properties require so manually. Lastly, it’s important to realize actors are reference types.
Actors are reference types but still different compared to classes
Actors are reference types which in short means that copies refer to the same piece of data. Therefore, modifying the copy will also modify the original instance as they point to the same shared instance. You can read more about this in my article Struct vs. classes in Swift: The differences explained.
Yet, Actors have an important difference compared to classes: they do not support inheritance.
Not supporting inheritance means there’s no need for features like the convenience and required initializers, overriding, class members, or open
and final
statements.
However, the biggest difference is defined by the main responsibility of Actors, which is isolating access to data.
How Actors prevent Data Races with synchronization
Actors prevent data races by creating synchronized access to its isolated data. Before Actors, we would create the same result using all kinds of locks. An example of such a lock is a concurrent dispatch queue combined with a barrier for handling write access. Inspired by techniques explained in my article Concurrent vs. Serial DispatchQueue: Concurrency in Swift explained I’ll show you a before and after using Actors.
Before Actors, we would create a thread safe chicken feeder as follows:
final class ChickenFeederWithQueue {
let food = "worms"
/// A combination of a private backing property and a computed property allows for synchronized access.
private var _numberOfEatingChickens: Int = 0
var numberOfEatingChickens: Int {
queue.sync {
_numberOfEatingChickens
}
}
/// A concurrent queue to allow multiple reads at once.
private var queue = DispatchQueue(label: "chicken.feeder.queue", attributes: .concurrent)
func chickenStartsEating() {
/// Using a barrier to stop reads while writing
queue.sync(flags: .barrier) {
_numberOfEatingChickens += 1
}
}
func chickenStopsEating() {
/// Using a barrier to stop reads while writing
queue.sync(flags: .barrier) {
_numberOfEatingChickens -= 1
}
}
}
As you can see, there’s quite some code to maintain here. We have to think carefully about using the queue ourselves when accessing data that are not thread-safe. A barrier flag is required to stop reading for a moment and allow writing. Once again, we need to take care of this ourselves as the compiler does not enforce it. Lastly, we’re using a DispatchQueue
here, but there are often discussions around which lock is best to use.
Actors, on the other hand, allow Swift to optimize synchronized access as much as possible. The underlying lock that’s used is just an implementation detail. As a result, the Swift compiler can enforce synchronized access, preventing us from introducing data races most of the time.
To see how this works we can implement the above example using our earlier defined chicken feeder Actor:
actor ChickenFeeder {
let food = "worms"
var numberOfEatingChickens: Int = 0
func chickenStartsEating() {
numberOfEatingChickens += 1
}
func chickenStopsEating() {
numberOfEatingChickens -= 1
}
}
The first thing you’ll notice is that the instance is much simpler and easier to read. All logic related to synchronizing access is hidden as an implementation detail within the Swift standard library. The most interesting part, however, occurs when we try to use or read any of the mutable properties and methods:
The same happens when accessing the mutable property numberOfEatingChickens
:
However, we are allowed to write the following piece of code:
let feeder = ChickenFeeder()
print(feeder.food)
The food
property on our chicken feeder is immutable and, therefore, thread-safe. There is no risk for a data race as its value can not change from another thread during reading.
Our other methods and properties, however, change the mutable state of a reference type. To prevent data races, synchronized access is required to allow access sequentially.
Using async/await to access data from Actors
As we’re unsure when access is allowed, we need to create asynchronous access to our Actor’s mutable data. If there’s no other thread accessing the data, we will directly get access. If there’s another thread performing access to the mutable data, however, we need to sit and wait till we’re allowed to go through.
In Swift, we can create asynchronous access by using the await
keyword:
let feeder = ChickenFeeder()
await feeder.chickenStartsEating()
print(await feeder.numberOfEatingChickens) // Prints: 1
Preventing unneeded suspensions
In the above example, we’re accessing two distinct parts of our actor. Firstly, we update the number of eating chickens after which we perform another asynchronous task to print out the number of eating chickens. Each await
can result in a suspension of your code to wait for access. In this case, having two suspensions makes sense as both parts don’t really have anything in common. However, you need to take into account that there could be another thread waiting to call chickenStartsEating
which might result in two eating chickens at the time we print out the result.
To understand this concept better, let’s look into a case in which you want to combine operations into a single method to prevent extra suspensions. For example, imagine having a notifier method in our actor that notifies observers about a new chicken that started eating:
extension ChickenFeeder {
func notifyObservers() {
NotificationCenter.default.post(name: NSNotification.Name("chicken.started.eating"), object: numberOfEatingChickens)
}
}
We could use this code by using await
twice:
let feeder = ChickenFeeder()
await feeder.chickenStartsEating()
await feeder.notifyObservers()
However, this can result in two suspension points, one for each await
. Instead, we could optimize this code by calling the notifyObservers
method from within chickenStartsEating
:
func chickenStartsEating() {
numberOfEatingChickens += 1
notifyObservers()
}
As we’re already within the Actor having synchronized access, we don’t need another await
. These are important improvements to consider as they could have an impact on performance.
Nonisolated access within Actors
It’s important to understand the concept of isolation within Actors. The above examples already showed how access is synchronized from outside actor instances by requiring the use of await
. However, if you watched closely, you might have noticed that our notifyObservers
method didn’t require to use await
for accessing our mutable property numberOfEatingChickens
.
When accessing an isolated method within actors, you’re basically allowed to access any other properties or methods that would require synchronized access. So you’re basically reusing your given access to get the most out of it!
There are cases, however, where you know that it’s not required to have isolated access. Methods in actors are isolated by default. The following method only accesses our immutable property food
but still requires await
to access it:
let feeder = ChickenFeeder()
await feeder.printWhatChickensAreEating()
This is odd, as we know that we don’t access anything requiring synchronized access. SE-0313 was introduced to solve exactly this problem. We can mark our method with the nonisolated
keyword to tell the Swift compiler our method is not accessing any isolated data:
extension ChickenFeeder {
nonisolated func printWhatChickensAreEating() {
print("Chickens are eating \(food)")
}
}
let feeder = ChickenFeeder()
feeder.printWhatChickensAreEating()
Note that you can use the nonisolated
keyword for computed properties as well, which is helpful to conform to protocols like CustomStringConvertible
:
extension ChickenFeeder: CustomStringConvertible {
nonisolated var description: String {
"A chicken feeder feeding \(food)"
}
}
However, defining them on immutable properties is not needed, as the compiler will tell you:
Why Data Races can still occur when using Actors
When using Actors consistently in your code, you’ll for sure lower the risks for running into data races. Creating synchronized access prevents weird crashes related to data races. However, you obviously need to consistently use them to prevent data races from occurring in your app.
Race conditions can still occur in your code but might no longer result in an exception. This is important to realize as Actors can be promoted as solving everything after all. For example, imagine two threads accessing our actors’ data correctly using await:
queueOne.async {
await feeder.chickenStartsEating()
}
queueTwo.async {
print(await feeder.numberOfEatingChickens)
}
The race condition here is defined as: “which thread is going to be the first to start isolated access?”. So there are basically two outcomes:
- Queue one being first, increasing the number of eating chickens. Queue two will print 1
- Queue two being first, printing the number of eating chickens which is still 0
The difference here is that we no longer access the data while it’s being modified. Without synchronized access, this could lead to unpredicted behavior in some cases.
Continuing your journey into Swift Concurrency
The concurrency changes are more than just async-await and include many new features that you can benefit from in your code. So while you’re at it, why not dive into other concurrency features?
- MainActor usage in Swift explained to dispatch to the main thread
- How to Use URLSession with Async/Await for Network Requests in Swift
- Async await in Swift explained with code examples
- Swift 6: Incrementally migrate your Xcode projects and packages
- Concurrency-safe global variables to prevent data races
- Unit testing async/await Swift code
- Thread dispatching and Actors: understanding execution
- @preconcurrency: Incremental migration to concurrency checking
- Detached Tasks in Swift explained with code examples
- Task Groups in Swift explained with code examples
- Sendable and @Sendable closures explained with code examples
- AsyncSequence explained with Code Examples
- AsyncThrowingStream and AsyncStream explained with code examples
- Tasks in Swift explained with code examples
- Nonisolated and isolated keywords: Understanding Actor isolation
- Async let explained: call async functions in parallel
- Actors in Swift: how to use and prevent data races
Conclusion
Swift Actors solve data races which were a common issue in applications written in Swift. Mutable data is accessed synchronously, which makes sure it’s safe. We haven’t covered the MainActor
instance, which is a topic on its own. I’ll make sure to cover this in a future article. Hopefully, you’ve been able to follow along and know how to use Actors in your applications.
If you like to learn more tips on Swift, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.
Thanks!