Browse Learn Clojure Foundations as a Java Developer

Practice Clojure Concurrency on the JVM

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.

Lab Setup

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.

Exercise 1: Atom State Under Contention

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.

Exercise 2: Ref Transaction With A Cross-Account Invariant

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.

Exercise 3: Agent For Ordered Background Work

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.

Exercise 4: Measure Before Tuning

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

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.

Knowledge Check

### Why does the atom exercise use a pure `record-order` function? - [x] `swap!` may retry, so the state transition must be safe to run more than once. - [ ] Pure functions are required only when using refs. - [ ] Atoms cannot hold maps unless the update function is pure. - [ ] Pure functions automatically run on virtual threads. > **Explanation:** `swap!` can call the update function again after a compare-and-set conflict. Pure transition functions make retries safe and testable. ### What makes the transfer exercise a good fit for refs? - [x] Two balances must change together while preserving one invariant. - [ ] A single counter is incremented independently. - [ ] The transfer must write directly to a database inside `dosync`. - [ ] The code needs asynchronous background execution. > **Explanation:** Refs and STM fit coordinated in-memory identities. The invariant is that the combined balance remains stable. ### What should you verify before trusting a concurrency benchmark? - [x] The tuned version still produces the same correct result under load. - [ ] The code uses the fewest possible Clojure forms. - [ ] The fastest run is copied into documentation. - [ ] Logging is enabled inside the measured loop. > **Explanation:** Correctness comes before speed. A performance result is only useful if the workload, result, and coordination guarantees are comparable.
Revised on Saturday, May 23, 2026