基于 Actor 模式并发的介绍 (Ruby) 已翻译 100%

Wyatt 投递于 2013/02/08 18:41 (共 11 段, 翻译完成于 02-20)
阅读 5776
收藏 32
10
加载中

老一套的至理名言道,并发编程很难,在Ruby中尤其如此。就是这个基本假设,导致许多Ruby爱好者们对类似Erlang和Scala的语言发生了兴趣。为了让一般的程序员更加容易地实现和理解并发系统,这些语言内置了对Actor模式(Actor Model,或译为Actor模型)的支持。

但是,真的需要到Ruby语言之外寻找能够使你的工作变轻松的并发原语(Concurrency Primitive)吗?这个问题的答案可能取决于你所需要的并发和可用性的层次,但在近几年情况已经确定无疑地成型了。尤其是Celluloid框架,已经带给我们一种便捷、干净的方法,用来在Ruby中实现基于Actor模式的并发系统。

fbm
fbm
翻译于 2013/02/19 18:47
3

为了领会Celluloid能为你做什么,首先你需要理解Actor模式是什么,以及同并发编程中传统的直接使用线程和锁的方式相比,Actor模式具有什么好处。在本文中,我们试图通过使用三种不同的方法解决同一个经典的并发谜题来弄清楚这些知识点。这三种方法是:使用Ruby内置的原语(线程和互斥锁)、使用Celluoid框架、使用一个我们从头创建的Actor模式的最小实现。

如果你现在还不是并发方面的专家,看完本文后当然也不会让你成为这样的专家,但能够使你在一些基本概念方面具有一个有利的开端,这些基本概念可以帮助你决定如何解决你自己项目中的并发编程问题。让我们开始吧!

fbm
fbm
翻译于 2013/02/19 19:10
3

哲学家就餐问题

哲学家就餐问题是由Edsger Djisktra在1965年提出的,用来说明在多个进程为获得互斥性资源的使用权进行竞争时可能会出现哪些问题。

在这个问题中,五个哲学家正进行聚餐。他们坐在一个圆形餐桌周围,每个哲学家面前都有一碗米饭。另外还有五根筷子,每个哲学家一边一根。哲学家要花时间来思考人生的意义。无论何时只要他们饿了,他们就要试图吃饭。但是哲学家需要在每个手中都握有一根筷子才能弄到米饭吃。如果任何其他哲学家已经拿走了他所需要的两根筷子中的一根,那么这个饥饿的哲学家将会等待,直到那根被拿走的筷子可用为止。

这个问题令人关注是因为,如果不恰当地解决该问题,就能很容易地导致死锁问题。我们马上就会看看这些问题,但首先让我们把这个问题域(problem domain)转化为几个基本的Ruby对象。

fbm
fbm
翻译于 2013/02/19 19:37
2

对餐桌及其上的筷子进行建模

我们即将在本文中进行讨论的所有这三种解决办法都会用到筷子类Chopstic和餐桌类Table。这两个类的定义如下所示:

class Chopstick
  def initialize
    @mutex = Mutex.new
  end

  def take
    @mutex.lock
  end

  def drop
    @mutex.unlock

  rescue ThreadError
    puts "Trying to drop a chopstick not acquired"
  end

  def in_use?
    @mutex.locked?
  end
end

class Table
  def initialize(num_seats)
    @chopsticks  = num_seats.times.map { Chopstick.new }
  end

  def left_chopstick_at(position)
    index = (position - 1) % @chopsticks.size
    @chopsticks[index]
  end

  def right_chopstick_at(position)
    index = (position + 1) % @chopsticks.size
    @chopsticks[index]
  end

  def chopsticks_in_use
    @chopsticks.select { |f| f.in_use? }.size
  end
end

Chopstick类仅仅是对普通的Ruby互斥锁(Mutex)进行了一个简单的封装, 该互斥锁将确保两个哲学家不能在同一时刻拿到同一根筷子。Table类处理谜题中的几何问题,它了解餐桌周围的每个座位在哪里,哪根筷子在某个座位的左边还是右边,它还知道当前有多少根筷子在使用中。

既然你已经看完了对此问题进行模型化后得到的基本领域对象(domain object),下面我们看看实现哲学家行为的不同的方法。我们将以不能正确解决问题的方法开始。

fbm
fbm
翻译于 2013/02/20 00:00
3

造成死锁的解决方案

以下所示的哲学家类,Philosopher,似乎是该问题最直截了当的一个解决方案了,但它有一个致命的缺陷,因此它不是一个线程安全的(Thread Safe)方案。你能找出这个缺陷吗?

class Philosopher
  def initialize(name)
    @name = name
  end

  def dine(table, position)
    @left_chopstick  = table.left_chopstick_at(position)
    @right_chopstick = table.right_chopstick_at(position)

    loop do
      think
      eat
    end
  end

  def think
    puts "#{@name} is thinking"
  end

  def eat
    take_chopsticks

    puts "#{@name} is eating."

    drop_chopsticks
  end

  def take_chopsticks
    @left_chopstick.take
    @right_chopstick.take
  end

  def drop_chopsticks
    @left_chopstick.drop
    @right_chopstick.drop
  end
end

如果你仍在挠头,那么请考虑一下,当每个哲学家对象都具有自己的线程,而且所有的哲学家都同时企图吃饭时,会发生什么情况。

在这个幼稚的实现中,有可能会出现一种状态,在这种状态下,每个哲学家都拿起了位于其左侧的筷子,这时餐桌上就没有筷子了。出现这样的情况后,每个哲学家都只是在无限地等待其右侧的筷子出现。这就导致了死锁。运行以下的代码可重现此问题:

names = %w{Heraclitus Aristotle Epictetus Schopenhauer Popper}

philosophers = names.map { |name| Philosopher.new(name) }
table        = Table.new(philosophers.size)

threads = philosophers.map.with_index do |philosopher, i|
  Thread.new { philosopher.dine(table, i) }
end

threads.each(&:join)
sleep

Ruby足够聪明,能够提示你哪里出了问题,因而最终你应该会看到同下面类似的回溯信息(Backtrace):

Aristotle is thinking
Popper is eating.
Popper is thinking
Epictetus is eating.
Epictetus is thinking
Heraclitus is eating.
Heraclitus is thinking
Schopenhauer is eating.
Schopenhauer is thinking

