Skip to content

Instantly share code, notes, and snippets.

@favila
Created April 2, 2016 09:22
Show Gist options
  • Save favila/64defa613f9e1bfc926ac5795188e742 to your computer and use it in GitHub Desktop.
Save favila/64defa613f9e1bfc926ac5795188e742 to your computer and use it in GitHub Desktop.
Somewhat realistic example of using refs and dosync, compared with an equivalent approach using an atom.
(ns stm-example.core
"A somewhat realistic use of Clojure refs and dosync.
In most cases you should use an atom: it will be simpler and faster.
But you may need to use refs when:
* You need to return something other than the entire state from a swap!. E.g.,
the id of a newly-created user, or whether an action succeeded. You can
emulate this by adding a \"return value\" key to the atom on any state
change, but this will increase the number of retries since they are not
truly state changes.
* You need to side effect or do IO from within a swap!. You can accomplish
this in a dosync using an agent, but you can't do it with atoms.
* The state is divisible into seldom-overlapping parts where reads and
writes are independent. You may be able to retry fewer times (reduce
contention, increase write speed) if you can split your state into more
refs, only a small subset of which are read and updated at any given time.
(Test though: in my experience atoms are still usually faster!)
* The bits of state are \"owned\" by different parts of the application which
do not coordinate with one another. Beware, this may be a code smell: with
modern systems like Component or Trapperkeeper you usually need only one
map to hold the entire app state.
In this example, we have user records, indexed by both id and login, and a
\"last-id\" counter to mint ids for new users.
The counter only needs to be updated (commutatively) when creating a new user,
and updates and deletes do not read or write the counter.
Each entry in the id and login indexes is itself a ref containing a user
record. When a user record is updated, we alter this ref; if the user's
login did not change, we do not need to read or alter the login index."
(:import [java.util.concurrent Executors ExecutorService TimeUnit]
[clojure.lang ExceptionInfo]))
(defn ^ExecutorService fixed-thread-pool
([] (fixed-thread-pool (+ 2 (.availableProcessors (Runtime/getRuntime)))))
([threads] (Executors/newFixedThreadPool threads)))
(defn execute-times
([^ExecutorService es n f]
(dotimes [_ n]
(.execute es f)))
([n f]
(let [es (fixed-thread-pool)]
(time (try
(execute-times es n f)
(finally
(.shutdown es)
(.awaitTermination es 1 TimeUnit/MINUTES))))
(.isTerminated es))))
(defn new-user [login monthly-budget]
{:login login
:monthly-budget monthly-budget
:total-expense 0})
(defn ref-user-state []
{:last-id (ref -1)
:users-by-id (ref {})
:users-by-login (ref {})})
(defn delete-user! [{:keys [users-by-id users-by-login]} id]
(dosync
(if-some [user (@users-by-id id)]
(let [{:keys [login]} @user]
(alter users-by-id dissoc id)
(alter users-by-login dissoc login)
true)
false)))
(defn create-user!
[{:keys [last-id users-by-id users-by-login]} {:keys [login] :as user}]
(dosync
(if-some [existing-user (@users-by-login login)]
(throw (ex-info "user with same login already exists!"
{:error :login-exists
:existing-user @existing-user
:desired-user user}))
;; Use a dedicated autoincrement id; note we can use `commute`.
;; An alternative is `(inc (apply max 0 (keys (ensure users-by-id))))`,
;; but this has a chance of reissuing an id if the highest-numbered id is
;; deleted, and is more costly to compute.
(let [next-id (commute last-id inc)
user-with-id (ref (assoc user :id next-id))]
(assert (nil? (users-by-id next-id)))
(alter users-by-id assoc next-id user-with-id)
(alter users-by-login assoc (:login user) user-with-id)
next-id))))
(defn update-user!
[{:keys [users-by-id users-by-login]} id update-fn]
(dosync
(if-some [existing-user (@users-by-id id)]
(let [old-user @existing-user
old-login (:login old-user)
new-user (alter existing-user update-fn)
new-login (:login new-user)]
(when-not (= old-login new-login)
(when (some? (@users-by-login new-login))
(throw (ex-info "changed login, but login already in use!"
{:error :login-in-use
:existing-user @existing-user
:updated-user new-user})))
(alter users-by-login #(-> % (dissoc %2) (assoc %3 %4))
old-login new-login existing-user))
new-user)
(throw (ex-info "user does not exist!" {:error :unknown-user
:action :update
:user-id id})))))
(defn test-user-actions [state]
(let [user (new-user (str "name" (rand-int 5000)) 1)]
(binding [*out* *err*]
(try
(let [user-id (create-user! state user)]
(try
(update-user! state user-id #(update % :monthly-budget inc))
(catch ExceptionInfo e #_(print (prn-str (ex-data e)))))
(try
(update-user! state user-id #(assoc % :login (str "name" (rand-int 5000))))
(catch ExceptionInfo e #_(print (prn-str (ex-data e)))))
(when (zero? (rand-int 100))
(delete-user! state user-id)))
(catch ExceptionInfo e #_(print (prn-str (ex-data e)))))))
nil)
(defn ref-test [n]
(let [state (ref-user-state)]
(assert (execute-times n (partial test-user-actions state)))
state))
(defn ref-check-invariants [{:keys [users-by-login users-by-id]}]
(dosync
(let [[ubl ubi] [@users-by-login @users-by-id]]
(and
(= (set (map deref (vals ubl))) (set (map deref (vals ubi))))
(every? #(= (:monthly-budget @%) 2) (vals ubl))))))
;;; Same as above, implemented with an atom
(defn atom-user-state []
(atom {:last-id -1
:users-by-id {}
:users-by-login {}}))
(defn create-user
[{:keys [last-id users-by-id users-by-login] :as state} {:keys [login] :as user}]
(if-some [existing-user (users-by-login login)]
(throw (ex-info "user with same login already exists!"
{:error :login-exists
:existing-user existing-user
:desired-user user}))
;; Use a dedicated autoincrement id; note we can use `commute`.
;; An alternative is `(inc (apply max 0 (keys (ensure users-by-id))))`,
;; but this has a chance of reissuing an id if the highest-numbered id is
;; deleted, and is more costly to compute.
(let [next-id (inc last-id)
user-with-id (assoc user :id next-id)]
(assert (nil? (users-by-id next-id)))
(-> state
(assoc :last-id next-id)
(assoc-in [:users-by-id next-id] user-with-id)
(assoc-in [:users-by-login (:login user-with-id)] user-with-id)))))
(defn update-user
[{:keys [users-by-id users-by-login] :as state} id update-fn]
(if-some [existing-user (users-by-id id)]
(let [updated-user (update-fn existing-user)
old-login (:login existing-user)
updated-login (:login updated-user)
ubl users-by-login
existing-user-by-login (ubl old-login)
new-login-id (:id (ubl updated-login))
old-login-id (:id existing-user-by-login)]
(when-not (or (= old-login-id new-login-id) (nil? new-login-id))
(throw (ex-info "changed login, but login already in use!"
{:error :login-in-use
:existing-user (ubl updated-login)
:updated-user updated-user})))
(-> state
(assoc-in [:users-by-id id] updated-user)
(update :users-by-login #(-> % (dissoc %2) (assoc (:login %3) %3))
old-login updated-user)))
(throw (ex-info "user does not exist!" {:error :unknown-user
:action :update
:user-id id}))))
(defn delete-user [{:keys [users-by-id] :as state} id]
(if-some [{login :login} (users-by-id id)]
(-> state
(update :users-by-id dissoc id)
(update :users-by-login dissoc login))
state))
(defn atom-test-user-actions [state-atom]
(let [user (new-user (str "name" (rand-int 5000)) 1)]
(binding [*out* *err*]
(try
(let [user-id (:last-id (swap! state-atom create-user user))]
(try
(swap! state-atom update-user user-id #(update % :monthly-budget inc))
(catch ExceptionInfo e #_(print (prn-str (ex-data e)))))
(try
(swap! state-atom update-user user-id
#(assoc % :login (str "name" (rand-int 5000))))
(catch ExceptionInfo e #_(print (prn-str (ex-data e)))))
(when (zero? (rand-int 100))
(swap! state-atom delete-user user-id)))
(catch ExceptionInfo e #_(print (prn-str (ex-data e)))))))
nil)
(defn atom-test [n]
(let [state (atom-user-state)]
(assert (execute-times n (partial atom-test-user-actions state)))
state))
(defn atom-check-invariants [state-atom]
(let [{ubl :users-by-login ubi :users-by-id} @state-atom]
(and
(= (set (vals ubl)) (set (vals ubi)))
(every? #(= (:monthly-budget %) 2) (vals ubl)))))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment