Search code examples
swiftswift-concurrency

Why cannot use actor as the type of the state machine in AsyncSequence?


This is the notes from WWDC 2023 session Beyond the basics of structured concurrency.

Many AsyncSequences are implemented with a state machine, which we use to stop the running sequence.

public func next() async -> Order? {
    return await withTaskCancellationHandler {
        let result = await kitchen.generateOrder()
        guard state.isRunning else {
            return nil
        }
        return result
    } onCancel: {
        state.cancel()
    }
}

  • While actors are great for protecting encapsulated state they can’t really protect the state machine.
  • To modify and read individual properties on the state machine actors aren't quite the right tool.
  • Can't guarantee the order that operations run on an actor, so we can't ensure that our cancellation will run first.
  • Use atomics from the Swift Atomics package, a dispatch queue or locks instead.
private final class OrderState: Sendable {
    let protectedIsRunning = ManagedAtomic<Bool>(true)
    var isRunning: Bool {
        get { protectedIsRunning.load(ordering: .acquiring) }
        set { protectedIsRunning.store(newValue, ordering: .relaxed) }
    }
    func cancel() { isRunning = false }
}

After watching this part I can't understand why actor is not a good option. Please shed some light on this.


Solution

  • You include one of the comments that explains why the state cannot be an actor:

    we can’t guarantee the order that operations run on an actor, so we can’t ensure that our cancellation will run first.

    For this reason, an actor is not well-suited for synchronization of the state machine. When you cancel a sequence, you want to be assured that the cancellation will run first.

    For those unfamiliar with the non-FIFO behavior of actors, I would refer you to SE-0306 – Actors, which says:

    The default serial executor is responsible for running the partial tasks one-at-a-time. This is conceptually similar to a serial DispatchQueue, but with an important difference: tasks awaiting an actor are not guaranteed to be run in the same order they originally awaited that actor. … This is in contrast with a serial DispatchQueue, which are strictly first-in-first-out.

    Also, that video goes on to explain a second concern (emphasis added):

    We do this by synchronously calling the cancel function on our sequence state machine. … These mechanisms [a lock, a sync call to GCD serial queue, or atomics] allow us to synchronize the shared state, avoiding race conditions, while allowing us to cancel the running state machine without introducing an unstructured task in the cancellation handler.

    They are suggesting that the synchronization of the state object should be synchronous. They are advising against launching unstructured task to update the state machine (which you would have to do if it was an actor).

    In short, when you cancel a sequence, you really want to be confident that no more elements can be yielded, which means synchronous synchronization, i.e., not an actor but rather something like a lock, an atomic, or a GCD serial queue.


    For the sake of completeness, here is the entire quote from that video:

    Like with synchronous iterators, the next function returns the next element in the sequence, or nil to indicate that we are at the end of the sequence. Many AsyncSequences are implemented with a state machine, which we use to stop the running sequence.

    In our example here, when isRunning is true, the sequence should continue emitting orders. Once the task is cancelled, we need to indicate that the sequence is done and should shut down.

    We do this by synchronously calling the cancel function on our sequence state machine.

    Note that because the cancellation handler runs immediately, the state machine is shared mutable state between the cancellation handler and main body, which can run concurrently. We’ll need to protect our state machine. While actors are great for protecting encapsulated state, we want to modify and read individual properties on our state machine, so actors aren’t quite the right tool for this.

    Furthermore, we can’t guarantee the order that operations run on an actor, so we can’t ensure that our cancellation will run first. We’ll need something else. I’ve decided to use atomics from the Swift Atomics package, but we could use a dispatch queue or locks.

    These mechanisms allow us to synchronize the shared state, avoiding race conditions, while allowing us to cancel the running state machine without introducing an unstructured task in the cancellation handler.