How does the `actor` builder let you safely mutate shared state without a Mutex?
answer
- actor -> SendChannel inbox
- one coroutine owns state, processes msgs serially
- for (msg in channel) inside, when over sealed msgs
- reply via CompletableDeferred
- ObsoleteCoroutinesApi
basics
~20 sactor runs a single coroutine that owns the state and processes messages one at a time from its inbox. Because only that one coroutine touches the state, there is no race, so you don't need a lock.
solid answer
~40 s`actor { }` is a coroutine builder returning a `SendChannel<T>`. Inside, the receiver is an `ActorScope<T>` (a `CoroutineScope` plus a `ReceiveChannel<T>` inbox), so you loop with `for (msg in channel)` and handle each message. State lives as locals/captured vars inside the actor coroutine; since messages are processed sequentially by one coroutine, mutation is confined to a single thread of execution and needs no `Mutex` or `@Synchronized`. Callers send messages instead of calling methods directly. To get a reply you embed a `CompletableDeferred<R>` in the message and `await()` it. This is the actor/share-by-communicating model: serialize access by funneling all mutations through one mailbox rather than guarding shared memory with locks.
code
kotlin · 19 linessealed class Msg
object Inc : Msg()
class Get(val r: CompletableDeferred<Int>) : Msg()
fun CoroutineScope.counter() = actor<Msg> {
var n = 0
for (m in channel) when (m) {
is Inc -> n++
is Get -> m.r.complete(n)
}
}
suspend fun main() = coroutineScope {
val a = counter()
repeat(100) { a.send(Inc) }
val d = CompletableDeferred<Int>(); a.send(Get(d))
println(d.await()) // 100
a.close()
}go deeper
Understands an actor is one coroutine handling messages one at a time so no lock is needed.
Builds the sealed-message + ActorScope loop and uses CompletableDeferred for replies.
Discusses capacity/backpressure, ObsoleteCoroutinesApi status, and when actor vs Mutex vs StateFlow fits.
Designs an actor-based subsystem with supervision, error handling, and migration path off the obsolete API.
## The problem actors solve When multiple coroutines mutate the same variable, you normally need a **`Mutex`** (`withLock`) or an atomic to avoid races. The **actor model** takes a different approach: *don't share mutable state — share it by message passing*. One coroutine privately owns the state; everyone else sends it messages. ## The `actor` builder `actor` is a `CoroutineScope` extension that returns a **`SendChannel<T>`** — the inbox. The lambda's receiver is **`ActorScope<T>`**, which is a `CoroutineScope` plus a `ReceiveChannel<T>`, so you read messages from inside. ```kotlin sealed class CounterMsg object Inc : CounterMsg() class Get(val response: CompletableDeferred<Int>) : CounterMsg() fun CoroutineScope.counterActor() = actor<CounterMsg> { var counter = 0 // confined to this coroutine for (msg in channel) { // sequential processing when (msg) { is Inc -> counter++ is Get -> msg.response.complete(counter) } } } ``` ## Why no lock is needed - The actor is **one coroutine**. The `for (msg in channel)` loop pulls messages **one at a time**. - `counter` is a local captured by that coroutine; **no other coroutine can touch it**. - Therefore mutations are **serialized** by construction — there is no data race, so no `Mutex`, no `@Synchronized`, no atomic. ## Request/response Messages are fire-and-forget by default. To read a value back, embed a **`CompletableDeferred<R>`** the actor completes: ```kotlin val actor = counterActor() repeat(1000) { actor.send(Inc) } val reply = CompletableDeferred<Int>() actor.send(Get(reply)) println(reply.await()) // 1000, no lock used actor.close() // stop the actor ``` ## Lifecycle notes - The inbox capacity defaults to `0` (rendezvous); pass `capacity = ...` for buffering. - Closing the `SendChannel` ends the `for` loop and the actor coroutine. - `actor` is `@ObsoleteCoroutinesApi` — usable today but the team is reconsidering it; a `StateFlow`-based or `Mutex`-guarded design is a common modern alternative.
- How does an actor send a result back to the caller?The message carries a CompletableDeferred<R>; the actor calls complete(value) and the caller await()s it.
- Why is the actor's state safe from races?Only the single actor coroutine accesses it, and it processes messages sequentially from its channel, so there is never concurrent access.
Like a single bank teller with a queue: customers line up (messages), the teller serves one at a time, so the cash drawer is never touched by two people at once.
saying these in an interview costs you the question
- Adding a Mutex inside the actor to 'be safe' (defeats the point)
- Saying actor returns a ReceiveChannel (it returns SendChannel)
- Forgetting to close the actor, leaking the coroutine
- Assuming send returns the result (it doesn't; you need CompletableDeferred)
- Claiming messages are processed in parallel