Crystal Concurrency Patterns
Use this skill when implementing concurrency in Crystal — fibers, channels,
select, WaitGroup, parallel execution contexts, or porting Go concurrency
patterns. Also use when debugging deadlocks, fiber leaks, or MT-safety issues
in Crystal code.
If the user asks whether the concurrency is actually faster, wants throughput
numbers, worker-count comparisons, hotspot validation, or says not to guess,
also use crystal-benchmarking.
Core Rules
These rules prevent the most common bugs. Violating any of them causes silent
deadlocks, data races, or ambiguous behavior.
Use Channel(Nil) only with receive; use a non-nil type with
receive? — Channel(Nil) is a fine one-shot completion signal when the
receiver calls receive. But receive? uses nil as its "closed" sentinel,
so a Channel(Nil) cannot distinguish "got a value" from "channel closed".
Use Channel(Bool) (or another non-nil type) for close-aware done, quit, and
semaphore channels.
receive? in select for close-safe receives — receive raises
ClosedError when a channel closes inside a select. Use receive? which
returns nil instead.
done.close for broadcast cancellation — closing a channel wakes ALL
fibers waiting on receive?. This is how you cancel an unknown number of
workers. Send a value to cancel one; close to cancel all.
select ... else ... end = Go's select { default: } — the else
branch fires when no channel operation can complete immediately (non-blocking).
Merge closeable fan-out outputs — bare select when ch.receive across
multiple channels that may close raises ClosedError. Merge outputs with
WaitGroup + receive?, especially when the outputs can run in parallel.
Double-close is safe — Crystal silently ignores closing an already-closed
channel. Go panics. Don't rely on this.
WaitGroup is built-in — require "wait_group". Has add, done,
wait, and spawn methods. Direct equivalent of Go's sync.WaitGroup.
Go-to-Crystal Translation
| Go |
Crystal |
go func() |
spawn { } |
chan T |
Channel(T) |
make(chan T, n) |
Channel(T).new(n) |
<-ch |
ch.receive |
ch <- v |
ch.send(v) |
close(ch) |
ch.close |
for v := range ch |
while v = ch.receive? |
select { case ... } |
select when ... end |
select { default: } |
select ... else ... end |
sync.WaitGroup |
WaitGroup |
time.After(d) |
helper: spawn + sleep + channel send |
context.WithCancel |
Channel(Bool) + done.close |
Pattern Index
41 patterns ported from dsisnero/crystal-concurrency-patterns (the Crystal port of
lotusirous' Go Concurrency Patterns) and split across six category reference files.
Open the file for the category you need — each entry has fuller, self-contained
Crystal code, the gotcha that bites people, and a citation to the upstream spec
(characterized) or src/example (demonstrated) it came from. The self-contained
code blocks type-check under Crystal 1.20.2 (the Subscription entry is an annotated
sketch of the nil-channel workaround, not a standalone program).
Patterns marked with an example file have a complete runnable program in examples/
showing the full lifecycle (producer → channel → workers → WaitGroup → close).
Basic — references/basic.md
| Pattern |
What it does |
Example |
| Generator |
Fiber + channel; the channel is the stream |
— |
| Fan-In |
Merge N input channels into one (WaitGroup close) |
— |
| Fan-Out |
N workers compete on one source; each value goes to one |
— |
| Pipeline |
Chain stages, each closing its outbound channel |
pipeline_cancel.cr |
| Confinement |
One fiber owns the data; publish via channel |
— |
| For-Select Loop |
Long-lived fiber polling done with select/else |
— |
| Repeat / Take |
Composable infinite generator bounded by take |
— |
| Error-Handling Channel |
Carry value-or-error so a stage never crashes |
— |
Coordination — references/coordination.md
| Pattern |
What it does |
Example |
| Worker Pool |
Fixed workers pull jobs, WaitGroup closes results |
worker_pool.cr |
| Bounded Parallelism |
Fixed pool walks a tree with done cancellation |
parallel_digest.cr |
| Queuing (Semaphore) |
Buffered Channel(Bool) caps concurrency |
— |
| Daisy Chain |
N fibers relay a token in a line |
— |
| Restore Sequence |
Per-message wait channel restores ordering |
— |
| Ping-Pong |
Volley one mutable object; ownership moves with send |
— |
Cancellation — references/cancellation.md
| Pattern |
What it does |
Example |
| Done Channel |
close broadcasts cancel to all waiters |
pipeline_cancel.cr |
| Quit Signal |
Two-way stop: request + acknowledge |
— |
| Or-Channel |
Merge signals; fire when any input closes |
— |
| Or-Done |
Wrap a value channel so reads respect done |
— |
| Errgroup |
Cancel siblings on first error, return it |
errgroup.cr |
| Graceful Shutdown |
Ordered teardown: done → jobs → wait |
— |
| Select Timeout |
Bound a receive with select ... when timeout(span) |
— |
| Context |
done + cancel proc = WithCancel / WithTimeout |
— |
Data Flow — references/data-flow.md
| Pattern |
What it does |
Example |
| Tee Channel |
Duplicate each value to two outputs (flag workaround) |
— |
| Bridge Channel |
Flatten a channel-of-channels into one stream |
— |
| Ring Buffer |
Keep last N, drop oldest (Deque, not select/else) |
— |
| Broadcaster |
Every subscriber gets every message |
— |
| Pub/Sub |
Topic-routed broadcaster with Mutex-guarded map |
pubsub.cr |
| Subscription |
RSS aggregator; the nil-channel-in-select workaround |
— |
Resilience — references/resilience.md
| Pattern |
What it does |
Example |
| Rate Limiting |
One op per fixed interval |
— |
| Bursty Rate Limiting |
Token bucket allowing short bursts |
— |
| Retry with Backoff |
Exponential delays between attempts |
— |
| Circuit Breaker |
Fail fast after N failures; cool down; half-open |
— |
| Backpressure |
Bounded channel buffer is the flow control |
— |
| Batch / Debounce |
Flush on size or on a quiet window |
— |
Computation — references/computation.md
| Pattern |
What it does |
Example |
| Future / Promise |
Start work now, collect later (cap-1 channel) |
— |
| First Response |
Race replicas, take fastest (buffered, no leak) |
— |
| Scatter-Gather |
Fan out, gather until one shared deadline |
— |
| Map-Reduce |
Parallel map, sequential reduce |
— |
| Stateful Fiber (Actor) |
One fiber owns state; access via request channels |
actor.cr |
| Ticker with Cancellation |
Tick on interval until done |
— |
| Mutex-Protected State |
Guarded counter/map when an Actor is overkill |
— |
Execution Context Decision Tree
Read references/execution-contexts.md for code examples and benchmarks.
Is the work I/O-bound?
├── Yes → `spawn` in the current context; standard-library I/O yields to the
│ event loop while it waits.
└── No (CPU-bound or intentionally blocking)
├── Parallelizable CPU work? → `ExecutionContext::Parallel`
│ ctx = Fiber::ExecutionContext::Parallel.new("name", maximum: capacity)
│ ctx.spawn { work }
├── One task must own a thread for its lifetime? → `ExecutionContext::Isolated`
│ main = Fiber::ExecutionContext::Isolated.new("name") { blocking_call }
│ main.wait
└── Need an independent, non-parallel group? → `ExecutionContext::Concurrent`
(one runnable fiber at a time; a blocking fiber blocks this context)
Crystal 1.21 execution-context rules
Execution contexts are enabled by default in Crystal 1.21. The default context is
Parallel, but its initial parallelism is 1 for backward compatibility. To
make process-default work parallel, resize it explicitly; otherwise create and
use a named Parallel context. Parallelism is a maximum capacity, not a promise
of a fixed number of dedicated worker threads. A parallel context autoscales up
to that capacity. Too many simultaneously blocking fibers can exhaust it and
then block remaining work; bound blocking work with a semaphore or use another
context.
default = Fiber::ExecutionContext.default
default.resize(Fiber::ExecutionContext.default_workers_count)
Outside an Isolated context, spawn uses the current fiber's execution
context; ctx.spawn chooses another one. An isolated fiber cannot spawn another
fiber in its own context: configure spawn_context: when creating it, or call a
different context's spawn. A fiber never moves between contexts, but a fiber in a Parallel or
Concurrent context is not pinned to an OS thread and can resume on a different
thread. Avoid @[ThreadLocal] and do not retain thread-local assumptions across
a yield or blocking call.
Do not jump to ExecutionContext because a path "looks parallelizable". Measure
the current path first and identify whether the real cost is I/O, parser work,
FFI, cache persistence, or actual CPU-bound computation.
Measured speedups (Apple Silicon arm64, 8 workers):
- Map-reduce (CPU math): 3.4x with 4 threads
- MD5 hashing (200 items): ~6x with 4 threads
- File digest (1014 files): 8.76x with 8 threads
- Mixed I/O+CPU (2154 files): 4.42x with 8 threads
MT Safety Checklist
In Crystal 1.21, any Parallel context (including a resized default context)
means shared state may be accessed concurrently:
- Channels are thread-safe by design
- WaitGroup is implemented with atomics
- Mutex#synchronize is fiber-safe
- Atomic maps to hardware atomics
- Actor pattern (single fiber owns state) is naturally safe
- Bare
select with receive on closeable channels — use receive? or
merge pattern
- Shared mutable state without locks — add Mutex or Atomic
- Timing-sensitive assertions — add tolerance, thread scheduling is
non-deterministic
- Thread-local assumptions across yields — invalid in
Parallel; a fiber
can resume on a different OS thread. Concurrent fibers can also switch
threads after a blocking syscall.
Scheduling and Lifetime Rules
- Fibers are cooperative. CPU-bound code that neither blocks nor calls
Fiber.yield monopolizes its scheduler; yield deliberately in long-running
cooperative work.
spawn queues work; it does not run the fiber immediately. The process exits
when the main fiber completes, so join work with a Channel or WaitGroup
instead of using sleep as completion synchronization.
- Prefer
spawn method_call(argument) when a loop-local value changes between
iterations: the spawn macro captures the call arguments. A bare spawned block
captures an outer local by reference; block parameters are safe.
- A buffered channel controls backpressure and scheduling, not worker lifetime.
A send blocks only when no receiver is already waiting and the buffer is full.
References
references/channel-rules.md — closed channels, nil channels, Channel(Nil)
ambiguity, receive vs receive?, MT behavior findings with before/after code
- Pattern reference files (full code + gotchas + source citations), one per
category in the index above:
references/basic.md, references/coordination.md, references/cancellation.md,
references/data-flow.md, references/resilience.md, references/computation.md
references/execution-contexts.md — Parallel, Concurrent, Isolated with
benchmarks, worker pool and map-reduce examples
- Crystal 1.21 official documentation: Concurrency guide,
Parallelism guide,
ExecutionContext API,
and 1.21 release notes
Full Examples
Read these when implementing a complex pattern — they show complete wiring
(producer → channel → workers → WaitGroup → results → close lifecycle):
examples/worker_pool.cr — producer→jobs→workers→results with spawn vs
ctx.spawn benchmark. The template for any worker pool.
examples/parallel_digest.cr — walk directory, hash files, benchmark
default vs ExecutionContext::Parallel. Real-world bounded parallelism.
examples/actor.cr — stateful fiber with read/write request channels,
concurrent readers and writers, clean shutdown.
examples/errgroup.cr — run N tasks, cancel all on first error via
done.close, capture first exception.
examples/pipeline_cancel.cr — gen→square→filter pipeline with done
channel through every stage, fan-out/fan-in with merge, early consumer exit.
examples/pubsub.cr — PubSub class with subscribe/unsubscribe/publish,
Mutex-protected subscriber map, topic routing, clean shutdown.
1---2name: crystal-concurrency3description: Crystal concurrency and parallelism patterns — fibers, channels, select, WaitGroup, ExecutionContext, and porting Go concurrency patterns. Use when implementing any concurrent or parallel Crystal code, debugging deadlocks or fiber leaks, choosing between spawn and ExecutionContext::Parallel, or translating Go channel patterns to Crystal. Covers 41 patterns across 6 categories, ported from Go and verified against an upstream spec suite, with runnable examples and measured parallel benchmarks (up to 8.76x speedup).4license: MIT5---67# Crystal Concurrency Patterns89Use this skill when implementing concurrency in Crystal — fibers, channels,10select, WaitGroup, parallel execution contexts, or porting Go concurrency11patterns. Also use when debugging deadlocks, fiber leaks, or MT-safety issues12in Crystal code.1314If the user asks whether the concurrency is actually faster, wants throughput15numbers, worker-count comparisons, hotspot validation, or says not to guess,16also use `crystal-benchmarking`.1718## Core Rules1920These rules prevent the most common bugs. Violating any of them causes silent21deadlocks, data races, or ambiguous behavior.22231. **Use `Channel(Nil)` only with `receive`; use a non-nil type with24 `receive?`** — `Channel(Nil)` is a fine one-shot completion signal when the25 receiver calls `receive`. But `receive?` uses `nil` as its "closed" sentinel,26 so a `Channel(Nil)` cannot distinguish "got a value" from "channel closed".27 Use `Channel(Bool)` (or another non-nil type) for close-aware done, quit, and28 semaphore channels.29302. **`receive?` in select for close-safe receives** — `receive` raises31 `ClosedError` when a channel closes inside a `select`. Use `receive?` which32 returns `nil` instead.33343. **`done.close` for broadcast cancellation** — closing a channel wakes ALL35 fibers waiting on `receive?`. This is how you cancel an unknown number of36 workers. Send a value to cancel one; close to cancel all.37384. **`select ... else ... end` = Go's `select { default: }`** — the `else`39 branch fires when no channel operation can complete immediately (non-blocking).40415. **Merge closeable fan-out outputs** — bare `select when ch.receive` across42 multiple channels that may close raises `ClosedError`. Merge outputs with43 `WaitGroup` + `receive?`, especially when the outputs can run in parallel.44456. **Double-close is safe** — Crystal silently ignores closing an already-closed46 channel. Go panics. Don't rely on this.47487. **`WaitGroup` is built-in** — `require "wait_group"`. Has `add`, `done`,49 `wait`, and `spawn` methods. Direct equivalent of Go's `sync.WaitGroup`.5051## Go-to-Crystal Translation5253| Go | Crystal |54|----|---------|55| `go func()` | `spawn { }` |56| `chan T` | `Channel(T)` |57| `make(chan T, n)` | `Channel(T).new(n)` |58| `<-ch` | `ch.receive` |59| `ch <- v` | `ch.send(v)` |60| `close(ch)` | `ch.close` |61| `for v := range ch` | `while v = ch.receive?` |62| `select { case ... }` | `select when ... end` |63| `select { default: }` | `select ... else ... end` |64| `sync.WaitGroup` | `WaitGroup` |65| `time.After(d)` | helper: spawn + sleep + channel send |66| `context.WithCancel` | `Channel(Bool)` + `done.close` |6768## Pattern Index697041 patterns ported from `dsisnero/crystal-concurrency-patterns` (the Crystal port of71lotusirous' Go Concurrency Patterns) and split across six category reference files.72Open the file for the category you need — each entry has fuller, self-contained73Crystal code, the gotcha that bites people, and a citation to the upstream spec74(characterized) or src/example (demonstrated) it came from. The self-contained75code blocks type-check under Crystal 1.20.2 (the Subscription entry is an annotated76sketch of the nil-channel workaround, not a standalone program).7778Patterns marked with an example file have a complete runnable program in `examples/`79showing the full lifecycle (producer → channel → workers → WaitGroup → close).8081### Basic — `references/basic.md`82| Pattern | What it does | Example |83|---------|--------------|---------|84| Generator | Fiber + channel; the channel is the stream | — |85| Fan-In | Merge N input channels into one (WaitGroup close) | — |86| Fan-Out | N workers compete on one source; each value goes to one | — |87| Pipeline | Chain stages, each closing its outbound channel | `pipeline_cancel.cr` |88| Confinement | One fiber owns the data; publish via channel | — |89| For-Select Loop | Long-lived fiber polling `done` with `select/else` | — |90| Repeat / Take | Composable infinite generator bounded by `take` | — |91| Error-Handling Channel | Carry value-or-error so a stage never crashes | — |9293### Coordination — `references/coordination.md`94| Pattern | What it does | Example |95|---------|--------------|---------|96| Worker Pool | Fixed workers pull jobs, WaitGroup closes results | `worker_pool.cr` |97| Bounded Parallelism | Fixed pool walks a tree with `done` cancellation | `parallel_digest.cr` |98| Queuing (Semaphore) | Buffered `Channel(Bool)` caps concurrency | — |99| Daisy Chain | N fibers relay a token in a line | — |100| Restore Sequence | Per-message `wait` channel restores ordering | — |101| Ping-Pong | Volley one mutable object; ownership moves with send | — |102103### Cancellation — `references/cancellation.md`104| Pattern | What it does | Example |105|---------|--------------|---------|106| Done Channel | `close` broadcasts cancel to all waiters | `pipeline_cancel.cr` |107| Quit Signal | Two-way stop: request + acknowledge | — |108| Or-Channel | Merge signals; fire when any input closes | — |109| Or-Done | Wrap a value channel so reads respect `done` | — |110| Errgroup | Cancel siblings on first error, return it | `errgroup.cr` |111| Graceful Shutdown | Ordered teardown: done → jobs → wait | — |112| Select Timeout | Bound a receive with `select ... when timeout(span)` | — |113| Context | `done` + cancel proc = WithCancel / WithTimeout | — |114115### Data Flow — `references/data-flow.md`116| Pattern | What it does | Example |117|---------|--------------|---------|118| Tee Channel | Duplicate each value to two outputs (flag workaround) | — |119| Bridge Channel | Flatten a channel-of-channels into one stream | — |120| Ring Buffer | Keep last N, drop oldest (Deque, not `select/else`) | — |121| Broadcaster | Every subscriber gets every message | — |122| Pub/Sub | Topic-routed broadcaster with Mutex-guarded map | `pubsub.cr` |123| Subscription | RSS aggregator; the nil-channel-in-select workaround | — |124125### Resilience — `references/resilience.md`126| Pattern | What it does | Example |127|---------|--------------|---------|128| Rate Limiting | One op per fixed interval | — |129| Bursty Rate Limiting | Token bucket allowing short bursts | — |130| Retry with Backoff | Exponential delays between attempts | — |131| Circuit Breaker | Fail fast after N failures; cool down; half-open | — |132| Backpressure | Bounded channel buffer is the flow control | — |133| Batch / Debounce | Flush on size or on a quiet window | — |134135### Computation — `references/computation.md`136| Pattern | What it does | Example |137|---------|--------------|---------|138| Future / Promise | Start work now, collect later (cap-1 channel) | — |139| First Response | Race replicas, take fastest (buffered, no leak) | — |140| Scatter-Gather | Fan out, gather until one shared deadline | — |141| Map-Reduce | Parallel map, sequential reduce | — |142| Stateful Fiber (Actor) | One fiber owns state; access via request channels | `actor.cr` |143| Ticker with Cancellation | Tick on interval until `done` | — |144| Mutex-Protected State | Guarded counter/map when an Actor is overkill | — |145146## Execution Context Decision Tree147148Read `references/execution-contexts.md` for code examples and benchmarks.149150```151Is the work I/O-bound?152├── Yes → `spawn` in the current context; standard-library I/O yields to the153│ event loop while it waits.154└── No (CPU-bound or intentionally blocking)155 ├── Parallelizable CPU work? → `ExecutionContext::Parallel`156 │ ctx = Fiber::ExecutionContext::Parallel.new("name", maximum: capacity)157 │ ctx.spawn { work }158 ├── One task must own a thread for its lifetime? → `ExecutionContext::Isolated`159 │ main = Fiber::ExecutionContext::Isolated.new("name") { blocking_call }160 │ main.wait161 └── Need an independent, non-parallel group? → `ExecutionContext::Concurrent`162 (one runnable fiber at a time; a blocking fiber blocks this context)163```164165### Crystal 1.21 execution-context rules166167Execution contexts are enabled by default in Crystal 1.21. The default context is168`Parallel`, but its initial parallelism is **1** for backward compatibility. To169make process-default work parallel, resize it explicitly; otherwise create and170use a named `Parallel` context. Parallelism is a maximum capacity, not a promise171of a fixed number of dedicated worker threads. A parallel context autoscales up172to that capacity. Too many simultaneously blocking fibers can exhaust it and173then block remaining work; bound blocking work with a semaphore or use another174context.175176```crystal177default = Fiber::ExecutionContext.default178default.resize(Fiber::ExecutionContext.default_workers_count)179```180181Outside an `Isolated` context, `spawn` uses the current fiber's execution182context; `ctx.spawn` chooses another one. An isolated fiber cannot spawn another183fiber in its own context: configure `spawn_context:` when creating it, or call a184different context's `spawn`. A fiber never moves between contexts, but a fiber in a `Parallel` or185`Concurrent` context is not pinned to an OS thread and can resume on a different186thread. Avoid `@[ThreadLocal]` and do not retain thread-local assumptions across187a yield or blocking call.188189Do not jump to `ExecutionContext` because a path "looks parallelizable". Measure190the current path first and identify whether the real cost is I/O, parser work,191FFI, cache persistence, or actual CPU-bound computation.192193**Measured speedups** (Apple Silicon arm64, 8 workers):194- Map-reduce (CPU math): 3.4x with 4 threads195- MD5 hashing (200 items): ~6x with 4 threads196- File digest (1014 files): 8.76x with 8 threads197- Mixed I/O+CPU (2154 files): 4.42x with 8 threads198199## MT Safety Checklist200201In Crystal 1.21, any `Parallel` context (including a resized default context)202means shared state may be accessed concurrently:203204- Channels are thread-safe by design205- WaitGroup is implemented with atomics206- Mutex#synchronize is fiber-safe207- Atomic maps to hardware atomics208- Actor pattern (single fiber owns state) is naturally safe209- **Bare `select` with `receive` on closeable channels** — use `receive?` or210 merge pattern211- **Shared mutable state without locks** — add Mutex or Atomic212- **Timing-sensitive assertions** — add tolerance, thread scheduling is213 non-deterministic214- **Thread-local assumptions across yields** — invalid in `Parallel`; a fiber215 can resume on a different OS thread. `Concurrent` fibers can also switch216 threads after a blocking syscall.217218## Scheduling and Lifetime Rules219220- Fibers are cooperative. CPU-bound code that neither blocks nor calls221 `Fiber.yield` monopolizes its scheduler; yield deliberately in long-running222 cooperative work.223- `spawn` queues work; it does not run the fiber immediately. The process exits224 when the main fiber completes, so join work with a `Channel` or `WaitGroup`225 instead of using `sleep` as completion synchronization.226- Prefer `spawn method_call(argument)` when a loop-local value changes between227 iterations: the spawn macro captures the call arguments. A bare spawned block228 captures an outer local by reference; block parameters are safe.229- A buffered channel controls backpressure and scheduling, not worker lifetime.230 A send blocks only when no receiver is already waiting and the buffer is full.231232## References233234- `references/channel-rules.md` — closed channels, nil channels, Channel(Nil)235 ambiguity, receive vs receive?, MT behavior findings with before/after code236- Pattern reference files (full code + gotchas + source citations), one per237 category in the index above:238 `references/basic.md`, `references/coordination.md`, `references/cancellation.md`,239 `references/data-flow.md`, `references/resilience.md`, `references/computation.md`240- `references/execution-contexts.md` — Parallel, Concurrent, Isolated with241 benchmarks, worker pool and map-reduce examples242- Crystal 1.21 official documentation: [Concurrency guide](https://crystal-lang.org/reference/1.21/guides/concurrency.html),243 [Parallelism guide](https://crystal-lang.org/reference/1.21/guides/parallelism.html),244 [ExecutionContext API](https://crystal-lang.org/api/1.21.0/Fiber/ExecutionContext.html),245 and [1.21 release notes](https://crystal-lang.org/2026/07/16/1.21.0-released/)246247## Full Examples248249Read these when implementing a complex pattern — they show complete wiring250(producer → channel → workers → WaitGroup → results → close lifecycle):251252- `examples/worker_pool.cr` — producer→jobs→workers→results with spawn vs253 ctx.spawn benchmark. The template for any worker pool.254- `examples/parallel_digest.cr` — walk directory, hash files, benchmark255 default vs ExecutionContext::Parallel. Real-world bounded parallelism.256- `examples/actor.cr` — stateful fiber with read/write request channels,257 concurrent readers and writers, clean shutdown.258- `examples/errgroup.cr` — run N tasks, cancel all on first error via259 done.close, capture first exception.260- `examples/pipeline_cancel.cr` — gen→square→filter pipeline with done261 channel through every stage, fan-out/fan-in with merge, early consumer exit.262- `examples/pubsub.cr` — PubSub class with subscribe/unsubscribe/publish,263 Mutex-protected subscriber map, topic routing, clean shutdown.