skip to content

How does the `actor` builder let you safely mutate shared state without a Mutex?

level: middleimportance: should knowfreq 40%

answer

  1. actor -> SendChannel inbox
  2. one coroutine owns state, processes msgs serially
  3. for (msg in channel) inside, when over sealed msgs
  4. reply via CompletableDeferred
  5. ObsoleteCoroutinesApi

basics

~20 s

actor 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 lines
kotlin
sealed 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

for a junior

Understands an actor is one coroutine handling messages one at a time so no lock is needed.

for a middle

Builds the sealed-message + ActorScope loop and uses CompletableDeferred for replies.

for a senior

Discusses capacity/backpressure, ObsoleteCoroutinesApi status, and when actor vs Mutex vs StateFlow fits.

for a principal

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

context