所谓状态,就是在某个时间点上一个标识所代表的值。
Clojure 的引用模型把标识和值清晰地区分开来。在 Clojure 中,几乎所有的东西都是值。为了加以标识,Clojure提供了四种引用类型。
- 引用(Ref),负责协同地、同步地更改共享状态。
- 原子(Atom),负责非协同地、同步地更改共享状态。
- 代理(Agent),负责异步地更改共享状态。
- 变量(Var),负责线程内的状态。
应用与软事务内存
Clojure 中的大多数对象都是不可变的。当你真的想要可变数据时,你必须明确地表示出来。比如说,你可以像下面这样创建一个可变的引用(ref),让它指向不可变对象。
(ref initial-state)
写个例子:
user=> (def s (ref "hahaha"))
#'user/s
引用包装并保护了对其内部状态的访问。要读取引用的内容,你可以调用deref。
(deref reference)
deref函数可以缩写为读取器宏@。
user=> (deref s)
"hahaha"
user=> @s
"hahaha"
ref-set
你可以使用ref-set来改变一个引用所指向的位置。
(ref-set reference new-value)
因为引用是可变的,你必须在更新它们时施以保护。在Clojure中,你可以使用事务。事务被包裹在dosync之中。
(dosync& exprs)
修改前面的引用。
user=> (dosync (ref-set s "heihei"))
"heihei"
事务的属性
和数据库事务一样,STM事务也具有一些重要的性质。
- 更新是原子的(atomic)。如果你在一个事务中更新了多个引用,所有这些更新的累积效果,在事务外部看来,就好像是在一个瞬间发生的。
- 更新是一致的(consistent)。可以为引用指定验证函数。如果这些函数中的任何一个失败了,整个事务都将失败。
- 更新是隔离的(isolated)。运行中的事务,无法看到来自于其他事务的局部完成结果。
alter
Clojure的alter能在事务中对引用对象应用一个更新函数。
(alter ref update-fn & args...)
alter会返回这个引用在事务中的新值。当事务成功完成后,引用将获得它在事务中的最后一个值。用alter来替代ref-set能使代码更具可读性。
看个简单的例子。
(def messages (ref ()))
(defn add-message [msg]
(dosync (alter messages conj msg)))
(add-message "abc")
(add-message "123")
(println @messages)
输出如下:
(123 abc)
注意这里的更新函数用了conj,alter函数调用它的update-fn时,把当前引用的值作为其第一个参数,这正是conj所期望的。
STM工作原理:MVCC
Clojure 的 STM 采用了一种名为多版本并发控制(Multiversion Concurrency Control,MVCC)的技术,这种技术也被用在了几个主要的数据库中。
下面说明了在Clojure中,MVCC是如何运作的。
事务 A 启动时会获取一个“起始点”,这个起始点就是一个简单的数字,被当作STM世界中的唯一时间戳。在事务A中访问任何一个引用,实际上访问的是这个引用与起始点相关的一份高效副本。Clojure 的持久性数据结构使得提供这些高效的私有副本相当廉价。
在事务A中,对引用进行操作时依赖(以及返回)的这个私有副本的值,被称为事务内的值。在任意时间点,如果STM检测到其他事务设置或更改了某个引用,而事务A正好也想要设置或更改,那么事务A将被迫重来。如果你在dosync块中抛出了一个异常,那么事务A会终止,而非重试。
事务A一旦提交,它一直以来那些私有的写操作就会暴露给外部世界,而且是在这个事务时间轴的一个点上瞬间发生的。
commute
commute是一种特殊的alter变体,允许更多并发。
(commute ref update-fn & args...)
当然,这需要进行权衡。之所以名为 commute ,是因为它们必须是可交换的commutative)。也就是说,更新操作必须能以任何的次序出现。这就赋予了 STM 系统对commute重新排序的自由。
使用原子进行非协同、同步的更新
相比引用,原子是一种更加轻量级的机制。在事务中对多个引用进行更新会被协同,而原子则允许更新单个的值,不与其他的任何事物协同。
你可以使用atom来创建原子,它的函数签名与ref非常类似。
(atom initial-state options?)
; options包括:
; :validator一个验证函数
; :meta一个元数据映射表
创建一个原子。
user=> (def s (atom "haha"))
#'user/s
对原子解引用就可以得到它的值,这和引用是一样的。
user=> @s
"haha"
原子并不参与事务,因而不需要dosync。要为一个原子设置值,简单的调用reset!即可。
(reset! an-atom newval)
修改上面的原子。
user=> (reset! s "ddd")
"ddd"
使用代理进行异步更新
有的应用程序会有这样一些任务,任务之间只需要很少地协同就能彼此独立进行。Clojure提供了代理来支持这种风格的任务。
代理和引用有很多共同点。和引用一样,你可以通过包装初始状态来创建代理。
(agent initial-state)
下面创建了一个计数器的代理,并把初始计数值设置为0。
user=> (def counter (agent 0))
#'user/counter
一旦得到了一个代理,你就可以向它send一个函数,来更新其状态。send把函数update-fn放进线程池里的某个线程中开始排队,等待随后执行。
(send agent update-fn & args)
向代理进行发送,和对引用进行交换非常相像。下面告诉计数器counter,准备好要自增(inc)了。
user=> (send counter inc)
#object[clojure.lang.Agent 0x4a11eb84 {:status :ready, :val 1}]
调用send不会返回代理的新值,而是返回了代理本身。
就像引用一样,你可以用deref或是@来检查代理当前的值。
user=> @counter
1
如果你希望确保代理已经完成了你发送给他的动作,你可以调用await或者await-for。
(await & agents)
(await-for timeout-millis & agents)
这两个函数会导致当前线程阻塞,直到所有发自当前线程或代理的动作全部完成。如果超过了超时时间,await-for会返回空,否则会返回一个非空值。await没有超时时间,所以一定要小心:await愿意永远等下去。
统一的更新模型
引用、原子和代理都提供了基于它们当前的状态,通过应用其他函数来更新这些状态的函数。
更新机制 | 引用函数 | 原子函数 | 代理函数 |
---|---|---|---|
应用函数 | alter | swap! | send-off |
函数(交换) | commute | 不适用 | 不适用 |
函数(非阻塞) | 不适用 | 不适用 | send |
简单设置 | ref-set | reset! | 不适用 |
用变量管理线程内状态
大多数变量都甘愿保持它们的根绑定永不改变。然而,你可以借助binding宏,为一个变量创建线程内的绑定。
(binding [bindings] & body)
绑定具有动态范围。换句话说,在binding创建的这个范围内,线程执行过程中需要经过的任何地方,绑定都是可见的,直到该线程离开了该范围。同时对于其他线程而言,绑定也是不可见的。
首先需要声明一个动态变量。
user=> (def ^:dynamic foo 10)
#'user/foo
在结构上,binding与let看起来非常相像。下面为foo创建一个线程内绑定,并检查它的值。
user=> (binding [foo 2] foo)
2