Clojure 与并发性

了解 Clojure 的四种并发模型

Clojure 编程语言最近受到广泛关注。然而,这些关注并非出于一些显而易见的原因,比如因为它是现在的 Lisp 语言的继承,或是因为它运行在 Java™ 虚拟机上。它的最吸引人之处是其并发特性。可能 Clojure 已广为人知,主要是因为它本身支持 Software Transactional Memory (STM) 模型。然而,STM 并不是解决所有并发问题的最佳方案。Clojure 能够以 agents 和 atoms 的形式支持其他范例。本文考查 Clojure 所提供的每种并发方法,并研究每种方法的适用情况。

Michael Galpin, 软件架构师, eBay

Michael_Galpin 的照片 Galpin 是 eBay 的一名架构师,经常为 developerWorks 撰写文章。他曾在各类技术会议上发表演说,这些会议包括 JavaOne、EclipseCon 和 AjaxWorld 等。要了解 Michael 的工作进展,请您在 Twitter 上跟随 @michaelg 。



2011 年 9 月 05 日

入门

本文将查看 Clojure 编程语言及其并发特性。本文并不是要介绍 Clojure ,因此假定读者已对 Clojure 比较熟悉。要运行这些例子,需要 Clojure 1.1 ,该版本需要 Java 1.5 或更高版本。本文采用了 Java 1.6.0_20,参见 参考资料 中这些工具的链接。可以从后面的 下载表格 中下载本文的源代码。


并集(union)的并发状态

多年来,软件开发人员早已了解到并发编程如何成为实际的编程方法。这一情况的主要原因是计算机处理器的速度增长趋缓,而每台计算机中处理器的数量却在不断增加。从每个芯片上的这些附加处理器可以看出,摩尔定律的确还在起作用。在 Wikipedia 中对此进行了很好的总结(见 参考资料 中的相关链接):

“最近并行计算成为利用摩尔定律支持的收益所不可或缺的技术。多年来,处理器制造厂商在不断提高时钟速度和指令级并行性,因此单线程代码不需要修改就可在新的处理器上更快运行。当前,为了管理 CPU 性能损耗,处理器生产厂商倾向于采用多核芯片设计,并且软件必须以多线程或者多进程的方式来编写,从而发挥硬件的最大优势。”

以上段落提倡将本文的内容付诸行动。然而,这类话语已经流行了多年,可是很多开发人员仍然乐于编写单线程代码。重要原因之一是互联网的盛行。有大量的新应用是 web 应用。服务器端 web 应用开发通常为单线程编程。web 服务器利用服务器的多个内核来同时处理来自用户的多个请求,但是每个这样的请求通常都由单线程代码处理。这是件好事,也是 web 应用获得成功的原因之一。因此,对于许多开发人员,用户笔记本电脑和台式机中的多个内核经常无法发挥作用。

web 的成功并非缺少并发编程的惟一原因。事实上,如果研究一下 web 应用开发及其历史,您就会注意到这对于开发人员来说已变得多么的容易。从 PHP 和 JSP 到 Ruby on Rails,web 开发已变得很简单,并使得开发人员能够在 web 上创造越来越多的奇迹。并发编程的情况正好与此相反。在最流行的编程语言(比如 C++ 和 Java)中,并发编程的构造(线程、锁)几十年来没有变化。并发编程总是很有难度,而且一直会很难。因此,很多人都尽量不使用。很多公司里只有一两个大家所公认的、可以进行任何类型并发编程的大师。

在这里新的、更现代的编程语言开始发挥作用,Clojure 就是个最好的例子。它在很低级别上将并发构建到其中。您不必处理线程和锁。相反,有更简单、问题更少的模型供您使用。您可将注意力放到应用逻辑中,而不必担心会产生死锁而导致系统突然宕机。让我们来看一下构建在 Clojure 中的并发构造。


Clojure 的并发特色

如前面所提到的,最流行的编程语言能提供一些基本的并发特性:线程。例如,Java 5 和 6 引入大量用于并发功能的新的实用 API,但是其中大部分要么构建在线程和锁之上,比如线程池和各类锁;要么其数据结构具有更优的并发/性能特征。设计并发编程的基础没有变化。您仍然需要解决相同的难题,而且解决方案会不可靠。只是不需要编写一些样板代码。

Clojure 在各个方面都有根本的不同。它不提供通常的基元、线程和锁。相反,它提供了与线程和锁无关的、完全不同的并发编程模型。请注意单词模型 —— 为复数形式。Clojure 具有四个并发模型。其中每个模型都可看做是基于线程和锁的抽象。我们来看一下每个并发模型,从最简单的开始:vars

线程本地 vars

最简单的 Clojure 并发模型是 vars。Vars 仅是变量及其值的声明。 清单 1 展示了在 Clojure 中使用 vars 的简单例子。

