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
、INVOKESPECIAL
JVM操作碼來找到所引用的對象、方法、內部使用的類。
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?
cloud-fan on 25 Oct 2017 Contributor
yea let’s leave it