Work through practical Clojure concurrency exercises for Java engineers: atom updates, STM transfers, agent-backed background work, and measurement habits that prove correctness before tuning.
This page is a small lab for the state and concurrency chapter. The goal is not to memorize APIs. The goal is to practice choosing the smallest coordination tool that preserves correctness on the JVM.
Run these exercises in a REPL and keep two questions visible:
| Practice question | Why it matters |
|---|---|
| What is the immutable value being transformed? | It keeps most logic thread-independent and testable. |
| Which identity changes over time? | It tells you whether you need an atom, ref, agent, queue, or Java executor boundary. |
| What invariant must survive concurrency? | It prevents benchmark-driven code from becoming incorrect. |
| Where are the side effects? | It keeps retryable state updates from sending duplicate email, writing duplicate logs, or repeating I/O. |
You can paste the examples into a namespace like this:
1(ns concurrency-lab.core
2 (:require [clojure.test :refer [is]]))
The examples use future only to create concurrent pressure. Production code should usually use the executor, framework, queue, or runtime boundary that matches your system.
Use an atom when one independent identity changes over time. Start with a pure transition function:
1(defn record-order
2 [state {:keys [id status] :as order}]
3 (if (contains? (:orders state) id)
4 state
5 (-> state
6 (assoc-in [:orders id] order)
7 (update-in [:counts status] (fnil inc 0)))))
8
9(def order-book
10 (atom {:orders {}
11 :counts {}}))
12
13(defn record-order!
14 [order]
15 (swap! order-book record-order order))
Now create concurrent writers:
1(def sample-orders
2 (for [id (range 1000)]
3 {:id id
4 :status (if (even? id) :accepted :rejected)}))
5
6(defn run-order-writers!
7 []
8 (reset! order-book {:orders {} :counts {}})
9 (let [workers (doall
10 (for [chunk (partition-all 100 sample-orders)]
11 (future
12 (doseq [order chunk]
13 (record-order! order)))))]
14 (doseq [worker workers]
15 @worker)
16 @order-book))
Check the invariant:
1(let [{:keys [orders counts]} (run-order-writers!)]
2 (is (= 1000 (count orders)))
3 (is (= 500 (:accepted counts)))
4 (is (= 500 (:rejected counts))))
Then change the workload so duplicate IDs arrive from several futures. The invariant should still hold because record-order is idempotent for an existing ID.
Use refs when multiple identities must change together. A bank transfer is useful because a failed transfer must not partially update either account.
1(def checking (ref 1000M))
2(def savings (ref 1000M))
3
4(defn total-balance
5 []
6 (+ @checking @savings))
7
8(defn transfer!
9 [from to amount]
10 (dosync
11 (when (<= amount @from)
12 (alter from - amount)
13 (alter to + amount)
14 true)))
Create concurrent transfers in both directions:
1(defn run-transfers!
2 []
3 (dosync
4 (ref-set checking 1000M)
5 (ref-set savings 1000M))
6 (let [workers (doall
7 (concat
8 (repeatedly 50 #(future (transfer! checking savings 10M)))
9 (repeatedly 50 #(future (transfer! savings checking 7M)))))]
10 (doseq [worker workers]
11 @worker)
12 {:checking @checking
13 :savings @savings
14 :total (total-balance)}))
Check the invariant:
1(is (= 2000M (:total (run-transfers!))))
For a Java comparison, write down how you would protect the same invariant with synchronized or ReentrantLock. The Clojure version is not valuable because it is shorter; it is valuable because the invariant is explicit at the transaction boundary.
Use an agent when one in-process state value should receive ordered asynchronous updates.
1(defn append-audit
2 [entries event]
3 (conj entries
4 (assoc event :recorded-at-ms (System/currentTimeMillis))))
5
6(defn audit!
7 [audit-log event]
8 (send audit-log append-audit event))
9
10(defn run-audit-events!
11 []
12 (let [audit-log (agent [])]
13 (doseq [id (range 10)]
14 (audit! audit-log {:type :order/accepted
15 :id id}))
16 (await audit-log)
17 @audit-log))
Check ordering:
1(is (= (range 10)
2 (map :id (run-audit-events!))))
Now modify the agent action to throw for one event. Observe what happens to the agent with agent-error, then fix the action so expected business rejections are represented as data instead of agent failure.
Do not benchmark a concurrency primitive without a workload shape. Measure one pure path and one coordinated path separately.
1(defn count-statuses
2 [orders]
3 (reduce (fn [counts {:keys [status]}]
4 (update counts status (fnil inc 0)))
5 {}
6 orders))
7
8(def status-totals (atom {}))
9
10(defn publish-status-counts!
11 [orders]
12 (let [counts (count-statuses orders)]
13 (swap! status-totals merge-with + counts)))
Use this comparison table before choosing a tool:
| Variant | What to measure before changing the design |
|---|---|
Pure count-statuses |
CPU and allocation for local transformation; change the design only if profiling shows hot allocation or avoidable intermediate collections. |
publish-status-counts! once per batch |
Atom retry pressure and total throughput; change the design if many writers contend on one state root. |
One swap! per order |
Update frequency and latency under contention; change the design if throughput falls as futures increase. |
| Agent-per-batch publisher | Queue depth and completion latency; change the design if the queue grows faster than actions complete. |
If you use Criterium, benchmark the pure function first. For the coordinated path, use a load harness that recreates state between runs and records throughput, latency, and correctness.
| Exercise | Review signal |
|---|---|
| Atom order book | Total unique IDs and status counts are correct; fewer, larger updates may beat per-event swap!. |
| Ref transfer | Combined balance never changes; transaction scope should stay small and pure. |
| Agent audit | Events remain ordered for one agent; blocking work can back up the agent queue. |
| Measurement pass | The same result appears before and after tuning; profile evidence tells you which layer is hot. |