夢とガラクタの集積場

落ちこぼれ三流エンジニアである管理人の夢想=『夢』と、潰えた夢=『ガラクタ』の集積場です。

Clojure勉強日記(その21 varを使用したスレッドローカルな状態管理

こんにちは。

STMの機構を用いた状態更新が完了し、次はvarの確認に入ります。
defやdefnの呼び出しは、メタデータ:dynamicが与えられると動的なvarになるとのことです。・・・わかりませんね(汗
なので、やはりこちらも一度書いて試してみます。

ルート束縛とletとbinding

^:dynamicという形で動的にルート変数を束縛することが可能になります。
別スレッドからも参照可能です。

user=> (def ^:dynamic foo 10)
#'user/foo
user=> foo
10
user=> (.start (Thread. (fn [] (println foo))))
nil
10

ここでルート束縛でないbindingが登場します。bindingマクロを使うことでスレッドローカルな束縛を持つことが可能になります。
letと似ていますが、違う点もあります。

; 束縛「bar」を定義した上で、barを出力する関数を定義
user=> (def ^:dynamic bar 10)
#'user/bar
user=> (defn print-bar [] (println bar))
#'user/print-bar
user=> (print-bar)
10
; letを用いて束縛。letを記述したコード内で参照可能
user=> (let [bar 50] bar)
50
; letを用いて束縛。「letを記述したコード内から呼び出したコード」ではletは適用されない。
user=> (let [bar 50] (print-bar))
10
nil
; bindingを用いて束縛。bindingを記述したコード内で参照可能
user=> (binding [bar 50] bar)
50
; bindingを用いて束縛。「bindingを記述したコード内から呼び出したコード」ではbindingが適用される。
user=> (binding [bar 50] (print-bar))
50
nil

letが定義したコード内でしか動作しないのに対して、
bindingは「定義したコード内から呼び出した処理」においても適用されます。
REPLは何か記述する度にスレッドが起動して動作するそうなので、REPLから起動した場合はその処理が終了するまで・・・となりますね。

離れた所から影響を及ぼすためには

動的な束縛を持たせるためのvar・・・システムの共通変数みたいなものでしょうか?はスペシャル変数と呼ばれるそうです。
スペシャル変数はClojureでは慣習的に「*debug*」のようにアスタリスクをつけるそうです。

こういった形で共通的に何かを行う機構としてはJavaでは
システムにおけるCrossCuttingConcernへの対処のようにAOPを使うケースが多いです。

ですが、Clojureはそういった機能は「動的束縛の2次的な作用に過ぎない」のだそうです。
とりあえずその想定例としてSleepを内部で行い、動作が遅い関数を作ってみます。

user=> (defn ^:dynamic sleep-double [n]
  (Thread/sleep 500)
  (* n 2))
#'user/sleep-double
; 時間がかかる乗算関数 
user=> (defn call-sleep-double []
  (map sleep-double [1 2 1 2 1 2]))
#'user/call-sleep-double
user=>  (time (doall (call-sleep-double)))
"Elapsed time: 2999.709249 msecs"
(2 4 2 4 2 4)
; 実際に実行してみると3秒程かかる。

コードを見ると、call-sleep-doubleが遅いのは明白で、「同じ計算を2回やっているから」です。
・・sleep-doubleはそれが前提になっているのでまぁ気にしない方向で^^;
これは「メモ化」によって解決できます。・・・以前の再帰でも言葉だけは触れましたね。
以下のようにClojureには関数を受け取ってメモ化したバージョンを返すmemorizeが存在します。

user=> (doc memoize)
-------------------------
clojure.core/memoize
([f])
  Returns a memoized version of a referentially transparent function. The
  memoized version of the function keeps a cache of the mapping from arguments
  to results and, when calls with the same arguments are repeated often, has
  higher performance at the expense of higher memory use.

docにもあるとおり、メモ化することで「引数と結果のマップを保持」し、
同じ引数での呼びだしが発生した際に結果を返すことで処理速度を向上させるというものです。

というところでメモ化したいのですが、
「call-sleep-double」関数は既に遅い関数sleep-doubleを直接呼び出してしまっています。
・・・そんな状況で、動的束縛は役に立つそうです。

実際にやってみます。

; bindingでsleep-doubleをメモ化版sleep-doubleで置き換えて実行する関数 
user=> (defn demo-memoize [] (time (dorun (binding [sleep-double (memoize sleep-double)] (call-sleep-double)))))
#'user/demo-memoize
user=> (demo-memoize)
"Elapsed time: 1000.279882 msecs"
nil
; 約1秒。つまりsleep部分が2回しか実行されていない結果になっている。

こういった形で、動的束縛で関数を入れ替えることで既存の関数に対してメモ化を適用することが可能であることを確認しました。
ただし、これは関数を再束縛することで他の関数(ここでは「call-sleep-double」の動作を元々の関数が知らないまま変えてしまったということでもあります。
うまく使えば便利ですが、逆に危険にもなります。
・・・そのあたり、letが記述したコード内のものしか置き換えられない所以なのかもしれませんね。

JavaコールバックAPIを使う

JavaAPIにはイベントハンドラをコールバックするように設計されたものがあります。
例えば、Swingのボタンを押したときのイベントや、SAXパーサの特定のタグを見つけた時のイベントなど。

Clojureでこれと同様の動作を実現するためには動的束縛を使う、とのことです。

Clojureはスレッドローカル束縛を変更する「set!」を持っています。

user=> (doc set!)
-------------------------
set!
  (set! var-symbol expr)
  (set! (. instance-expr instanceFieldName-symbol) expr)
  (set! (. Classname-symbol staticFieldName-symbol) expr)
Special Form
  Used to set thread-local-bound vars, Java object instance
fields, and Java class static fields.

・・・なのですが、紹介後set!の使用はできるだけ避けるべきと書かれているあたり、かなり影響度の大きい関数であるのは間違いなさそうですね。
実際にClojureのコアソースの中でもSAXパーサ位でしか使用されていないようです。

ClojureXMLをパースする・・・と考えますと、
「現在位置」は変更不可なXMLストリーム上の特定の場所をさす変更可能なポインタとなります。
その上で、Clojureのソースは以下のようになっています。

           (startElement [uri local-name q-name ^Attributes atts]
             (let [attrs (fn [ret i]
                           (if (neg? i)
                             ret
                             (recur (assoc ret
                                           (clojure.lang.Keyword/intern (symbol (.getQName atts i)))
                                           (.getValue atts (int i)))
                                    (dec i))))
                   e (struct element
                             (. clojure.lang.Keyword (intern (symbol q-name)))
                             (when (pos? (.getLength atts))
                               (attrs {} (dec (.getLength atts)))))]
               (push-chars)
               (set! *stack* (conj *stack* *current*))
               (set! *current* e)
               (set! *state* :element))
             nil)
           (endElement [uri local-name q-name]
             (push-chars)
             (set! *current* (push-content (peek *stack*) *current*))
             (set! *stack* (pop *stack*))
             (set! *state* :between)
             nil)

XMLの開始タグに出会うとstartElementが呼び出されます。
それによって以下の変数が更新されます。

  • 全ての要素のスタック:*stack*
  • 現在の要素:*current*
  • 現在がタグの中にいるという状態:*state*

endElementにおいては以下のように変数が更新されます。

  • 全ての要素のスタックからpopして現在見ているタグを除去:*stack*
  • 先頭要素:*current*
  • 現在がタグの間にあるという状態:*state*

JavaとのAPI上に記述するとどうしても「オブジェクトは変更可能」「プログラムは並行動作のことは考えずに一本道で書かれる」となり、
Clojureもそれに縛られるようです。

set!でスレッドローカルな束縛を更新することで、
ClojureのSAXパーサはその外に影響を出さないようにしています。
その上で、ClojureはSAXパーサの上に変更不可なClojureの構造体を作り、それで並列化を可能にしているようです。

とりあえず、同期の仕組みについて最終的にまとめると以下のようになりました。

モデル 用途 更新中に使える関数
refとSTM 協調的、同期的な更新 副作用なし
アトム 非協調的、同期的な更新 副作用なし
エージェント 非協調的、非同期的な更新 すべて
var スレッドローカルな動的スコープ すべて
Javaのロック 協調的、同期的な更新 すべて

・・・と、後は実際これを使って何かを作ってみる形になりますね。