Clojure core.async: coordinating producer and consumer work without manual thread management
Use core.async channels and go blocks to coordinate Clojure work with blocking-style syntax while parking threads, not blocking OS threads.
25 Jul 2025, 20:03 UTC

When a Clojure program needs to hand work between stages without spawning and joining threads by hand, core.async channels and go blocks are a practical coordination tool. The useful answer: put the handoff on a channel and run the coordinating logic in a go block. The library parks the underlying thread when an operation cannot proceed, so you can write sequential-looking code for asynchronous flows. Keep blocking Java calls out of go blocks.
What channels and go blocks actually do
core.async is a library, not a language primitive. A channel is a queue: fixed-size when you pass a buffer size to chan, unbounded when you do not. The raw operations are put! and take!. A go block runs a body on a shared dispatcher and uses <! and >! for channel takes and puts. If the operation cannot complete immediately, the go block suspends and the dispatcher thread is released for other work. When the channel becomes ready, execution resumes. This is why the syntax looks blocking but does not consume an OS thread per waiting operation.
A worked configuration
Add core.async as a dependency in deps.edn. Run this in a terminal in the project root; normal user permissions are enough. Replace the version strings with the current stable releases for your environment.
{:deps {org.clojure/clojure {:mvn/version "1.11.1"}
org.clojure/core.async {:mvn/version "1.6.681"}}}
Start a REPL with clj or clojure -M. The expected check is that clj -Stree includes org.clojure/core.async. If dependency resolution fails, check network access, proxy settings, and whether the version exists; a missing artifact usually means a typo or an unpublished version.
Evaluate this example in the REPL:
(require '[clojure.core.async :as async])
(let [c (async/chan 1)]
(async/go
(async/>! c "hello")
(async/close! c))
(async/go
(println (async/<! c))
(println (async/<! c))))
The first go block is a producer: it puts "hello" into a buffered channel and closes it. The second is a consumer: it takes the value, prints it, then takes again. A take from a closed, empty channel returns nil, so the expected REPL output is hello followed by nil. The buffer size of 1 lets the producer finish its put without waiting for the consumer to arrive.
Buffering, backpressure, and execution style
Buffering is a design decision, not a default. An unbuffered channel forces producer and consumer to meet; it gives strong backpressure but can suspend a go block until a partner appears. A buffered channel absorbs temporary mismatches, but if consumers lag, a large buffer can hide backpressure and grow memory.
| Choice | Use it when | Watch for |
|---|---|---|
Unbuffered chan | You want strict handoff and natural pacing | Permanent suspension if no matching producer or consumer exists |
Buffered chan | Short bursts need smoothing | Hidden backpressure and memory growth if consumers lag |
go block | Coordinating channel operations and lightweight logic | Blocking or CPU-bound calls starving the shared dispatcher |
thread block | You must call blocking Java APIs or Thread/sleep | One OS thread per block; do not create unbounded threads |
Limits and common mistakes
- Do not block inside
go.Thread/sleep, synchronous file or socket IO, and long database calls occupy dispatcher threads. Because thegopool is fixed-size, enough blocking calls can starve it and make unrelatedgoblocks stop making progress. - Do not use
gofor CPU-bound work. The dispatcher is cooperative; heavy computation blocks it. Usethread, an executor, or a separate pipeline for CPU work. - Close channels when producers are done. Forgetting
close!can leave consumers waiting forever. Closing too early can truncate a stream. - Match unbuffered partners. A
goblock that takes from an unbuffered channel with no producer will suspend indefinitely. The same applies to puts with no consumer. - Check the version before upgrading.
core.asyncis an external library. Inspect the installed version and changelog; the API described here has been stable across recent releases, but verify compatibility in your project.
A practical way to check the result
After evaluating the example, confirm the REPL printed hello and then nil. If nothing appears, check whether the REPL is still waiting on a channel operation. A useful diagnostic is to replace a suspicious blocking call inside a go block with async/thread and see whether responsiveness returns. To inspect dependencies, run clj -Stree in the project root and look for org.clojure/core.async. For production changes, test the specific channel topology under load; buffered channels can pass small tests and still fail when consumers lag.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.