lyng/docs/Channel.md
sergeych f9a07f176a Add Channel primitive for coroutine inter-task communication
Exposes Kotlin's Channel<Obj> to Lyng scripts as a first-class type
with rendezvous, buffered, and unlimited capacity modes. Supports
suspending send/receive, non-suspending tryReceive, close/drain
semantics, and isClosedForSend/isClosedForReceive properties.

Also fixes a pre-existing typo in BookTest.kt that blocked JVM test
compilation, and adds Channel reference docs and a parallelism.md section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 02:28:55 +03:00

6.1 KiB

Channel

A Channel is a hot, bidirectional pipe for passing values between concurrently running coroutines. Unlike a Flow, which is cold and replayed on every collection, a Channel is stateful: each value sent is consumed by exactly one receiver.

Channels model the classic producer / consumer pattern and are the right tool when:

  • two or more coroutines need to exchange individual values at their own pace;
  • you want back-pressure (rendezvous) or explicit buffering control;
  • you need a push-based, hot data source (opposite of the pull-based, cold Flow).

Constructors

Channel()            // rendezvous — sender and receiver must meet
Channel(n: Int)      // buffered — sender may run n items ahead of the receiver
Channel(Channel.UNLIMITED)  // no limit on buffered items

Rendezvous (Channel(), capacity 0): send suspends until a matching receive is ready, and vice-versa. This gives the tightest synchronisation and the smallest memory footprint.

Buffered (Channel(n)): send only suspends when the internal buffer is full. Allows the producer to get up to n items ahead of the consumer.

Unlimited (Channel(Channel.UNLIMITED)): send never suspends. Useful when the producer is bursty and you do not want it blocked, but be careful not to grow the buffer without bound.

Sending and receiving

val ch = Channel()         // rendezvous channel

val producer = launch {
    ch.send("hello")       // suspends until the receiver is ready
    ch.send("world")
    ch.close()             // signal: no more values
}

val a = ch.receive()       // suspends until "hello" arrives
val b = ch.receive()       // suspends until "world" arrives
val c = ch.receive()       // channel is closed and drained → null
assertEquals("hello", a)
assertEquals("world", b)
assertEquals(null,    c)

receive() returns null when the channel is both closed and fully drained — that is the idiomatic loop termination condition:

val ch = Channel(4)

launch {
    for (i in 1..5) ch.send(i)
    ch.close()
}

var item = ch.receive()
while (item != null) {
    println(item)
    item = ch.receive()
}

Non-suspending poll

tryReceive() never suspends. It returns the next buffered value, or null if the buffer is empty or the channel is closed.

val ch = Channel(8)
ch.send(42)
println(ch.tryReceive())   // 42
println(ch.tryReceive())   // null — nothing buffered right now

Use tryReceive for polling patterns where blocking would be unacceptable, for example when combining channel checks with other work inside a coroutine loop.

Closing a channel

close() marks the channel so that no further send calls are accepted. Any items already in the buffer can still be received. Once the buffer is drained, receive() returns null and isClosedForReceive becomes true.

val ch = Channel(2)
ch.send(1)
ch.send(2)
ch.close()

assert(ch.isClosedForSend)
assert(!ch.isClosedForReceive)   // still has 2 buffered items

ch.receive()  // 1
ch.receive()  // 2
assert(ch.isClosedForReceive)    // drained

Calling send after close() throws IllegalStateException.

Properties

property type description
isClosedForSend Bool true after close() is called
isClosedForReceive Bool true when closed and every buffered item is consumed

Methods

method suspends description
send(value) yes send a value; suspends when buffer full (rendezvous: always until partner ready)
receive() yes receive next value; suspends when empty; returns null when closed + drained
tryReceive() no return next buffered value or null; never suspends
close() no signal end of production; existing buffer items are still receivable

Static constants

constant value description
Channel.UNLIMITED Int.MAX_VALUE capacity for an unlimited-buffer channel

Common patterns

Producer / consumer

val ch = Channel()
val results = []
val mu = Mutex()

val consumer = launch {
    var item = ch.receive()
    while (item != null) {
        mu.withLock { results += item }
        item = ch.receive()
    }
}

launch {
    for (i in 1..5) ch.send("msg:$i")
    ch.close()
}.await()

consumer.await()
println(results)

Fan-out: one channel, many consumers

val ch = Channel(16)

// multiple consumers
val workers = (1..4).map { id ->
    launch {
        var task = ch.receive()
        while (task != null) {
            println("worker $id handles $task")
            task = ch.receive()
        }
    }
}

// single producer
for (i in 1..20) ch.send(i)
ch.close()

workers.forEach { it.await() }

Ping-pong between two coroutines

val ping = Channel()
val pong = Channel()

launch {
    repeat(3) {
        val msg = ping.receive()
        println("got: $msg → sending pong")
        pong.send("pong")
    }
}

repeat(3) {
    ping.send("ping")
    println(pong.receive())
}

Channel vs Flow

Flow Channel
temperature cold (lazy) hot (eager)
replay every collector gets a fresh run each item is consumed once
consumers any number; each gets all items one receiver per item
back-pressure built-in via rendezvous configurable (rendezvous / buffered / unlimited)
typical use transform pipelines, sequences producer–consumer, fan-out

See also

  • parallelismlaunch, Deferred, Mutex, Flow, and the full concurrency picture
  • Flow — cold async sequences