Swift Actors


Greetings, traveler!

At the WWDC21 conference, Apple announced a new Actors feature that helps solve fundamental problems in asynchronous code. Here are these problems:

  1. Deadlocks arise when two or more processes are waiting for each other to complete, preventing neither process from progressing.
  2. Starvation is when a process cannot access necessary resources, and the program is never completed.
  3. Race condition arises when two or more threads attempt to access the same shared data simultaneously, and at least one of those accesses involves a write operation.
  4. Livelock occurs when two or more threads are locked in a loop, as they keep responding to each other’s actions, leading to constant change without progress.

Let’s use the code to create a race condition.

final class Shelf {

    var books: [String] = []
    
    func put(book: String) {
        self.books.append(book)
    }
    
}

final class Client {
    
    func action() {
        let shelf = Shelf()

        DispatchQueue.concurrentPerform(iterations: 6) { _ in
            shelf.put(book: "W. Somerset Maugham. Novels.")
        }
    }
    
}

The compiler will throw a Bad Access error since we are trying to access the variable from different threads. Previously, we would have had to solve this problem with locks and barriers, but now we have a safer and more convenient way.

Actors

What is an actor? It’s not a class (so there is no inheritance) or structure. But actors also have initializers, properties, and methods. However, properties and methods can only be used asynchronously using await. Moreover, actors can only execute one method at a time.

Consider the example with a bookshelf, but performed by an actor.

actor Shelf {
    
    var books: [String] = []
    
    func put(book: String) {
       books.append(book)
    }
    
}

Remember that actor methods can only be called in an asynchronous context, so we can put this method’s call into a Task.

final class Client {
    
    func action() {
        let shelf = Shelf()
        DispatchQueue.concurrentPerform(iterations: 6) { _ in
            Task {
                await shelf.put(book: "W. Somerset Maugham. Novels.")
            }
        }
    }
    
}

By the way, if you want to read more about Tasks in Swift Concurrency, you can do so here. Moreover, you can read about detached tasks here. And here is my article about its cancellation.

Alright then, but what about the actor’s properties? Can we print the ‘books’ property value, for example? The answer is yes once we do it asynchronously.

final class Client {
    
    func action() async {
        let shelf = Shelf()
        await print(shelf.books)
    }
    
}

But if we had a constant inside the actor, we could access it without additional code. The fact is that constants are immutable values, which means that accessing them will be thread-safe.


actor Shelf {
    let books: [String] = []
}

final class Client {
    
    func action() {
        let shelf = Shelf()
        print(shelf.books)
    }
    
}

If the compiler knows we are working inside the actor’s isolation context, it won’t prevent us from using functions without additional async code.

actor Shelf {
    
    var books: [String] = []
    
    func put(book: String) {
       books.append(book)
    }
    
    func putBooks() {
        put(book: "Book #1")
        put(book: "Book #2")
        put(book: "Book #3")
    }
    
}

If we create a method that doesn’t alter the state of an actor, we can mark it with the ‘nonisolated’ keyword, which also means we don’t need to write ‘await’ every time we call this function. However, we need to ensure it doesn’t change the actor’s state, so, for example, we won’t be able to retrieve the value of a variable using it. We’ll have to use a constant again.

actor Shelf {
    
    let color: UIColor = .brown
    
    nonisolated
    func shelfColor() -> UIColor {
        color
    }
    
}

class Client {
    
    func action() {
        let shelf = Shelf()
        let color = shelf.shelfColor()
    }
    
}

You can read more about the ‘nonisolated’ and the ‘isolated’ keywords here.

How Actors prevent a race condition

The actor’s methods are placed in a queue or, more precisely, a stack. Why wouldn’t I use the word ‘queue’? Because it can cause an association with GCD’s serial queues, which is wrong. The actor’s queue is dynamic. If the method does not change the actor’s state, its execution may be postponed. In this case, another method will take its place in the execution queue.

When using await inside a method’s body, this method waits for another asynchronous method inside itself. The actor processes other requests at this time. As a result, we get a problem that causes unexpected behavior. This problem is called actor reentrancy. But there is an easy way out of this situation. You can read more about it here.

This article is coming to an end. We learned the basics of working with actors. But this topic has yet to be exhausted. Here, you can read more about other aspects of it:

  1. The ‘nonisolated’ and the ‘isolated’ keywords
  2. Global Actors
  3. Actor reentrancy