热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

【译】无控制反转的基于事件的程序设计

为什么80%的码农都做不了架构师?简介并发编程是编程界永远绕不开的话题。一是分布式和移动环境天生需要并发,二是支持并发多线程的多核系统实乃大势所趋

为什么80%的码农都做不了架构师?>>>   hot3.png

简介

并发编程是编程界永远绕不开的话题。一是分布式和移动环境天生需要并发,二是支持并发多线程的多核系统实乃大势所趋。

Actors为并发分布式计算提供了一种适合的计算模型。它使用异步消息通信来实现并发。基于actor的进程模型结合消息的模式匹配,已在Erlang的成功中被证明是非常高效的。

Erlang是为实时控制系统设计的一种动态类型函数式语言。在电话交换机、网络模拟器,还有分布式资源控制器中应用广泛。这些系统需要同时激活大量的并发进程,且因为这些进程处于不断变化中,想估算它们的内存消耗非常困难。

常规的实现方式——包括操作系统线程以及虚拟机线程——通常过于重量级。主要原因在于:1)超额配置的栈会快速消耗虚拟地址空间;2)锁竞争机制通常缺乏合适的管理。因此Erlang在运行时使用的是自建一套并发体系而不是基于底层的操作系统。

Erlang这种轻量级的进程在当前流行的虚拟机上无法适用。而同时标准的虚拟机在Erlang曾大获成功的实时操作领域的地位也越来越重要。

同时智能手机和个人电子设备的普及也带动了其虚拟机的发展。通常情况下这些设备提供的资源都很有限,如虚拟机可能只能调用几百字节的内存。

这些现状带来重要结果如下:1)移动设备上的虚拟机提供的服务通常只是桌面、服务器虚拟机的子集。如KVM没有提供自检(introspection)及序列化。2)应用程序所使用的编程抽象必须非常轻量级才能发挥作用。基于线程的并发抽象就显得过于笨重了。除此之外,编程模型还必须适配虚拟机提供的有限功能。

线程编程常见的代替者是基于事件驱动(Event-based)的编程模型。而直接对显式的事件驱动模型编程是很困难的。

许多编程模型所谓的基于事件编程是通过控制反转来实现的。基于事件的编程意指程序仅仅注册其感兴趣的事件,从而在特定事件下被唤醒(如:按下按钮、输入框输入了不同的字符等),而非调用阻塞操作(如:等待获取用户输入)。其逻辑是事件处理器被注册到执行环境并在特定事件发生时被调用。程序自身并不直接调用这些处理器,而由执行环境来负责向处理器分发事件。因此这些代码的控制逻辑被“反转”了。

上述系统会有两个显而易见的缺点:1)整个程序的交互逻辑被碎片化到多大事件处理器上。2)处理器之间的控制流在操作共享状态的过程中变得模糊。

为了从轻量级抽象模型中去掉控制反转,我们试图让actors与线程无关(Thread-less)。我们向‘不友好’的虚拟机中引入了基于事件的actors用作轻量级抽象。‘不友好’指虚拟机不提供对程序执行状态的直接管理。

Actor的中心思想是:等待中的actor并不是一条阻塞的线程,而表示为一个捕获其他actor计算结果的闭包。当消息被发送到匹配的actor,闭包就会立刻执行。闭包的执行发生在发送者的线程上。receive 闭包终止后控制权将回到发送者,就好像一个过程正常返回一样。如果receive闭包阻塞了随后的接收,控制权仍将回到发送者,但同时会抛出一个特殊的异常来释放发送者的调用栈。

这种方案正常运行的必要条件之一是接受者永远不正常返回到它所包含的actor。换句话说,actor的代码不能依赖于接收方的结束或者阻塞。我们可以在编译时期通过Scala的类型系统来表达这种非返回的属性。不过在实际应用中这并算严格的限制,因为程序永远可以表达为:一个actor剩余的计算总是发生在某一个接收下。

据我们所知,基于事件的actors第一个同时做到了下面两点:1)在无控制反转时允许交互行为;2)同时在程序中允许任意的阻塞操作。我们的actor库在消息传递速度及内存消耗上优于其他已有的actor语言几个数量级。我们的实现可以利用多核多进程,因为互作用可以同时发生在多个处理器中。通过在可移动的运行时系统中扩展这种的基于事件的actors,我们将展示如何在Scala中实现分布式Erlang的精髓。我们的库几乎已经实现了Erlang所有的原型及内置函数。当前,我们的运行时系统分别建立在TCP及JXTA点到点框架(Sun微系统对等网络(P2P)标准)这两种标准上,来保证可移植性。

在具体实现中我们并没有扩展及改变编程语言本身。因此actor库是Scala抽象能力的一个好例子。在2.1.7版本中,它成为了Scala标准库的一部分(现在已经被akka代替)。

下面和其他相关工作进行比较:Actalk,一个Smalltakl-80下的actors库,扩展了纯Smalltalk对象的最小内核。他们的实现并不是基于事件的,且Smalltalk-80并不支持多核并发。

Actra扩展了Smalltalk/V虚拟机并使用了提供轻量级进程的基于对象的实时内核。但我们的实现并没有修改虚拟机本身。

Chrysanthakopoulos和Singh讨论过设计和实现基于通道的异步消息库。通道可以被看做特殊的无状态且必须指明接收类型的actors。他们开发了自己的调度程序来替代重量级的操作系统线程来支持连续传递风格(CPS)的代码。使用集群风格的阻塞样式迭代器代码由C#编译器转换而来。

SALSA(简单Actor语言,系统及架构)扩展了Java的并发并直接支持了actor概念。SALSA程序由预处理器翻译为Java源码以链接一个定制的actor库。对比前面的Smalltalk及通道实现而言,SALSA在JVM实现actors这点上和我们很相似。本文后面我们将发布两者benchmark的性能测试对比结果。

Timber是一个结合OO和函数式编程的语言,一般用于实时嵌入式系统。它提供消息传递原型用于交互对象的同步/异步通信。不同于基于事件的actors,交互对象在长期阻塞时无法调用任何操作。相反它们在计算环境中注册了回调方法以代替它们自身执行操作。

Frugal objects(FROBs) 是通过类型化事件来通信的分布式交互对象。本质上说FROBs是基于事件的计算模型,就像我们的actors一样。但是FROBs和基于事件的actors两者的目标是正交的。前者提供了适用于资源受限设备上的计算模型。后者目的是为基于事件的actors提供一个编程模型(如:方便的语法等),当然也包括FROBs。目前为止,FROBs只能使用Java底层API。未来我们计划双方合作集成目标。

本文剩余章节结构如下。第二章展示了如何用Scala库来表示方便的、基于线程的actors。第三章介绍如何修改上节actor模型,使其变为基于消息。第四章简述Scala分布式actor。第五章评估了我们actor库的性能。第六章总结。

解构Actors

本章描述如何用Scala库实现类似Erlang的抽象。Actors是异步消息传递的、自包含的有效逻辑实体。下面将展示了一个计数器actor。此actor重复一个receive操作,等待三种消息:

  • Incr消息,计数器值+1
  • Value消息,计数器将当前值发给指定的actor
  • Lock消息让事情更加有趣。当收到一个Lock,计数器会将当前值传递给指定的actor,然后阻塞直到收到一个UnLock消息。此消息会指定一个计数器开始继续的值。因此除非它解锁,其他进程将无法观测到一个锁定的计数器状态。

def loop(value: Int): Unit = {Console.println("Value: " + value)receive {case Incr() => loop(value + 1)case Value(a) => a ! value; loop(value)case Lock(a) => a ! valuereceive { case UnLock(v) => loop(v) }case _ => loop(value)}}

除了上面三种消息,其它的都会被直接丢弃。和计数器典型通信方式如下:

val counter = new Countercounter.start()counter ! Incr()counter ! Value(this)receive { case cvalue => Console.println(cvalue) }

首先新建一个计数器,启动,发送Incr()消息来使其自增,然后发送Value来查询当前值。然后等待计数器actor的响应。一旦收到响应就打印它的值(值应该是1,除非有其他actor同时使用了此计数器)。

此模型中的消息是任意对象。在通道编程中一个通道必须指定自身能够处理的消息类型,而一个actor可以收到任何消息类型。

我们的例子中,actors之间使用下面四种消息类来通信:

case class Incr()case class Value(a: Actor)case class Lock(a: Actor)case class UnLock(value: Int)

所有类都用case修饰符来使用构造器模式。所有类都仅有声明而没有主体。Incr类构造器没有参数,Value和Lock类构造器都有一个Actor参数,UnLock有一个整形构造器参数。

a!m 表示将消息m发给actor a。这个通信是异步的,即如果a没有准备好接收m,m将在a的等待队列中排队并立即返回结束操作。

消息在接收结构中处理:

receive {case p1 => e1// ...case pn => en}

这里actor等待队列中的消息会和p1到pn的模式依次进行匹配。模式由构造器和变量组成。每个构造器命名了一个用例类(case class)用来匹配本类的所有实例。每个变量模式匹配每个绑定的值。如模式Value(a)匹配了Value类的所有实例v,并且将变量a绑定到v的构造函数参数中。

接受时将选择actor等待队列中第一条消息来进行匹配操作。如果匹配到了模式pi,相应的ei将会被执行。如果没有消息匹配任意一个模式,actor将会被挂起等待新的消息到达。

在上面例子显示Scala语言简直是为actor并发量身定制的。而事实上并非如此。Scala仅假设了底层主机的基础线程模型。所有高级操作都被定义为Scala库的类和方法。本章剩余部分,我们来看看幕后每个构造器是如何定义及实现的。

一个actor只是定义了!和 receive 方法用来接收发送消息的主机环境线程类的子类。

abstract class Actor extends Thread {private var mailbox: List[Any]def !(msg: Any) = ...def receive[a](f: PartialFunction[Any, a]): a =...// ...
}

!方法用来向actor发送消息。a!m是a.!(m)的缩写,如x+y是x.+(y)的缩写一样。此方法有两个作用,一是将消息发到actor等待队列mailbox——List[Any]类型的私有字段。第二如果能处理此发送消息的接收actor当前处于挂起状态,那么它将被恢复执行。

receive{...} 结构就有趣的多。Scala将括号模式匹配表达式视为头等对象并作为参数传递给接收方。参数类型是一元函数Function1子类PartialFunction的实例。它们的定义如下:

abstract class Function1[-A,+B] {def apply(x: A): B
}abstract class PartialFunction[-A,+B] extends Function1[A,B] {def isDefinedAt(x: A): Boolean
}

我们可以看到函数是有着apply方法的对象。偏函数(Partial function)则多了一个isDefinedAt方法,用来识别指定的a是否有此函数。这两个类都是泛型,第一个类型参数指明函数的参数类型,第二个类型参数指明了返回值的类型。
{case p1 => e1; ...; case pn => en} 也是一种偏函数,其方法如下定义:

  • isDefinedAt:pi匹配成功返回true,匹配失败返回false
  • apply:第一个成功匹配的pi返回ei。如果匹配不到任何模式,将抛出MatchError异常将被抛出。

receive首先按序扫描等待队列中的消息。如果和接收方参数 f匹配成功,那么相应的消息将被从队列中移除并用于f。反之,如果队列中的所有消息对于f.isDefinedAt(m)方法都返回false,此actor对应的线程将被挂起。

上面总结了基于线程的actors。当然并没有涵盖Scala的actor库的全部功能。例如,receiveWithin方法可以指定actor接收消息时的等待超时时间。如果超时一个特殊的模式 TIMEOUT() 将会被触发。超时可用来挂起actor,刷新全部mailbox或用来实现优先级消息。

基于线程的actors作为线程的高级别抽象很有用,因为它们去掉了异步消息传递时易于出错的共享锁。但是和线程一样,它们给JVM之类的平台带来了性能损失,如限制了其可扩展性。下一章我们将展示如何从actor模型中去掉线程。

重写Actors

从逻辑上来说 actor 的执行并没有绑定到某个线程上。但是,所有的actor模型实现都将单个的actor关联到独立的线程、甚至是操作系统进程上。

在Scala中,标准库的线程抽象映射到相应目标平台的线程实现上,如当前的JVM和微软的CLR。

为了克服伸缩性问题,我们提出了一个基于事件的actor实现:1)线程无关;2)两个事件间的运算被认为已完成。一个事件表示一个新消息到达actor的队列。

执行例子

首先,我们直观的介绍此实现是如何工作的。让我们回到第二章的计数器例子。

首先创建一个可锁定的计数器实例c,c有着一个空队列。启动后,c立即阻塞并等待消息匹配。另一个actor p向c发送一个Lock(p)的消息(c ! Lock(p))。Lock消息的到达使得c从阻塞中恢复并获得控制权。c恢复自己的阻塞receive语句。这里我们重用了发送方的线程,而不是在接收actor自己的线程上执行。

