This short program creates a polymorphic protocol that provides a
unified interface for reading and for applying a function to mutable
objects and extends it to refs and atoms while creating a Cell type
that amounts to a volatile mutable variable. (look thingie) will
return the value in all three cases and (mutate! thingie f args) does
analogous to (swap! an-atom f args) in all three cases.

Then it builds a function rand-xfers-conc! that creates X mutable
objects of type Y, then starts Z threads that each perform W random
transactions on random pairs of the mutables, then waits for the
threads to finish and reports statistics on the outcome.

In the process, it demonstrates protocols and protocol extension for
abstraction, a simple mutable deftype, some concurrency tricks for
making and awaiting large thread pools, gambler's ruin, AND the
differing thread-safety and performance characteristics of volatile
mutables, atoms, and refs.

The mutables are created with integer values and the transactions
consist of "withdrawing" a random amount from one "account" and
"depositing" it in the other, with overdrawing disallowed, a
per-transaction transfer maximum of 10000, and a short delay between
determining the amount and performing the transfer (to allow time for
other transactions involving one of the same cells to be fairly
likely, but not so likely as to make the case with refs take forever).

Here are the results from one run with each type of mutable:

=> (rand-xfers-conc! #(ref 100) 100 100 1000)
init-sum = 10000
final-sum = 10000
final-minimum = 0
nil
=> (rand-xfers-conc! #(atom 100) 100 100 1000)
init-sum = 10000
final-sum = 10000
final-minimum = -330128
nil
=> (rand-xfers-conc! #(Cell. 100) 100 100 1000)
init-sum = 10000
final-sum = 14632
final-minimum = -211252
nil

Each run started the "accounts" with a "balance" of 100 and each
created 100 of these and then ran 1000 transactions in each of 100
simultaneous threads.

With refs, the initial and final sum agree and no account becomes
overdrawn. Transactional integrity is perfect.

With atoms, the initial and final sum still agree -- each transaction
adds an amount to one account and removes the same amount from
another, and atom's swap! guarantees that there won't be any data
races to updates to any individual atom, so none of the separate
withdrawals and deposits can "go missing", and since every withdrawal
is paired with a matching deposit the total is invariant after all
transactions have completed.

However, accounts end up overdrawn, with the worst ending up over
three hundred grand in the hole! What happened here? Atoms only
protect against data races involving concurrent updates of the same
atom. Nothing stops an account with $130 being seen by rand-xfer! as
able to lose up to $130, so rand-xfer! decides to withdraw $70 from
it, then the account having $90 withdrawn by another thread, leaving
$40, and then rand-xfer! yanking out the $70 to leave it overdrawn by
thirty bucks, for instance.

With Cells, the results are worse by far: the final sum shows almost
five thousand bucks created out of thin air, as *well* as accounts
overdrawn by as much as two hundred grand. If banks had this poor
transactional integrity in their computers, we'd have skyrocketing
inflation.

The cause here is the blatant data race between two updates to the
same volatile: x is 40, thread a reads it as 40, thread b reads it as
40, thread a adds 30 to get 70, thread b subtracts 20 to get 20,
thread a writes 70 to x, and thread b writes 20 to x. Thread a's
addition of 40 to x "went missing" as a result of this race condition,
but may still have been withdrawn successfully from some other
account. Withdrawals can go missing too, and in this instance some
must have since the net change in the total balance was positive.

With atoms, the additions of 70 and -20 are done atomically (hence the
name "atom") so the read, addition, and subsequent write cannot get
interleaved with a concurrent swap! in another thread, but Cells offer
no such protection.

Performance-wise, the Cells case takes a few seconds to execute on my
hardware, the atoms case maybe a smidgen longer, and the refs case
about half a minute. Refs perform significantly more slowly with the
high level of contention artificially created by putting that 10ms
delay in rand-xfer! because there end up being a lot of transaction
retries -- one for every case where Cells and atoms generate an
overdrawn account, and then some. However, slow and correct beats fast
and wrong any day, so for transactions like these refs are absolutely
the best choice -- nay, the only correct choice (bar explicit locking
-- but see below).

Without the 10ms delay in rand-xfer!, the atoms case usually produces
a nonnegative final minimum balance, but the data race still exists;
the error rate is just too low for overdrafts to occur very often. But
in a real-world system, even data races allowing invariants like
(not-any? neg? balances) to be violated only at the low rate of one
error a day are terrible, evil things.

As an added bonus, gambler's ruin is demonstrated. The refs outcome
almost *always* has the minimum account balance end up zero, even
though the odds are not biased in favor of any account over any other.
The thing is, once you run out of money, that's it -- game over.
Unless the house is using atoms or Cells to keep track of the chips,
in which case you can end up losing your house in the bargain. :)

Modifying rand-xfer! to replace (dosync ...) with (locking x1 (locking
x2 ...)) and rerunning the Cells case will wedge your repl,
demonstrating the evils of relying on fine-grained locking instead of
STM: deadlock. By contrast, transactions cannot even livelock; every
time a transaction fails it's because another transaction succeeded
somewhere else to make it fail, so some progress must have been made.
In an open-ended run such as a live banking system, the subtler risk
of a particular account's transaction stream being starved (always
failing at the expense of other transactions involving the other
accounts involved in transfers to that account) may exist, but if
threads have equal priority it's statistically expected for all of the
threads to make progress at roughly equal rates, and thus for accounts
to be updated in roughly as timely a fashion as one another.


(defprotocol Xfer
  (look [this])
  (mutate [this f args]))

(defn mutate! [x f & args]
  (mutate x f args))

(extend-type clojure.lang.Ref
  Xfer
  (look [this] @this)
  (mutate [this f args]
    (apply alter this f args)))

(extend-type clojure.lang.Atom
  Xfer
  (look [this] @this)
  (mutate [this f args]
    (apply swap! this f args)))

(deftype Cell [^{:volatile-mutable true} x]
  Xfer
  (look [this] x)
  (mutate [this f args]
    (set! x (apply f x args))))

(defn abs [x]
  (if (neg? x) (- x) x))

(defn rand-xfer! [x1 x2]
  (dosync
    (let [rng (rem (abs (min (look x1) (look x2))) (int 1e4))
          amt (- (rand-int (+ 1 rng rng)) rng)]
      (Thread/sleep 10)
      (mutate! x1 + amt)
      (mutate! x2 - amt))))

(defn rand-xfers! [xs n]
  (dotimes [_ n]
    (let [x1 (rand-nth xs)
          x2 (rand-nth xs)]
      (rand-xfer! x1 x2))))

(defn rand-xfers-thread [xs n]
  (Thread. #(rand-xfers! xs n)))

(defn sum-xs [xs]
  (apply + (map look xs)))

(defn done? [thread]
  (= Thread$State/TERMINATED (.getState thread)))

(defn make-xs [create-fn n]
  (take n (repeatedly create-fn)))

(defn rand-xfers-conc!
  [create-fn nxs nthreads nxfers-per-thread]
  (let [xs (make-xs create-fn nxs)
        init-sum (sum-xs xs)
        threads (make-xs
                  #(rand-xfers-thread xs nxfers-per-thread)
                  nthreads)]
    (doseq [thread threads] (.start thread))
    (loop []
      (when-not (every? done? threads)
        (Thread/sleep 10) ; don't hog the CPU here
        (recur)))
    (println "init-sum =" init-sum)
    (println "final-sum =" (sum-xs xs))
    (println "final-minimum =" (apply min (map look xs)))))



A note on locking:

Reducing deadlocks due to locking is possible with a clever little trick.

(let [switch? (<
                (System/identityHashCode x1)
                (System/identityHashCode x2))
      [x1 x2] (if switch? [x2 x1] [x1 x2])]
  (locking x1
    (locking x2
      ...)))

This locks any given two resources in descending order of identity
hash code. The identity hash code is not guaranteed to be unique per
live object but collisions seem to be exceedingly rare on most JVMs.
The risk of deadlock is greatly reduced because locks on the same two
objects are almost always acquired in the same order.

A somewhat safe, general multi-object locking macro can be built on this base:

(defn- locking-wrap [n obs body]
  (if (zero? n)
    body
    [`(locking (nth ~obs ~(dec n))
       ~@(locking-wrap (dec n) obs body))]))

(defmacro locking-all [obs & body]
  (let [ooo (gensym)]
    `(let [~ooo (sort-by
                    (fn [ob#] (System/identityHashCode ob#))
                    ~obs)]
       ~@(locking-wrap (count obs) ooo body))))

Testing:

=> (def xxx 42)
#'user/xxx
=> (def yyy 192)
#'user/yyy
=> (def zzz 1024)
#'user/zzz
=> (locking-all [xxx yyy zzz]
     (doall (map #(Thread/holdsLock %) [xxx yyy zzz])))
(true true true) ; It does acquite all three locks.
=> (do
     (doto
       (Thread. #(locking-all [xxx yyy zzz]
                   (Thread/sleep 5000)))
       (.start))
     (Thread/sleep 100)
     (locking-all [yyy zzz xxx] (println "boo!")))
; 5 second pause
boo! ; Look, ma! No deadlock! Even with the items rearranged!
nil

The wild card here is that locking-all has a small but positive chance
to cause deadlock, particularly on 64-bit JVMs. It would require an
identity hash collision between two resources that were being locked
by different threads at the same time, and with the resources given in
opposite orders to locking-all (assuming that sort-by performs a
stable sort). The odds are literally trillions to one against,
assuming a hash space of size 2^32 (the maximum possible given that
System.identityHashCode() returns int) and fairly frequent locking of
fairly many resources, but it would happen eventually. The real hash
space is potentially smaller: if truncated to the least significant 32
bits and the object's address and objects are aligned to 8 byte
boundaries on a 64-bit JVM, only 2^24. Another factor that might allow
rare collisions even on 32-bit JVMs is that the object may eventually
get relocated by the GC if it's long-lived enough. The identity hash
code has to be constant, so if it's the object's address, it's the
object's address when *first created*. Subsequently the object may be
moved by the GC, and then a new object could be created at the same
initial address, yielding a hash collision. The odds of these
particular two objects ever being locked together at all, let alone
simultaneously by different threads in opposite orders, is
microscopic, but add up enough object pairs over a long enough JVM
session lifespan and eventually kaboom!

So locking-all, even in the best case, can only (drastically!) reduce
the frequency of deadlocks; it cannot eliminate deadlocks entirely,
even on 32-bit JVMs. It also suffers another problem, namely it
doesn't cope with the situation where the nested critical sections are
not all in the same function. Suppose we have functions foo and bar
that lock an x and baz and quux that lock a y, and bar takes an x and
a y and calls quux on the y while baz takes an x and a y and calls foo
on the x, then if one thread calls bar with a particular x and y at
the same time as another calls baz with the same x and the same y you
can have deadlock. Rearranging things so that xs are always locked
before (or always locked after) ys fixes this, but may require putting
the program logic through contortions away from the natural expression
of the domain problem. And this gets exponentially worse the larger
the number of simultaneously held locks, e.g. if zs enter the mix,
while locking-all won't help you one tiny bit.

Transactions are therefore preferable to the locking-all macro given
above if you don't want to have to reboot the heavily-used server (or
whatever) every few days, or have customers complaining that their
fancy new graphics app hangs now and again, only roughly as often as
Friday the 13ths roll around but sometimes when they have lots of
unsaved work.

Another reason to prefer transactions, of course, is that they support
commute and a form of read/write locking that permits many readers
(even in transactions, when not ensured). These may make transactions
actually more performant in some cases, as well as absolutely
deadlock-proof in every case.

-- 
Protege: What is this seething mass of parentheses?!
Master: Your father's Lisp REPL. This is the language of a true
hacker. Not as clumsy or random as C++; a language for a more
civilized age.

-- 
You received this message because you are subscribed to the Google
Groups "Clojure" group.
To post to this group, send email to clojure@googlegroups.com
Note that posts from new members are moderated - please be patient with your 
first post.
To unsubscribe from this group, send email to
clojure+unsubscr...@googlegroups.com
For more options, visit this group at
http://groups.google.com/group/clojure?hl=en

Reply via email to