---
title: Actors
source: 'https://academia.sh/en/courses/data-networking-and-concurrency/actors'
course: 'Data, Networking and Concurrency'
language: en
updated: '2026-10-21T05:03:08+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains that an actor isolates its state and gives access to only one piece of work at a time'
    - 'Measures that a seat map that accepts the first claim is sensitive to the order of claims'
    - 'Measures that a counter that increments on every call is sensitive to repetition'
    - 'Distinguishes that isolation prevents a data race but does not fix the order'
---

# Actors

A measurement showing that actor isolation allows only one piece of work at a time, yet the order of two requests and the repetition of a counter still change the result.

In the previous lesson, collecting the results of a task group in a set erased arrival order, while pouring them into an array wrote the order into the result. There the problem was the form of the collection. In this lesson the problem is the state itself: if several pieces of work reach the same data, what does the guarantee given by isolation cover, and what does it not cover?

The core file is the same in every lesson of the topic.

```swift
// Replay.swift — classifies an operation's sensitivity to repetition and order
struct Trial<State: Equatable & CustomStringConvertible> {
    let label: String
    let once: State
    let twice: State
    let forward: State
    let backward: State

    var sensitivity: String {
        switch (once != twice, forward != backward) {
        case (false, false): return "robust"
        case (true, false): return "repeat-sensitive"
        case (false, true): return "order-sensitive"
        case (true, true): return "doubly-sensitive"
        }
    }

    func report() -> String {
        print("\(label) | once=\(once) twice=\(twice) ab=\(forward) ba=\(backward) -> \(sensitivity)")
        return sensitivity
    }
}

func replay<State>(_ label: String, from start: State, _ a: (State) -> State, then b: (State) -> State) -> Trial<State> {
    Trial(label: label, once: a(start), twice: a(a(start)), forward: b(a(start)), backward: a(b(start)))
}

func countSensitivities(_ results: [String]) {
    for name in ["robust", "repeat-sensitive", "order-sensitive", "doubly-sensitive"] {
        print("\(name): \(results.filter { $0 == name }.count)")
    }
}
```

Actor operations are asynchronous; calls require `await`. For this reason each run is built by hand instead of with `replay` and given straight to `Trial`. The model builds what the operation does to the state; it is not the network or the runtime itself.

## Actor and Isolation

An **actor** (`actor`) is a reference type that protects its own mutable state. The actor's state is not touched directly from outside; access is made by entering one of the actor's functions with `await`, and the actor runs only one piece of work at a time. This is called **actor isolation**. What isolation gains is the prevention of a **data race**: two pieces of work cannot read and write the same memory area at the same time.

The difference between this and a **race condition** is important. A data race is an error at the memory level, and isolation removes it. A race condition is the result depending on the order of the work, and isolation does not solve this: the two pieces of work run one after the other, one at a time, safely; but which one ran first still changes the result.

```swift
// Seats.swift — seat map and ticket counter
actor SeatMap {
    private(set) var owner = "-"

    func claim(_ name: String) -> Bool {
        if owner == "-" {
            owner = name
            return true
        }
        return false
    }

    func currentOwner() -> String { owner }
}

actor TicketCounter {
    private(set) var sold = 0

    func sell() { sold += 1 }
    func total() -> Int { sold }
}

func runClaims(_ names: [String]) async -> String {
    let map = SeatMap()
    for name in names { _ = await map.claim(name) }
    return await map.currentOwner()
}

func runSales(_ times: Int) async -> Int {
    let counter = TicketCounter()
    for _ in 0..<times { await counter.sell() }
    return await counter.total()
}
```

`SeatMap` holds the owner of a single seat. The first to ask wins; later claims are rejected. `TicketCounter` increases the sales count by one on every call. Both are actors; in both, isolation is complete.

## First Claim Wins and the Counter Increments

The seat map is measured in four runs for two names: a single claim, two claims by the same name, `alice` then `bob`, `bob` then `alice`. The counter is measured with one and two sales; since there are not two different operations, the forward and backward orders are both two sales.

```swift
// main.swift — seat claims and ticket sales
let claimOnce = await runClaims(["alice"])
let claimTwice = await runClaims(["alice", "alice"])
let claimForward = await runClaims(["alice", "bob"])
let claimBackward = await runClaims(["bob", "alice"])
let seat = Trial(label: "seat-claim", once: claimOnce, twice: claimTwice, forward: claimForward, backward: claimBackward).report()

let saleOnce = await runSales(1)
let saleTwice = await runSales(2)
let sales = Trial(label: "ticket-sale", once: saleOnce, twice: saleTwice, forward: saleTwice, backward: saleTwice).report()

countSensitivities([seat, sales])
```

```
seat-claim | once=alice twice=alice ab=alice ba=bob -> order-sensitive
ticket-sale | once=1 twice=2 ab=2 ba=2 -> repeat-sensitive
robust: 0
repeat-sensitive: 1
order-sensitive: 1
doubly-sensitive: 0
```

