Phase 6 · Coroutines & AsyncModule 34~34 min read

Channels & Shared State

Communicate between coroutines with channels and manage shared state safely.

What you'll learn

Flows are great for streams of data, but sometimes coroutines need to talk to each other more directly, or share a piece of mutable state safely. Channels are coroutine-safe queues, and Mutex and atomics protect shared state. Together they round out concurrent Kotlin.

By the end you'll be able to:

  • Pass values between coroutines with a Channel
  • Build producer-consumer pipelines with produce
  • Protect shared mutable state with Mutex and atomics
  • Understand the actor model at a high level

Channels

A Channel is conceptually a queue you can send to and receivefrom across coroutines — safely, without locks. send suspends if the channel is full and receive suspends if it's empty, so coroutines naturally throttle one another. close() signals that no more values are coming, ending a for loop over the channel:

Channel.kt
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
    val channel = Channel<Int>()
    launch {
        for (x in 1..3) channel.send(x * x)  // send suspends if full
        channel.close()                      // no more values coming
    }
    for (y in channel) println(y)            // receive until closed
}

Note

Channels come in flavours by capacity: Rendezvous (default, no buffer), Buffered, Unlimited, and Conflated (keeps only the latest). The buffer choice decides when send suspends.

Producers & consumers

The produce builder wires a coroutine to a channel of results, giving you a tidy producer that consumers pull from. This is the foundation of concurrent pipelines — one stage produces, the next consumes and transforms:

Produce.kt
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

// produce {} builds a coroutine that owns a channel of results.
fun CoroutineScope.squares(): ReceiveChannel<Int> = produce {
    for (x in 1..5) send(x * x)
}

fun main() = runBlocking {
    val squares = squares()
    repeat(3) { println(squares.receive()) }
    coroutineContext.cancelChildren()        // stop the producer
}

Tip

Channel vs Flow: reach for a Flow when you're modelling a stream of data with operators, and a Channel when you need explicit, hot, point-to-point communication — for example distributing work across several worker coroutines (a "fan-out").

Sharing state safely

Concurrent code sharing mutable state invites race conditions: two coroutines updating the same variable can clobber each other. The simplest fix is to confine mutation to a single coroutine, but when you must share, a Mutex guarantees only one coroutine enters the critical section at a time — and unlike a thread lock, withLock suspends instead of blocking:

Mutex.kt
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.*

fun main() = runBlocking {
    var counter = 0
    val mutex = Mutex()
    val jobs = List(1000) {
        launch(Dispatchers.Default) {
            mutex.withLock { counter++ }     // one coroutine at a time
        }
    }
    jobs.forEach { it.join() }
    println("counter = $counter")            // exactly 1000, no races
}

Watch out

Never guard coroutines with synchronized or Thread locks — they block the thread and can deadlock the dispatcher. Use the coroutine-aware Mutex, or atomic types like AtomicInteger for simple counters.

Actors

The actor model combines both ideas: a single coroutine owns some private state and the outside world interacts with it only by sending messages through a channel. Because just one coroutine ever touches the state, there are no races and no locks — a very safe way to manage shared state. (Kotlin's actor builder is currently experimental, but the pattern is easy to reproduce by hand with a Channel and a loop.)

Key idea

The concurrency toolkit in one line: coroutines for concurrency, Flows for streams, Channels for direct communication, and Mutex/atomics/actors for shared state. Prefer not sharing mutable state at all — pass immutable data through channels and flows instead.

Recap & quick check

Key takeaways

  • A Channel is a coroutine-safe queue: send suspends when full, receive suspends when empty.
  • close() a channel to end iteration; pick a capacity (rendezvous, buffered, conflated) to control back-pressure.
  • produce {} builds a producer coroutine that consumers receive from — the base of pipelines.
  • Guard shared mutable state with a suspending Mutex (withLock) or atomics — never Thread locks.
  • The actor pattern confines state to one coroutine reached only by messages — race-free by design.

Quick check

1. What happens when you call send() on a full rendezvous channel?

2. How do you signal that no more values will be sent on a channel?

3. Why use Mutex instead of synchronized in coroutines?

4. The actor pattern avoids races by...

5. When is a Flow a better fit than a Channel?

That completes Phase 6 — you can write concurrent, non-blocking Kotlin with coroutines, flows, and channels. Next, Phase 7 — Professional & Applied Kotlinbegins with living alongside the JVM's biggest language: Java interoperability.