Раздел 28 · Clojure

Атом: swap!, reset!, валидаторы и наблюдатели

middle~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Главная мысль урока: атом это ссылка, которую меняют чистой функцией. swap! берёт текущее значение, применяет функцию и кладёт результат, а если кто-то успел раньше, повторяет попытку. Поэтому функция внутри swap! обязана быть чистой: её могут вызвать несколько раз.

В прошлом уроке мир игры стал идентичностью, а его состояния неизменяемыми значениями. Теперь соберём настоящий мир «Хроник Пограничья»: номер хода, герои, монстры. Всё это одна мапа в одном атоме:

Посмотри на вызовы swap!. После атома идёт обычная функция над мапой и её аргументы: update :turn inc, update-in [...] + 7. Ровно те функции, что в уроке про мапы работали над героем без всякого атома. swap! вызывает (update текущее-значение :turn inc) и кладёт результат в атом. Логика игры остаётся чистыми функциями, атом только хранит «сейчас».

swap! и reset!

Две главные операции атома:

ОперацияЧто делаетКогда брать
(swap! a f & args)кладёт (f текущее args...)новое значение зависит от старого
(reset! a v)кладёт v, не глядя на староезагрузили сохранение, начали заново

Обе возвращают новое значение. Почти всегда тебе нужен swap!. Соблазн написать (reset! world (update @world :turn inc)) понятен, но это ошибка: между чтением @world и reset! другой поток может поменять мир, и его изменение пропадёт. swap! делает чтение и запись одной операцией.

Как swap! переживает гонку

Атом это независимая синхронная ссылка. Независимая значит, что изменение одного атома никак не связано с другими. Синхронная значит, что swap! возвращает управление, когда новое значение уже лежит в атоме.

Как атом обходится без блокировок, когда восемь потоков одновременно меняют одно значение? Через сравнение с обменом, операцию, которую процессор выполняет атомарно. swap! работает так:

  1. Прочитать текущее значение атома, назовём его old.
  2. Вычислить (f old), это new.
  3. Записать new, только если в атоме всё ещё old.
  4. Если за это время кто-то успел записать своё, вернуться к шагу 1 и посчитать заново.

Повтор на четвёртом шаге и есть цена отказа от блокировок. На настоящем Clojure это видно, если посчитать вызовы функции: восемь потоков по тысяче раз добавляют монету в общий кошелёк.

(def gold (atom 0))
(def calls (atom 0))

(defn add-coin [g]
  (swap! calls inc)
  (inc g))

(let [workers (doall (for [_ (range 8)]
                       (future (dotimes [_ 1000] (swap! gold add-coin)))))]
  (run! deref workers))

(println "золото:" @gold "вызовов add-coin:" @calls)
(assert (= 8000 @gold))
(assert (>= @calls 8000))

Золота ровно 8000: ни одна монета не потерялась. А add-coin вызвалась больше 8000 раз: в наших прогонах от 12 до 22 тысяч, число меняется от запуска к запуску. Каждый лишний вызов это попытка, которую обогнал другой поток, и swap! посчитал заново. future запускает код в отдельном потоке, о нём будет урок про агенты и future. В браузере потоков нет, JavaScript выполняет код в одном потоке, поэтому гонку можно показать только на JVM.

Отсюда железное правило: функция в swap! должна быть чистой. Если в add-coin вместо счётчика стояло бы списание золота с банковского счёта игрока или отправка письма, лишние вызовы списали бы деньги несколько раз. Побочный эффект делают после swap!, по его результату, или в наблюдателе, к которому мы придём ниже.

compare-and-set! и swap-vals!

Шаг 3 доступен и напрямую. compare-and-set! записывает новое значение, только если текущее равно ожидаемому, и сообщает, получилось ли:

Ирма и Тим одновременно открыли сундук и увидели одно и то же значение seen. Ирма успела первой. Тим пытается записать себя владельцем, но в сундуке уже не seen, и запись отвергнута. На практике compare-and-set! нужен редко, swap! делает то же самое с повтором.

Тут браузер и JVM расходятся. В ClojureScript compare-and-set! сравнивает через =, по значению. На JVM он сравнивает через identical?: ожидаемое значение должно быть тем самым объектом, который лежит в атоме, а не равной ему копией.

