Cooperative Concurrency in Crystal with Fibers and Channels
Crystal schedules lightweight fibers cooperatively on a single OS thread. Learn how to pass messages between fibers with Channel(T) and avoid stalling the scheduler.
03 Jul 2025, 12:51 UTC

The problem: concurrency without thread overhead
You are writing a network service in Crystal and need to handle many simultaneous connections. Spawning an operating system thread for each one is simple, but threads are heavy: each carries its own stack and kernel bookkeeping, so a few thousand threads can consume gigabytes of RAM. Crystal takes a different default approach.
The useful takeaway: use spawn to launch lightweight fibers and Channel(T) to pass messages between them. This keeps memory usage low and avoids shared mutable state, but it only works if every fiber regularly yields control back to the scheduler.
Fibers are cooperative, not preemptive
Crystal starts on a single OS thread by default. A fiber is a lightweight unit of execution scheduled by Crystal's runtime, not the operating system. The scheduler is cooperative: a fiber keeps running until it explicitly yields, typically by performing a blocking I/O operation or calling sleep. When that happens, the scheduler can switch to another fiber.
spawn { ... } launches a new fiber. Because fibers share the same OS thread, spawning ten thousand of them costs far less memory than spawning ten thousand threads. Blocking I/O in one fiber does not block the whole program; the runtime suspends that fiber and runs others while waiting.
Send messages through Channel(T)
A Channel(T) is a fiber-safe, typed queue. Fibers push values with send and pull them with receive. By default a channel is unbuffered, meaning a sender blocks until a receiver is ready, and vice versa. You can create a buffered channel by passing a capacity, for example Channel(Int32).new(8).
Channels let you avoid locks and shared mutable state. Instead of multiple fibers mutating the same variable, each fiber owns its data and hands it off through a channel.
Worked example: fan-out jobs to workers
This program spawns one producer fiber, three worker fibers, and a collector that uses select to wait for results with a timeout.
jobs = Channel(Int32).new
results = Channel(String).new
# Producer: enqueue six jobs, then close the channel.
spawn do
6.times do |i|
jobs.send(i)
end
jobs.close
end
# Workers: each worker takes exactly two jobs.
3.times do
spawn do
2.times do
job = jobs.receive
sleep 0.05 # simulate work; yields to the scheduler
results.send("job #{job} done")
end
end
end
# Collector: wait for all six results, with a per-result timeout.
6.times do
select
when results.receive? => result
puts result
after 2.seconds
puts "timeout waiting for a result"
break
end
end
Run this from an ordinary terminal with no special permissions:
crystal run job_fanout.cr
Replace job_fanout.cr with your actual filename. Expected behavior: six lines like job 0 done appear as workers finish, in an order determined by the scheduler. If a worker is slow, the after 2.seconds branch prints a timeout instead of waiting forever.
Do not replace sleep 0.05 with a tight calculation loop or a blocking C call. Because the scheduler is cooperative and single-threaded by default, one fiber that never yields stalls every other fiber.
Limitations to keep in mind
- Single-threaded stall: a long-running pure-Crystal computation or blocking C call blocks the scheduler for all fibers.
- Unbuffered rendezvous: an unbuffered channel requires a sender and receiver to be ready at the same time; mismatched timing can deadlock.
- Garbage collector pauses: Crystal's Boehm collector runs at safepoints and may pause fibers, so avoid heavy allocation in latency-sensitive hot paths.
Verify before you rely on it
Check your compiler version and consult the standard library reference for Channel, spawn, and select to confirm syntax matches your installed compiler:
crystal --version
Then run the example and confirm that all six results print without deadlock. For a release build, use crystal build --release job_fanout.cr and measure with time ./job_fanout. If you change the channel to a buffered capacity or add more workers, rerun to confirm the timing still meets your requirements.
Bottom line
Crystal's fibers and channels are a good fit for I/O-bound concurrency where you want many lightweight tasks and simple message passing. Reserve OS threads or native extensions for CPU-bound work that cannot yield, and always design channels so every send has a matching receive.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.