依据Spark 1.4版

序列化和反序列化

前边提到,TorrentBroadcast的关键就在于特殊的序列化和反序列化设置。1.1版的TorrentBroadcast实现了自己的readObject和writeObject方法,但是1.4.1版的TorrentBroadcast没有实现自己的readObject方法,那么它是如何进行序列化和反序列化的呢?

// obj就是被广播的对象
private val numBlocks: Int = writeBlocks(obj) override protected def getValue() = {
_value
} @transient private lazy val _value: T = readBroadcastBlock()

可以认为TorrentBroadcast对象经过了三个主要阶段的处理:构造器,序列化,反序列化

构造器

在构造TorrentBroadcast对象时,numBlocks会被初始化,此时writeBlocks会被执行。writeBlocks会执行把obj序列化,分块,存储进BlockManager等操作。

而_value域是lazy的,因此在TorrentBroadcast对象初始化时,_value不会初始化,readBroadcastBlock也不会执行。

序列化

当在driver端对RDD调用一个action时,会生成Task对象,Task对象引用到的对象会被序列化,然后对每一个task,反序列化一个Task对象。

TorrentBroadcast需要保证被广播的对象不会随Task一起序列化。需要注意以下两点:

private[spark] class TorrentBroadcast[T: ClassTag](obj: T, id: Long)
extends Broadcast[T](id) with Logging with Serializable {
  ……
}
@transient private lazy val _value: T = readBroadcastBlock()

Scala的构造函数里的参数并不一定会成为对象的字段,像obj这种只是用来构造对象、没有被用于实现方法的构造器参数,不会成为TorrentBroadcast的字段,因此不会被序列化。

而_value尽管引用了被广播的数据,但它是@transient的,因此也不会被序列化。

反序列化

反序列化的关键在于,_value不会被反序列化。因此,如果某个executor没有task使用TorrentBroadcast的value方法,被广播的数据就不会被在这个executor端获取。

实现这种功能的关键在于Scala的lazy val。

首先,考虑这个问题:lazy val可能被多个线程同时访问,这会触发lazy val的初始化,但是需要保证这个初始化的过程就线程安全的,即lazy val只被初始化一次,且初始化的结果对所有线程可见。实现这种行为,最简单的做为是使用this做同步,但是这样的效率会很低,而Scala实现lazy val使用了一种效率更高的方法,但不管怎么做,lazy val比普通的val的访问效率会降低。

举一个Double-checked locking idiom, sweet in Scala!中的例子:

lazy val myLazyField = create();

会被编译成:

   public volatile int bitmap$0;
private Object myLazyField; public String myLazyField() {
if((bitmap$0 & 1) == 0)
{
synchronized(this)
{
if((bitmap$0 & 1) == 0)
{
myLazyField = ...
bitmap$0 = bitmap$0 | 1;
}
}
}
return myLazyField;
}

即通过一个volatile变量来判断这个lazy val是否已经初始化,通过双重检查加锁来做初始化。

现在有了新的问题:

1. 默认的序列化过程是否会触发lazy val被初始化呢?

2. 如果在TorrentBroadcast对象被序列化之前,lazy val被访问,触发了初始化过程,那么被广播的数据相关于作为TorrentBroadcast的一个field,也会被序列化。

问题1的答案是不会触发。问题2的答案_value需要被注明是transient,就像TorrentBroadcast里所做的一样。

所以,在函数中如果经常使用Broadcast.value方法返回的对象时,比如在循环中使用它,最后先在循环外创建一个对这个对象的引用,以减少一些开销。

但是,lazy val的这种线程安全机制对于TorrentBroadcast是浪费的。因为Broadcast变量是随Task一起序列化的,每个线程有自己的Task对象,也就是线程间不共享Broadcast对象。实际上,为了保证同一个JVM上运行的不同task得到同样的被广播的对象,readBroadcastBlock方法是使用TorrentBroadcast这个class做了同步,

下面来看一下把被广播的对象分块存储的过程

将广播的对象分块存储

这一步是在TorrentBroadcast对象初始化时候做的。

val numBlocks: Int = writeBlocks(obj)

触发。下面看一下writeBlocks方法

