Swift has introduced a new type of actor to protect mutable state, making it possible to avoid data contention in concurrent programming. Using keywords enables the compiler to pre-check potential concurrency risks and write more robust code.
Data competition from a few examples and how to avoid it
Data contention and status checking
Data competition example
Data races occur when two independent threads simultaneously access the same data, and at least one of those accesses is a write.
Data races are simple to construct, but notoriously difficult to debug. Here is a simple counter class whose one action is to increment the counter and return its new value. Suppose we go ahead and try to increment from two concurrent tasks. Depending on how long it takes to execute, we might get 1 and then 2, or 2 and then 1. This is to be expected; in both cases, the counter is left in a consistent state. But since we introduced a data race, if both tasks read 0 and write 1, we might as well get 1 and 1.
Or, if the return statement occurs after two delta operations, even 2 and 2. Data races are notoriously hard to avoid and debug.
The “let “attributes of value semantic types are truly immutable, so they are safe to access from different concurrent tasks. Trying to solve the data race with value types, turning our counter into a structure, making it a value type.
We must also mark the delta function as mutating so that it can modify the value attributes. You also need to change the counter variable to var to make it mutable, which brings us back to the data race dilemma.
Unless you use locks or serial queues to ensure atomicity, let’s look at how actors work.
The Actor concept
Actors provide a synchronization mechanism for sharing mutable state.
An Actor has its own state, which is isolated from the rest of the program. The only way to access this state is through actors.
Synchronization in an Actor ensures that no other code is accessing the state of the Actor at the same time as you do through the Actor. This gives us the same mutually exclusive property as manual use of locks or serial scheduling queues, but for actors it is a basic guarantee that Swift provides.
Actors are a new type in Swift. They provide the same capabilities as all named types in Swift.
They can have properties, methods, initializers, subscripts, and so on. They can be protocol-compliant or enhanced with extensions.
Like classes, they are reference types; Because the purpose of actors is to express shared mutable state.
In fact, the main characteristic of Actor types is that they isolate instance data from the rest of the program and ensure synchronous access to that data.
Use actors to resolve data contention
We have two more concurrent tasks trying to increment the same counter. The Actor’s internal synchronization mechanism ensures that one incremental call completes before another begins. So we can get 1 and 2 or 2 and 1, since both are effectively executed concurrently, but we can’t get the same count twice or skip any values because internal synchronization in actors has eliminated the possibility of data races in Actor state.
Let’s consider what actually happens when two concurrent tasks both try to increment the counter at the same time. One will arrive first, and the other will have to wait her turn. But how do we ensure that the second task patiently waits for its Actor turn? Swift has such a mechanism. Whenever you interact externally with an Actor, you’re doing it asynchronously. If the Actor is busy, your code will pause so that the CPU you’re running can do other useful work. When the Actor becomes idle again, it will wake up your code — resume execution — so that the call can run on the Actor.
The await keyword in this example indicates that asynchronous calls to actors may involve such a pause. Let’s extend our counterexample a bit further and add an unnecessarily slow reset operation. This operation sets the value to 0 and then calls the appropriate number of increments to make the counter reach the new value. The resetSlowly method is defined in an extension of the counter Actor type, so it is inside the Actor. This means it has direct access to the state of the Actor, which it does, resetting the value of the counter to 0.
It can also call other methods on actors synchronously, such as calling INCREMENT. There’s no need to wait, because we already know we’re running on Actor.
This is an important property of actors.
Synchronized code on actors always runs to completion without interruption.
Thus, we can reason sequentially about synchronized code without considering the effect of concurrency on our Actor state.
Actor picture download example
We’ve emphasized that our synchronous code runs continuously, but actors often interact with each other or with other asynchronous code in the system. Let’s take a few minutes to talk about asynchronous code and actors.
It is responsible for downloading images from other services. It also stores downloaded images in a cache to avoid downloading the same image multiple times.
The logic is simple: check the cache, download the image, and then record the image in the cache before returning. Because we are in an Actor, there is no low-level data race in this code; Any number of images can be downloaded simultaneously. Synchronization in actors ensures that only one task at a time can execute code that accesses cached instance properties, so the cache cannot be corrupted. That is, the await keyword here is communicating something very important. Every time an await occurs, it means that the function can be paused at that time.
It gives up its OWN CPU, so other code in the program can execute, which affects the state of the entire program. When your function resumes, the state of the entire program changes.
It is important to make sure that you are not making assumptions about the state before you wait, which may not be true after you wait.
Imagine that we have two different concurrent tasks trying to get the same image at the same time. The first task sees no cached entry, starts to download the image from the server, and then is suspended because the download takes a while.
While the first task is downloading the image, a new image may be deployed to the server, under the same URL.
Now, the second concurrent task tries to get the image from under that URL.
It also does not see the cache entry because the first download has not yet been completed, and then begins the second download of the image.
It is also suspended when its download is complete.
After a while, one of the downloads — let’s say the first one — completes and its task resumes on the Actor.
It fills the cache and returns the resulting image of the cat.
Now the second task has completed its download, so it is woken up.
It overwrites the same entry in the cache with an image of the sad cat it gets.
So even though we already have an image in the cache, we now have a different image at the same URL.
This is a bit of a surprise.
We expect that once we cache an image, we will always get the same image at the same URL, so that our user interface will be consistent, at least until we go to manually clear the cache. But here, the cached image has changed unexpectedly.
We didn’t have any low-level data races, but because we were carrying assumptions about state with us in the wait, we ended up with a potential error.
The fix here is to wait and check our assumptions.
If there is already an entry in the cache when we restore, we keep the original version and discard the new one. A better solution is to avoid redundant downloads altogether.
Actor reentrant prevents deadlocks and guarantees forward progress, but it requires you to check your assumptions on each wait.
To better design reentrancy, perform changes to the Actor state in synchronized code.
It is best to do this in a synchronous function so that all state changes are well encapsulated.
A state change might involve temporarily putting our Actor in an inconsistent state.
Be sure to restore consistency before waiting.
Remember that await is a potential pause point.
If your code is paused, the program and the world will move on before your code can be restored.
Any assumptions you make about global state, clock, timer, or your Actor need to be checked after await.
Actor Isolation is the foundation of actor-type behavior
In this section, we discuss how Actor isolation interacts with other language features, including protocol conformance, closures, and classes. Like other types, actors can be protocol-compliant as long as they meet the requirements of the protocol.
Abide by the agreement
For example, let’s make this LibraryAccount Actor compliant to the Equatable protocol. The static equality method compares the ID numbers of two library accounts.
Because this method is static and has no instances of itself, it is not actor-isolated. Instead, we have two actor-type parameters, and this static method is outside of those parameters. This doesn’t matter, because the implementation is just accessing the immutable state of the Actor.
Let’s extend our example further to make our LibraryAccount compliant with the Hashable protocol. To do so, we need to implement the **hash(in)** operation, which we can do like this.
However, the Swift compiler complains that this consistency is not allowed.
What happened? Well, being Hashable means that this function can be called from outside actors, but hash(in) is not asynchronous, so there’s no way to keep actors isolated.
To solve this problem, we can make this method non-isolated.
Non-isolated means that this method is treated as outside of the Actor, even though it is syntactically described on the Actor.
This means it can meet the synchronization requirements of the Hashable protocol.
Because non-isolated methods are considered outside the Actor, they cannot reference mutable state on the Actor.
This method is good because it refers to an immutable ID number.
If we tried to hash based on something else, such as an array of borrowed books, we would get an error because accessing mutable state externally would allow data races.
So much for compliance.
closure
Let’s talk about closures. Closures are small functions defined in one function that can then be passed to another function to be called after a period of time.
Like functions, closures may or may not be actor-isolated.
In this example, we are going to read some from each book we borrow and return the total number of pages we read.
The call to reduce involves a closure that performs reading.
Notice that there is no await in the call to readSome.
This is because the closure is formed in the actor-isolated function “read “, which is itself isolated from **(actor-Isolated)Actor**.
We know this is safe because the Reduce operation will be executed synchronously and the closure cannot be moved to another thread to avoid concurrent access.
Now, let’s do something a little different.
I don’t have time to read it now, so we’ll read it later.
Here, we create a detached task.
A separate task executes the closure while performing other work that the Actor is doing.
Therefore, the closure cannot be on actors, otherwise we would introduce data races.
So the closure is not actor-isolated.
When it wants to call the read method, it must do so asynchronously, as shown in await.
We’ve talked a little bit about Actor isolation, whether the code runs inside or outside actors.
Now, let’s talk about Actor isolation and data.
In the case of our library account, we deliberately avoided saying exactly what the type of book was.
I’ve always assumed that it’s a value type, like a structure.
This is a good choice because it means that all states of the library account Actor instance are independent.
If we continue calling this method to randomly select a book to read, we get a copy of the book we can read.
The changes we make to the copy of the book do not affect the Actor and vice versa.
However, if the book is turned into a class, things are a little different.
Our library account Actor now references instances of the book class.
This is not a problem in itself.
However, what happens when we call this method to select a random book? We now have a reference to the mutable state of the Actor that has been shared outside of the Actor. We have created the possibility of a data race.
Now, if we update the title of the book, the modification takes place in the accessible state inside the Actor.
Because the access method is not on the Actor, this change could end up being a data race.
Both value types and actors are safe for concurrent use, but classes can still cause problems.
We have a name for a type that can safely be used concurrently. Sendable Indicates the Sendable type.
A Sendable Sendable type is one whose value can be shared between different actors.
If you copy a value from one place to another, and both places can safely modify copies of their own values without interfering with each other, then this type is sendable.
Value types are sendable because each copy is independent, as discussed earlier.
Actor types are sendable because they synchronously access their mutable state.
Classes can be sendable, but only if they are carefully implemented.
For example, a class and all its subclasses may be said to be sendable if it holds only immutable data.
Or, if the class performs synchronization internally, such as using locks to ensure secure concurrent access, it can be sendable.
But most classes are not, and therefore cannot be called sendable classes.
Functions don’t have to be sendable, so there’s a new type of function that can safely be passed across actors.
Your actors — in fact, all of your concurrent code — should communicate primarily in the sendable type. Sendable types protect code from data races.
This is a property that Swift will eventually start statically checking.
At that point, passing non-sendable types across Actor boundaries becomes an error.
How does one know that a type is sendable? Well, Sendable is a protocol, and you declare that your type conforms to Sendable, just like you do with any other protocol.
Swift then checks to make sure your type is reasonable as a sendable type.
A book structure can be sendable if all of its storage properties are of sendable type.
For example, the author is actually a class, which means that it — and the array of authors — is not sendable.
Swift produces a compiler error indicating that Book cannot be sendable.
For generic types, whether they are sendable or not may depend on their generic parameters.
We can use conditional consistency to propagate Sendable when appropriate.
For example, a type is sendable only if both of its generic parameters are sendable.
The same method is used to conclude that an array of sendable types is itself sendable.
We encourage you to introduce Sendable conformance into types whose values can safely be shared concurrently.
Use these types in your actors.
Then, when Swift starts executing cross-actor Sendable, your code is ready. The function itself can be sendable, which means it is safe to pass function values across actors.
This is especially important for closures, because it limits what closures can do to help prevent data races.
For example, a sendable closure cannot capture a mutable local variable, because that would allow data races for local variables.
Anything captured by a closure needs to be sendable to ensure that closures cannot be used to move non-sendable types across Actor boundaries.
Finally, a synchronized Sendable closure cannot be isolated by actor-Isolated actors, because this would allow code to run on actors from outside.
In this talk, we’ve actually been relying on the idea of the Sendable closure.
The operation to create the detached task requires a Sendable function, which is written in the function type ** @sendable **.
Remember the counter example we had at the beginning of the lecture? We are trying to set up a counter of value type.
Then, we tried to modify it from two different closures at the same time.
This will be a data race on mutable local variables.
However, because the closure for the detach task is Sendable, Swift generates an error here.
The type of function that can be sent is used to indicate where it can be executed concurrently, preventing data races.
Here’s another example of what we saw earlier.
Because the closure of the detached task is sendable, we know that it should not be quarantined into actors.
Therefore, the interaction with it must be asynchronous.
Sendable types and closures help maintain isolation of actors by checking whether mutable state is shared between actors and whether it cannot be modified concurrently.
Main Actor
We’ve been talking about Actor types and how they interact with protocols, closures, and sendable types.
There’s one more Actor to discuss — a special Actor we call MainActor.
When you build an application, you need to keep the main thread in mind.
It is where core user interface rendering takes place and where user interaction events are processed.
Operations related to the user interface usually need to be performed in the main thread.
However, you don’t want to do all the work on the main thread.
If you do too much work on the main thread, for example, because you have some slow I/O operations or blocking interactions with the server, your user interface freezes.
Therefore, you need to be careful to work on the main thread when it is interacting with the user interface, but to leave the main thread quickly during computationally expensive or long wait times.
So, we do work from the main thread when we can and then call dispatchqueue.main.async.
You can use Async in your code as long as you have a specific operation that must be performed on the main thread.
Looking back at the mechanics, the structure of this code looks vaguely familiar.
In fact, interacting with the main thread is like interacting with an Actor.
If you know you’re already running on the main thread, you can safely access and update your user interface state.
If you’re not running on the main thread, you need to interact with it asynchronously.
That’s how actors work.
There is a special Actor that describes the main thread, which we call MainActor. A MainActor is an Actor that represents the main thread.
It differs from regular actors in two important ways.
First, MainActor performs all synchronization through the main scheduling queue.
This means that MainActor is interchangeable with using dispatchqueue.main from a runtime perspective
Second, the code and data that need to be on the main thread are scattered all over the place.
It’s in SwiftUI, AppKit, UIKit, and other system frameworks.
It’s scattered across your own view, view controller, and user-interface oriented parts of your data model.
With Swift concurrency, you can mark a declaration with the MainActor property, indicating that it must be executed on the main Actor.
We marked the checkedOut operation here so that it always runs on MainActor.
If you call it from outside of MainActor, you need to wait so that the call is executed asynchronously on the main thread.
By marking code that must run on the main thread as running on MainActor, you no longer need to guess when to use dispatchqueue.main.
Swift ensures that this code is always executed on the main thread.
Types can also be placed on MainActor, which makes all their members and subclasses on MainActor.
This is useful for the parts of your code base that have to interact with the UI, where most things need to run on the main thread.
Individual methods can be opt-out with the nonisolated keyword, and the rules are the same as normal actors you’re familiar with.
By using MainActors for user-interface types and operations, and introducing your own actors to manage other program states, you can build your application to ensure that concurrency is used safely and correctly.
conclusion
In this session, we talked about how actors use Actor isolation actors to isolate and serialize execution by requiring asynchronous access from outside actors, thus protecting their mutable state from concurrent access.
Use actors to build secure, concurrent abstractions in your Swift code.
Always design for reentrancy when implementing your actors and any asynchronous code; Waiting in your code means the world can move on and invalidate your assumptions.
Value types work with actors to eliminate data races.
Be aware of classes that do not handle their own synchronization, and other non-Sendable types that reintroduce shared mutable state.
Finally, use main Actor on the code you interact with the UI to ensure that code that must run on the main thread always runs on the main thread.