dRuby 的机制与实现

jopen 8年前

dRuby 的机制与实现

面向对象的脚本语言 Ruby 由于它代码容易编写并且灵活,动态的特性被众多程序员喜爱。过去的几年里,Ruby onRails 的 web 开发框架非常流行,这是得益于它的开发效率,而且再次引起了企业对 Ruby 的关注。Ruby 的焦点已经开始从小的脚本工具向大型应用程序转移,针对于 Ruby 的分布式系统的需求和 Ruby 一般用法的例子与特性的相关教程也正在与日俱增。一本由日本作者写的关于 dRuby 的书,其英译版正在由 Pragmatic Bookshelf 计划出版。

作者开发 dRuby 和 Rinda 是为 Ruby 语言提供分布式对象系统和共享元组空间功能, 并且也作为 Ruby 标准库的一部分。dRuby 通过网络扩展了 Ruby 同时还保留了其优点。Rinda 在 dRuby 的基础上构建,并且带有 Linda 的功能,是作为 Ruby 与分布式协作系统通信的中间语言,这篇文章讨论了这两个系统的设计原则和实现要点,并且用示例代码和实际应用程序的例子来展示它们的简单易用性。dRuby 和 Rinda 除了可以做分布式系统,这篇文章还会给你展示它们可以作为现实中应用程序的底层组件。

1. 介绍

这篇文章的目标是给你一个关于 dRuby 和 Rinda 的细节。dRuby 为 Ruby 提供分布的对象环境。Rinda 是一个运行在 dRuby 上的协调机制。首先,我来介绍 Ruby。

Ruby 是一个面向对象的脚本语言,它是由松本行弘(Yukihiro Matsumoto)创造。

直到现在,Ruby 的流行被限制在早期采用的程序员社区中。但是,Ruby 因为其潜在的生产力,现在正在快速引得商业用户的注意。

Ruby 有如下特性:

  • 标准的面向对象特性,比如类与方法

  • 一切皆为对象

  • 弱类型变量

  • 简捷方便的使用库

  • 简单易学的语法

  • 垃圾回收器

  • 丰富的反射功能

  • 用户级线程

Ruby 号称是动态的面向对象语言。一切都由对象构成,并且无强类型变量。方法统一在运行时创建。此外,Ruby 还有丰富的反射功能,并且可以使用元编程。

Ruby 是一门难解的语言,好像是老天对 Ruby 开了个玩笑。就像 Ruby 在主导着我们进行编程一样,在我们没有得到明示的情况下就觉得已经触及了面向对象的本质。

dRuby 是一个使 Ruby 运行于其上的分布式系统环境。dRuby 通过网络扩展了 Ruby 的方法调用,并且使得其他进程或者机器也可以调用当前对象的方法。另一个就是 Rinda 在它自己内部加入了 Linda 的实现,所以它提供了公共的元组空间。这篇文章不但介绍了 dRuby 的基本概念和设计原则,而且还包括 dRuby 的实现和实际用法。这篇文章会讨论如下主题:

  • 在 Ruby 之路 - dRuby 概述与其设计原则

  • 实现 - dRuby 的实现

  • 性能 - dRuby 的使用开销

  • 应用程序 - 一个真正运行在 dRuby 上的系统应用,Rinda 的概述与运行在 Rinda 上的一个系统应用。

Blaine Cook,推ter(一个微博服务)的主要开发者负责人,在他的“"Scaling 推ter”发布会中提到过dRuby。

  • 简单至极,速度够快

  • 些许古怪,无冗余,高耦合


他的观点是合理的看法。

在这章里,我会说一下 dRuby 的设计原则和特性。

2.1 dRuby 的特性

dRuby 是用于 Ruby 的 RMI(远程方法调用)库的其中一个,我的最终目标不是让 dRuby 成为另一个用于 Ruby 的传统的分布式系统。准确来说,我打算扩展 Ruby,使其可以调用其他进程或者别的机器上的 Ruby 方法。

最终,dRuby 扩展了 Ruby 解释器,使其无论在物理环境还是虚拟环境都可以跨进程,跨机器。

dRuby 有如下特性:

  • 专门用于 Ruby

  • 完全纯 Ruby 编写

  • 不需要像 IDL一样的规范

从常规的分布式对象系统的角度来看,dRuby 还有这些特性:

  • 便于安装

  • 易于学习

  • 能自动选择对象的传递策略(传值或者传引用)

  • 足够快速

  • 服务器端与客户端无差别

dRuby 是专门用于 Ruby 上的分布式对象系统,dRuby 运行的平台上可以交换对象,也可以在对象之间调用方法。dRuby 可以不受操作系统影响,运行在任何可以跑 Ruby 的机器上。

dRuby 完全由 Ruby 编写,没有特殊由 C 编写的扩展。感谢 Ruby 优秀的 thread,socket 和 marshaling 类库,早期的 dRuby 实现仅有 200 行代码,我相信这足以展示了 Ruby 类库的强大和优雅。现在 dRuby 包含了 Ruby 的标准发布版作为其标准库之一,所以 dRuby 在任何装了 Ruby 环境的机器上都可以跑起来。

dRuby 特别注意保持与 Ruby 脚本的兼容性。dRuby 向 Ruby 加入分布式对象系统的同时也尽可能多的保留了 Ruby 的原汁原味。Ruby 程序员会发现 dRuby 是 Ruby 的无缝集成扩展。程序员可能习惯于其他的分布式对象系统,而且可能会感觉到 dRuby 有点奇怪。

Ruby 里的变量都是弱类型的, 而且对象赋值不受继承体系限制,不像一些静态检查变量的语言,如java,在代码执行之前不会检查对象的正确性,并且方法定位也只在运行时(方法被调用的时候),这是 Ruby 语言一个重要的特性。

dRuby 也以相同的方式运行。dRuby 中,客户端静态方式 (DRbObject,在 dRuby 也称之为 "reference" ) 也同样是弱类型的,而且方法定位也只在运行时进行。这就不需要提前暴露方法列表或者知道继承信息。 因此,不需要定义一个接口(e.g. by IDL)。

除了允许跨网络方法调用,dRuby 也一直在用心的开发,遵守尽可能接近常规 Ruby 行为的准则。因此,开发者可以尽情的享受大量 Ruby 的独特优势。

例如,方法使用 blocks(原始叫法是迭代器)和异常是可以被处理的,如果是在本地的话。互斥,队列和其他线程同步机制都可以同时被用来进行进程间同步,无需任何特殊考虑。

2.3. 对象传递

只要不是原生存在于 Ruby 里专有的概念,这里都会尽量自然的在 dRuby 的环境下介绍。

对象数据传输是个不错的例子。当方法被调用时,各种对象数据,如方法参数,方法返回值异常对象等数据就会被传输。方法参数从客户端传递到服务器,同时方法返回值和异常对象从服务器返回客户端。在这篇文章里,我把这两种对象传输的方式归类为对象交换。

在 Ruby 中,赋值(或者绑定)给变量总是通过引用的方式。对象克隆绝不不能用赋值。但是,这一切在 dRuby 都不同于 Ruby。在分布式对象的世界里,分辨“传值”与“传引用”是日常不可避免的。对于 dRuby 也是这样的。

可以想象在一个应用程序中,当一个处于引用中的计算模型长时间不断进行对象数据交换(直到它们都变为 nil)。而这时候,就需要“值类型”了。

dRuby 提供的机制会让程序员最小化关注对不同类型对象之间数据交换,同时又能如愿获取到想要的结果。程序员不需要显式指定传值还是传引用。系统会自己决定使用哪一种方式。决定的规则很简单,可序列化的对象就是通过传值,不可序列化的对象就是传引用。

即使这个规则不总是对的,但大多数情况都是正常的。这里我简要的说一下这个规则。首先要明白一点,对象不经过序列化是不能使用传值这种方式的。问题是有的序列化的对象传引用比传值更合适。为解决这个问题,dRuby提供了一种机制,使用这种机制可以使序列化的对象显式的指定以传引用的方式传递。之后会在这篇文章中详述。

dRuby自动选择对象传输方式的意义所在就是最小化手写处理对象传输的代码规模数量。

dRuby几乎不需要接口定义和对象传输形式定义,这并非只是dRuby不同于其他分布式系统的一个方面。这是因为dRuby旨在成为一个“类Ruby的分布式对象系统”,可能也是被当做“kinda flaky”的原因。

2.4. 不支持的特性

接下来我将介绍一些 dRuby 中不支持的特性,叫做垃圾回收和安全机制。

dRuby 没有实现分布式垃圾回收的原因是,我没找到一种合适的廉价解决方案。

现在,由应用程序自己负责防止导出的对象被回收。防止对象被垃圾回收的一个办法是使用已经提供的 ping 机制,但是这会使对象被循环引用导致对象永远不会被回收,针对这个问题可能的解决方案,包括修改 Ruby 解释器,现在正在探索中。


dRuby目前没有提供任何安全机制,最多就是想Ruby那样强制限制方法的可见性。但是这对于恶意攻击于事无补。所以尽可能使用SSL来保证网络通信的安全。

这节我会介绍一下dRuby的设计原则。

总起来说,dRuby扩展了Ruby的方法调用,dRuby不仅仅只是一个类Ruby的RMI接口,dRuby也可以与XML-RPC、SOAP、CORBA等共存。实际上,有些地方使用http作为外网接口而使用dRuby作为其内网的后台。

3. 实现

这个章节我会介绍几个dRuby有趣的特性和其实现。

我会使用dRuby最初版本的代码与其他示例的代码解释基本的RMI和对象传输机制。

3.1. 基本RMI

首先我会用实例代码解释基本的方法调用。下面的代码是经典的共享队列实现的消费者生产者例子。

# shared queue server  require 'thread'  require 'drb/drb'                                     # (1)    queue = SizedQueue.new(10)                            # (2)  DRb.start_service('druby://localhost:9999', queue)    # (3)  sleep                                                 # (4)

首先,我来解释共享队列服务器。

使用dRuby的应用必须通过加载'drb/drb'来启动(1),接下来,一个带有一定数量缓冲区元素的SizedQueue(2)被实例化。这时DRB服务就已经启动了(3)。给定DRb.start_service的对象和服务的URI通过dRuby来公开。在这个例子中,SizedQueue对象在URI"druby://localhost:9999"这里公开。

任何通过dRuby创建的系统总要有一个指定系统入口的对象。这个对象被称做前端对象。


dRuby 的机制与实现

最后服务通过调用sleep停止,不是退出。 即使主线程已经停止,服务也仍然可用,因为它一直在后台线程中运行。

# producer  require 'drb/drb'    DRb.start_service                                            #(1)  queue = DRbObject.new_with_uri('druby://localhost:9999')     #(2)    100.times do |n|    sleep(rand)    queue.push(n)                                              #(3)  end
# consumer  require 'drb/drb'    DRb.start_service  queue = DRbObject.new_with_uri('druby://localhost:9999')    100.times do |n|    sleep(rand)    puts queue.pop                                            #(4)  end

类似客户端的生产者与消费者应用程序也需要调用DRb.start_service (1),一个没有参数的DRb.start_service方法调用说明应用程序没有前端对象。注意,如果应用程序从不导出对象,那就没有必要调用DRb.start_service方法。

接下来,分布式队列的引用对象进行实例化(2)。DRbObjects 是一个远程对象的代理引用。DRbObject 通过一个指定了另一个URI对象引用的URI来实例化。

消息就在这个时候被发送到远程对象中(3)。

这个示例需要准备三个终端才能执行,并且要按顺序在各个终端中运行脚本。不需要其他额外的设置。

% ruby queue.rb
% ruby consumer.rb
% ruby producer.rb

这个例子简单的展示了如何只用几行代码在不同进程之间共享Ruby对象。

用dRuby来写分布式系统很容易,就像使用Ruby写应用程序很容易一样儿。dRuby不仅适于写原型或者学习体系结构的分布式系统,而且这些系统也可以用在产品实现中。

这节里使用第一版的dRuby代码来解释dRuby的RMI实现。第一版的dRuby代码易于理解,不像高版本dRuby那样复杂。

当消息被发送至 DRbObject(远程对象引用)时,消息与接收器标识一起最先到达 dRuby 服务器。dRuby 服务器使用接收器标识查找对象并调用方法。下面一起看一下 dRuby 第一个版本的 DRbObject 类实现。

class DRbObject    def initialize(obj, uri=nil)      @uri = uri || DRb.uri      @ref = obj.id if obj    end      def method_missing(msg_id, *a)      succ, result = DRbConn.new(@uri).send_message(self, msg_id, *a)      raise result if ! succ      result    end      attr :ref  end

DRbObject 类中只定义了一个名为 method_missing 的方法。在 Ruby 中,当接受器对象收到一个未知方法的时候就会调用 method_missing 方法。也就是“一个未知的丢了的方法被调用了”的意思。DRbObject 中的 method_missing 方法连接服务器,并发送相应的接收器的 ID 和调用的方法名,同时返回结果。

换言之,任何没有在 DRbObject 中定义的方法都会被转发到远程对象上去。

就像之前的脚本中的"pop"和"push"方法的例子。这些方法都不会在 DRbObject 中定义, 所以method_missin 就会被调用,同时消息被转发到由初始化时 URI 指定的 dRuby 服务器上。

class DRbServer    ...    def proc      ns = @soc.accept      Thread.start(ns) do |s|        begin          begin             ro, msg, argv = recv_request(s)            if ro and ro.ref              obj = ObjectSpace._id2ref(ro.ref)            else              obj = DRb.front            end            result = obj.__send__(msg.intern, *argv)            succ = true          rescue            result = $!            succ = false          end          send_reply(s, succ, result)        ensure          s.close if s        end      end    end    ...  end

上面的伪代码描述了 dRuby 服务器的主要的行为。

在"accpet"一个 socket (套接字)之后,创建一个新的线程接收对象标识符,消息还有参数。通过标识符找到对应的对象之后,所有接收到的消息都会被发送给这个对象。

在大多数分布式对象系统,通用的是为每个接收类代理定义可转发方法做准备。在 dRuby 中,Ruby 中的 method_missing 用法和运行时方法回调机制允许一个单一的代理作为所有的类的根客户端。

当远程接收器(例如:面向对象)接收到一个消息没有处理,它会抛出一个 NameError 异常——这样的行为是作为一个常规的 Ruby 对象的。

注意,要为每一个单独的消息创建一个线程。创建线程有很多优点,这样可以通过提供独立的代码执行上下文并避免网络 IO 阻塞来减少异常的发生。但是对用户最大好处就是允许多个 RMI (远程方法调用)。

这就是生产者-消费者问题导致死锁的原因。当由消费者调用 pop 而锁定 DRbServer 时,这时生产者仍然可以进行 push 操作。甚至在 dRuby 正在进行 RMI 的时候,它还可以再进行 RMI。因此,回调执行方式就是迭代器和递归调用在 dRuby 中进行,同时线程同步机制的使用可以跨进程。

3.2 消息格式

这节会描述一下消息格式和对象交换机制。

dRuby使用TCP/IP协议来处理网络通信,使用Marshal类库来处理编码。

Marshal库是一个唯一对象序列化库,是Ruby核心类库的一部分,从提供的对象开始,Marshal跟踪所有相关联的引用,并最终序列化整个在这个关联图中的对象。

dRuby对Marshal重度依赖,在一些系统中,只有对象有“可序列化”属性才会被认为可以序列化。但是在dRuby中,所有的对象初始化时都被认为可以被序列化。如果dRuby遇到没有必要进行序列化的对象或者无法序列化的对象(如文件,线程,进程对象等)就会抛出一个异常。

前面请求的远程对象的内容就是由下面的信息集合构成。每个组件都是通过 Marshal 序列化过的对象。

  • 接收器标识

  • 消息字符串

  • 参数表

下面我们看一下第一版的实现。最后一版实现还会发送内容长度,但是与现在没有什么太大区别。

  def dump(obj, soc)      begin        str = Marshal::dump(obj)   # (1)      rescue        ro = DRbObject.new(obj)    # (2)        str = Marshal::dump(ro)    # (3)      end      soc.write(str) if soc      return str    end

这一小段代码是不同于 dRuby 实现的一部分。

首先,作为参数传递过来的经过Marshal.dump序列化的对象,这是会抛出一个异常,这个对象的引用被Marshal.dump序列化了。

如果Marshal.dump失败,说明目标对象是不可序列化的,或者是目标对象引用了一个不可序列化的对象。

这个例子中dRuby不允许RMI失败。不可序列化的对象,或者不可以通过传值来传递的对象,都需要传递对象的引用。

这种行为是 dRuby 用来缩小 dRuby 和 vanilla Ruby 之间的差异的一种技巧。

让我们来看看实际应用。

在这个例子当中,我们将准备一个共享的字典。在字典里一个 service 注册 service,而另一个 service 使用字典里的 service。

首先我们先来解决字典 service。按照分布式系统的说法,我们可以称它为命名服务器。

由于我们只做了一个 Hash public,所以他看起来非常短。

# dict.rb  require 'drb/drb'    DRb.start_service('druby://localhost:23456', Hash.new)  sleep

接下来是日志服务。这是一个非常简单的只记录时间和字符串的服务。

SimpleLogger 类定义在主逻辑里。

运行 logger.rb 就会注册一个 SimpleLogger 对象和一个对应的描述到字典服务里,同时进入 sleep。

由于 SimpleLogger 会引用一个文件对象(在下面这个脚本里是标准错误输出),所以不能通过Marshal.dump 来进行序列化。因此,不能通过传值,只能通过传引用来传递。

# logger.rb  require 'drb/drb'  require 'monitor'    DRb.start_service  dict = DRbObject.new_with_uri('druby://localhost:23456')    class SimpleLogger    include MonitorMixin      def initialize(stream=$stderr)      super()      @stream = stream    end      def log(str)      s = "#{Time.now}: #{str}"      synchronize do        @stream.puts(s)      end    end  end    logger = SimpleLogger.new  dict['logger'] = logger  dict['logger info'] = "SimpleLogger is here."  sleep

最后我解释一下服务用户。 

app.rb 脚本创建了一个字典服务的引用,并通过日志描述字符串索引到日志服务。在用 p 方法检查每个对象之后可以发现 Info 对象就是一个简单的内容是“SimpleLogger is her”的字符串对象,同时还看得出日志对象就是一个 DRbObject。

# app.rb  require 'drb/drb'    DRb.start_service  dict = DRbObject.new_with_uri('druby://localhost:23456')    info = dict['logger info']  logger = dict['logger']    p info      #=>  "SimpleLogger is here."  p logger    #=>  #<DRb::DRbObject:0x....>    logger.log("Hello, World.")  logger.log("Hello, Again.")

logger.log() 是一个生成输出日志的 RMI。我们可以通过在终端运行 logger.rb 脚本来查看日志输出。

这章里我们用一个实用的例子介绍一下 dRuby 两个不同特性的实现:方法调用的实现,还有对象传输方法选择策略和消息格式的实现。

摘录 dRuby 的第一个版本的实现来解释,但是读者应该知道这个道理,第一个版本与现在的版本实现原理是完全相同的。dRuby 的第一个版本最适合来讲述代码实现。

4.性能

这章我们通过测试结果讨论一下dRuby的RMI性能。

下面的试验标示了在同一个机器进程之间最大可能数量的RMI。这个试验的结果应该是理想情况下的,并非是常规使用情况下的结果。这个结果也不失为dRuby中的使用开销的一个很好的参考。

require 'drb/drb'    class Test    def count(n)      n.times do |x|        yield(x)      end    end  end    DRb.start_service('druby://yourhost:32100', Test.new)  sleep
require 'drb/drb'    DRb.start_service(nil, nil)  ro = DRbObject.new_with_uri('druby://yourhost:32100')  ro.count(10000) {|x| x}

我在二个不同的环境中测试10000次远程调用所需要的时间。

第一个例子是宿主系统与运行于宿主系统虚拟机里的访客系统之间的传输测试。宿主系统在同一个机器上(奔腾4主频3.0GHz)。二种测试组合分别是,Ruby在Windowx XP中,Ruby在coLinux中分别做为访客系统运行在Windows XP的宿主系统上。

% time ruby count.rb  real   0m11.250s  user   0m0.810s  sys    0m0.260s

下面是没有使用RMI,只是在同一个进程中测试常规方法调用的结果,

% time ruby count.rb  real   0m0.044s  user   0m0.040s  sys    0m0.010s

下面二个是一台iMac G5运行测试结果,第一个是在同一个系统中的不同进程的测试结果,第二个是在同一个进程中常规方法调用的测试结果

real    0m13.858s  user    0m6.517s  sys     0m1.032s
real    0m0.079s  user    0m0.031s  sys     0m0.012s

每秒700-900个远程调用还是没问题的。是否满足你的应用程序的需要由你来决定。注意如果只是单纯的在一个进程里进行常规方法的调用(没有远程方法调用)大约会比带RMI的快200倍。RMI(远程方法调用)的频率会对应用程序性能产生很大的影响,所以在构建真正的产品应用程序时要着重考虑这点。

5. 应用

只有很少的几个应用是通过dRuby来实现的。

众所周知,Ruby on Rails的调试器和Web应用的异步进程模型通常是通过dRuby来实现的。

在这个章节里,我会介绍几个由dRuby来实现的应用,在介绍应用之前,我还是会先说一下基于dRuby的分布式系统,Rinda和一些实际的例子。

5.1. 大型程序的后端服务

现在,我先通过 Hatena 截图服务介绍一下大型应用程序的后端服务。Hatena 截图服务是由 Tateno 在上 Ruby Kaigi 2006 发布的。这个截图服务是用来显示类似 Hatena 博客服务的注册链接的截图缩略图服务。

Web 前端配置在 Linux 上,截图功能是基于 IE 的组件来实现的。因为据说在较高的速度下 Windows 下的截图性能更好一些。

这个截图服务是做为异步进程执行在前端的。

运行在Windows上的进程接收来自通过dRuby处理过的Linux进程的URL和的方法,并执行截图方法。

根据2006年RubyKaigi提供的数据,这个由二台机器组成的并行系统可以处理的数据吞吐量已经接近120SS/分钟,170,000SS/天。

5.2. 内存数据库还是持久进程

接下来,我将要介绍 dRuby 持久内存或者叫持久进程的用法。Web 应用里的很多问题都与需要处理短生命周期的请求回应循环和语义进程有关系。

一个很具体的例子就是 CGI。CGI 程序在接收到一个请求之后被调用,调用完成之后返回一个响应。在用户从他们的浏览器角度看起来,应用程序响应了很长时间。由于是一系列的小而短的请求回应循环,所以看起来像是一个长驻应用,每个 CGI 程序在终结之前必须给下一次调用留下一些“有用的信息”。

这些"wills"(会话管理)的管理是 web 应用编程的痛点之一。很多因素需要被考虑--状态串行化,处理文件或者关系型数据库相互排斥,处理多重同时请求引起的矛盾。

有一个方法就是最小化甚至消除,“will”通过合并短暂的前端和长久的持久应用来留给下一个进程。

RWiki 是一个有趣的 WikiWikiWeb 实现,它应用了这样的架构。

元数据比如 Wiki 页面来源,HTML 输出,连接和更新时间戳的 cache,都维持在一个长久服务器的进程内存中。短暂的 CGI 进程通过 dRuby 访问找个服务器,检索用户请求的 Wiki 页面。一个私有的 RWiki 服务器 host 大概在内存中有 20000 页。为了能够重启后重新建立站点,服务器要不断的记录充足的数据到硬盘里边。然而这些日志在正常的执行中从不被引用。

下面的例是一个特别简单的 CGI 脚本(四步),还有一个“计数”服务器。我们先来快速的过一遍 CGI的运行机制。CGI 程序接受一个通过标准输入和环境变量产生的 CGI 环境下的 http 请求,并通过标准输出返回一个对此请求的响应。

CGI 脚本调用相对长时的计数服务器,并将环境变量和输入输出引用传递过去。CGI 应用不会使用暂存模式,特别容易编写,可以用其他例子代替这个简单的计数的例子。

#!/usr/local/bin/ruby    require 'drb/'drb'    DRb.start_service('druby://localhost:0')  ro = DRbObject.new_with_uri('druby://localhost:12321')  ro.start(ENV.to_hash, $stdin, $stdout)
require 'webrick/cgi'  require 'drb/drb'  require 'thread'    class SimpleCountCGI < WEBrick::CGI    def initialize      super      @count = Queue.new      @count.push(1)    end      def count      value = @count.pop    ensure      @count.push(value + 1)    end      def do_GET(req, res)      res['content-type'] = 'text/plain'      res.body = count.to_s    end  end    DRb.start_service('druby://localhost:12321', SimpleCountCGI.new)  sleep



6. Rinda 和 Linda

最后我要介绍一下基于 dRuby 的 Linda 的实现,还有 Rinda 和它的实际使用例子。

Linda 在分布式的协作系统就是一个胶水语言的概念。一个简单的元组和元组空间模型能够协调多个任务。也就是说,即使这是一个简单的模型,也可以管理由并行编程引发的各种情况。因此,许多语言都纳入自己的元组空间。

接下来从下面的实现就可以看出 C-Linda、 JavaSpace 和 Rinda 都是典型的例子。


C-Linda 增强了一门基本语言(C 语言)并且加入了 Linda 的一些操作。它们都是通过执行预处理器来实现的。说起 JavaSpace 的实现,它的元组空间是由 java 来实现的。dRuby 的元组空间是由 Rinda 实现的。也就是说,Rinda 用 Ruby 实现了 dRuby 的元组和元组空间模型。

在 C-Linda 中,操作元组是被元组空间隐含限制的,所以元组空间不能手动指定。而在 Rinda 里,由于元组和对象都是通过消息传递来通信,所以必须要决定应用使用哪一种元组空间。

在 Rinda 里,除了 Linda 的 eval 操作之外,其他基本操作如 out,in,inp,rd,rdp 都是可用的。但是可以取代 Ruby 的线程。

最新版本的 Rinda 修改成了类似 JavaSpace 的方法名。

  • write

  • 向元组空间加入元组(out)

  • take

  • 在元组空间删除对应的元组,并返回对应的元组,如果元组不存在,锁定它们。(in)

  • read

  • 返回元组拷贝,如果对应元组不存在,锁定它们。(rd)

take 和 read 操作可以设置超时。如果设置超时为 0,这两个的操作类似 inp 和 rdp。

除了这些基本的操作之外,read_all 也可以读取所有匹配模式的 tuple。read_all 似乎对调试有用处。

tuple 和 pattern 通过 Ruby 数组来表示。匹配 tuple pattern 的规则被扩大到和 Ruby 相似的规则,以致于不仅是通配符(Rinda 中的通配符表示 nil),而且有类,此外 Range 和 Regexp 也可以被指定。Rinda 可以像一组查询语言一样被处理。

此外,“时间限制”可以在 tuple 中设置,尽管这个功能还处于试验性。还有,表示秒数和时间线的更新对象(Rinda 里边称为重建者)的数字也可以在时间限制中被指定。无论更新时间线与否都要被对象所询问。Rinda 作为重建者也能够给 dRuby 提供引用参考。打比方,tuple 创建者被不正常关闭。过了一段时间后,超过时间限制的 tuple 会被提供。

6.1. 哲学家就餐问题

在 Linda 角度我用实例代码来为你解释这个问题。

require 'rinda/tuplespace'                       # (1)    class Phil    def initialize(ts, num, size)                  # (2)      @ts = ts      @left = num      @right = (@left + 1) % size      @status = ' '    end    attr_reader :status      def think      @status = 'T'      sleep(rand)      @status = '_'    end      def eat      @status = 'E'      sleep(rand)      @status = '.'    end      def main_loop                                  # (3)      while true        think        @ts.take([:room_ticket])        @ts.take([:chopstick, @left])        @ts.take([:chopstick, @right])        eat        @ts.write([:chopstick, @left])        @ts.write([:chopstick, @right])        @ts.write([:room_ticket])      end    end  end    ts = Rinda::TupleSpace.new                       # (4)  size = 10  phil = []  size.times do |n|    phil[n] = Phil.new(ts, n, size)    Thread.start(n) do |x|                         # (5)      phil[x].main_loop    end    ts.write([:chopstick, n])  end    (size - 1).times do    ts.write([:room_ticket])  end    while true                                       # (6)    sleep 0.3    puts phil.collect {|x| x.status}.join(" ")  end

这里开始介绍众所周知的"哲学家就餐"问题。

这个程序使用了二种元组类型。一个是 chopstick,另一个是 room_ticket。

chopstick 是一个含有二个元素的元组。第一个元素是":chopstick"标记,第二个元素是一个表示chopstick 编号的整型数字。room_ticket 是一个限制房间里哲学家人数的标签元组,里面只包含一个":room_ticket"标记。

Phil 类表示一个哲学家。

Phil 对象与元组空间,各种数字,桌子的座位数一起生成。这个对象有一个实例变量表示其状态。这些变量用来监听哲学家们的状态。

与 C-Linda 相比,Rinda 必须传递目标元组空间。

Phil 类中的 main_loop 方法是用来标识哲学家动作的无限循环。在执行 think() 方法后,main_loop 方法获得用餐的 room_ticket,同时还获得了左右两边的筷子。所有的都准备好之后,开始执行 eat() 方法。用餐结束之后,返还左右两边的筷子与 room_ticket 至元组空间,同时再次开始执行 think() 方法。

在主程序中,首先创建元组空间和 philosopher,同时由子线程调用 main_loop 方法。这些操作与 C-Linda 中的 eval() 操作非常类似。与人数相应的筷子数量和 room_ticket 元组中的小写数字被写入元组空间中。

最后的无限循环每 0.3 秒为一组来监听哲学家。在循环中指出哲学家在某一刻是在思考,就餐还是在拿着筷子等行为。

6.2. Tuple and Pattern

这里,我解释一下 Rinda 中的 tuple,pattern 已经模式匹配。之前提到的,那些 tuple 和 pattern 用数组表示。元素中有很多特征。关于这些元素,所有的 Ruby 对象包括 dRuby 的引用都可以被指定。

[:chopstick, 2]  [:room_ticket]  ['abc', 2, 5]  [:matrix, 1.6, 3.14]  ['family', 'is-sister', 'Carolyn', 'Elinor']

同样的 pattern,所有 Ruby 对象可以被作为元素。在模式匹配方面,它的规则有点奇怪。nil 被解释为可以匹配任何对象的通配符,并且每个元素通过"===(实例等于)"来对比。

Ruby 有一个 case 表达式,这种表达式是一个分支,像 C 语言的 switch。"===(实例等于)"是一个特殊的等式比较。然而,“===”和”==“在某一类中基本相似,它表现”像 pattern“。

例如,Regexp 只不过是字符串的模式匹配,Range 识别值是否在限定的 range 里边。当一个类被指定为 pattern 元素,使用 kind_of() 也是合理的,以致于带有指定类的 pattern 也可以被描述。

下面给出 pattern 的例子。

[/^A/, nil, nil] (1)  [:matrix, Numeric, Numeric] (2)  ['family', 'is-sister', 'Carolyn', nil] (3)  [nil, 'age', (0..18)] (4)
  1. 一个由三个元素组成的 tuple,第一个元素使用”A“开头的字符串。

  2. 一个由第一个元素是 matrix 标志,第二,第三个元素都是 Numveric 类排列而成的 tuple 。

  3. Tuple of Carolyn sister。

  4. 年龄从0到18的 tuple。