根据receive的语义,新的消息匹配上之后被选中并从队列中移除。随后将以匹配的变量执行相应action:

{ case Incr() => loop(value + 1)case Value(a) => a ! value; loop(value)case Lock(a) => a ! valuereceive { case UnLock(v) => loop(v) }case _ => loop(value)
}.apply(Lock(p))

可以直观的缩写如下:

p!value
receive {case UnLock(v) => loop(v) }

 执行之后,由于c的队列中没有其他消息,receive再次被阻塞。请记住此时我们仍然在p的原始消息发送中(发送还没有返回)。因此,阻塞当前线程(通过调用wait()方法)将同样会阻塞p。

这并不合理,因为在我们的编程模型中发送操作 ! 有着非阻塞的语义。相反,我们希望的是挂起 c 的同时 p 仍然继续运行。为了做到这一点,我们在阻塞的receive记住c剩余的计算。本例中,它以如下闭包的形式保存:

receive { case UnLock(v) => loop(v) }

第二,让 p 对于发送的操作返回,我们需要将运行时堆栈解除到控制转移到c的地方。为了做到这一点我们抛出了一个特殊的异常。 ! 方法会捕获此异常,并正常返回,保持其非阻塞语义。

一般来说,保存一个闭包来捕获actor剩余的计算是不够的。考虑一个actor执行如下语句的场景:

val x = receive { case y => f(y) }
g(x)

这里,receive操作产生一个值并传递给一个函数。假设 receive 阻塞了。我们需要在这个阻塞的 receive 操作中保存剩余的计算。

为了保存receive余下的语句信息,我们需要保存调用栈,或者捕获(最高级别的)continuation。由于安全性方面的原因,类似JVM这样的虚拟机并没有提供显式的栈管理。因此,语言本身要实现最高级别的continuation 就必须在堆上模拟运行时栈空间,这会带来一系列性能上的问题。此外,这样做会导致原生VM上的程序调试工具无法找到运行时信息,因为实际上它们被放到了堆上。现有的工具将无法使用。

因此,大多自身支持continuation的语言(Scheme、Ruby)在一些不支持此特性的虚拟机(JRuby)上都选择了不支持最高级别continuation端口。Scala同样也不支持最高级别continuation,主要是因为和现有Java代码的兼容及互操作性。

总结一下,管理调用receive之后的操作信息需要修改编译器或者虚拟机。但是我们期望提供一个基于代码库的方式来避免这些修改。

相对的,我们需要receive方法永远不正常返回。因此处理正常返回的部分可以被省略掉。进一步,我们可以通过Scala类型系统在编译期保证这一点,即返回类型为Nothing之后的代码不执行。注意,抛出异常这种返回仍然是可以的。事实上,上面已经提到过我们的receive正是依赖这一点。

使用一个不返回的receive,上面的例子会看起来如下:

receive{ case y => x = f(y); g(x) }

本质上,actor剩下的计算将在各个匹配分支中参数的receive函数中被调用(continuation passing风格)。

单线程Actor

我们期望避免控制反转的receive在sender上执行。如果所有的actor都在同一个线程上运行,向actor A发送一个消息将唤醒执行导致A挂起的receive。下面的代码展示了单线程actors发送操作的一个简单实现:

