Управление состоянием: atom, ref, agent и STM
Разбираемся, как Clojure разрешает изменения состояния, не теряя потокобезопасности.
Идентичность в Clojure — ссылочный тип (atom, ref, agent), который со временем указывает на разные неизменяемые значения; само значение никогда не меняется.
Зачем отдельные ссылочные типы
Значения в Clojure неизменяемы — это надёжно, но программам нужно состояние, которое меняется во времени (счётчик, баланс счёта). Решение: значение остаётся неизменным, а меняется ссылка на него. Clojure даёт несколько видов ссылок под разные сценарии конкурентности.
atom: независимое синхронное состояние
atom — самый частый. Подходит, когда одна независимая величина меняется потокобезопасно. Читают через @ (или deref), меняют через swap! (применить функцию) или reset! (задать значение).
(def счётчик (atom 0))
@счётчик ; => 0 (прочитать текущее значение)
(swap! счётчик inc) ; => 1 (применить inc)
(swap! счётчик + 10); => 11 (применить (+ x 10))
(reset! счётчик 0) ; => 0 (задать напрямую)
@счётчик ; => 0Вывод:
0 1 11 0 0
swap! гарантирует, что обновление применится атомарно: даже если несколько потоков одновременно зовут swap!, ни одно изменение не потеряется.
ref и STM: согласованные изменения нескольких ячеек
Когда нужно изменить несколько величин согласованно (классика — перевод денег: списать с одного счёта, зачислить на другой), используют ref и STM (Software Transactional Memory). Изменения оборачивают в транзакцию dosync — либо применятся все, либо ни одного.
(def счёт-а (ref 100))
(def счёт-б (ref 0))
(dosync
(alter счёт-а - 30)
(alter счёт-б + 30))
@счёт-а ; => 70
@счёт-б ; => 30Вывод:
70 30
agent: асинхронные изменения
agent похож на atom, но обновления выполняются асинхронно в фоновом пуле потоков. Отправляют изменение через send. Удобно для побочной работы, которая не должна блокировать.
(def лог (agent []))
(send лог conj "событие 1")
; обновление произойдёт в фоне; читаем через @лог позжеПочему конкурентность проще
В Java два потока, меняющие один объект, легко создают гонку данных, и приходится вручную ставить блокировки. В Clojure значения неизменяемы, поэтому «испортить» их нельзя, а контролируемые точки изменения (atom/ref/agent) сами обеспечивают потокобезопасность. Вы думаете не о блокировках, а о том, какой вид согласованности нужен.
Как работает под капотом
swap! использует приём compare-and-swap: читает текущее значение, вычисляет новое, и атомарно записывает, только если значение не успело измениться; иначе повторяет. STM ведёт журнал транзакции и при конфликте откатывает и перезапускает её. Поскольку значения неизменяемы, такие откаты и повторы безопасны — старое значение всегда доступно целиком.
Частые ошибки
- Забыть
@при чтении.счётчик— это сам atom, а@счётчик— его текущее значение. - Делать побочные эффекты внутри
swap!илиdosync. Функция может быть вызвана повторно при конфликте, поэтому печать/запись в файл оттуда опасны. - Брать ref там, где хватает atom. STM нужен только для согласованных изменений нескольких ячеек.
Итоги
- Значения неизменяемы; меняются ссылки на них через atom/ref/agent.
atom— одна независимая величина (swap!/reset!, чтение через @).ref+dosync(STM) — согласованные изменения нескольких ячеек.agent— асинхронные обновления; неизменяемость делает конкурентность безопасной.