Главная мысль урока: атом это ссылка, которую меняют чистой функцией.
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! работает так:
- Прочитать текущее значение атома, назовём его
old. - Вычислить
(f old), этоnew. - Записать
new, только если в атоме всё ещёold. - Если за это время кто-то успел записать своё, вернуться к шагу 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 гарантирует, что меч не исчезнет и не раздвоится по дороге.
домашка