def !(msg: Any): Unit = {mailbox += msgif (continuation != null && continuation.isDefinedAt(msg))try {receive(continuation)}catch {case Done => // do nothing}
}

要发送的消息被追加到发送目标actor队列的末尾。我们用A来表示目标actor。如果continuation属性被设置为非空,那么A将会挂起并等待一个合适的消息(否则A不执行receive调用)。这里continuation指的是最后一个被调用的阻塞receive的偏函数(闭包),我们可以测试新的消息是否可以让A继续。

注意,如果我们将一个阻塞的receive(f)保存为continuation,我们将无法进行判断而只能执行此continuation。如果新的消息无法匹配任意模式,receive会重复遍历队列中的所有消息试图找到匹配的消息。当然,这种尝试是徒劳的,因为只有新添加的消息才能使A继续下去。

如果A能够处理新来的消息,我们让A继续直到它阻塞于一个嵌套的receive(g),或者直到它结束自己的计算。对前一种情形,我们保存g的闭包作为A的continuation。然后由于其非阻塞语义,发起A执行的发送操作将返回。因此阻塞的receive将抛出一个类型为Done的特殊异常,此异常将在发送操作(!)中被捕获。此技巧可以释放堆栈到控制权被转给A的地方。来为了解释发送操作是如何运作的,我们深入到reveive实现中。

receive方法从actor的队列中选择消息,然后负责在不解析上下文的同时保存continuation:

def receive(f: PartialFunction[Any, unit]): Nothing = {mailbox.dequeueFirst(f.isDefinedAt) match {case Some(msg) => continuation = nullf(msg)case None => continuation = f}throw new Done
}

我们从队列中取出第一个可以匹配某个偏函数定义的消息,此偏函数作为receive的参数被提供。注意 f.isDefinedAt 的类型为 Any=>boolean。结果的对象类型为Option[Any],它的两种情况可以用两种模式来分别匹配。当一个消息出队时,我们首先重置当前被保存的continuation。这将避免因为调用的 f(msg) 中有向当前actor的发送请求而导致之前的continuation被多次调用。

如果队列中找不到匹配的消息,我们记住作为f闭包的continuation。无论哪种情形我们都需要通过抛出Done来禁止解析上下文,此时请求发送方正常地继续执行。

多线程Actors

为了利用多线程处理器我们希望在多个线程上并发计算。依赖于现代的虚拟机在多个处理器上可以同时执行多个虚拟机线程。

调度器决定一个指定负载的actors需要多少线程,并自然地实现特定的调度策略。因为其异步性,发送消息将引入一个并发活动,即恢复先前被挂起的actor。我们将该活动封装在一个任务中并交给调度器(从某种意义上说这是一次重发):

def send(msg: Any): Unit = synchronized {if(continuation != null&& continuation.isDefinedAt(msg)&& !scheduled) {scheduled = trueScheduler.putTask(new ReceiverTask(this, msg))} else mailbox += msg
}

如果在发送时发现接收actor A的continuation未定义,A将不等待消息。通常这是因为A的任务当前已预订但是未执行。除非接收方actor正处于等待中、并且能够处理此消息,否则发送操作都会将消息追加到队列中。此时我们向调度器提交新的任务来安排执行接收方actor continuation。

调度器维持了一个工作线程池来执行任务。一个ReceiverTask就是接收到一个特殊消息的java.lang.Runnable实例,且有一个异常处理器来禁止上下文的解析:

class RecervierTask(actor: Actor, msg: Any) extends Runnable {def run(): unit = {try {actor receiveMsg msg}catch {case Done => // do nothing }}
}

receiveMsg是receive的特殊形式,用actor的continuation来处理指定消息。

Actors无法阻止永久的阻塞操作。接下来我们描述一个在阻塞情况下也能正常进行的调度器。

阻塞操作

我们实现基于事件特性源于下面两个事实:1)actors是线程无关的;2)在各消息的到达之间,运算被认为已完成。第二点在事件驱动的系统中很常见,它反映了我们对于大多数actor的角色交互的假设。与通信开销相比,各条消息本身需要的计算通常要短得多。

虽然如此,我们也希望能支持长期运行的、CPU绑定的actors。这种actors将不阻止其它actors运行。如果某个阻塞actor妨碍到其它actor甚至引起整个程序无响应将是很不幸的。

在用户级的线程包我们遇到了同样的问题:进程在某些特定的程序节点将控制权交给调度器。在这些节点之间,无法阻止调用阻塞操作或执行无限循环。比如一个actor可能调用一个会引起系统阻塞的本地方法。

对我们而言,调度器仅仅在发送的消息导致另一个actor恢复时才执行。因为发送操作不允许阻塞,所以(被恢复的)接收方需要在其他线程上执行。这样即使接收方阻塞了也不会影响到发送方。

由于调度器可能没有任何空闲的工作线程(即全部处于阻塞状态),因此它需要按需创建新线程。但如果有至少一个可用线程(该线程正在执行某个actor),我们都不会创建新线程。这样做是为了防止在没有阻塞操作时创建过多的线程。

Actors仍然是线程无关的,即使:每次actor由于一个阻塞的(不成功的)receive而挂起时,它将从自己的线程上分离而非去阻塞它。当执行完了一个接受方任务,线程重新变为空闲状态。然后会向调度器请求新任务。因此线程在执行多个actors时重用了。

通过这种方式,基于actor的应用在低并发时甚至可以只需要两个线程就能执行,而不管同一时间活跃的actor数量。

不幸的是,用户级的代码无法得知JVM中的一条线程是否处于阻塞态。因此我们实现了简单的模拟来估计执行actor的线程是否阻塞了。

基本的想法是actor执行时向调度器提供心跳。即send(!)和receive方法调用调度器的tick方法。调度器检查当前执行actor的线程,然后更新其时间戳。当有新的任务被提交到调度器,调度器首先检查是否所有线程都处于阻塞态。有着‘近期’时间戳的线程被认为是非阻塞的。只有所有工作线程都被判定为阻塞时,才会创建新的工作线程。否则任务将被简单的追加到队列中等待工作线程来消费。下面展示了调度器实现的主要部分。

注意到其中使用了近似这个词,因为想明确区分开阻塞的和那些仅仅是执行长时间任务的线程是不可能的。这就意味着计算绑定的actors在它们自己的线程上执行。

def execute(item: ReceiverTask): Unit = synchronized {if (idle.length > 0) {val worker = idle.dequeueexcuting.update(item.actor, worker)worker.execute(item)} else {val iter = workers.elementsvar foundBusy = falsewhile (iter.hasNest && !foundBusy) {val worker = iter.nextticks.get(worker) match {case None => foundBusy = truecase Some(ts) => val currTime = System.currentTimeMillisif (currTime - ts

对于某些应用来说,有必要根据运行时配置来优化空闲线程的数量。使用我们的库很容易实现这类自定义调度器。

总结一下就是,仅在处理意外出现的阻塞操作时才会创建额外的线程。而唯一阻塞操作就是receive。因此,对比很多其它类型的actors,使用我们的库的内存消耗要少的多,因为我们大大减少了线程数量。

另一方面,对于用户需要使用阻塞操作而无现成库支持的情况,我们的方案有着很强的扩展性。因为我们的actors可以和标准虚拟机线程无缝衔接。比如将JXTA作为传输层移植我们的运行时系统时,我们为基于线程的库提供基于actor的接口。

分布式Actors

借助于可移植的运行时系统,actors也能够以分布式的方式执行。具体来说,就是可以透明化消息的发送并在远程节点上生成actors。我们也兼顾到了那些资源有限的设备,它们提供的资源通常比桌面虚拟机少的多。如KVM不提供reflection。这意味着我们的序列化机制不能基于普通的反射机制。我们提供了一个“组合”库来取而代之,允许容易地构造定制的特定数据类型。此组合库基于Kenedy的Haskell库。生成的字节码非常紧凑:1)数据结构是共享的;2)使用base128来编码整数。

网络协议依赖于一个独立的网络层(连接管理、消息传递),在此意义上我们的系统是可移植的。两种工作原型分别相互独立的基于TCP和JXTA。TCP和JXTA是如此的不同,我们希望将来也能够移植到其它协议上。

我们现在致力于SOAP协议。目标是提供基于actor的web service服务,像google和亚马逊暴露的公共API那样。甚至把web service集成进actors内部。

性能评估

本章我们调查一下基于事件的actors的性能。我们对比了我们库中一个基于线程的版本以及SALSA(基于Java的最先进的actor语言)。作为对比我们也展示了直接使用线程和同步数据块结构的实现。除了执行时间,我们对水平扩展性也很感兴趣,即一个系统最多能同时处理的actors数量。

实验准备。我们测量了一个基于队列的系统中阻塞操作的吞吐量。用于测试的数据结构是一个由n个生产者/消费者(下文简称为进程) 组成的环,其中每个有一个共享队列。初始化时,其中k条队列含有token,其他为空。每个进程循环的从右边的队列取得数据之后放到左边的队列中。

测试运行在1.60GHz Intel奔腾 M处理器/ 512MB内存上,软件环境是Linux 2.6.12下Sun的Java HotSpot Client虚拟机。虚拟机堆内存设置为256MB来避免物理内存不足时发生的磁盘交换。每个用例我们取5次测试的中值。

我们用 1)基于事件的actor库;2)基于线程的近似库;3)SALSA 这三种方式来比较。

实验结果。上图展示了最多2000个进程需要的启动时间(注意横纵坐标坐标都是对数形式)。基于事件的actor和基于线程的原生实现表现的很稳定。基于事件的actors要慢上60%。但我们有理由相信这是因为不同的benchmark实现所导致。在所有基于actor的实现中,所谓的启动时间是让所有actors启动并等待一个特定的continue消息。相反基于线程的实现仅仅需要创建所需线程而不需要启动。这表明JVM优化了线程的创建过程,很可能才去的是在需要时才真正创建的懒加载。基于线程的actors的启动时间在进程上千后呈指数级增长。4000个进程时JVM会由于超出堆内存而崩溃。

使用SALSA时虚拟机无法创建2000个进程。由于每个actor都有一个基于线程的状态对象与其关联,虚拟机无法处理所需要的栈空间。相反,基于事件的actors 10秒内可以创建310000个进程。

生成的Java代码显示出SALSA在设置actor远程连接时耗费了大量的时间(包括创建本地描述符,命名表管理等等),与之对应的是我们的actor会明确声明是否想要参与到远程通信中(通过调用alive()方法)。创建本地描述符和命名表管理可以随之被推迟到调用alive的节点。并且SALSA创建actor时会以一种特殊结构的消息来发送自身,这也耗费了额外的时间。

上图展示了不同环大小时每秒传递的token数量。为了更好的描述进程快速增长所带来的影响,我们同样选择了对数坐标。到1000个进程时,基于事件的actors的吞吐量比纯线程的实现平均高出22%。由于阻塞操作占主导地位,线程开销可能主要源于上下文切换及争用锁。有趣的是,少量进程时此开销会消失(10/20个进程时)。这表明这种情况下竞争不是问题所在,Sun的HotSpot VM 1.5对非竞争锁管理进行了优化。2000个进程之后,竞争开始变的明显。当进程达到4000,比起交换token时间被更多地耗费在管理共享缓冲区。在这一点上,基于事件的吞吐量大概是线程的三倍。

SALSA的吞吐量比前面的要低两个数量级。10到1000个进程平均每秒交换数是1700。生成的Java代码显示它的每次消息发送都会调用一次反射方法。在我们的机器上,反射方法调比JIT编译方法调用慢30倍。

基于线程的actors在吞吐量在200个进程时保持恒定(平均每秒38000次)。500进程后减为一半(每秒15772次)。和纯线程实现一样,吞吐量在2000个进程时突然下跌(每秒仅5426次)。很可能是因为争用锁和上下文切换开销导致。由于用尽内存,虚拟机无法创建4000个进程。

总结。基于事件的actors支持的并发活跃actors比SALSA高出两个数量级,而吞吐量比SALSA高50倍。基于线程的实现在我们的测试中表现令人惊讶。但是在大量线程的情况下(2000个),锁竞争导致性能下降。同事,线程的数量受限于内存消耗。

总结

Scala有别于其他并发语言,除了主机提供的线程模型外并没有在语言层面对并发提供支持。我们更多的是依赖Scala的抽象能力,来建立一个高级并发模型。我们用这种方式来在Scala中重建Erlang的actor精华部分。

由于Scala实现在Java虚拟机上,因此从主机环境中继承了一些不足,即线程最大数量过小及上下文切换的高开销。本文中我们展示了如何扬长避短。通过定义一个基于事件的actors模型,我们可以显著提高效率及扩展性。同时我们在很大程度上保持了基于线程的编程模型,这在传统的基于事件的结构中是不可能的,因为后者导致了控制反转。

本文很好地展示了通过基于类库的设计所带来的伸缩性。我们可以开发一个基于事件的并行类架构来快速定位之前基于线程actor的问题。目前这两种方式同时存在着。基于线程的actors仍很有用,因为它们允许一个receive操作返回。基于事件的actors在编程风格中有着更多限制,但是它们效率也更高。

未来我们计划基于其他通讯协议来扩展当前的actor实现。同时我们也在探索新的actors实现方式。


转:https://my.oschina.net/landas/blog/1608385



推荐阅读
  • 一、RabbitMQ是什么1、MQ的主要作用是:异步、消峰、解耦2、高并发、高可用的成熟方案,支持多种消息协议,易于部署和使用Rabbit ... [详细]
  • ejava,刘聪dejava
    本文目录一览:1、什么是Java?2、java ... [详细]
  • 分布式消息_58分布式消息队列WMB设计与实践
    篇首语:本文由编程笔记#小编为大家整理,主要介绍了58分布式消息队列WMB设计与实践相关的知识,希望对你有一定的参考价值。 ... [详细]
  • Android中高级面试必知必会,积累总结
    本文介绍了Android中高级面试的必知必会内容,并总结了相关经验。文章指出,如今的Android市场对开发人员的要求更高,需要更专业的人才。同时,文章还给出了针对Android岗位的职责和要求,并提供了简历突出的建议。 ... [详细]
  • t-io 2.0.0发布-法网天眼第一版的回顾和更新说明
    本文回顾了t-io 1.x版本的工程结构和性能数据,并介绍了t-io在码云上的成绩和用户反馈。同时,还提到了@openSeLi同学发布的t-io 30W长连接并发压力测试报告。最后,详细介绍了t-io 2.0.0版本的更新内容,包括更简洁的使用方式和内置的httpsession功能。 ... [详细]
  • Oracle优化新常态的五大禁止及其性能隐患
    本文介绍了Oracle优化新常态中的五大禁止措施,包括禁止外键、禁止视图、禁止触发器、禁止存储过程和禁止JOB,并分析了这些禁止措施可能带来的性能隐患。文章还讨论了这些禁止措施在C/S架构和B/S架构中的不同应用情况,并提出了解决方案。 ... [详细]
  • 一句话解决高并发的核心原则
    本文介绍了解决高并发的核心原则,即将用户访问请求尽量往前推,避免访问CDN、静态服务器、动态服务器、数据库和存储,从而实现高性能、高并发、高可扩展的网站架构。同时提到了Google的成功案例,以及适用于千万级别PV站和亿级PV网站的架构层次。 ... [详细]
  • 本文介绍了OpenStack的逻辑概念以及其构成简介,包括了软件开源项目、基础设施资源管理平台、三大核心组件等内容。同时还介绍了Horizon(UI模块)等相关信息。 ... [详细]
  • HashMap的相关问题及其底层数据结构和操作流程
    本文介绍了关于HashMap的相关问题,包括其底层数据结构、JDK1.7和JDK1.8的差异、红黑树的使用、扩容和树化的条件、退化为链表的情况、索引的计算方法、hashcode和hash()方法的作用、数组容量的选择、Put方法的流程以及并发问题下的操作。文章还提到了扩容死链和数据错乱的问题,并探讨了key的设计要求。对于对Java面试中的HashMap问题感兴趣的读者,本文将为您提供一些有用的技术和经验。 ... [详细]
  • C#多线程解决界面卡死问题的完美解决方案
    当界面需要在程序运行中不断更新数据时,使用多线程可以解决界面卡死的问题。一个主线程创建界面,使用一个子线程执行程序并更新主界面,可以避免卡死现象。本文分享了一个例子,供大家参考。 ... [详细]
  • 深入理解Java虚拟机的并发编程与性能优化
    本文主要介绍了Java内存模型与线程的相关概念,探讨了并发编程在服务端应用中的重要性。同时,介绍了Java语言和虚拟机提供的工具,帮助开发人员处理并发方面的问题,提高程序的并发能力和性能优化。文章指出,充分利用计算机处理器的能力和协调线程之间的并发操作是提高服务端程序性能的关键。 ... [详细]
  • 云原生应用最佳开发实践之十二原则(12factor)
    目录简介一、基准代码二、依赖三、配置四、后端配置五、构建、发布、运行六、进程七、端口绑定八、并发九、易处理十、开发与线上环境等价十一、日志十二、进程管理当 ... [详细]
  • 深入理解线程、进程、多线程、线程池
    本文以QT的方式来走进线程池的应用、线程、进程、线程池、线程锁、互斥量、信号量、线程同步等的详解,一文让你小白变大神!为什么要使用多线程、线程锁、互斥量、信号量?为什么需要线程 ... [详细]
  • 近期看见一篇来自Intel的很有意思的分析文章,作者提到在他向45名与会的各公司程序员开发经理战略师提问“什么是实施并行编程的最大障碍”时,下面五个因素 ... [详细]
  • 随着分布式系统的规模和复杂度提高,往往会出现如下问题:(1)系统间同步通信,客户端发出调用后,必 ... [详细]
author-avatar
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有