Spark闭包清理类ClosureCleaner简析

Spark闭包清理类ClosureCleaner简析

从6月初开始因为一些工作上的事情,已经好久没有写博客了,这次把之前Spark源码阅读中深入了解的Spark闭包清理类ClosureCleaner简单介绍下,将知识留个档以便以后忘记了还有个地方来还原下思路。

Scala闭包机制回顾

在之前文章Spark闭包清理类ClosureCleaner简析中已经简单介绍了Scala的闭包实现方式,即用$outer字段来从闭包中引用外部的变量。

另外在另一篇文章慎用Scala中的return表达式中,介绍了在lambda表达式中,return语句的含义是NonLocalReturn而不是仅仅退出该lambda表达式,因此在lambda表达式中,return语句是很危险的,并且随时可能引起严重的后果。

Spark算子

这里并不会详细讲解Spark的RDD算子,我们仅仅从最简单的一个算子map入手,来看下你在rdd.map(func)中填入的func函数是如何运行在各个执行机之上的。

我们先从RDD类的map源码看起:

def map[U: ClassTag](f: T => U): RDD[U] = withScope {
    val cleanF = sc.clean(f)
    new MapPartitionsRDD[U, T](this, (context, pid, iter) => iter.map(cleanF))
}

def mapPartitions[U: ClassTag](f: Iterator[T] => Iterator[U], preservesPartitioning: Boolean = false): RDD[U] = withScope {
    val cleanedF = sc.clean(f)
    new MapPartitionsRDD(this, (context, index, iter) => cleanedF(iter), preservesPartitioning)
}

在这里多列出了下mapPartition的源码,原因是:当初我还是个Spark小白的时候,阅读各种博客,在文章中经常会看到一个spark效率优化点,就是使用mapPartition而不是map可以提高运行效率,然而从源码来看,两者的效率在大多数情况下应该是一致的。除非某些特殊情况:你自己写了Partitioner做分区,并且需要对整个分区的元素集合做某种操作,这种情况下必须用mapPartition。

我们回到map方法中来,它一共只有两句:

1、第一句对我们输出的func方法做了clean,我们下一节会详细介绍这个方法。
2、构造MapPartitionsRDD类来存放当前的RDD与计算函数。

从这里可以看出RDD的原理其实就是一个类似链表的存储结构,链表中存储的就是该链节点应该对上个节点的结果所做的操作函数。

在我们的func函数被存在了RDD里之后,它就会最终被Action算子触发,进入自己该在的Stage里,并被Spark序列化taskBinary = sc.broadcast(taskBinaryBytes)后存在Task类中下发给各个Executor节点运行。

这里有个重要的一点,Stage会被序列化成二进制,这说明其所有的成员都应该能被序列化,否则不可能在Executor中将其还原出来。因此,我们在写Spark代码的时候经常会碰到的org.apache.spark.SparkException: Task not serializable问题,就是因为我们在func闭包里引用了不可序列化的对象导致的。如果我们确实在闭包里使用了这些对象,那没话说,就是你的锅,自己改成可序列化得类去。但是还有些我们根本没用到的类,由于Scala的闭包机制,自动给我们引进来了,导致类的不可序列化,这种情况可就让我们为难了,举个栗子:
 

object TestObject {
  def run(): Int = {
    var nonSer = new NonSerializable
    val x = 5
    withSpark(new SparkContext("local", "test")) { sc =>
      val nums = sc.parallelize(Array(1, 2, 3, 4))
      nums.map(_ + x).reduce(_ + _)
    }
  }
}

以上我引用了Spark测试代码中的一个类来进行解释。我们传入map算子的闭包中,引用了外部的变量x,x为val整数,从之前的文章中分析,它会被直接存进闭包里,因此在闭包里不会存在$outer引用外部,因此并没啥问题。所以这是假栗子,仅仅帮助大家回顾下闭包而已。

再看个真正的栗子:
 

class TestClass extends Serializable {
  var nonSer = new NonSerializable
  var x = 5
  def getX: Int = x

  def run(): Int = {

    withSpark(new SparkContext("local", "test")) { sc =>
      val nums = sc.parallelize(Array(1, 2, 3, 4))
      nums.map(_ + getX).reduce(_ + _)
    }
  }
}

在传入map的闭包里,我们引用了外部TestClass类的getX方法,因此在闭包里会存在外部类的引用,这时如果我们直接对这个闭包做序列化,那由于闭包->$outer->nonSer间接包含了不可序列化对象,因此肯定会造成序列化失败异常。

Spark闭包清理类ClosureCleaner

为了解决以上问题,spark引入了闭包清理类,即在map的第一行调用了val cleanF = sc.clean(f)方法来实现的。下面我们深入该方法来一探究竟。

闭包清理最终调用了ClosureCleaner.clean(f, checkSerializable)方法来实现对f函数的闭包清理。

其内部代码涉及到的点很多,本文就不详细进行介绍了,以下介绍几个关键的技术点供大家来理解:

1、前一节提到return表达式是非常危险的,因此代码中对return做了判断,一旦发现return出现就会抛出异常快速失败:
 

if (op == NEW && tp.contains("scala/runtime/NonLocalReturnControl")) {
            throw new ReturnStatementInClosureException
          }

2、既然Scala的闭包是使用成员变量$outer来引用的,并且一个成员变量是否被用到必然要从字节码层面来进行判断,因此Spark闭包清理类中大量使用了org.apache.xbean.asm5包来遍历所有用到的方法的字节码,通过GETFIELD、INVOKEVIRTUAL、INVOKESPECIALJVM操作码来找到所引用的对象、方法、内部使用的类。

3、以传入clean的方法func为基准,向内查找找出所有内部使用到的对象所对应的成员变量和方法。

4、向外$outer查找所有外部所引用到的类,并将所有这些类克隆一份,但保持所有成员变量为初始值。

5、使用在内部查找到的所有引用变量值填充外部的克隆类成员变量。并将外部的$outer引用关系按照原始形势链接起来。

6、最终,使用反射将func的$outer成员换成对应的克隆后的克隆$outer对象

经过以上操作,就完成了对外部闭包引用的清理。简而言之就是,克隆个新的,用到的就填上,没用到的对象就是null了,这样就避免了不可序列化对象影响到了Spark的任务序列化功能。


不过这个闭包清理类还并不是那么完善,如SPARK-22328中的以下对话就隐藏了一个如果外部引用对象存在父类时的问题,当时我对这种场景以及延伸场景进行了验证,确实无法正确处理。
还有就是用方法获取了一个外部类,但是只用到了类中的一个可序列化对象,而该类存在其他不可序列化对象,这种情况下,序列化也是失败的。因为1、确实使用到了这个对象,因此对象会被赋值。2、无法简单从字节码中识别出改引用对象,因此代码中也没对该场景做特殊处理。

cloud-fan on 25 Oct 2017 Contributor
Assume we have class A and B having the same parent class P. P has 2 fields a and b. The closure accessed A.a and B.b, so when we clone A object, we should only set field a, when we clone B object, we should only set field b. However here seems we set field a and b for A and B object, which is sub-optimal.

cloud-fan on 25 Oct 2017 Contributor
Seems this is also a issue for the outerClasses, maybe I missed something…

viirya on 25 Oct 2017 Contributor
Seems that is true. For a closure that only accessed A.a, we clone the whole A object which contains both a and b fields. This is the fact in existing ClosureCleaner.

viirya on 25 Oct 2017 Contributor
As this is not a regression, IIUC, will it block this change?

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值