Spark的TorrentBroadcast:實現

devos發表於2015-08-19

依據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已經提供了足夠的基礎設施。 

相關文章