清单 1. Clojure vars
1:1 user=> (defstruct item :title :current-price)
#'user/item
1:2 user=> (defstruct bid :user :amount)
#'user/bid
1:3 user=> (def history ())
#'user/history
1:4 user=> (def droid (struct item "Droid X" 0))
#'user/droid
1:5 user=> (defn place-offer [offer] 
  (binding [history (cons offer history) 
  droid (assoc droid :current-price (get offer :amount))] 
    (println droid history)))
#'user/place-offer
1:9 user=> (place-offer {:user "Anthony" :amount 10})
{:title Droid X, :current-price 10} ({:user Anthony, :amount 10})
nil
1:17 user=>  (println droid) ;there should be no change
{:title Droid X, :current-price 0}
nil

在清单 1 中首先声明了一对数据结构,itembid。接下来,创建一个名为 historyvar,它只是一个空列表,然后是名为 droidvar ,它是个项。然后,创建名为 place-offer 的函数。它接受一个 bid,并变更 droidcurrent-price ,再将 bid 添加到 history 。注意,这里的操作需要用到绑定宏。这改变了 var 的线程本地值。因此,在 place-offer 函数的执行范围内 droidhistory 所指向的值将不同。然而,在执行之外,该值没有变化。要记得,默认情况下在 Clojure 中所有事物都是不可变的(immutable)。绑定 vars 允许在线程本地范围内对事物进行变更。如果任何其他线程想要读取该值,将看不到任何变化。对于仅需要改变状态并将其作为执行离散任务的一部分时,这是完成任务的简单方法。如果想以其他线程可见的方式来变更状态,可能需要采用 Clojure 的 atoms

简单、同步 atoms

atom 是状态可变更的变量。其使用非常简单并且完全同步。换句话说,如果调用了改变 atom 值的函数,那么当该函数结果返回时, 可以确保所有的线程都能看到新值。清单 2 展示了使用 atoms 的例子。

清单 2. Clojure atoms
1:21 user=> (def droid (atom (struct item "Droid X" 0)))
#'user/droid
1:22 user=> (def history (atom ()))
#'user/history
1:28 user=> (defn place-offer [offer] 
  (reset! droid (assoc @droid :current-price (get offer :amount))))
#'user/place-offer
1:33 user=> (place-offer {:user "Anthony" :amount 10})
{:title "Droid X", :current-price 10}
1:36 user=> (println @droid)
{:title Droid X, :current-price 10}
nil

此代码基于前面 清单 1 中的例子。此次,利用函数 atomdroidhistory 作为 atoms 进行重定义。利用 atom 函数,可获得作为初始值包装的 atom 对象。现在,在新的 place-offer 函数中,可利用 reset! 函数来改变 droid 的值。注意,在 droidhistory 的前面加上 @ 符号。这告知 Clojure 取消对指针的引用,并给出真实值。接下来,调用新的 place-offer 函数,之后可以打印 droid,可以看到值确实改变了。注意,在 place-offer 中,仅改变了一个 atomdroid。没有改变 historyatom。当然也可以采用 reset。然而,不确保两个变更都可见。换句话说,一个线程有可能看到 droid 值的变更,但看不到 history 值的变更。想获取此类一致性,需要协调。需要事务。需要 refs

事务性 refs

Clojure 的 refs 提供了其最强大的并发特色。那就是 Clojure 的 Software Transactional Memory (STM) 实现。Refs 与 atoms 类似。与 atoms 对比,它通常只需要一行额外的代码,其主要优势是协调。利用 refs,可在单个事务中改变多个对象的状态。该事务将具有原子性、一致性、并与 ACID 的 ACI 隔离(由于全在内存中,因此不具有持久性)。这一隔离属性意味着任何观察者将会要么见到事务中的所有变更,要么一个也见不到。atoms 不会出现这种情况。清单 3 展示了使用 refs 的例子。

清单 3. Clojure refs
1:90 user=> (def droid (ref (struct item "Droid X" 0)))
#'user/droid
1:91 user=> (def history (ref ()))
#'user/history
1:92 user=> (defn place-offer [offer] 
  (dosync
    (ref-set droid (assoc @droid :current-price (get offer :amount)))
    (ref-set history (cons offer @history))    
    ))
1:97 user=> (place-offer {:user "Tony" :amount 22})
({:user "Tony", :amount 22})
1:99 user=> (println @droid @history)
{:title Droid X, :current-price 22} ({:user Tony, :amount 22})
nil

清单 3 中的代码与 清单 2 中的很类似。Refs 遵循与 atoms 相同的包装模式。place-offer 函数的执行从调用 dosync 开始。此函数包装一个事务。它提供前面所提到的协调。它允许同时改变 droidhistory,并了解不会有任何对数据的直接读取。如同 atoms 一样,您可以取消引用,并在执行完函数后打印该值,验证该值已经改变。