writeBlocks

  private def writeBlocks(value: T): Int = {
// Store a copy of the broadcast variable in the driver so that tasks run on the driver
// do not create a duplicate copy of the broadcast variable's value.
SparkEnv.get.blockManager.putSingle(broadcastId, value, StorageLevel.MEMORY_AND_DISK,
tellMaster = false)
val blocks =
TorrentBroadcast.blockifyObject(value, blockSize, SparkEnv.get.serializer, compressionCodec)
//blocks的类型是Array[ByteBuffer]
blocks.zipWithIndex.foreach { case (block, i) =>
SparkEnv.get.blockManager.putBytes(
BroadcastBlockId(id, "piece" + i),//以BroadcastBlockId为BlockId存储
block,
StorageLevel.MEMORY_AND_DISK_SER,
tellMaster = true)
}
blocks.length
}

正如代码中的注释所说的,writeBlocks会首先把被广播的对象用putSingle方法放在driver的BlockManager里,这是为了当在driver运行task时,不会额外创建一个被广播的对象的副本。若没有这一步,在driver端运行task时,会和executor端一样,通过Broadcast对象的value方法新建一个被广播的对象,这就使得driver端有两份这个对象。但实际上driver端运行task的情况并不常见。所以这里最好根据conf判断下是否有必要这么做。

接下来,使用伴生对象的blockifyObject方法把对象分块,得到的结果是一个ByteBuffer的数组。然后把这些块存进BlockManager, 这里有两点需要注意:

1. 把块存进BlockManager时,使用的id是BroadcastBlockId(id, "piece" + i)。也就是说跟据Broadcast对象的id,以及总共的块的数量就可以还原出所有的块存储时所使用的id。这也就是为什么TorrentBroadcast要有numBlocks这个field的原因。而id字段是Broadcast这个虚类里的val, 所以根据TorrentBroadcast对象的字段,即可以它所划分的所有block的id。在从这些块还原被broadcast的对象时,也的确是这么做的。

2. 把划分出的块存储进BlockManager时,tellMaster字段的值为true,这就使得master可以知道哪个BlockManager存储了这个块,因此executor端的BlockManager最初的时候才能从driver端的BlockManager获取这个块。相反的是,writeBlocks第一句putSingle时,tellMaster是false,因为并不准备让其它BlockManager获取putSingle进去的对象。

blockifyObject

blockifyObject作的工作就是将被广播的对象序列化,如果启用了压缩就进行压缩,然后将得到的字节流写入到一系列字节数组中。

它的返回值类型为:Array[ByteBuffer], 之所有是ByteBuffer, 是为了BlockManager使用方便,因为BlockManager的putBytes方法接受ByteBuffer作为参数。

  def blockifyObject[T: ClassTag](
obj: T,
blockSize: Int,
serializer: Serializer,
compressionCodec: Option[CompressionCodec]): Array[ByteBuffer] = {
val bos = new ByteArrayChunkOutputStream(blockSize)
val out: OutputStream = compressionCodec.map(c => c.compressedOutputStream(bos)).getOrElse(bos)
val ser = serializer.newInstance()
val serOut = ser.serializeStream(out)
serOut.writeObject[T](obj).close()
bos.toArrays.map(ByteBuffer.wrap)
}

它实现的关键在于ByteArrayChunkOutputStream, 这个类实现了Java的OutputStream接口。它的主体部分如下:

private[spark] class ByteArrayChunkOutputStream(chunkSize: Int) extends OutputStream {

  private val chunks = new ArrayBuffer[Array[Byte]]
private var lastChunkIndex = -1
private var position = chunkSize
override def write(b: Int): Unit = {
allocateNewChunkIfNeeded()
chunks(lastChunkIndex)(position) = b.toByte
position += 1
}  override def write(bytes: Array[Byte], off: Int, len: Int): Unit = { ... }
 def toArrays: Array[Array[Byte]] = { ... }
 ... 

}

即,它在内部使用一些长度等于chunkSize的数组来存储被写入的字节。

组装还原被广播的对象

在executor端(如果有task在driver执行的话,也可以是在driver端)需要把被切块后的对象组装起来,还原成被广播的对象。这是通过对lazy val _value访问触发的。

 @transient private lazy val _value: T = readBroadcastBlock()

readBroadcast会首先在本地的BlockManager寻找之前存入的被广播的对象,因此如果同一个executor中已经有task访问过_value,那么它就能直接取到已被放入本地BlockManager中的对象,

