大数据处理中的Scala可扩展框架与Actor模型
1. 学习分类器系统的现状
学习分类器系统(LCS)在科学界的认可进程较为缓慢,其广泛应用受到以下因素的阻碍:
-
算法配置复杂
:由于存在大量用于探索和利用的参数,算法配置极为复杂。
-
缺乏统一理论
:缺少验证进化策略或规则概念的统一理论。毕竟这些算法是独立技术的融合,LCS执行的准确性和性能依赖于每个组件以及组件之间的交互。
-
执行难以预测
:在可扩展性和性能方面,执行情况并非总是可预测的。
-
变体过多
:LCS存在太多变体。
2. 强化学习相关问题
强化学习算法有时会被软件工程界忽视,以下是一些常见问题:
- 什么是强化学习?
- 哪些不同类型的算法属于强化学习?
- 如何在Scala中实现Q学习算法?
- 如何将Q学习应用于期权交易优化?
- 使用强化学习的优缺点是什么?
- 什么是学习分类器系统?
- XCS算法的关键组件有哪些?
- 学习分类器系统的潜力和局限性是什么?
3. 可扩展框架的需求
随着社交网络、交互式媒体和深度分析的出现,每日处理的数据量急剧增加。对于数据科学家来说,不仅要找到最合适、最准确的算法来挖掘数据,还需要利用多核CPU架构和分布式计算框架及时解决问题。因为如果模型无法扩展,数据挖掘应用的价值就会大打折扣。
4. Scala开发者的选择
Scala开发者有多种选择来构建用于大型数据集的分类和回归应用,包括Scala并行集合、Actor模型、Akka框架和Apache Spark内存集群。具体涵盖以下主题:
- 介绍Scala并行集合
- 评估并行集合在多核CPU上的性能
- Actor模型和反应式系统
- 使用Akka进行集群和可靠的分布式计算
- 使用Akka路由器设计计算工作流
- 介绍Apache Spark集群及其设计原则
- 使用Spark MLlib进行聚类
- Spark的相对性能调优和评估
- Apache Spark框架的优缺点
5. 框架间的依赖关系
不同的堆叠框架和库提供了分布式和并发处理的支持。Scala并发和并行集合类利用了Java虚拟机的线程能力。Akka.io实现了最初作为Scala标准库一部分引入的可靠动作模型。Akka框架支持远程Actor、路由和负载均衡协议,以及调度器、集群、事件和可配置邮箱管理,还支持不同的传输模式、监督策略和类型化Actor。Apache Spark的弹性分布式数据集借助了Scala和Akka库,具备高级序列化、缓存和分区功能。
以下是框架间相互依赖关系的堆栈表示:
| 框架 | 相关组件 |
| — | — |
| Spark | Partitioner, Accumulator: org.apache.spark
Broadcast: org.apache.spark.broadcast
Resilient datasets: org.apache.spark.rdd
Caching: org.apache.spark
Listeners: org.apache.spark.scheduler.
Serialization: org.apache.spark.serializer |
| Scala | Scheduler: scala.actors.scheduler
Concurrency: scala.concurrent
Parallel collections: scala.collection.parallel |
| Akka | Threads, executors: java.util.concurrent*
Actors, Supervisors: akka.actors.
Remote actors: akka.remote
Type actors: akka.actors.
Mailbox management: akka.mailbox.
Clusters: akka.cluster.
Dispatchers: akka.dispatch
Events management: akka.event.
Routing, Broadcast: akka.routing
Persistency: akka.persistence._ |
每个层都为前一层添加了新功能以提高可扩展性。Java虚拟机在单个主机内作为一个进程运行。Scala并发类通过利用多核CPU能力支持应用的有效部署,而无需编写多线程应用。Akka将Actor范式扩展到具有高级消息传递和路由选项的集群。最后,Apache Spark利用Scala高阶集合方法和Akka对Actor模型的实现,通过其弹性分布式数据集和内存持久性,为大规模数据处理系统提供更好的性能和可靠性。
6. Scala标准库的工具
Scala标准库提供了丰富的工具,如并行集合和并发类,可用于扩展数值计算应用。尽管这些工具在处理中等规模数据集时非常有效,但开发者往往会因为更复杂的框架而放弃使用它们。
6.1 控制对象创建
在使用Scala处理大型数据集时,创建大量对象和垃圾回收器的负载是一个令人头疼的问题。可以采取以下一些简单步骤来提高应用的可扩展性:
- 使用可变实例限制迭代函数中对象的不必要复制。
- 使用惰性值和Stream类按需创建对象。
- 利用高效的集合,如布隆过滤器或跳表。
- 运行javap来解读JVM生成的字节码。
6.2 并行集合
Scala标准库包含并行化集合,其目的是让开发者无需处理并发线程执行和竞态条件的复杂性。并行集合是将并发构造封装到更高抽象级别的便捷方法。
创建并行集合有两种方式:
- 使用
par
方法将现有集合转换为具有相同语义的并行集合,例如
List[T].par: ParSeq[T]
,
Array[T].par: ParArray[T]
,
Map[K,V].par: ParMap[K,V]
等。
- 使用
collection.parallel
、
parallel.immutable
或
parallel.mutable
包中的集合类,例如
ParArray
、
ParMap
、
ParSeq
、
ParVector
等。
6.3 处理并行集合
并行集合在分配线程池和任务调度器之前无法进行并发处理。Scala并行和并发包为开发者提供了强大的工具,可将集合的分区或段映射到不同CPU核心上运行的任务。相关组件如下:
-
TaskSupport
:该特质继承自通用的
Tasks
特质,负责调度并行集合上的操作。有三种具体实现:
-
ThreadPoolTaskSupport
:使用旧版本JVM中的线程池。
-
ExecutionContextTaskSupport
:使用
ExecutorService
,将任务管理委托给线程池或ForkJoinTasks池。
-
ForkJoinTaskSupport
:使用Java SDK 1.6中引入的
java.util.concurrent.FortJoinPool
类型的fork - join池。在Java中,fork - join池是
ExecutorService
的一个实例,它不仅尝试运行当前任务,还会运行其任何子任务。它执行的
ForkJoinTask
实例是轻量级线程。
以下是一个使用并行向量和
ForkJoinTaskSupport
生成随机指数值的示例:
val rand = new ParVector[Float]
Range(0, MAX).foreach(n =>rand.updated(n, n*Random.nextFloat))//1
rand.tasksupport = new ForkJoinTaskSupport(new ForkJoinPool(16))
val randExp = vec.map( Math.exp(_) )//2
随机概率的并行向量
rand
由主任务创建和初始化(第1行),但转换为指数值向量
randExp
由16个并发任务组成的池执行(第2行)。
6.4 保持元素顺序
使用迭代器遍历并行集合的操作可以保留集合元素的原始顺序。而像
foreach
或
map
这样无迭代器的方法不能保证处理元素的顺序。
7. 基准测试框架
Scala标准库有一个
testing.Benchmark
特质,可用于通过命令行进行测试。只需将函数或代码插入
run
方法即可:
object test with Benchmark { def run { /* fill the blank */ }
并行集合的主要目的是通过并发提高执行性能。首先,创建一个参数化类
Benchmark
来评估并行数组
v
相对于数组
u
操作的性能:
class ParArrayBenchmark[U](u: Array[U], v: ParArray[U], times:Int)
然后创建一个
timing
方法来计算并行集合上给定操作的持续时间与单线程集合上相同操作的持续时间之比:
def timing(g: Int => Unit ): Long = {
var startTime = System.currentTimeMillis
Range(0, times).foreach(g)
System.currentTimeMillis - startTime
}
该方法测量处理用户定义函数
g
times
次所需的时间。
以下是比较并行化数组和默认数组在
map
和
reduce
方法上的代码:
def map(f: U => U)(nTasks: Int): Unit = {
val pool = new ForkJoinPool(nTasks)
v.tasksupport = new ForkJoinTaskSupport(pool)
val duration = timing(_ => u.map(f)).toDouble //3
val ratio = timing( _ => v.map(f))/duration //4
Display.show(s"$nTasks, $ratio", logger)
}
def reduce(f: (U,U) => U)(nTasks: Int): Unit = {
val pool = new ForkJoinPool(nTasks)
v.tasksupport = new ForkJoinTaskSuppor(pool)
val duration = timing(_ => u.reduceLeft(f)).toDouble
val ratio = timing( _ => v.reduceLeft(f) )/duration
Display.show(s"$nTasks, $ratio", logger)
}
同样的模板可用于其他Scala高阶方法,如
filter
。记录并行化数组上操作的执行持续时间与单线程数组的持续时间之比更有价值。
ParMapBenchmark
类用于评估
ParHashMap
,与
ParArray
的基准测试类似。例如,
ParMapBenchmark
的
filter
方法评估并行映射
v
相对于单线程映射
u
的性能:
def filter(f: U => Boolean)(nTasks: Int): Unit = {
val pool = new ForkJoinPool(nTasks)
v.tasksupport = new ForkJoinTaskSupport(pool)
val duration = timing(_ => u.filter(e => f(e._2))).toDouble
val ratio = timing( _ => v.filter(e => f(e._2)))/duration
Display.show(s"$nTasks, $ratio", logger)
}
8. 性能评估
8.1 第一次性能测试
创建单线程和并行的随机值数组,并使用递增的任务数量执行
map
和
reduce
评估方法:
val sz = 1000000
val data = Array.fill(sz)(Random.nextDouble)
val pData = ParArray.fill(sz)(Random.nextDouble)
val times: Int = 50
val bench1 = new ParArrayBenchmark[Double](data, pData, times)
val mapper = (x: Double) => Math.sin(x*0.01) + Math.exp(-x)
Range(1, 16).foreach(n => bench1.map(mapper)(n))
val reducer = (x: Double, y: Double) => x+y
Range(1, 16).foreach(n => bench1.reduce(reducer)(n))
测试在具有8GB可用内存的8核CPU的JVM上执行
mapper
和
reducer
函数100万次。结果如下:
-
reducer
没有利用数组的并行性。
ParArray
的归约在单任务场景下有小的开销,之后与
Array
的性能相当。
-
map
函数的性能受益于数组的并行化。当分配的任务数量等于或超过CPU核心数量时,性能趋于平稳。
8.2 第二次性能测试
比较两个并行集合
ParArray
和
ParHashMap
在
map
和
filter
方法上的行为:
val sz = 1000000
val mData = new HashMap[Int, Double]
Range(0, sz).foreach(n => mData.put(n, Random.nextDouble)) //1
val mParData = new ParHashMap[Int, Double]
Range(0, sz).foreach(n => mParData.put(n, Random.nextDouble))
val bench2 = new ParMapBenchmark[Double](mData, mParData, times)
Range(1, 16).foreach(n => bench2.map(mapper)(n)) //2
val filterer = (x: Double) => (x > 0.8)
Range(1, 16).foreach(n => bench2.filter(filterer)(n)) //3
测试用100万个随机值初始化
HashMap
实例及其并行对应物
ParHashMap
。
bench2
使用第一次测试中引入的
mapper
实例和过滤函数
filterer
处理这些哈希映射的所有元素。集合并行化的影响在不同方法和集合之间非常相似。需要注意的是,对于5个或更多并发任务,并行集合的性能约为单线程集合的4倍时趋于平稳。部分原因是核心停车,它会禁用一些CPU核心以节省电量,在单个应用中几乎会消耗所有CPU周期。
8.3 进一步性能评估
性能测试的目的是突出使用Scala并行集合的好处。建议进一步试验
ParArray
和
ParHashMap
之外的集合以及其他高阶方法,以确认这种模式。虽然并行集合的性能有四倍的提升,但它仅限于单主机部署。如果需要可扩展的解决方案,Actor模型可以提供高度分布式应用的蓝图,我们将在下半部分详细介绍。
大数据处理中的Scala可扩展框架与Actor模型
9. 传统多线程应用的问题
传统的多线程应用依赖于访问共享内存中的数据,为避免死锁和不一致的可变状态,需要使用同步监视器,如锁、互斥锁或信号量。然而,即使是经验丰富的软件工程师,调试多线程应用也并非易事。
此外,Java中共享内存线程还存在高计算开销的问题,这是由连续的上下文切换引起的。上下文切换包括将当前由基指针和栈指针界定的栈帧保存到堆内存中,并加载另一个栈帧。
10. Actor模型的引入
为避免上述限制和复杂性,可以使用基于以下关键原则的并发模型:
-
不可变数据结构
:数据结构在创建后不可修改,避免了因数据共享带来的并发问题。
-
异步通信
:通过异步消息传递进行通信,减少了线程之间的依赖和阻塞。
Actor模型最初在Erlang编程语言中引入,它解决了上述问题,其目的有两个方面:
-
分布式计算
:尽可能将计算分布到多个核心和服务器上,提高计算效率。
-
减少并发问题
:减少或消除Java开发中常见的竞态条件和死锁问题。
Actor模型由以下组件构成:
-
Actor
:独立的处理单元,通过异步交换消息进行通信,而不是共享状态。
-
邮箱(Mailbox)
:不可变消息在被每个Actor依次处理之前会被发送到队列(即邮箱)中。
Actor之间的消息传递有两种机制:
-
Fire-and-forget(即发即弃)或tell
:将不可变消息异步发送到目标或接收Actor,立即返回而不阻塞。语法如下:
targetActorRef ! message
-
Send-and-receive(发送并接收)或ask
:异步发送消息,但返回一个
Future实例,该实例定义了来自目标Actor的预期回复。
val future = targetActorRef ? message
Actor消息处理程序的通用结构与Java中的
Runnable.run()
方法有些相似:
while( true ){
receive { case msg1: MsgType => handler }
}
receive
关键字实际上是一个类型为
PartialFunction[Any, Unit]
的偏函数。其目的是避免强迫开发者处理所有可能的消息类型。因为生产消息的Actor和消费消息的Actor可能运行在不同的组件甚至应用中,很难预测Actor在应用的未来版本中需要处理的消息类型。对于类型不匹配的消息,会直接被忽略,无需在Actor的例程中抛出异常。Actor模型的实现努力避免上下文切换和线程创建的开销。
11. Actor模型中的I/O阻塞操作
虽然强烈建议不要将Actor用于I/O等阻塞操作,但在某些情况下,发送者需要等待响应。需要注意的是,在Actor内部阻塞底层线程可能会使其他Actor无法获得CPU周期。建议要么将运行时系统配置为使用大的线程池,要么通过将
actors.enableForkJoin
属性设置为
false
来允许线程池调整大小。
12. 数据集的分区处理
数据集通常被定义为Scala集合,如
List
、
Map
等。并发处理需要以下步骤:
1.
数据集拆分
:将数据集分解为多个子数据集。
2.
独立并发处理
:独立且并发地处理每个子数据集。
3.
结果聚合
:将所有处理结果聚合为一个最终结果。
这些步骤通过与集合关联的单子(monad)来定义,具体如下:
1.
创建子集合
:
apply
方法用于创建子集合或分区,例如
def apply[T](a: T): List[T]
。
2.
映射操作
:类似
map
的操作定义了第二阶段,最后一步依赖于Scala集合的幺半结合性,例如
def ++ (a: List[T], b: List[T]): List[T] = a ++ b
。
3.
结果聚合
:聚合操作(如
reduce
、
fold
、
sum
等)将所有子结果展平为单个输出,例如
val xs: List(…) = List(List(..), List(..)).flatten
。
可以并行化的方法包括
map
、
flatMap
、
filter
、
find
和
filterNot
;而不能完全并行化的方法有
reduce
、
fold
、
sum
、
combine
、
aggregate
、
groupBy
和
sortWith
。
下面是数据集分区处理的流程图:
graph TD;
A[数据集] --> B[拆分数据集为子数据集];
B --> C[独立并发处理子数据集];
C --> D[聚合子数据集结果];
D --> E[最终结果];
13. 反应式编程与Actor模型
Actor模型是反应式编程范式的一个示例。反应式编程的概念是函数和方法在响应事件或异常时执行,它将并发与基于事件的系统相结合。
高级函数式反应式编程构造依赖于可组合的未来(futures)和延续传递风格(CPS)。例如,一个Scala反应式库可以在https://github.com/ingoem/scala-react 找到。
14. Akka框架
Akka框架扩展了Scala中的原始Actor模型,增加了提取功能,如对类型化Actor的支持、消息调度、路由、负载均衡和分区,以及监督和可配置性。可以从www.akka.io网站下载Akka框架,也可以通过http://www.typesafe.com/platform 上的Typesafe Activator下载。
Akka通过将Scala Actor的一些细节封装在
akka.actor.Actor
和
akka.actor.ActorSystem
类中,简化了Actor的实现。需要重写的三个方法如下:
-
preStart
:可选方法,在Actor执行之前调用,用于初始化所有必要的资源,如文件或数据库连接。
-
receive
:定义Actor的行为,返回一个类型为
PartialFunction[Any, Unit]
的偏函数。
-
postStop
:可选方法,用于清理资源,如释放内存、关闭数据库连接、套接字或文件句柄。
15. 类型化与非类型化Actor
- 非类型化Actor :可以处理任何类型的消息。如果接收Actor不匹配消息类型,消息将被丢弃。非类型化Actor可以看作是无契约的Actor,是Scala中的默认Actor类型。
- 类型化Actor :类似于Java远程接口,响应方法调用。调用是公开声明的,但执行会异步委托给目标Actor的私有实例。
16. Akka的应用与监督策略
Akka提供了多种功能来部署并发应用。可以创建一个通用模板,用于主Actor和工作Actor,以使用从
PipeOperator
特质继承的任何预处理或分类算法来转换数据集。主Actor可以通过以下方式管理工作Actor:
-
单个Actor
:分别管理每个工作Actor。
-
集群管理
:通过路由器或调度器进行集群管理。
路由器是Actor监督的一个简单示例。在Akka中,监督策略是使应用具有容错性的重要组成部分。监督者(Supervisor)Actor管理其子Actor(即下属)的操作、可用性和生命周期,Actor之间的监督组织成一个层次结构。监督策略分为以下几类:
| 监督策略 | 说明 |
| — | — |
| 一对一策略(One-for-one strategy) | 默认策略,当某个下属Actor失败时,监督者仅对该下属执行恢复、重启或恢复操作。 |
| 全对一策略(All-for-one strategy) | 当某个Actor失败时,监督者对所有下属执行恢复或补救操作。 |
综上所述,Scala的并行集合、Actor模型以及Akka框架为大数据处理提供了强大的工具和解决方案。Scala并行集合在单主机环境下能有效提高处理性能,而Actor模型和Akka框架则适用于构建高度分布式的应用,以应对大规模数据处理的挑战。在实际应用中,可以根据具体的需求和场景选择合适的技术和方法,以实现高效、可靠的大数据处理系统。
超级会员免费看
122

被折叠的 条评论
为什么被折叠?



