Lesson 09 / 19
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.
Published Updated
In this lesson
- 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
Contents
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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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 awaits: 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.
// 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 awaits; 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.