In the seat line, the second claim by the same name does not change `alice`, because the second claim is rejected; `once` and `twice` are the same. But `ab` is `alice` and `ba` is `bob`: the first to arrive wins, and who arrives first is written into the result. The operation is **order-sensitive**. In the counter line `once=1` and `twice=2`: every call increases the counter, and repetition changes the result. Order does not affect the counter; since ticket sales are commutative, `ab` and `ba` are the same. The operation is **repeat-sensitive**. The count is one repeat-sensitive, one order-sensitive.

**CC7.** This measurement models a single owner field for the seat. How several seats or a waiting list would affect the same rule was not measured.

**CC8.** In the counter measurement, order means the order of two identical `sell` calls. The case where two operations of different kinds are used together was not measured.

## Why Actors Exist

Before actors, the way to protect shared state was a lock: every piece of code that read or wrote the state first took a lock and released it when it finished. The method works, but the protection depends on the programmer's discipline. A single line that forgets to take the lock invalidates the whole protection, and the compiler does not notice. The bug also rarely shows itself: when two pieces of work hit that line at the same time, mostly only under load, sometimes only on one customer's device.

The actor moves the protection into the language itself. The state is inside the actor and the only way to reach it is the actor's functions; the compiler rejects direct access from outside. Forgetting the lock becomes impossible, because the actor does the locking itself. In return there is a cost: every entry into an actor is an `await` point, and the caller waits in line if the actor is busy. This wait does not block the caller; the caller is suspended and the thread is given to other work. For this reason an actor is suited not to long and slow work but to short and frequent state changes.

The typical wrong setup is trying to make the same protection with a class and skipping the protection.

```swift
// Wrong: a mutable class, no protection
final class UnsafeTicketCounter {
    var sold = 0
    func sell() { sold += 1 }
}
```

This class works correctly when used from a single flow. When two tasks call `sell` on the same instance at the same time, however, `sold += 1` consists of the steps read, increment, and write, so the two can interleave and one sale is lost. Since the class is not Sendable, the Swift 6 language mode should not let you share this example between two tasks; this compiler behaviour was not measured here. The fix is to make the class an `actor`; the `TicketCounter` above is exactly this, and its body does not change.

## Sendable Types

The state inside an actor is isolated, but the values entering and leaving the actor cross the boundary. For a value to be sent safely across concurrent boundaries, its type must be **Sendable**. Value types and immutable values generally meet this condition on their own; mutable reference types do not, and the compiler reports this as an error.

```swift
// Sendable type: an immutable value that can be sent between actors
struct Reservation: Sendable {
    let seat: Int
    let owner: String
}

func describe(_ reservation: Reservation) -> String {
    "seat \(reservation.seat) -> \(reservation.owner)"
}
```

`Reservation` carries only constant fields, so it is sent safely. Immutable values are copied or shared as they cross the boundary; nobody can change them at the same time. This fragment is not measured, because it shows what no operation does to the state; it only explains the type rule.

## Releasing the Seat

Until now the seat was only won. In a real reservation the seat is also given back, and releasing, together with winning, brings a second form of order sensitivity. There is a rule here: only the current owner of the seat can release it; someone else's release request is silently rejected.

```swift
// Release.swift — a releasable seat
actor ReleasableSeat {
    private(set) var owner = "-"

    func claim(_ name: String) {
        if owner == "-" { owner = name }
    }

    func release(_ name: String) {
        if owner == name { owner = "-" }
    }
}

func runSeatOperations(_ operations: [(isRelease: Bool, name: String)]) async -> String {
    let seat = ReleasableSeat()
    for operation in operations {
        if operation.isRelease {
            await seat.release(operation.name)
        } else {
            await seat.claim(operation.name)
        }
    }
    return await seat.owner
}
```

The measured pair is claiming and releasing for `alice`. Four runs: a single claim, two claims, claim then release, release then claim.

```swift
// main.swift — claiming and releasing
let releaseOnce = await runSeatOperations([(false, "alice")])
let releaseTwice = await runSeatOperations([(false, "alice"), (false, "alice")])
let releaseForward = await runSeatOperations([(false, "alice"), (true, "alice")])
let releaseBackward = await runSeatOperations([(true, "alice"), (false, "alice")])
let released = Trial(label: "release", once: releaseOnce, twice: releaseTwice, forward: releaseForward, backward: releaseBackward).report()

countSensitivities([released])
```

```
release | once=alice twice=alice ab=- ba=alice -> order-sensitive
robust: 0
repeat-sensitive: 0
order-sensitive: 1
doubly-sensitive: 0
```

When the same claim is made a second time the result does not change: `once` and `twice` are both `alice`. But claiming and then releasing empties the seat (`-` for `ab`), while releasing and then claiming gives the seat to `alice` (`ba`). A release that comes before the claim has no effect, because there is no ownership to release. The operation pair is **order-sensitive**. This is the model of a familiar bug on reservation screens: the user chooses a seat and immediately changes their mind; if the two requests swap places on the network, the seat appears to belong to the user even though they gave it up.

