Управление состоянием: 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 — асинхронные обновления; неизменяемость делает конкурентность безопасной.
Проверьте себя
1. Чем читают текущее значение atom?
AЧерез swap!
BЧерез @ (deref)
CЧерез reset!
DЧерез get
2. Какой механизм нужен для согласованного изменения нескольких ячеек?
Aatom со swap!
Bref внутри dosync (STM)
Cagent со send
Dобычный def
3. Почему конкурентность в Clojure проще, чем в Java с блокировками?
AClojure не поддерживает потоки
BЗначения неизменяемы, их нельзя испортить, а точки изменения потокобезопасны
CClojure всегда однопоточен
DБлокировки в Clojure ставятся автоматически на всё