JavaRush/Courses/Kotlin SELF/Confinement and actor via Channel: a single state owner

Confinement and actor via Channel: a single state owner

Open

1. Introduction

When you write concurrent code, your brain very quickly switches into “I’ll synchronize everything carefully, honestly-honestly” mode. But reality is brutal: the more places in the code mutate the same var, the higher the chance you’ll catch a rare, nasty “can’t reproduce on my laptop” bug. So today we’ll talk about an approach that doesn’t “fix” races, but tries to make it so that races cannot exist by construction.

In coroutines, the shared mutable state problem (shared mutable state) appears when multiple coroutines can read and write the same data without coordination—and then race condition is easy to get. You can solve this with Mutex and atomics, but there’s a third path: don’t share state at all.

Confinement: “hands off my var”

Confinement (often called “state isolation” or “state in one place”) is a rule: only one owner mutates the data, and everyone else “talks” to the owner. It’s like a shared kitchen where only one cook is allowed in, and everyone else writes them notes: “add some salt,” “make tea,” “how many cookies are left?”. The cook doesn’t argue with themself and doesn’t fight for the frying pan—because there’s only one cook.

In practice, confinement gives you a huge simplification: you stop considering “all possible interleavings of threads/coroutines” for a given variable. You start considering sequential message processing. And sequential logic is what programmers usually handle best (we didn’t start the course with println for nothing).

Important: confinement works only if the rule is actually followed. If you give someone direct access to the state “just for a minute,” you’re back in the world of races, except now they’re even trickier, because some changes go through the message protocol and some go “around the cashier.”

2. The actor approach: state owner and Channel

Now let’s bring the idea into code. The actor approach is a very practical implementation of confinement: we have a coroutine that owns the state, and we have a Channel through which other coroutines send it messages. Channel acts as a queue here: senders do send(...), the owner does receive (usually via a for loop), processes the message, and updates its local state.

A quick reminder of the key Channel model: it’s a communication mechanism between coroutines where each message is delivered to exactly one receiver. This matches the “single owner” idea perfectly: we want all state changes to go through one point.

Let’s draw a diagram to make it easier on the brain (not just the compiler):

flowchart LR
    subgraph Workers["Other coroutines (producers)"]
        W1["worker #1"]
        W2["worker #2"]
        W3["worker #3"]
    end

    C["Channel⟨Msg⟩ (mailbox)"]

    subgraph Owner["Actor (owner coroutine)"]
        S["State (only here!)"]
        L["for (msg in channel) { ... }"]
    end

    W1 -->|"send(msg)"| C
    W2 -->|"send(msg)"| C
    W3 -->|"send(msg)"| C
    C -->|"receive(msg)"| L
    L -->|"mutates"| S

The important point: the state is not visible to the workers at all. They only see the channel, i.e., the “message API.” This drastically reduces the chance of accidentally doing something wrong.

4. Minimal actor: a counter without races

So we don’t start with a “real application” right away, let’s build a small example that shows the mechanics. The actor will store counter, and external coroutines will send it “add 1.”

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val mailbox = Channel<Int>() // message = delta

    val owner = launch {
        var counter = 0
        for (delta in mailbox) counter += delta
        println("final counter=$counter") // final counter=1000
    }

    repeat(1_000) { launch { mailbox.send(1) } }
    mailbox.close()
    owner.join()
}

What matters here: counter is a plain var, with no Mutex and no AtomicInteger. And yet it’s correct, because nobody touches it except the owner. We don’t have shared mutable state—we have “private state + message passing.”

5. Typed messages and a protocol

In real life, messages like “just a number” quickly turn into a mess: what does 1 mean? “add one”? “error code”? “number of kittens”? So the next step is to make messages explicit and typed. The most convenient tool for this in Kotlin is sealed class.

The idea is simple: instead of Channel<Int>, we make Channel<Msg>, where Msg is a set of commands to our owner.

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

sealed class Msg
data class Add(val value: Int) : Msg()
object Stop : Msg()

fun main() = runBlocking {
    val mailbox = Channel<Msg>()

    val owner = launch {
        var sum = 0
        for (msg in mailbox) when (msg) {
            is Add -> sum += msg.value
            Stop -> break
        }
        println("sum=$sum") // sum=15
    }

    mailbox.send(Add(10))
    mailbox.send(Add(5))
    mailbox.send(Stop)
    owner.join()
}

Why is this stronger than “just numbers”? Because you get a documented protocol: there is an Add command, there is a Stop command, and the compiler helps you avoid sending a “delta” where a “stop command” was expected.

6. Example: an actor-based expense store

Let’s imagine that over the course we grew a console app called “Budget Tracker”: we store spending (expenses), can add them, and compute totals. So far everything was simple: one execution thread, one input, one collection. But now suppose expenses arrive from different sources: one worker “imports” operations, another simulates the user, a third adds periodic charges. If all of them start mutating a single MutableList, it’ll be sad.