(def chest (atom {:owner nil}))
(println (compare-and-set! chest {:owner nil} {:owner "Тим"}))   ; равная копия
(println (compare-and-set! chest @chest {:owner "Ирма"}))        ; тот самый объект
(def gold (atom 1000))
(println (compare-and-set! gold 1000 0))                         ; 1000 упаковано заново
(assert (= {:owner "Ирма"} @chest))
(assert (= 1000 @gold))

Первая и третья попытки на JVM вернули false. Даже число 1000 в третьей строке это новый упакованный объект, не тот, что лежит в атоме. Поэтому в compare-and-set! передают значение, прочитанное из атома, а не собранное заново.

Ещё две функции есть только на JVM, в браузерной песочнице их нет. swap-vals! и reset-vals! возвращают пару «старое значение, новое значение». Это нужно, когда после swap! надо знать, что было до него, а читать атом второй раз нельзя, он мог снова измениться:

(def hp (atom 18))
(println (swap-vals! hp + 7))      ; [18 25]
(println (reset-vals! hp 30))      ; [25 30]
(assert (= [30 29] (swap-vals! hp dec)))

Валидатор: недопустимое состояние не войдёт

Здоровье героя не может быть отрицательным или выше максимума. Это правило можно проверять в каждой функции, а можно поручить атому. Валидатор вызывается перед каждой записью, и если он вернул ложь, значение не попадает в атом:

Попытка дать 123 здоровья бросила исключение, а в атоме осталось 23. Валидатор задают опцией :validator при создании атома. На JVM его можно повесить и позже через set-validator!, в браузерной песочнице этой функции нет, а опция работает везде:

(def hp (atom 18))
(set-validator! hp #(<= 0 % 30))
(println (try (reset! hp 99)
              (catch IllegalStateException e (ex-message e))))
(assert (= 18 @hp))

Валидатор это последний рубеж, а не замена проверкам. Исключение посреди хода выглядит для игрока как падение игры. Функции игры по-прежнему ограничивают здоровье сами, через min и max, а валидатор ловит ошибку в коде, который об этом забыл.

Наблюдатель: реакция на изменение

Когда герой погибает, игра должна показать надпись, записать событие в лог и, может быть, отправить уведомление. Это побочные эффекты, им не место внутри swap!. Для них есть наблюдатели:

add-watch принимает ключ и функцию от четырёх аргументов: ключ, сам атом, старое значение, новое значение. Наблюдатель вызывается после успешной записи, один раз на изменение, поэтому эффекты в нём не повторятся из-за гонки. Ключ нужен, чтобы снять наблюдателя через remove-watch.

Старое и новое значение приходят целиком, поэтому наблюдатель сам сравнивает их и решает, случилось ли что-то интересное: здоровье перешло через ноль, сменился ход, в сундуке появился владелец. На JVM наблюдатель выполняется в том потоке, который сделал swap!, так что долгая работа в нём тормозит игру. Тяжёлое отправляют дальше, например агенту.

Весь мир в одном атоме

Ты уже видел приём, ради которого атомы любят: всё состояние приложения в одной мапе в одном атоме. В ClojureScript-фронтенде это стандарт, на нём стоят Reagent и re-frame. Плюсы видны из этого урока:

  • Одно место правды. Чтобы понять, что сейчас в игре, хватит @world.
  • Снимок бесплатный. @world даёт целый мир на один момент, его можно сохранить, сравнить, отправить на сервер.
  • История и отмена в пару строк: наблюдатель пишет старые значения в вектор, reset! возвращает любое.

Пределы тоже есть. Все изменения идут через одну ссылку, и при большой конкуренции swap! будет часто повторять функцию: восемь потоков из примера выше пересчитывали её в полтора-два с лишним раза чаще нужного. А если мир разнесён по нескольким атомам, изменить два из них согласованно не получится: перевод предмета от Ирмы к Тиму может застрять на полпути, между двумя swap!. Для согласованных изменений нескольких ссылок есть ref и транзакции, о них следующий урок. Похожие вопросы о гонках и блокировках в JavaScript-мире разбирали в уроке про инструменты конкурентности.

Попробуй руками

В задаче ты соберёшь мир в одном атоме: создашь его с валидатором, напишешь spawn! и damage! через swap! с чистыми функциями и повесишь наблюдателя, который ведёт список павших героев.

Проверь себя

Задание

Дальше

Один атом не умеет менять две ссылки согласованно. В следующем уроке Ирма передаст Тиму меч, и транзакция dosync гарантирует, что меч не исчезнет и не раздвоится по дороге.

домашка

Домашка