Spike in response time when multiple threads update a single Clojure ref via dosync
0 reputation · 20 Aug 2020, 11:42 UTC
0 reputation · 20 Aug 2020, 11:42 UTC
When a Clojure application processes concurrent requests that each perform a write to a shared ref inside a dosync block, the measured latency per request remains low in single‑threaded runs but rises sharply as the number of simultaneous threads increases.
The goal is to determine whether this latency increase stems primarily from STM retry cycles, from the overhead of ref‑set dependency tracking, or from other factors such as transaction length or contention on the underlying lock‑free structures. The system under consideration uses the default STM implementation, with no custom retry policies, and the dosync blocks contain only a single alter operation on the ref.
34775 reputation · 20 Aug 2020, 14:45 UTC
The observed latency spike is primarily attributed to STM retry cycles. Because Clojure's Software Transactional Memory (STM) uses Multiversion Concurrency Control (MVCC), transactions are optimistic. When multiple threads attempt to commit a change to the same ref, only one succeeds; all other concurrent transactions that read that ref are invalidated and must retry from the beginning of the dosync block.
While the STM does perform bookkeeping (tracking read/write sets), this overhead is constant and relatively low for a single alter operation. In contrast, the cost of retries grows non-linearly as contention increases. As more threads compete for the same ref, the probability of a conflict increases, leading to a "retry storm" where threads spend more time re-executing the transaction than performing actual work.
ref is almost always in a state of flux, which sustains high retry rates.Moving the write operation to an atom using swap! would likely eliminate the latency spike. Atoms use a Compare-And-Swap (CAS) loop, which is significantly more lightweight than the full STM transaction coordinator. If switching to an atom resolves the issue, it confirms that the STM's coordination overhead and optimistic retry mechanism were the bottlenecks.
ref with an atom and dosync/alter with swap!. Compare the latency curves under identical load.clojure.lang.Ref and STM internal retry loops.Missing Diagnostic Detail: Are you performing any reads of other refs within the same dosync block, or is it strictly a single alter on one ref?
Use comments to ask for clarification. Post a solution as an answer.
34,775 reputation · 20 Aug 2020, 20:58 UTC
To move beyond theoretical causes, you can quantify the impact of retries by monitoring the *transaction-count* dynamic var. Wrapping the dosync block in a timing wrapper and logging this count allows you to correlate the exact number of retries per request with the observed latency spikes.
If the logic allows, consider using commute instead of alter. While alter requires the transaction to be retried immediately upon detecting a conflict, commute defers the update by storing a function. This allows the STM to merge updates at commit time, significantly reducing the retry rate for commutative operations (like counters or set additions) without abandoning the STM's transactional guarantees.
Verification Tip: Compare the latency of alter vs. commute under high contention. If commute flattens the latency curve, it confirms that the conflict window—rather than general bookkeeping—is the primary bottleneck.