如果本地还没有, 那么就会调用readBlocks获取组成这个对象的块,然后用unblockifyObject还原这个对象,接着把它放入BlockManager,以使得同一个executor的其它task不必重复组装还原。

 private def readBroadcastBlock(): T = Utils.tryOrIOException {
TorrentBroadcast.synchronized {
setConf(SparkEnv.get.conf)
//从本地的blockManager里读这个被broadcast的对象,根据broadcastId
SparkEnv.get.blockManager.getLocal(broadcastId).map(_.data.next()) match {
case Some(x) => //本地有
x.asInstanceOf[T] case None => //本地无
logInfo("Started reading broadcast variable " + id)
val startTimeMs = System.currentTimeMillis()
val blocks = readBlocks()//如果本地没有broadcastId对应的broadcast的block,就读
logInfo("Reading broadcast variable " + id + " took" + Utils.getUsedTimeMs(startTimeMs)) val obj = TorrentBroadcast.unBlockifyObject[T](
blocks, SparkEnv.get.serializer, compressionCodec)
// Store the merged copy in BlockManager so other tasks on this executor don't
// need to re-fetch it.
SparkEnv.get.blockManager.putSingle( //读了之后再放进BlockManager
broadcastId, obj, StorageLevel.MEMORY_AND_DISK, tellMaster = false)
obj
}
}
}

这里有一个细节是,组装还原之后的对象被用putSingle放入BlockManager, 存储级别为MEMORY_AND_DISK,这就意味着,在MemoryStore无法容纳被广播的对象时,同一个executor的两个task可能会获取两个不同的对象(需要研究下BlockManager相关的代码才能确定)。如果这种情况发生,而被广播的对象是线程安全的,那么就是对内存的浪费。如果这种情况不发生,一个executor的所有task共享一个被广播的对象,那么可能会产生线程安全的问题。但是无论如何,使用被广播的对象时,需要以只读的方式,对它的修改可能会产生问题。

TorrentBroadcast是通过readBlocks获取构成序列化后的对象的块。

  /** Fetch torrent blocks from the driver and/or other executors. */
private def readBlocks(): Array[ByteBuffer] = {
//获取到的block被存在本地的BlockManager中并且上报给driver,这样其它的executor就可以从这个executor获取这些block了
val blocks = new Array[ByteBuffer](numBlocks)
val bm = SparkEnv.get.blockManager //需要shuffle,避免所有executor以同样的顺序下载block,使得driver依然是瓶颈
for (pid <- Random.shuffle(Seq.range(0, numBlocks))) {
val pieceId = BroadcastBlockId(id, "piece" + pid)//组装BroadcastBlockId
logDebug(s"Reading piece $pieceId of $broadcastId")
// 先试着从本地获取,因为之前的尝试可能已经获取了一些block
def getLocal: Option[ByteBuffer] = bm.getLocalBytes(pieceId)
def getRemote: Option[ByteBuffer] = bm.getRemoteBytes(pieceId).map { block =>
//如果从remote获取了block,就把它存在本地的BlockManager
SparkEnv.get.blockManager.putBytes(
pieceId,
block,
StorageLevel.MEMORY_AND_DISK_SER,
tellMaster = true)
block
}
val block: ByteBuffer = getLocal.orElse(getRemote).getOrElse(
throw new SparkException(s"Failed to get $pieceId of $broadcastId"))
blocks(pid) = block
}
blocks
}

readBlocks还是很简单易懂的,只是这里使用putBytes时,使用的存储级别是MEMORY_AND_DISK_SER,有些奇怪,不知道为啥对于这些bytes还需要序列化。

总结

TorrentBroadcast的实现有一些巧妙的细节,但是整体的代码还是很简洁,也比较容易理解。之所以有如此少的代码,是因为BlockManager已经提供了足够的基础设施。

