Four Tools, Four Coordination Models
Clojure gives you four distinct concurrency primitives, and each one exists because it solves a different combination of two questions: does the update need to be coordinated with other state, and does the caller need to wait for it to finish? Atoms are uncoordinated and synchronous, refs are coordinated and synchronous via STM, agents are uncoordinated and asynchronous, and core.async channels step outside that grid entirely to provide ongoing communication between independent processes rather than managing a single value at all.
Cricket analogy: Like a team's score-keeping needs — a player's personal run tally updates alone (atom), the team total and required-run-rate update together consistently (ref/STM), the PA announcer's updates happen independently in the background (agent), and commentators relaying updates between the box and field form an ongoing conversation (core.async).
As a quick decision guide: single value, no coordination, synchronous — use an atom. Multiple values needing joint consistency, synchronous — use a ref (STM). Single value, no coordination, asynchronous — use an agent. Ongoing communication or pipelines between independent processes — use a core.async channel.
Choosing Between Atoms and Refs
The deciding question between an atom and a ref is always: does this update need to be kept consistent with some other piece of state at the same moment? If you're tracking a single independent counter, cache, or config map, an atom is simpler, has less overhead, and needs no dosync wrapping. The moment two or more values must move together — so that no other thread can ever observe one changed without the other — you need refs inside a dosync transaction, because atoms provide no mechanism to coordinate an update across more than one reference.
Cricket analogy: Choose an atom when tracking just one bowler's personal wicket count independently, but choose a ref when updating both the team's total runs and the required run rate together, since an inconsistent view where runs updated but the rate didn't would mislead the chasing batsmen.
Choosing Between Agents and core.async
Agents and core.async can look similar at a glance — both let you do work asynchronously — but they solve different problems. Choose an agent when you have one specific value that needs to be updated over time by a queue of background actions, with retained state, ordering guarantees, and built-in error handling. Choose core.async when the real requirement is an ongoing exchange of messages between multiple independent processes — a pipeline, a fan-out, a request/response conversation — rather than the management of one particular stateful value.
Cricket analogy: Choose an agent when you want a single value, like a running injury-report log, updated by queued background actions with retained history and built-in error handling, but choose core.async when you need an ongoing conversation between independent processes, like the field umpire and the third umpire exchanging review requests and rulings back and forth.
;; Atom: uncoordinated, synchronous
(def hits (atom 0))
(swap! hits inc)
;; Ref: coordinated, synchronous (STM)
(def acct-a (ref 100))
(def acct-b (ref 50))
(dosync (alter acct-a - 20) (alter acct-b + 20))
;; Agent: uncoordinated, asynchronous
(def logger (agent []))
(send logger conj :event-1)
;; core.async channel: coordinated communication between processes
(require '[clojure.core.async :as async :refer [chan go >! <!]])
(def pipe (chan))
(go (>! pipe :message))
(go (println "received" (<! pipe)))Combining Primitives Safely in Real Systems
Production Clojure systems routinely use all four primitives together, each handling the concern it's best suited for: an atom for a local cache, refs for a coordinated ledger update, an agent for background logging, and core.async channels for a streaming pipeline to downstream consumers. The one rule that ties all four together safely is that dosync transaction bodies must stay pure — any side effect performed inside a transaction, including sending to an agent or putting onto a channel, can fire more than once if the STM transaction has to retry, so those calls belong after the transaction commits, not inside it.
Cricket analogy: Like a broadcast rig combining an atom for the 'overs bowled' ticker, a ref for the jointly consistent score-and-rate pair, an agent for background commentary logging, and a channel for live director-to-camera cueing — but you'd never let that live cueing block inside the scorer's atomic score-correction transaction, since a stalled channel could force a costly retry.
Mixing primitives carelessly is a common source of bugs: sending to an agent, writing to a core.async channel, or performing any other side effect from inside a dosync transaction can cause that side effect to fire more than once if the transaction retries. Keep transactions pure and push side effects to after the dosync block commits.
- Atom = single value, sync, uncoordinated; Ref = multiple values, sync, coordinated (STM).
- Agent = single value, async, uncoordinated; core.async = ongoing communication between processes.
- Use refs when two or more values must change together consistently, like a funds transfer.
- Use core.async when you need a pipeline or exchange, not just a single stateful value.
- Real systems often combine all four primitives for different concerns within the same application.
- The cardinal rule when combining them: keep dosync transaction bodies free of side effects.
Practice what you learned
1. Which primitive would you use for a single, independent counter that many threads increment without needing to coordinate with any other state?
2. Which primitive guarantees that updates to two or more pieces of state commit together or not at all?
3. Which primitive is best suited for an ongoing, back-and-forth message exchange between independent, concurrently-running processes?
4. Why is it unsafe to send a message on a core.async channel, or send an action to an agent, from inside a dosync transaction body?
5. Which pairing correctly matches each primitive to its coordination and timing model?
Was this page helpful?
You May Also Like
Atoms in Clojure
Atoms provide Clojure's simplest managed reference type for safe, synchronous, uncoordinated mutable state backed by lock-free compare-and-swap updates.
Refs and Software Transactional Memory
Refs and Clojure's software transactional memory (STM) let you update multiple pieces of state together, atomically and consistently, using dosync transactions.
Agents in Clojure
Agents manage a single piece of state that's updated asynchronously and independently by a queue of action functions, with built-in error handling.
core.async Basics
core.async brings Go-style CSP concurrency to Clojure through channels and lightweight go blocks, decoupling producers and consumers.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics