So far, we have covered about writing concurrent code in Swift using tasks that are independent of each other. You run some tasks, and oftentimes deliver the results to the main thread. But there are cases in which you need to have shared mutable state in your program – a common mutable source that your code can read from and write to. This source can be a simple variable, or it can be a file, or it can be any other kind of resource that is dangerous to access concurrently, yet it needs to be available to multiple tasks at the same time.
If you have multiple pieces of code that can access a read-only resource, your program will always be in a valid state, and you do not have to worry about data corruption in that case. But if you have multiple tasks and at least one of them can write data to a shared resource, then we have a problem. This is called a data race , or, as we called it in Chapter 1, a race condition. Race conditions are dangerous, because if multiple pieces of code can write concurrently to a file, it will lead to data corruption. If multiple tasks are reading from that resource, all of them are going to have trash data that may not even be the same they attempted to read. The worst part is, if you are writing concurrent code at the low level, introducing data races is very easy to do, and very hard to debug.
For this reason, if you are using value semantics (structs or enums), you will not have this problem. Value semantics are read-only, and if they mutate, a copy is created, and all mutation is local to that variable. Each task would operate on a different copy of the data, although it may not be what you want.
In Chapter 1, we saw that the low-level fix solution to this problem is to manually synchronize access to the resources by using locks. But in the new Swift concurrency model, we have a much easier way to create a such synchronization: Actors.
Introducing Actors
Actors are a new type of reference types introduced in Swift , supported by the new concurrency model. Actors isolate their own state from the rest of the program, and they provide synchronized access to mutable state. In simpler terms, if you have an actor that has a simple variable that can be read by and written to by multiple tasks, the task ensures only one of them can do so at a time. It ensures mutual access to a given resource.
Every access to a mutable state is done through the actor. To declare an actor, you simply use the actor keyword and give it a name, very similar to declaring a struct or a class. Actors, being reference types, have a lot of features like that of classes. They can conform to protocols and receive more functionality through extensions. The main difference with classes is actors isolate their own state. An actor may suspend zero or more times (have other await calls within its methods), so you can integrate actors with the rest of the new concurrency system easily.
Declaring an actor
The Videogame struct
We can appreciate that it’s declaration looks very similar to a class, and it being a reference type, we can expect its behavior to be similar. You might be wondering why actors are reference types instead of value types. The reason is simple: An actor encapsulates shared mutable state . It’s a type that expects to be mutated by multiple code paths. If it were a struct, callers mutating it would end up with their own copy.
Interacting with an Actor
All calls to an actor need to be done asynchronously, hence needing the await keyword. This is the mechanism actors use to synchronize their state and prevent concurrent mutations. Because a call to an actor can be awaited, other callers to it will be suspended, waiting their turn to access it.
Internally, there is no need for the actor to call its own methods and properties asynchronously. Because the actor is isolated, any calls within itself will run sequentially until an operation is complete.
Adding games to a library
The addGames method is just a convenience function to quickly add some videogames. However, if you tried to compile this, you would get an error because “Actor-isolated instance method ‘add(games:)’ can not be referenced from a non-isolated context”.
The method is expecting us to add the await keyword. Despite the fact that the add(games:) method is not marked as async, the API exposed to us is indeed async as a protection provided by the actor. The compiler is helping you misuse actors, avoiding the introduction of data races .
Interacting with actors needs to be done in an async context
Adding more games to the library
Calling two methods that call the actor
Now this is where things become interesting. There is no guaranteed to know if the games from addGames or addNewGames will be added first. But what is guaranteed is that whichever gets to execute first, the actor will ensure all the games in one of these functions are added to the array before the other one gets to run. So, the array will either contain all the games from addGames first, in the order they were added, followed by the games in addNewGames in order they were added, or it will contain the games of addNewGames in the order they were added followed by games from addGames in the correct order as well. The games will never be added in an interleaved order or anything crazy like that.
Accessing the actor from different tasks
addGames has one game released in 1998. addNewGames also has one game released in 1998. In total, there’s two games released in that year. Ideally, the code above would print 2 games, but because the tasks attempt to access the actor concurrently, only of three tasks will get to execute its content first. If you try to run this code, you will notice it prints 0 games most of the time. It could also print 1 games. Mutually exclusive access means that only one person will enter the actor and do its work, it doesn’t matter what method of the actor is being called. Any call to the actor will block it until it’s done doing its work.
Assigning priorities to tasks
Nonisolated Access to an Actor
There will be times in which you know that accessing an actor will not cause data races . For these cases, you can mark some methods in your actor as nonisolated .
The new Owner type
Adding the owner property to the actor
Note that we also added a fetchOwnerInfo method. This will help us get a concatenated string with the owner’s name and favorite videogame genre.
Initializing the VideogameLibrary with the owner info
Calling the fetchOwnerInfo method
Marking a method as nonisolated
Calling a nonisolated method doesn’t require asynchronicity
Actors and Protocol Conformance
There is one important thing you need to keep in mind if you are planning on making your actor conform with protocols , or even extend them with extensions.
Making an actor conform to a type
We are making the actor conform to the Equatable protocol so we can compare two Games with the == operator. This example will compile fine, because the == function relies on two immutable properties, and because the == function itself does not require our actor to be mutated.
Conforming an actor to the Hashable Protocol
Attempting to use this conformance as-is will cause Swift to yield an error saying that the actor-isolated method hash(into:) cannot be used to satisfy a protocol requirement. The compiler is expecting hash(into:) to be a synchronous operation.
Marking hash(into:) as nonisolated
This will effectively satisfy the compiler, but you should always stop to consider if this makes sense. Will the hash(into:) method depend on the isolated properties? If so, any attempts you do to implement this method will likely be broken. You may find times in which it really is not easy to conform to protocols that expect their methods to be synchronous.
Actor Reentrancy
When we enter an actor, the actor may suspend if it has a lot of work to do, calling other asynchronous methods. This can lead to a common problem known as the Actor Reentrancy problem .
The addGamesAndPrintResults method illustrates the actor reentrancy problem
- 1.
Get the number of games currently in our game library.
- 2.
Sleep and await the task for a second.
- 3.
Add games to the library.
- 4.
Print how many games were there before we added new games.
- 5.
Print how many games there are now.
The actor reentrancy problem occurs when multiple tasks access this method. When one task accesses it, it will store the current game count in the existingGameCount variable. It will then hit a lengthy await call. In the meantime, a second task could access this method and also store the number of games in a new existingGameCount variable. If the first task finishes before the second one leaves the await, the existingGameCount variable in the second task will essentially hold incorrect data, because the library has more games than it had before.
Two tasks are accessing the actor to add more games. We assume the library has 3 games already. The first task adds 4 games to it, and the second one adds 2. At the end of their executions, Task 1 will print that there are now 7 games, and Task 2 will print that we have 5. But both tasks entered at the same time, and we know that actors prevent the state to be modified at the same time. So, one of the tasks is printing incorrect data.
Each of the tasks is printing the results as if it were the only one accessing the actor and printing it. If Task 1 finishes before Task 2, Task 2 should know that the library had in fact 7 games, and so it should have printed 11 games total.
The problem here is that we assumed how many games we had before we entered the await call. There is no guarantee that after we leave a suspension point the state of the program will be as it was before. We are checking our assumptions before an awaited call.
Moving the assumptions to a more appropriate place
By moving our assumptions to be after the await call, Task 1 and Task 2 may still print different results in each call, but one thing is for sure, that no matter who finishes first, the task to finish second will have all the right data to show to the user.
Actors and Detached Tasks
Launching an actor method from a detached task
The playRandomGame method is isolated to the actor. But note that playRandomGameLater requires we call self.playRandomGame with await. This is because the moment we launched the detached task, we jumped to a different actor. Being in a different actor will force us to call the actor’s method with await.
General Tips for Working with Actors
Actors are great for those situations in which you have a shared mutable state . It makes accessing mutable data from multiple parts easy, but it is not a silver bullet by any means.
- 1.
Make your actors as small as possible. While actors will protect you from a lot of misuse thanks to the Swift compiler, it will not protect you from problems such as actor reentrancy. Keeping your actors small will help you make effective use of them.
- 2.
Keep all your actor’s operations atomic. An atomic operation is treated as a single unit. Keeping your methods short will help with this. In general, atomicity means that you either complete all your work at once, or if a single operation fails, the entire operation is discarded. Avoiding await calls within actors as much as possible will aid with atomicity.
- 3.
All your assumptions about the needed state should go after the await calls when used within an actor’s methods. It is very easy to do if your method contains a single await call, but if you have multiple, it can be hard to reason about your program’s state.
- 4.
Do not use an actor as an ObservableObject in SwiftUI . The compiler has no issues allowing you to do that, but ObservableObject will expect that it will always run on the main thread. Having an actor with expensive calls will result in visible lags in your SwiftUI app.
Summary
In this chapter, we learned about actor reference types. These types are great when we need to have a shared mutable state in our program. Shared mutable state is data that multiple processes may want to access asynchronously. Allowing asynchronous access to mutable data is dangerous and can lead to data corruption. Actors have a mechanism that helps them automatically isolate their own state from the rest of the program and provide synchronized access to itself, preventing its data from being written to at the same time by multiple processes and avoiding corrupted reads. We can opt out of this isolated behavior for specific methods by using the nonisolated keyword.
Due to its isolated status, all calls to the actor outside of itself must be done asynchronously, using the await keyword, unless they are marked as nonisolated. The Swift compiler will not let you access the actor synchronously as it will complain about the lack of the await keyword.
As actors are normal types, they can conform to protocols and have behaviors similar to that of classes. The main difference between the two is the isolation of the actor. If we want to conform to a protocol, we need to keep in mind the protocol’s requirements for mutation or other requirement that would need certain conformances to be nonisolated. It will not always be possible to conform to all protocols, as marking certain methods as nonisolated could prevent us from getting the behavior we want.