让我们看一下通过共享 TupleSpace  和交互环境irb匹配的模式。

require 'rinda/tuplespace'  ts = Rinda::TupleSpace.new  DRb.start_service('druby://localhost:12121', ts)  sleep

这4行代码就是共享 TupleSpace 的脚本。

% irb --simple-prompt -r rinda/rinda   >> DRb.start_service  >> ro = DRbObject.new_with_uri('druby://localhost:12121')  >> ts = Rinda::TupleSpaceProxy.new(ro)  >> ts.write(['seki', 'age', 20])  >> ts.write(['sougo', 'age', 18])                        >> ts.write(['leonard', 'age', 18])  >> ts.read_all([nil, 'age', 0..19])  => [["sougo", "age", 18], ["leonard", "age", 18]]  >> ts.read_all([/^s/, 'age', nil])  => [["seki", "age", 20], ["sougo", "age", 18]]  >> exit

你可以看到类 Ruby 的灵活的模式是可用的。

你可能会考虑元组空间可以当简单的数据库来用。在这点上,数量巨大的元组必须要谨慎的处理,根据 API 和灵活模式的特性,Rinda 会进行线性的搜索。

6.3. 非均衡优化

仅仅当第一个元素是一个符号、存储元组的集合时,为了以高速通过通用的应用程序,最新版本的 Rinda 已经实施了非均衡优化。

根据我的经验,以下类型是应用程序经常使用的元组。

[:screenshot, 12345, "http://www.druby.org"]  [:result_screenshot, 12345, true]  [:prime, 10]

也就是说,它是由一个消息类型和一些参数组成的元组。在接受或读取时,下列模式适用。

[:screenshot, nil, String]  [:result_screenshot, 12345, nil]  [:prime, Numeric]

这只是一个模式,以任何一个从一个特定消息类型的元组。

考虑到这种情况,你可以通过将第一个元素作为自己的集合,并集中在搜索目标上,这样就可以期望高性能。还有另外一个问题是,是否有任意对象成为实现高性能的关键点。在 Rinda 情况下,使用“= = =(情况匹配)”不适用于 String 和 Integer 作为键。

然而,符号(Symbol)是仍然适合作为一个关键点的,因为“===(情况匹配)”的符号已经有一个符号(Symbol)类和它的值。此外,符号(Symbol)比字符串更易于阅读。

让我们来总结一下这种非均衡的优化。在最新版本的 Rinda 里,当在元组第一个元素是一个符号(Symbol)时,Rinda 执行这种非均衡的优化,并且在取/读的性能上是有改进的。类似取/读,当一个模式搜索的第一个元素是一个符号(Symbol)时,搜索的性能会变得比通常要快。


6.4. Rinda 的应用

这里,我将介绍一个Rinda的实际应用。Buzztter是一个翻译推ter句子的网络服务。推ter是一个微博社交网站。Buzztter收集发到推ter上的微博,将其翻译,并找出比平时更常用的单词。通过这些,Buzztter得到了当前推ter中单词的总趋势。

Buzztter由数个子系统组成,分别是一个分布式爬虫子系统;一个通过推ter API(HTTP)收集微博的子系统;这个子系统中使用了Rinda。爬虫子系统是由多个从推ter中抓取信息的的抓取器和导入器组成,导入器可以使信息持久保存。Rinda和dRuby是抓取器和导入器之间的中介器。

作为参考,Buzztte 在 2007 年十一月三日处理的数据量如下:

  • 每天 125000 条

  • 每天 72MB

6.5. Rinda 更新

最后,我再说一下 Rinda 的最新更新趋势。去年的 RubyKaigi 2007 中,发布了持续性元组空间。一旦过程完成,关于 Rinda::TupleSpace 的信息就消失了。持续性 TupleSpace 在过程调用时开辟了一个连续的元组空间。在运行过程中,为了为再次调用这个过程时做准备,持续性 TupleSpace 将保持记录。再次调用时,查询日志信息,TupleSpace 将重建过程。运行中,TupleSpace 只需要保持记录,这意味着不需要从存储器中读取内容。

7. 结论

我论述了 dRuby 的设计策略,其概念的实现,以及它的实际使用。除此之外,我还论述了基于 dRuby 和 Rinda 开发的实现。dRuby 和 Rinda 都是设计成一个简单的系统从而让 Ruby 程序员轻松的了解更多。所以,它们最适合简单的构建分布式系统。然而,在这篇文章中讨论的实际例子不是像玩具一样讨论其简单为目的,而是证明了 dRuby 和 Rinda 几乎可用于构建实际应用程序的基础设施。