So we’ll create a state owner—an “expense store”—and communicate with it using messages.

Let’s start with the data model:

data class Expense(
    val id: Int,
    val title: String,
    val amount: Int
)

Now the message protocol. We want “add an expense,” “remove by id,” “stop.” Note: for now we’re not doing complex responses back, so commands will be “one-way.”

sealed class BudgetMsg
data class AddExpense(val expense: Expense) : BudgetMsg()
data class RemoveExpense(val id: Int) : BudgetMsg()
object StopBudget : BudgetMsg()

Let’s assemble the owner (actor) that stores the expense list:

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val mailbox = Channel<BudgetMsg>(Channel.BUFFERED)

    val owner = launch {
        val expenses = mutableListOf<Expense>()
        for (msg in mailbox) when (msg) {
            is AddExpense -> expenses.add(msg.expense)
            is RemoveExpense -> expenses.removeIf { it.id == msg.id }
            StopBudget -> break
        }
        println("stopped, total items=${expenses.size}")
    }

    mailbox.send(AddExpense(Expense(1, "Coffee", 250)))
    mailbox.send(AddExpense(Expense(2, "Pizza", 900)))
    mailbox.send(RemoveExpense(1))
    mailbox.send(StopBudget)

    owner.join()
}

Here’s the key: expenses cannot be “accidentally touched” from the outside at all. External code can’t do expenses.add(...) or expenses.clear()— because it doesn’t have a reference. This is confinement in action: access to the state is physically limited to the owner’s scope.

7. Multiple senders: simulating concurrency

Now let’s add what this was all about: multiple coroutines sending commands at the same time. This is exactly the moment when shared mutable state almost guaranteed catches you, while the actor keeps living calmly.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val mailbox = Channel<BudgetMsg>(Channel.BUFFERED)

    val owner = launch {
        var total = 0
        for (msg in mailbox) when (msg) {
            is AddExpense -> total += msg.expense.amount
            StopBudget -> break
            else -> Unit
        }
        println("total=$total") // for example: total=3000
    }

    val w1 = launch(Dispatchers.Default) { mailbox.send(AddExpense(Expense(1, "A", 1000))) }
    val w2 = launch(Dispatchers.Default) { mailbox.send(AddExpense(Expense(2, "B", 1000))) }
    val w3 = launch(Dispatchers.Default) { mailbox.send(AddExpense(Expense(3, "C", 1000))) }

    w1.join(); w2.join(); w3.join()
    mailbox.send(StopBudget)
    owner.join()
}

Notice the nice property: message arrival order can be anything, but the final sum will be correct, because the owner has no “parallel writes” into total. It processes messages sequentially.

8. How to get data back: request and response

When the application becomes a bit more realistic, a question comes up: “OK, we can send commands… but how do we know the current total?” And it’s very easy to slip into bad design here: “well, let’s just expose expenses or total, no big deal.” That’s exactly the moment you break confinement.

The right move is to request data from the owner via a message and receive the response through a separate reply channel. Yes, it sounds like “a channel to a channel,” but it’s a perfectly normal actor technique.

Let’s create a GetTotal message that contains “where to send the reply”:

import kotlinx.coroutines.channels.SendChannel

sealed class BudgetMsg
data class AddExpense(val expense: Expense) : BudgetMsg()
data class GetTotal(val replyTo: SendChannel<Int>) : BudgetMsg()
object StopBudget : BudgetMsg()

Now the owner can reply:

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val mailbox = Channel<BudgetMsg>(Channel.BUFFERED)

    val owner = launch {
        var total = 0
        for (msg in mailbox) when (msg) {
            is AddExpense -> total += msg.expense.amount
            is GetTotal -> msg.replyTo.send(total)
            StopBudget -> break
        }
    }

    mailbox.send(AddExpense(Expense(1, "Coffee", 250)))

    val reply = Channel<Int>(capacity = 1)
    mailbox.send(GetTotal(reply))
    println("total=${reply.receive()}") // total=250

    mailbox.send(StopBudget)
    owner.join()
}

The key idea: external code still doesn’t see total, but it can ask the owner a question and get an answer in a safe form. It’s like contacting support: you don’t go into their database; you open a ticket and receive a response.

9. Lifecycle: how to shut down an actor correctly

When you write an actor for the first time, it’s tempting to say “let it live as long as the program lives.” And then you add a real shutdown scenario—and suddenly you realize that protocol termination is just as important as startup. If you don’t stop the actor, it may hold resources (memory, file descriptors, connections), and if you stop it incorrectly, senders will start failing with errors on send.

There are two basic shutdown styles, and you can choose either one, as long as it’s consistent and agreed upon.

First style: send a special StopBudget message, the handler does a break, and then the owner cleanly finishes the loop. This is convenient because shutdown is part of the protocol.

