Synchronizing Crystal Fibers with Typed Channels
Crystal's typed channels synchronize fibers with compile-time type safety, but unbuffered channels can deadlock without a matching receiver.
11 May 2026, 16:31 UTC

A fiber that never wakes up
You spawn two fibers in Crystal. One pushes a result onto a channel; the other is supposed to read it. The program compiles cleanly, but at runtime it simply stops. No exception, no stack trace—just silence. This is usually not a logic error. It is the default behavior of an unbuffered channel: the sender blocks until a receiver is ready, and if that receiver never arrives, the fiber blocks forever.
The useful takeaway is that Crystal's channels move synchronization out of shared memory and locks and into typed message passing. The compiler enforces what can travel through a channel, and the runtime blocks predictably. The trade-off is that blocking is mandatory by default, so you must design for the case where no receiver is waiting.
What a typed channel guarantees
A channel is created with an explicit element type, such as Channel(Int32).new. Only values of that type can be sent. If you try to send a String to an Int32 channel, the compiler rejects it before you build. This is different from a shared variable, where type mistakes often surface at runtime.
An unbuffered channel has no internal storage. A send call pauses the sending fiber until another fiber calls receive on the same channel. A receive call pauses until a sender appears. This handshake is the synchronization mechanism. Crystal schedules fibers cooperatively, so a blocked fiber does not freeze the entire program, but the fibers involved in the missing handshake stay parked.
Worked example: strict producer and consumer
Create a file named sync.cr:
channel = Channel(Int32).new
spawn do
channel.send(42)
puts "sent"
end
spawn do
value = channel.receive
puts "received #{value}"
end
sleep 1.second
Run it from your project directory:
crystal run sync.cr
No special permissions are required beyond having Crystal installed. Replace sync.cr with your actual filename. The expected check is that the program prints sent and received 42, then exits. The risk is that removing either spawn block leaves the remaining fiber blocked on send or receive, so the program hangs after the sleep timer expires.
For a release build, run:
crystal build --release sync.cr
./sync
Confirm the binary exits with code 0 and the same output. If it does not exit, an unmatched send or receive is the likely cause.
Buffered channels and the copy trade-off
If your producer runs faster than your consumer, an unbuffered channel forces the producer to wait. A buffered channel holds a small queue:
channel = Channel(Int32).new(2)
channel.send(1)
channel.send(2)
puts channel.receive # => 1
The send calls do not block while the buffer has space. Once full, send blocks again until a receiver drains the channel.
Crystal copies message payloads on transmission. Sending a large array or struct copies the entire value, which can add latency and memory pressure. For performance-critical paths, send a reference type or an identifier instead of the full payload, or stream data in smaller chunks.
How to check for a stuck channel
Use a non-blocking receive to test whether a value is waiting without parking the fiber:
if value = channel.receive?
puts "got #{value}"
else
puts "nothing ready yet"
end
receive? returns nil immediately if no value is available, unlike receive, which blocks. This is handy in diagnostic loops or select statements where you need to avoid deadlock.
When to use each channel type
| Situation | Channel choice | Reason |
|---|---|---|
| Strict rendezvous between two fibers | Unbuffered | Sender and receiver must meet before continuing |
| Producer faster than consumer | Buffered | Buffer absorbs temporary speed differences |
| Large payloads | Reference or ID | Avoids copying the whole structure |
Closing
Start with an unbuffered channel when you need two fibers to meet at a known point. Switch to a buffered channel when their speeds differ, and use receive? when you need to probe without blocking. Remember that Crystal copies what you send, so keep payloads small or send references. Compile with crystal build --release, run the binary, and verify it exits rather than hanging.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.