Created
April 2, 2016 09:22
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(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