dining_philosophers_uncoordinated.rb:79:in `join': deadlock detected (fatal)
  from dining_philosophers_uncoordinated.rb:79:in `each'
  from dining_philosophers_uncoordinated.rb:79:in `<main>

在许多情况下,最简单的解决方案往往就是最好的解决方案,但这个方案显然不在此列。 既然我们已经吸取了教训,无法安全地让哲学家自行其是,那么我们就来多做一些事情,确保哲学家们的行为保持协调。

fbm
fbm
翻译于 2013/02/20 00:30
2

协调的基于互斥锁的解决方案

这个问题的一个简单易行的方案是,引入一个侍者对象Waiter。在这个模型中,每个哲学家在吃之前必须先向侍者进行询问。如果在使用中的筷子的数目是大于或等于4根,侍者将让该哲学家等待直到有人吃完为止。这样就能保证在任何时刻至少有一个哲学家能够进食,避免了死锁状态。

不过这里仍然存在一个问题。从侍者检查使用中的筷子数目开始到下一个哲学家开始吃饭这段时间,我们的问题中有个临界区(Critical Region):如果两个并发的线程同时执行这段代码,死锁仍有可能发生。比如,假设侍者检查使用中的筷子数后发现是3根在使用中。就在此时,调度器将控制权交给了一个正要拿起筷子的哲学家。当执行流(Execution Flow)返回原先的线程后,即使已经有多于4跟筷子处于使用中了,侍者仍将允许原先那个哲学家开始吃饭。

为了避免出现这种情况,我们需要用一个互斥锁将临界区保护起来,代码如下所示:

class Waiter
  def initialize(capacity)
    @capacity = capacity
    @mutex    = Mutex.new
  end

  def serve(table, philosopher)
    @mutex.synchronize do
      sleep(rand) while table.chopsticks_in_use >= @capacity 
      philosopher.take_chopsticks
    end

    philosopher.eat
  end
end

引入Waiter对象,就需要我们对Philosopher对象稍微进行一点修改,修改相当简单明了:

class Philosopher

  # ... all omitted code same as before

  def dine(table, position, waiter)
    @left_chopstick  = table.left_chopstick_at(position)
    @right_chopstick = table.right_chopstick_at(position)

    loop do
      think

      # instead of calling eat() directly, make a request to the waiter 
      waiter.serve(table, self)
    end
  end

  def eat
    # removed take_chopsticks call, as that's now handled by the waiter

    puts "#{@name} is eating."

    drop_chopsticks
  end
end
运行代码也需要一些小的调整,但和大部分代码同我们前面见到的相似:
names = %w{Heraclitus Aristotle Epictetus Schopenhauer Popper}

philosophers = names.map { |name| Philosopher.new(name) }

table  = Table.new(philosophers.size)
waiter = Waiter.new(philosophers.size - 1)

threads = philosophers.map.with_index do |philosopher, i|
  Thread.new { philosopher.dine(table, i, waiter) }
end

threads.each(&:join)
sleep

这种方法合情合理并且也解决了死锁问题,但使用互斥锁同步化代码需要一些底层思维。即使在这个简单的问题中,就有几个麻烦的事情需要考虑清楚。随着问题越来越复杂,要在确保代码访问临界区时具有正常的行为时,掌握所有临界区的所有情况会变得是否困难。

Actor模式就是用来为在线程间共享数据提供一种更加系统化和比较自然的方法的。我们现在就来看看这个问题基于Actor的解决方案,以便我们能看到它同基于互斥锁的方法相比怎么样。

fbm
fbm
翻译于 2013/02/20 01:14
3

采用Celluloid的基于Actor的解决方案

为了利用Celluloid,我们将对Philosopher和Waiter两个类进行改进。大部分代码将保持原样不动,但有些重要的细节会有一些改变。 为了讨论的连续性,下面给出了类的全部定义,只是用注释标出了经过修改的部分。

我们将在本文余下的部分解释这些代码内部的运行原理,因此现在无须发愁去理解其中每一个小细节。只要尽量理解个大概即可:

class Philosopher
  include Celluloid

  def initialize(name)
    @name = name
  end

  # Switching to the actor model requires us get rid of our
  # more procedural event loop in favor of a message-oriented
  # approach using recursion. The call to think() eventually
  # leads to a call to eat(), which in turn calls back to think(),
  # completing the loop.

  def dine(table, position, waiter)
    @waiter = waiter

    @left_chopstick  = table.left_chopstick_at(position)
    @right_chopstick = table.right_chopstick_at(position)

    think
  end

  def think
    puts "#{@name} is thinking."
    sleep(rand)

    # Asynchronously notifies the waiter object that
    # the philosophor is ready to eat

    @waiter.async.request_to_eat(Actor.current)
  end

  def eat
    take_chopsticks

    puts "#{@name} is eating."
    sleep(rand)

    drop_chopsticks

    # Asynchronously notifies the waiter
    # that the philosopher has finished eating

    @waiter.async.done_eating(Actor.current)

    think
  end

  def take_chopsticks
    @left_chopstick.take
    @right_chopstick.take
  end

  def drop_chopsticks
    @left_chopstick.drop
    @right_chopstick.drop
  end

  # This code is necessary in order for Celluloid to shut down cleanly
  def finalize
    drop_chopsticks
  end
end


class Waiter
  include Celluloid

  def initialize
    @eating   = []
  end

  # because synchronized data access is ensured
  # by the actor model, this code is much more
  # simple than its mutex-based counterpart. However,
  # this approach requires two methods
  # (one to start and one to stop the eating process),
  # where the previous approach used a single serve() method.

  def request_to_eat(philosopher)
    return if @eating.include?(philosopher)

    @eating << philosopher
    philosopher.async.eat
  end

  def done_eating(philosopher)
    @eating.delete(philosopher)
  end
end
运行代码同前面所见类似,只有非常少量的修改:
names = %w{Heraclitus Aristotle Epictetus Schopenhauer Popper}

philosophers = names.map { |name| Philosopher.new(name) }

waiter = Waiter.new # no longer needs a "capacity" argument
table = Table.new(philosophers.size)

philosophers.each_with_index do |philosopher, i| 
  # No longer manually create a thread, rely on async() to do that for us.
  philosopher.async.dine(table, i, waiter) 
end

sleep

这个解决方案在运行态(runtime)的行为和基于互斥锁的是类似的。然而,下面列出的在实现方面的差异很值得注意:

  • 每个使用Celluloid的类都变成了一个拥有自己的执行线程的Actor。

  • Celluloid库通过async代理对象截获了方法调用,并将其保存到相应Actor的邮箱(mailbox)中。Actor的线程将按照顺序,一个接一个地执行前面保存起来的方法。

  • 这种行为使得我们无须显式地管理线程和互斥锁同步。Celluloid库以一种面向对象的方式在幕后处理了这些问题。

  • 如果我们将所有数据封装进Actor对象中,那么就只有Actor的线程能够访问和修改它自己的数据了。这样就避免了两个线程同时写入临界区,从而消除了死锁和数据损坏的风险。

这些特性非常有用,可以简化我们对并发编程的思考方法。你可能会好奇,实现这些特性到底需要有多神奇。让我们创建一个用来临时代替Celluoid的最小实现来发现到底有多神奇吧!

fbm
fbm
翻译于 2013/02/20 01:52
3

打造我们自己的Actor模式

Celluloid提供了太多功能,我们无法都在本文中对其一一进行全面讨论,但我们完全能做到,仅仅实现Actor模式的核心部分。 实际上,下面列出的80行代码足够代替我们前面例子中所利用到的Celluloid的那部分功能:

require 'thread'

module Actor  # To use this, you'd include Actor instead of Celluloid
  module ClassMethods
    def new(*args, &block)
      Proxy.new(super)
    end
  end

  class << self
    def included(klass)
      klass.extend(ClassMethods)
    end

    def current
      Thread.current[:actor]
    end
  end

  class Proxy
    def initialize(target)
      @target  = target
      @mailbox = Queue.new
      @mutex   = Mutex.new
      @running = true

      @async_proxy = AsyncProxy.new(self)

      @thread = Thread.new do
        Thread.current[:actor] = self
        process_inbox
      end
    end

    def async
      @async_proxy
    end

    def send_later(meth, *args)
      @mailbox << [meth, args]
    end

    def terminate
      @running = false
    end

    def method_missing(meth, *args)
      process_message(meth, *args)
    end

    private

    def process_inbox
      while @running
        meth, args = @mailbox.pop
        process_message(meth, *args)
      end

    rescue Exception => ex
      puts "Error while running actor: #{ex}"
    end

    def process_message(meth, *args)
      @mutex.synchronize do
        @target.public_send(meth, *args)
      end
    end
  end

  class AsyncProxy
    def initialize(actor)
      @actor = actor
    end

    def method_missing(meth, *args)
      @actor.send_later(meth, *args)
    end
  end
end

这些代码大部分都是基于我们在本文中前面已经讲解过的一些概念实现的,因此花点精力理解一下也并不应该是件太难的事情。话虽如此,将元编程(meta-programming)和并发结合起来编程所得的代码,还是会让你感到有些迷茫,因此我们还是应该试着从较高的层次讨论一下这个模块的运行原理。现在就让我们开始吧!

任何包含了Actor模块的类都将被转化为一个actor,从而具有了接收异步调用的能力。我们是通过覆盖(override)目标类的构造器(constructor)来实现这个功能的,覆盖目标类的构造器后,我们就能够在每次实例化目标类的对象时,得到该新对象的代理对象(proxy object)。同时我们还将代理对象保存到了一个线程级的变量之中。这一步很有必要,因为在actor间发送消息时,如果我们在方法调用中引用self,那么我们暴露出的就是目标对象而不是代理对象。这个同样的小技巧在Celluloid中也用到了

fbm
fbm
翻译于 2013/02/20 17:04
2

使用这样的混入类(mixin),当我们要创建Philosopher对象时,我们实际上得到的是Actor::Proxy类的实例。Philosopher类大部分代码都可以保持不动,和Actor一样的行为全部由代理对象完成。一旦实例化后,代理对象就创建一个邮箱,用来保存发进来的异步消息,随后创建一个线程处理收到的这些消息。收件箱是一个线程安全的队列,能够保证发进来的消息即使是同时到达的也会按顺序进行一一处理。每当收件箱空了时,actor的线程就会被阻塞(blocked),直到有新消息需要处理为止。

虽然为了提供更多附加的功能,Celluloid的实现要复杂得多得多,但其原理大致也同如上所述一样。接下来,如果你掌握了上面的代码,你就可以起步去熟知actor模式到底有些什么内容了。

fbm
fbm
翻译于 2013/02/20 17:28
3

Actor 很有用但也不是万灵药

即使这个actor模式的最小实现也是将底层的同步原语从普通类的定义中拿了出来,放到了一个集中的地方,从而可以以一种一致和可靠的方式处理这些同步操作。Celluloid在这方面比我们走地更远,提供了一个极好的容错机制(fault tolerance mechanism)从而具有从各种错误中恢复正常执行的能力以及其它一些很有意义的东西。然而,要得到这些好处必然要付出一定的代价,也会带来一些潜在的陷阱。

那么在Ruby中使用actor会出现哪些问题呢?我们已经暗示过,在代理对象中会出现的自我精神分裂症(self schizophrenia)从而导致的潜在问题。也许更加复杂的是可变状态(mutable state)问题:尽管使用actor可以保证按顺序访问对象之中的状态,但这可无法对对象间传递的消息做出同样的保证。在象Erlang这样的语言中,消息是由不可变参数(immutable parameter)组成的,因而一致性就在语言层得到了保证。在Ruby中,我们没有这种约束条件,所以,要么我们得用惯例来解决此问题,要么我们就得冻结作为参数传来传去的对象,要是这么作的话,局限性就有点太大了!

我们在这里不去列举其它所有可能会出现问题的地方,此处的关键就在于,并发编程中没有一种叫做万灵药的东西。希望这篇文章能让你对在Ruby中使用Actor模式的好处和坏处都有一个基本的感受,并能让你能够掌握足够的背景知识,从而让你可以在你自己的项目中应用一些这里介绍的理念。如果真的达到了此目的,请一定要和大家分享你的心得体会。

fbm
fbm
翻译于 2013/02/20 18:33
2
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接。
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
加载中

评论(11)

qwfys
qwfys
~~
bjava
bjava
还有 Waiter的capacity根本就是多余的。 因为拿筷子在同步代码块里面, 所以只有当有一个哲学家拿到两个筷子的时候,waiter才会去受理下一个哲学家。这样子的话根本就不会线程饿死。
bjava
bjava
这翻译的... table.right_chopstick_at position不需要加1. 要不然座位跟筷子的位置是错误的。
pillsilly
pillsilly
不知道是原文还是原来就有问题,首先这个就餐问题就没有叙述清楚,让看的人难以继续下去
rubyist
rubyist
不错。。和erlang的貌似差不多
VincentTone
VincentTone
不错,终于一篇actor的。
c
caihuadaxia

引用来自“vingzhang”的评论

不懂ruby,看着很难理解

同感啊。
fbm
fbm

引用来自“Nemesis_E”的评论

@fbm 自己翻译了整篇文章 支持一个

谢谢支持!有任何翻译不到的地方,还请多多指教!
hyper0x
hyper0x
不错
vingzhang
vingzhang
不懂ruby,看着很难理解
返回顶部
顶部