© Andrés Ibañez Kautsch 2023
A. I. KautschModern Concurrency on Apple Platformshttps://doi.org/10.1007/978-1-4842-8695-1_6

6. Actors

Andrés Ibañez Kautsch1  
(1)
La Paz, Bolivia
 

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.

Listing 6-1 shows how to declare an actor. In this case, we will also create a Videogame struct which we will use for the examples in this chapter.
actor VideogameLibrary {
    var videogames: [Videogame] = []
    func fetchGames(by company: String) -> [Videogame] {
        let games = videogames.filter { $0.title.caseInsensitiveCompare(company) == .orderedSame }
        return games
    }
    func countGames(by company: String) -> Int {
        let games = fetchGames(by: company)
        return games.count
    }
    func add(games: [Videogame]) {
        self.videogames.append(contentsOf: games)
    }
    func fetchGames(by year: Int) -> [Videogame] {
        let games = videogames.filter { $0.releaseYear == year }
        return games
    }
}
Listing 6-1

Declaring an actor

We have a Videogame object referenced here, whose declaration is in Listing 6-2.
struct Videogame {
    let title: String
    let releaseYear: Int
    let company: String
}
Listing 6-2

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.

Listing 6-3 shows how we might try to add some games to a videogame library .
let library = VideogameLibrary()
func addGames(to library: VideogameLibrary) {
    let zelda5 = Videogame(title: "The Legend of Zelda: Ocarina of Time", releaseYear: 1998, company: "Nintendo")
    let zelda6 = Videogame(title: "The Legend of Zelda: Majora's Mask", releaseYear: 2020, company: "Nintendo")
    let tales1 = Videogame(title: "Tales of Symphonia", releaseYear: 2004, company: "Namco")
    let tales2 = Videogame(title: "Tales of the Abyss", releaseYear: 2005, company: "Namco")
    let eternalSonata = Videogame(title: "Eternal Sonata", releaseYear: 2008, company: "tri-Crescendo")
    let games = [zelda5, zelda6, tales1, tales2, eternalSonata]
    library.add(games: games)
}
Listing 6-3

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 .

The direct implication of this is that actors can only run in async contexts , so ultimately, to get that to compile, we need to both add the await keyword to the add(games:) method and wrap it in a Task (or Task.detached). The fixed method that compiles is in Listing 6-4.
func addGames(to library: VideogameLibrary) {
    let zelda5 = Videogame(title: "The Legend of Zelda: Ocarina of Time", releaseYear: 1998, company: "Nintendo")
    let zelda6 = Videogame(title: "The Legend of Zelda: Majora's Mask", releaseYear: 2020, company: "Nintendo")
    let tales1 = Videogame(title: "Tales of Symphonia", releaseYear: 2004, company: "Namco")
    let tales2 = Videogame(title: "Tales of the Abyss", releaseYear: 2005, company: "Namco")
    let eternalSonata = Videogame(title: "Eternal Sonata", releaseYear: 2008, company: "tri-Crescendo")
    let games = [zelda5, zelda6, tales1, tales2, eternalSonata]
    Task {
        await library.add(games: games)
    }
}
Listing 6-4

Interacting with actors needs to be done in an async context

When multiple calls to the add(games:) method are performed, the actor will only execute one of those calls at once. In Listing 6-5, we have created a new method that adds even more games to the library.
func addNewGames(to library: VideogameLibrary) {
    let pokemon1 = Videogame(title: "Pokémon Yellow", releaseYear: 1998, company: "Game Freak")
    let pokemon2 = Videogame(title: "Pokémon Gold", releaseYear: 1999, company: "Game Freak")
    let pokemon3 = Videogame(title: "Pokémon Ruby", releaseYear: 2002, company: "Game Freak")
    let games = [pokemon1, pokemon2, pokemon3]
    Task {
        await library.add(games: games)
    }
}
Listing 6-5

Adding more games to the library

We now have two methods that add games. Let’s try calling them now, as in Listing 6-6.
addGames(to: library)
addNewGames(to: library)
Listing 6-6

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.

Moreover, access to the entire actor state is synchronized. If you have someone adding games to the library and someone querying the collection at the same time, the actor will only execute one of those calls until it is done executing. Listing 6-7 will create three different tasks , two to add games and one to filter the games by a given year and print how many of them there are.
Task {
    addGames(to: library)
}
Task {
    let games1998 = await library.fetchGames(by: 1998)
    print("(games1998.count) games")
}
Task {
    addNewGames(to: library)
}
Listing 6-7

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.

This is also a good chance to show you how task priorities work. You can tell the compiler that you want the games to be added first by assigning a higher priority to the tasks that add games. Listing 6-8 changes the priority of the tasks . .utility is the lowest possible priority, so in this case, it will execute last most of the time, printing 2 games.
Task(priority: .high) {
    addGames(to: library)
}
Task(priority: .utility) {
    let games1998 = await library.fetchGames(by: 1998)
    print("(games1998.count) games")
}
Task(priority: .high) {
    addNewGames(to: library)
}
Listing 6-8

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 .

To demonstrate this, we will add a new type and property to our VideogameLibrary. We will add the owner information to it. Listing 6-9 shows this new type.
struct Owner {
    let name: String
    let favoriteGenre: String
}
Listing 6-9

