Clojure软件事务存储器

banq 18-09-15
              

多核或多CPU使得并发的要求更加迫切,传统使用锁来管理并发,遗憾的是已被证明不太理想,因为它们经常导致死锁、饥饿、竞争和容易出错。在这篇文章中,我们将探讨如何利用Clojure的软件事务存储器(STM)来更好地利用现代硬件。

Clojure的方式
并发难题的核心是状态问题,实质上,大多数编程语言都会使用多线程共享数据更新的方式管理状态。

Clojure提供不可变数据结构和语言级语义来解决这个状态的并发更新问题,通过对状态的托管引用实现安全并发​​,这是由称为软件事务内存(STM)的控制机制提供的。

STM是一种软件级设计,用于控制对内存中共享数据的访问,它的工作方式类似于数据库事务(但限于控制对内存数据的访问,而不是持久的数据存储)。

STM事务确保以原子方式完成状态更改 ,与事务关联的所有更改,一旦过程中有失败,则全部回滚已经做出的更改,此外,必须实现一致地进行更改,这意味着如果更改无法满足某些指定的约束,则事务也会失败,最后,更改必须以隔离的方式发生,对事务中的数据所做的所有更改仅对当前的线程是可见的。

Clojure STM使用多版本控制并发,它在事务开始之前获取数据的快照,对快照所做的更改对于外部世界(其他线程)是不可见的,直到提交更改成功完成事务。

Clojure提供了ref(reference)结构,用于管理允许同步和
协调更改的数据,对refs的所有修改必须在STM事务中发生,该事务是使用dosync语法指定的。

银行业务情景
让我们考虑典型的银行账户处理方案,在一个帐户甚至多个帐户上进行大量交易。但是,该帐户预计必须在所有这些交易中保持一致的平衡。

让我们创建一个函数,使用ref创建一个帐户,指定帐户名、帐号和初始起始余额。

(defn create-account [ account-name account-number balance ]
( ref { :account-name account-name
:account-number account-number
:balance balance
} :validator allowable-balance? ) )

(defn allowable-balance? [ { :keys [ balance ]}]
( or ( > balance 0 )
(throw (IllegalStateException. "Balance cannot be less than zero" ) )
))

此函数返回一个ref,它表示调用时的帐户。我们还添加了
验证器功能,以确保帐户余额始终大于零。(毕竟我们是银行。)

现在,让我们创建三个帐号。

(def first-account ( create-account "Robert" 300045 120 ) )
( def second-account (create-account
"Mike" 30046 500 ) )
( def third-account (create-account
"Rose" 30047 200 ) )

这将分别创建具有指定详细信息的三个帐户引用,现在,假设Mike决定做慈善事业并指示银行从他的账户转账到Robert账户200美元。

要做到这一点,必须做两件事:首先,我们需要将Mike的账户扣除200美元,然后将其存入Robert的账户。对于外界来说,这两个操作必须同时发生(原子),转移需要以这种一种方式进行:即从任何外部观察者的角度来看,被转移的资金一次只能在一个账户中。

为此,Clojure要求使用dosync在事务中实现此操作。

让我们定义一个名为make-transfer的函数,它将指定数量的资金从一个帐户转移到另一个帐户。


(defn make-transfer [ from-account to-account transfer-amount ]
(dosync
(alter from-account update-in [ :balance ] - transfer-amount )
(alter to-account update-in [ :balance ] + transfer-amount )))

现在,让我们发出转移:

(transfer second-account first-account 200 )
(println "first account -> " @first-account "Second account -> " @second-account )


这是我屏幕上显示的输出。

first account -> {:account-name Robert, :account-number 300045, :balance 320} Second account ->
{:account-name Mike, :account-number 30046, :balance 300}


但是,下面这样的转移失败了:

first account -> {:account-name Robert, :account-number 300045, :balance 320} Second account ->
{:account-name Mike, :account-number 30046, :balance 300}

出现错误:
CompilerException java.lang.IllegalStateException: Balance cannot be less than zero, compiling:(~/clojure/concurrency.clj:44:1)

我们可以在不同的线程上执行这两个操作,如下所示:

(future (make-transfer second-account first-account 100 ) )
(future (make-transfer second-account third-account 600) )


理解alter函数
alter函数用于原子地更新一个Ref对象,它由提供的ref和一个函数来调用,该函数将ref作为参数并返回一个值,返回值将用作ref的新值。因为Clojure允许在多个线程中同时执行多个事务,所以传给alter函数的ref的快照值如果与ref的当前值相同时才允许事务提交,否则所有更改都将是丢弃,并使用ref的最新值重试事务。这种无锁方法允许线程自由执行事务而不会被阻塞,包括那些只能读取的事务。

commute函数
为了限制alter function在提交时需要的重试次数,可以在函数应用程序的顺序无关紧要的情况下使用commute函数,如果在两个事务结束时,哪个线程是否首先提交本身无所谓,可以采用这个函数。

因此,银行转账的顺序并不重要的情况下,可以像下面这样实施转移功能:

(defn make-transfer-2 [ from-account to-account transfer-amount ]
(dosync
(commute from-account update-in [ :balance ] - transfer-amount )
(commute to-account update-in [ :balance ] + transfer-amount )
))


结论
Clojure是一种动态类型的函数式语言,它的设计考虑了并发性。

Clojure Software Transactional Memory (STM) · Swee