此时,您可能想知道 STM 在此处到底是如何工作的。如果一个线程利用数量 25 来调用 place-offer 函数,同时,另一个线程利用数量 22 调用同一函数,将会发生什么情况?Clojure 会确保在事务中间该值没有发生改变,因此,如果事务到达了 dosync 块的结尾,并且,STM 可看到在当前事务启动以来有另一事务已经完成了,那么当前事务将回滚并再次运行。这使得,仅纯函数 —— 也就是不具有副作用的函数 —— 才能作为事务的一部分,因为函数可能将被运行多次。Clojure 采用性能非常高的永久数据结构,来确保此类事务/回滚的效率。

如果想确保在新的提供值的数量比原有提供值更高时,只接受新的提供值,那么仅需为 ref 声明添加验证函数。在本例中,如果在事务中间检测到变更,它将回滚并重新启动。现在,如果验证检测失败,那么事务将被舍弃。

要使用 Clojure 的 STM,关键是包装 dosync 函数内部的对象。精明的观察者可能会指出,这与同步块内部的包装代码,或者与获取/释放流内部的代码很相似,当然,这些传统的并发机制非常有难度。Clojure 更加直截了当。如果想变更状态,那么必须使用 dosync。无法在 dosync 的外部改变 ref 的状态。另外,可以组建 Clojure 的事务。可在 dosync 块中调用具有 dosync 块的另一函数。不必弄清由函数所共享的、某些类型的锁。也不必担心死锁。Refs 和 atoms 都是同步函数。如果不必同步变更状态,那么,agents 将提供一些优势。

简单、异步 agents

您经常需要变更状态,但是不必等待其变更,或者如果可对多个线程进行变更时,不必关心其变更的顺序。这是个通用的模式,并且 Clojure 提供了编程模型来予以解决:agents。清单 4 展示了使用 agents 的例子。

清单 4. Clojure agents
1:100 user=> (def history (agent ()))
#'user/history
1:101 user=> (def droid (agent (struct item "Droid X" 0)))
#'user/droid
nil
1:107 user=> (defn place-offer [offer]
  (send droid #(assoc % :current-price (get offer :amount))))
1:110 user=>  (place-offer {:user "Tony" :amount 33})
#<Agent@396477d9: {:title "Droid X", :current-price 0}>
1:111 user=> (await droid)
nil
1:112 user=> (println @droid)
{:title Droid X, :current-price 33}
nil

再次利用 agent 函数包装 droidhistory 的初始值。然后定义新版的 place-offer 。此时,不能仅直接变更 agents 后的值。相反,可以使用 send 函数。此函数将 agent 和另一函数作为其参数。第二个函数是适用于 agent 值的其他函数。产生的值将用于替代 agent 的值。在清单 4 中,将传递给 send 一个匿名函数。需要注意,atoms 和 refs 也都支持此类语义,此处一个函数将被传递并用于更新状态。接下来,使用 await 函数。这将阻止线程,直到 agent 执行了发送给它的函数。这是确保所要进行的变更确实已经执行的好办法。否则,agents 的异步性将意味着无法确保所发送的函数是否已得到应用。


结束语

本文展示了 Clojure 的每个并发模型。这里列出了很多不同的并发问题,但很多问题将很好地对应到 Clojure 的模型之一。在这样的事例中,利用 Clojure 的功能,可以轻松解决这一问题。在无法准确对应问题时,可凭借 Clojure 与 Java 的互操作性,并使用 Java 的线程和锁。这就使得,在应对任何大量依赖并发性的任务时,人们首先会想到 Clojure 语言。


下载

描述名字大小
文章源代码auctions.clj.zip1KB

参考资料

学习

  • 学习来自 Wikipedia 的 Moore's law
  • Clojure 编程语言(Michael Galpin, developerWorks, 2009 年 9 月):此文章对 Clojure 进行了介绍。
  • 查看 clojure-contrib 中由 Clojure 社区创建的并被多个 Clojure 项目所使用的基础库。该库默认包含在 Eclipse 插件中。
  • 从 Clojure 的初学者成长为相关专家的最好办法是,阅读 Stuart Halloway 的 Programming Clojure
  • Beginning Haskell(David Mertz, developerWorks, 2001 年 12 月): 这是介绍另一函数式语言的教程。
  • developerWorks developerWorks 中国网站 Web 开发专区 主要发布各种基于 web 解决方案的文章。
  • 查看 HTML5 专题,了解更多和 HTML5 相关的知识和动向。

获得产品和技术

  • 访问 Clojure site 来下载 Clojure,阅读教程,并访问参考文档。
  • 获取 Java SDK。 在此文章中采用了 JDK 1.6.0_17 。
  • 下载 IBM 产品评估试用版软件,并开始使用来自 DB2®、Lotus®、Rational®、Tivoli® 和 WebSphere® 的应用程序开发工具和中间件产品。

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Web development, Java technology
ArticleID=755704
ArticleTitle=Clojure 与并发性
publish-date=09052011