The new Owner type

We will now modify the actor to have this new property, alongside an initializer. Listing 6-10 shows these modifications.
actor VideogameLibrary {
    var videogames: [Videogame] = []
    let owner: Owner
    init(owner: Owner) {
        self.owner = owner
    }
    func fetchOwnerInfo() -> String {
        return "(owner.name) ((owner.favoriteGenre))"
    }
    //...
}
Listing 6-10

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.

Finally, Listing 6-11 initializes the library with this new type.
let owner = Owner(name: "Andy Ibanez", favoriteGenre: "JRPG")
let library = VideogameLibrary(owner: owner)
Listing 6-11

Initializing the VideogameLibrary with the owner info

Now, if you wanted to call the fetchOwnerInfo method , you will need to call it asynchronously, as seen in Listing 6-12.
Task {
    let ownerInfo = await library.fetchOwnerInfo()
    print(ownerInfo)
}
Listing 6-12

Calling the fetchOwnerInfo method

But in this example, we know that there’s nothing dangerous about accessing the owner info synchronously. Because it is an immutable struct, we should really be able to call it without needing to do so with await. We can tell the compiler that this method is nonisolated so we can call it as we would any other method. Listing 6-13 shows how this is done.
nonisolated func fetchOwnerInfo() -> String {
    return "(owner.name) ((owner.favoriteGenre))"
}
Listing 6-13

Marking a method as nonisolated

And because of this, not only can we lose the await keyword (you will get a helpful warning if you leave it), but we also no longer need to call this method in an asynchronous context. In Listing 6-14 we create a new method that prints the owner info of a VideogameLibrary. Note that function is not async, it does not use a Task, and the fetchOwnerInfo method no longer needs to be awaited.
func printOwnerInfo(of library: VideogameLibrary) {
    let ownerInfo = library.fetchOwnerInfo()
    print(ownerInfo)
}
Listing 6-14

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.

When you intend to conform to a protocol, you will find issues along the way if the protocol has mutable requirements. For example, take a look at the new type in Listing 6-15.
actor Game {
    let name: String
    let year: Int
    init(name: String, year: Int) {
        self.name = name
        self.year = year
    }
}
extension Game: Equatable {
    static func == (lhs: Game, rhs: Game) -> Bool {
        return lhs.name == rhs.name
    }
}
Listing 6-15

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.

However, consider the conformance in Listing 6-16.
extension Game: Hashable {
    func hash(into hasher: inout Hasher) {
    }
}
Listing 6-16

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.

In this case, we can easily solve the issue by adding the nonisolated keyword to the method. Listing 6-17 adds the missing keyword.
extension Game: Hashable {
    nonisolated func hash(into hasher: inout Hasher) {
    }
}
Listing 6-17

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 actor reentrancy problem occurs when you assume the overall state of your program before it reaches an await call. Let’s explain this with an example. We will add a new method to our existing VideogameLibrary actor. Listing 6-18 shows this new method.
func addGamesAndPrintResults(_ games: [Videogame]) async throws {
    let existingGameCount = videogames.count
    // Imagine a long-running operation is taking place here.
    try await Task.sleep(nanoseconds: 1_000_000_000)
    add(games: games)
    let newGameCount = existingGameCount + games.count
    print("Games before: (existingGameCount)")
    print("Games now: (newGameCount)")
}
Listing 6-18

The addGamesAndPrintResults method illustrates the actor reentrancy problem

This new method will:
  1. 1.

    Get the number of games currently in our game library.

     
  2. 2.

    Sleep and await the task for a second.

     
  3. 3.

    Add games to the library.

     
  4. 4.

    Print how many games were there before we added new games.

     
  5. 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.

To better illustrate this problem, take a look at Figure 6-1.

A model diagram represents andysLibrary, Task 1, and Task 2, which includes videogames dot count equal to 3, and with codes inside Tasks 1 and 2.

Figure 6-1

The actor reentrancy problem illustrated

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.

Unfortunately, actors cannot protect you in this situation. This is purely a logic issue that is up to the developer. But a good rule of thumb is that you should check for your assumptions after an awaited call. In this case, our assumption is the number of games in the videogame library. It would make more sense if we calculated that after we come back from a suspension. By simply moving the existingGameCount variable, the program will be fixed. Listing 6-19 places the existingGameCount variable in a more logical place.
func addGamesAndPrintResults(_ games: [Videogame]) async throws {
    // Imagine a long-running operation is taking place here.
    try await Task.sleep(nanoseconds: 1_000_000_000)
    let existingGameCount = videogames.count
    add(games: games)
    let newGameCount = existingGameCount + games.count
    print("Games before: (existingGameCount)")
    print("Games now: (newGameCount)")
}
Listing 6-19

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

Recall that detached tasks don’t inherit anything from a parent task. This includes the actor a parent task may be running on. Actors are not inherited. Consider the new methods added to VideogameLibrary in Listing 6-20.
func playRandomGame() {
    guard let game = videogames.randomElement() else { return }
    print("Playing (game.title)")
}
func playRandomGameLater() {
    Task.detached {
        await self.playRandomGame()
    }
}
Listing 6-20

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.

To make effective use of actors, here’s a list of tips I recommend you keep in mind when you are working with them:
  1. 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. 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. 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. 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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.191.226.24