Spark的TorrentBroadcast:实现的更多相关文章

  1. Spark的TorrentBroadcast:概念和原理

    依据Spark 1.4.1源码 SparkContext的broadcast方法 注释 可以用SparkContext将一个变量广播到所有的executor上,使得所有executor都能获取这个变量 ...

  2. Spark Streaming job的生成及数据清理总结

    关于这次总结还是要从一个bug说起....... 场景描述:项目的基本处理流程为:从文件系统读取每隔一分钟上传的日志并由Spark Streaming进行计算消费,最后将结果写入InfluxDB中,然 ...

  3. spark错误记录总结

    1.执行spark-submit时出错 执行任务如下: # ./spark-submit --class org.apache.spark.examples.SparkPi /hadoop/spark ...

  4. java操作spark1.2.0

    虽然推荐的是scala,但是还是试一下 package org.admln.java7OperateSpark; import java.util.Arrays; import java.util.L ...

  5. Spark2.x(六十二):(Spark2.4)共享变量 - Broadcast原理分析

    之前对Broadcast有分析,但是不够深入<Spark2.3(四十三):Spark Broadcast总结>,本章对其实现过程以及原理进行分析. 带着以下几个问题去写本篇文章: 1)dr ...

  6. Spark源码系列(五)分布式缓存

    这一章想讲一下Spark的缓存是如何实现的.这个persist方法是在RDD里面的,所以我们直接打开RDD这个类. def persist(newLevel: StorageLevel): this. ...

  7. 【Spark学习】Apache Spark安全机制

    Spark版本:1.1.1 本文系从官方文档翻译而来,转载请尊重译者的工作,注明以下链接: http://www.cnblogs.com/zhangningbo/p/4135808.html 目录 W ...

  8. Spark大师之路:广播变量(Broadcast)源代码分析

    概述 近期工作上忙死了--广播变量这一块事实上早就看过了,一直没有贴出来. 本文基于Spark 1.0源代码分析,主要探讨广播变量的初始化.创建.读取以及清除. 类关系 BroadcastManage ...

  9. [Spark內核] 第42课:Spark Broadcast内幕解密:Broadcast运行机制彻底解密、Broadcast源码解析、Broadcast最佳实践

    本课主题 Broadcast 运行原理图 Broadcast 源码解析 Broadcast 运行原理图 Broadcast 就是将数据从一个节点发送到其他的节点上; 例如 Driver 上有一张表,而 ...

随机推荐

  1. python学习day4--python基础--元组,字符串

    1.元组 #只读列表,元组,当希望生成后不被修改则用元组 r=(1,2,3,4,5) 元组 2.字符串,python字符串操作非常丰富,编程时可先查询python本身是否已设计了相关函数 #移除空白 ...

  2. python遍历目录文件脚本的示例

    例子 自己写的一个Python遍历文件脚本,对查到的文件进行特定的处理.没啥技术含量,但是也记录一下吧. 代码如下 复制代码 #!/usr/bin/python# -*- coding: utf-8 ...

  3. Excel Operation

    在日常工作中, 常常需要收集统计一些数据, 然后整理到excel, 这种重复性的操作可以自己写个工具来实现. 采用HtmlUnitDriver 访问页面, 抓取数据, 再把数据列表通过调用POI放到e ...

  4. Transaction Script模式

    Transcation Script模式适合于小项目,维护量小的项目. 好比cs文件中有一个主方法,调用了本文件中的其他方法,如果说不需要怎么维护的话Tranacation Script模式就可以了, ...

  5. (转)Ehcache作为分布式缓存的研究

    ehcache支持两种拓扑结构,一种是Distributed Caching,另一种是Replicated Caching Distributed Caching 这和一般意义上的分布式缓存非常类似, ...

  6. 从0开始学习react(三)

    这次我们来讲解第三节知识,考虑了下,先不去讲什么理论了,毕竟网上一搜一大堆,而且理论真心看不太懂啊!!! 今天我们就直接上实例喽! 大家HIGH起来!!!(想了好久,还是没舍得删这句话) 1.根据下图 ...

  7. jQuery获取同级元素

    next()相邻下一个同级元素 prev()相邻上一个同级元素 siblings()所有同级元素 $("#id").next(); $("#id").prev( ...

  8. Node.js之【正则表达式函数之match、test、exec、search、split、replace使用详解】

    1. Match函数 使用指定的正则表达式函数对字符串惊醒查找,并以数组形式返回符合要求的字符串 原型:stringObj.match(regExp) 参数: stringObj 必选项,需要去进行匹 ...

  9. 解析php file_exists无效的解决办法

    php中file_exists无效的解决办法. 方法1 :据官方手册上描述若php教程的safe mode相关的设置过于苛刻,就会出现这样的情形:尽管文件真实存在也被误报,认为文件不存在. 由于服务器 ...

  10. ics OverbyteIcsHttpProt

    else begin { V7.05 begin } if (FRequestType = httpPOST) or (FRequestType = httpPUT) then begin {$IFD ...