**CC9.** In this measurement, release was built as a request made with the name of the current owner only. Another user's release request and automatic release on timeout were not measured.

## In a Real Application

Think of a seat selection screen in an events app. The user taps a seat, the app sends a request to the server and shows the result on screen. On the client side an actor holds the state of the seats: which seat is free, which is selected, which is still waiting for a response from the server. The user acts quickly, selects a seat and releases it, and then selects another; that is, several requests are sent one after another in a short time. The actor keeps the state from being corrupted in the middle of these requests. Which request is processed first, however, is decided not by the actor but by the order in which the requests reach the actor.

This distinction shows on screen as two separate bugs. With a data race the app would crash with memory corruption; the actor prevents this. With an order bug the app does not crash, it only shows the wrong seat as selected; the actor does not prevent this, because both requests are valid separately. For this reason, in flows that need order, one of two ways is chosen: the requests are sent from a single task, one after another with `await`, or each request carries a sequence number and the actor rejects a request with an old number.

A common setup mistake is doing the check outside the actor and the action inside it.

```swift
// Wrong: other work can come in between the check and the action
func claimIfFree(_ map: SeatMap, _ name: String) async {
    if await map.currentOwner() == "-" {
        _ = await map.claim(name)
    }
}
```

Another task can enter the actor between the two `await`s: both calls see the seat as free, and both call `claim`. Since `SeatMap.claim` checks and writes inside itself, the second call is rejected and the result stays correct in this example; but when the rule is moved outside the actor, for example when an action with a side effect such as "if free, send a payment request" is added, the payment can be sent twice. The fix is to combine the check and the action in a single function of the actor; `claim` already does this. This fragment was not measured, because the sequential run of this lesson cannot build an execution where two calls interleave.

## Common Mistakes

- Thinking the actor is immune to race conditions. An actor prevents a data race; it does not fix the order of two pieces of work.
- Reading the state inside an actor and writing it after an `await`. Another piece of work may have come in at the suspension point.
- Trying to reach the actor's state directly from outside. Access is made with `await`; the compiler enforces this.
- Giving an actor a reference type that is not Sendable. The type of every value crossing the boundary must be Sendable.

## The Bounding Measurement

Reentrancy is the ability of another piece of work to enter the same actor when the actor is suspended at an `await` point. The seat map below writes the claim after a suspension point. In the sequential run the same calls run one after another and no work comes in between; the measurement does not see this.

```swift
// main.swift — a suspending seat map, the same four runs
actor SlowSeatMap {
    private(set) var owner = "-"

    func pause() async {}

    func claim(_ name: String) async -> Bool {
        if owner == "-" {
            await pause()
            owner = name
            return true
        }
        return false
    }
}

func runSlowClaims(_ names: [String]) async -> String {
    let map = SlowSeatMap()
    for name in names { _ = await map.claim(name) }
    return await map.owner
}

let slowOnce = await runSlowClaims(["alice"])
let slowTwice = await runSlowClaims(["alice", "alice"])
let slowForward = await runSlowClaims(["alice", "bob"])
let slowBackward = await runSlowClaims(["bob", "alice"])
let slow = Trial(label: "slow-seat", once: slowOnce, twice: slowTwice, forward: slowForward, backward: slowBackward).report()

countSensitivities([slow])
```

```
slow-seat | once=alice twice=alice ab=alice ba=bob -> order-sensitive
robust: 0
repeat-sensitive: 0
order-sensitive: 1
doubly-sensitive: 0
```

A suspension point was added and the line is the same output as `seat-claim`. The rule does not see that no other work came between the `await`s; it only looks at whether the states are equal at the end of the four runs. On top of that, the actor is free of data races and this does not make the result robust: the same line stays **order-sensitive**. The rule does not see the cause, it sees the inequality. The case where two requests suspend at the same time and both see the seat as free was not built here and was not measured; in that case the seat would have been won twice. The count is one order-sensitive.

**CC10.** Another piece of work coming in at a suspension point was not built in any measurement in this lesson. Reentrancy is only explained here.

As a rule, if the actor's function decides and writes in a single step, the state stays consistent; if you make the decision outside and have the actor only write, consistency returns to being your responsibility. This distinction looks small, but in areas such as reservations, stock, and balances it determines where the bug will appear.

## Summary

- An actor isolates its state and allows only one piece of work at a time; this prevents a data race.
- Isolation does not fix the order of two pieces of work: the seat map that accepts the first claim is order-sensitive.
- A counter that increases on every call is repeat-sensitive and does not see the order.
- Sendable types cross the boundary safely; mutable references cannot.
- Adding a suspension point does not change the class in a sequential measurement; interleaving was not measured.
- This lesson's sensitivity: **order-sensitive** (seat) and **repeat-sensitive** (counter).

## Next Step

Protecting state inside actors is one thing, protecting the interface's state is another. In the next lesson we will measure code that is tied to the main thread, and a model in which search results are written to the screen in arrival order.
