Skip to main content

Swift Actors vs Serial Queues: Understanding the Key Differences


The Swift Evolution proposal SE-0306 introduces actors as a mechanism for protecting mutable state from data races. In the implementation notes, the proposal draws a direct comparison to a familiar concurrency primitive:

Implementation note: Each actor instance contains its own serial executor. 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. …

Actors are “conceptually similar” to serial queues, but lack the ordering guarantee. What does that actually mean in practice? When does this difference manifest, and how should it change the way we reason about our code?

In this article, I want to build an intuition for this distinction by examining concrete examples that reveal the behavioral differences between actors and serial DispatchQueues.

The Classic Example

Let’s consider the classic example of a bank account actor:

actor BankAccountActor {
    private(set) var balance = 0.0

    func deposit(_ amount: Double) {
        balance += amount
    }
}

This, at first glance, seems to be a straight upgrade in terms of readability to this more verbose implementation using a serial DispatchQueue:

final class BankAccountGCD: @unchecked Sendable {
    private let queue = DispatchQueue(label: "bank.account")

    private(set) var balance = 0.0

    func deposit(_ amount: Double) {
        queue.async {
            self.balance += amount
        }
    }
}

Both types provide a very similar interface to the caller. Of course, when called from outside the actor, the Swift compiler enforces the usage of the await keyword on the actor’s deposit invocation. But let’s disregard the syntax for a moment and take a closer look at the runtime behavior. Which is also almost identical in the implementation above. To do that, let’s first insert a print statement to the balance’s willSet:

  private(set) var balance = 0.0 {
      willSet {
          print(Self.self, "adding", newValue - balance)
      }
  }

Then, we observe the output of the following code block:

let account = BankAccountActor()
let account2 = BankAccountGCD()

for index in 1 ... 1000 {
    let amount = Double(index)

    Task.detached {
        await account.deposit(amount)
    }

    Task.detached {
        account2.deposit(amount)
    }
}

Depending on how exactly the Tasks are scheduled, of course we can observe many different exact results. But they have one thing in common: Looking at the instance of either BankAccountActor or BankAccountGCD, the order of the deposit calls are preserved. Only the calls to the other instance might be interleaved. Here is an example:

BankAccountActor adding 1.0
BankAccountGCD   adding 1.0
BankAccountActor adding 2.0
BankAccountGCD   adding 2.0
BankAccountActor adding 3.0
BankAccountGCD   adding 3.0
BankAccountActor adding 4.0
BankAccountGCD   adding 4.0
BankAccountGCD   adding 5.0  <--
BankAccountActor adding 5.0
BankAccountGCD   adding 6.0
BankAccountActor adding 6.0
BankAccountGCD   adding 7.0
BankAccountActor adding 7.0
BankAccountGCD   adding 8.0
BankAccountActor adding 8.0
BankAccountGCD   adding 9.0
BankAccountActor adding 9.0
BankAccountGCD   adding 10.0
BankAccountActor adding 10.0
BankAccountGCD   adding 11.0
BankAccountActor adding 11.0
BankAccountGCD   adding 12.0
BankAccountGCD   adding 13.0 <--
BankAccountActor adding 12.0
BankAccountGCD   adding 14.0
BankAccountActor adding 13.0
BankAccountGCD   adding 15.0
BankAccountActor adding 14.0

On one hand, this result is not surprising. Of course the order is preserved, that’s why we used an actor or queue in the first place.

On the other hand, this seems to contradict the statement from the Swift Evolution proposal SE-0306. Again:

tasks awaiting an actor are not guaranteed to be run in the same order they originally awaited that actor

So, let’s iterate on this example and find a case that actually delivers on the promise of unordered calls, by using a slightly different actor implementation. We do that by introducing a suspension point #1 inside of the deposit function.

actor BankAccountActor {
    private(set) var balance = 0.0

    func deposit(_ amount: Double) async {
        // This await marks a suspension point.
        try? await Task.sleep(for: .milliseconds(10)) // #1
        balance += amount
    }
}

Now the ordering of the balance += amount calls breaks. The BankAccountActor no longer completes deposit calls in order.

But why? And what does an actor guarantee? An actor guarantees, there is no concurrent access to its state. It does so, by only allowing one task to mutate its state at a time. Still, a task that at one point started execution, but suspends by awaiting in the meantime, may still fall behind in the execution order and another task then takes precedence.

This behavior is called actor reentrancy and the Swift Evolution proposal talks about this in detail here.

Task Priority

Actor reentrancy is not the only thing that can influence the execution order of a task: Task priority is out of the actors control. When selecting the next task to run on an actors executor the Swift runtime prefers tasks with higher priority. This means higher-priority tasks can “jump the queue” even when they were enqueued later:

for index in 1 ... 10 {
    let amount = Double(index)
    Task.detached(priority: index % 2 == 0 ? .low : .high) {
        await account.deposit(amount)
    }
}

Conclusion

Actors guarantee mutual exclusion: only one task can access an actors isolated state at a time. This prevents data races and provides thread safety without manual synchronization.

Actors do not guarantee ordering. Two factors can cause tasks to execute out of order:

  1. Reentrancy (internal): When an actor method suspends at an await, other tasks can run on the actor before it resumes
  2. Priority (external): The runtime prioritizes higher-priority tasks when selecting what to run next on the actor’s executor

Understanding these factors help us better predict the behavior of concurrent code.

Key Takeaway: Use synchronous, i.e. non-async functions on the actor to guarantee synchronized access on the internal state. Do not lightly use async on an actor function without understanding the implications.