Second style: close the channel via mailbox.close(). Then the for loop completes automatically when messages run out. This is convenient because the “channel as a resource” is explicitly closed from the outside.

In learning examples, it’s often easier to use Stop because it’s more explicit. But important: don’t mix styles without a reason. If you agreed on “we close the channel,” then don’t also send Stop, because you might accidentally send it into an already closed channel (and get an exception), or stop the owner before buffered messages are processed.

10. Actor vs Mutex: a short comparison

After a few examples, a question usually appears: “So why Mutex at all, if we can use an actor?” Answer: both tools are useful, but they represent different ways of thinking.

With Mutex, you still have shared mutable state; you just put a “barrier” at the entry to a critical section. This can be convenient when you need to quickly protect a small piece of code and you tightly control the boundaries.

With an actor, you change the architecture: you don’t protect access to the data—you remove access. You get a message queue and an explicit command protocol. This can be a bit more code, but often drastically fewer bugs, because you rely less on “all developers will always remember which Mutex to take.”

A small table to help the brain file this into two shelves:

Approach What happens to state How safety is achieved Where it’s usually convenient
Mutex
State is shared, but access is “one at a time” Locking a critical section Small changes on top of existing code
Actor / confinement
State is not shared at all Single owner + messages Centralized state (counter, balance, task queue, “store”)

One more important nuance: an actor naturally “scales your thinking.” When a new command appears, you add a new message type and a new when branch in one place. That doesn’t guarantee perfection, but it helps a lot to keep the system in your head.

Antipattern: the actor exists, but the state “leaked” outside

This is going to be a moment of pain, because it’s a very typical beginner mistake. Someone honestly built an actor, and then thought: “But I need to show the list of expenses somewhere… I’ll just return the MutableList, it’s fine.” And that’s where confinement ends.

The bad idea looks roughly like this (the example is intentionally “by meaning”; don’t do this):

// BAD: a reference to a mutable list leaks outside.
// Then anyone can do expenses.add(...) in parallel with the actor.
fun leakState(expenses: MutableList<Expense>): MutableList<Expense> = expenses

The correct style: if you need to “show the list,” make a request to the actor and return either a copy of the list or an already prepared report string. In other words, what should leave the actor is the result, not a “handle for controlling its insides.”

11. Common mistakes with confinement/actor via Channel

Mistake #1: you left direct access to the state “just in case.”
Often it looks harmless: a global var total, and the actor “kind of also counts total.” Then one piece of code accidentally updates total directly, another via messages, and you’re back to shared mutable state. Confinement works only with a strict rule: state is mutated by a single owner, period.

Mistake #2: messages aren’t typed and turn into “magic numbers/strings.”
When commands start flying around in a Channel<Any> or Channel<String> as things like "ADD 10" and "STOP", you’re building a mini protocol parser for yourself and manually keeping formats correct. With sealed class and when, your protocol is checked by the compiler, and that’s the rare case where the compiler isn’t nitpicking—it actually saves you.

Mistake #3: you didn’t agree on termination, and the channel is never closed.
If the owner is sitting in a for loop and you never send Stop and never call close(), it will wait forever. In a console app this often means “the program hangs after it has done everything.” You need to decide in advance who is responsible for stopping and by what method.

Mistake #4: you closed the channel too early while senders are still working.
If the “main coroutine” closes mailbox while workers are still trying to send, you’ll get exceptions and message loss. First you need to wait for the senders (join()), and only then shut down the actor. Yes, it sounds boring. But it works.

Mistake #5: heavy work inside the owner turns the actor into a bottleneck.
An actor processes messages sequentially—this is its superpower and its limitation. If inside the when you do something long (for example, blocking I/O or heavy computations), the queue will start growing and the system will become slow. A good style is to keep message handling short: update the state, record the fact, and organize heavy work so it doesn’t turn the owner into an eternal sufferer.

Mistake #6: trying to “get data” by leaking references instead of request/response.
The most tempting path is to return a reference to an internal list or state object. But that completely destroys the point of confinement. If you need to provide data outside, provide a safe form: a copy, a number, a report string, or a response via a separate reply channel.

Mistake #7: mixing different protocol styles for no reason.
For example, sometimes you close the channel with close(), sometimes you send Stop, and sometimes you do both in a random order. As a result, the owner may terminate before it processes the queue, or a sender may try to send Stop into an already closed channel. The protocol should be single, simple, and predictable—then debugging will be humane.

1
Task
Kotlin SELF,  55 level,  3 lecture
Locked
Mailbox
Mailbox
1
Task
Kotlin SELF,  55 level,  3 lecture
Locked
Summing actor commands
Summing actor commands
1
Task
Kotlin SELF,  55 level,  3 lecture
Locked
Three couriers
Three couriers
1
Task
Kotlin SELF,  55 level,  3 lecture
Locked
Balance on Request
Balance on Request
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet