Kotlin SharedFlow 源碼解析

前言

對于SharedFlow使用可以看之前的文章 Kotlin SharedFlow 使用。在這篇文章中已經通過多個demo實戰幫大家總結了SharedFlow的一些特性和使用場景。但是也遺留了一些疑惑,所以本文打算通過輕度閱讀源碼的方式給大家答疑解惑。放心,不會從頭到尾的說。

SharedFlow源碼分析的重點

在源碼中可以分析的點很多,但我認為最重要的點有2點。emit和collect是如何關聯的,緩存機制是怎樣的。

從上一篇文章可以了解到emit和collect是掛起函數,但是否被掛起是有一定條件的。而且生產者,消費者出現的時機也會影響數據流的執行,這些問題就是本文要研究的重點。

下面分析emit和collect的小節中還會提出幾個問題,帶著大家通過解決問題的方式閱讀源碼。

回顧下之前文章的demo
為什么先發射數據,再collect就收不到數據呢

 runBlocking {
        val sharedFlow = MutableSharedFlow<Int>()
        sharedFlow.emit(1)

        launch {
            sharedFlow.collect {
                println("collect: $it")
            }
        }
    }

為什么設置了replay緩存,超出其緩存數量的時候,會丟失前面的數據,只能收到最新replay數量的數據呢?比如下面demo,只能收到2和3

  runBlocking {
        //默認參數情況,先emit,再collect收不到數據
        val sharedFlow = MutableSharedFlow<Int>(replay = 2, extraBufferCapacity = 0)

        sharedFlow.emit(1)
        sharedFlow.emit(2)
        sharedFlow.emit(3)

        launch {
            sharedFlow.collect {
                println("collect: $it")
            }
        }
    }

emit發射數據

對于初學者來說,讀懂這個方法確實有一定難度,不過我換了一個角度來帶大家理解,從不同業務場景,使用角度來逐步分析,大致分為4種。

  • 沒有收集者,無replay緩存
  • 沒有收集者,有replay緩存(extraBufferCapacity緩存無影響)
  • 有收集者,無replay緩存
  • 有收集者,有replay緩存
  override suspend fun emit(value: T) {
//嘗試發送數據,這是一個快速路徑,可以提高發送數據的效率。 
        if (tryEmit(value)) return // fast-path
//在掛起的協程中發送數據
        emitSuspend(value)
    }

emit方法先用不需要掛起的方式發數據,發失敗之后采用掛起的方式發。

以不掛起的方式發射數據

 override fun tryEmit(value: T): Boolean {
        var resumes: Array<Continuation<Unit>?> = EMPTY_RESUMES
        val emitted = synchronized(this) {
//嘗試發射數據,發射成功返回true,失敗返回false
            if (tryEmitLocked(value)) {
              //找到需要恢復的協程,并將結果保存到  resumes  數組中,
                resumes = findSlotsToResumeLocked(resumes)
                true
            } else {
                false
            }
        }
//上面已經找到了需要恢復的協程,這里只需要恢復協程的執行
        for (cont in resumes) cont?.resume(Unit)
        return emitted
    }
 private fun tryEmitLocked(value: T): Boolean {
        // 沒有收集者,一定返回true
        if (nCollectors == 0) return tryEmitNoCollectorsLocked(value) 
        // 有收集者,緩存區已滿,超過replay + extraBufferCapacity數量,且消費者沒有消費最舊的數據(replayIndex)
        if (bufferSize >= bufferCapacity && minCollectorIndex <= replayIndex) {
//執行下面的緩存溢出策略
            when (onBufferOverflow) {
                BufferOverflow.SUSPEND -> return false // will suspend
                BufferOverflow.DROP_LATEST -> return true // just drop incoming
                BufferOverflow.DROP_OLDEST -> {} //丟棄最舊的數據,這里暫不處理
            }
        }
//將數據加入到緩存數組中,這里不會掛起emit所在的協程
        enqueueLocked(value)
        bufferSize++ //緩存數組長度
        // 上面的緩存溢出策略,丟棄最老數據是沒做處理的,實際上延遲在這里處理
        if (bufferSize > bufferCapacity) dropOldestLocked()
        //  如果replayCache中數據的數量超過了最大容量
        if (replaySize > replay) {
// 更新replayIndex的值,replayIndex向前移動一位
            updateBufferLocked(replayIndex + 1, minCollectorIndex, bufferEndIndex, queueEndIndex)
        }
        return true
    }

這個方法已經涉及到shareflow緩存機制,所以有必要先來張圖大概了解下緩存機制。

image.png

shareflow緩存是個數組,大小由 bufferSize 控制,而緩存容量由 bufferCapacity 控制。緩存由3部分組成,replay緩存的數量,extraBufferCapacity緩存的數量,這2部分加起來就是buffered values,還有就是掛起時候的Emitter對象。圖中還展示了兩個慢速收集器的位置,即可能收集緩存隊列中的值的最慢速度的收集器的位置。這兩個位置分別由 minCollectorIndex 和 replayIndex 控制。

這個tryEmitLocked方法很重要,因為它可以解釋之前文章中一些業務場景的困惑。 下面我們就來跟大家一起解剖下這個方法

tryEmitLocked方法沒有收集者

  //走到這里,說明沒有收集者
 private fun tryEmitNoCollectorsLocked(value: T): Boolean {
        assert { nCollectors == 0 }
//replay緩存為0,就丟棄數據,emit方法就結束了
        if (replay == 0) return true 
        enqueueLocked(value) // 加入到緩存數組
        bufferSize++ // value was added to buffer
         //若是emit發射的數量超過了重放個數,則丟棄最舊的值
        if (bufferSize > replay) dropOldestLocked()
        minCollectorIndex = head + bufferSize // a default value (max allowed)
        return true
    }

這段代碼也解釋了之前的2個疑惑

  • 在不配置replay緩存的情況下,先emit發數據再collect是收不到數據的
  • 在配置了replay的情況下,先emit再collect是能收到數據,但是 emit發射的數量超過了replay的話,就只能收到最新的replay個數的數據

如下2段代碼,運行結果都只能收到2和3

 runBlocking {
        //默認參數情況,先emit,再collect收不到數據
        val sharedFlow = MutableSharedFlow<Int>(replay = 2, extraBufferCapacity = 0)

        sharedFlow.emit(1)
        sharedFlow.emit(2)
        sharedFlow.emit(3)

        launch {
            sharedFlow.collect {
                println("collect: $it")
            }
        }
    }


 runBlocking {
        //這里配置了extraBufferCapacity根本不會起到效果
        val sharedFlow = MutableSharedFlow<Int>(replay = 2, extraBufferCapacity = 2)

        sharedFlow.emit(1)
        sharedFlow.emit(2)
        sharedFlow.emit(3)

        launch {
            sharedFlow.collect {
                println("collect: $it")
            }
        }
    }

從上面demo和tryEmitNoCollectorsLocked源碼分析可以看出:如果emit發射到buffered values的數據數量超過了replay的值,會丟棄最舊的數據,保持buffered values中數據的數量最大為replay。

當有新的訂閱者時,會先從replayCache中獲取數據,在buffered values中,replayCache前的數據只對已經訂閱的訂閱者有用,而此時又沒有訂閱者,因此緩存超過replayCache最大容量的數據只會占用更多內存,是沒有意義的。記住:沒有收集者時,extraBufferCapacity是不會起作用的

tryEmitLocked方法有收集者
上面已經分析了這個方法沒有收集者的情況,接下來就分析下有收集者的情況

 private fun tryEmitLocked(value: T): Boolean {
        // 沒有收集者,一定返回true
        if (nCollectors == 0) return tryEmitNoCollectorsLocked(value) 
        // 有收集者,緩存區已滿,超過replay + extraBufferCapacity數量,且消費者沒有消費最舊的數據(replayIndex)
        if (bufferSize >= bufferCapacity && minCollectorIndex <= replayIndex) {
//執行下面的緩存溢出策略
            when (onBufferOverflow) {
                BufferOverflow.SUSPEND -> return false // will suspend
                BufferOverflow.DROP_LATEST -> return true // just drop incoming
                BufferOverflow.DROP_OLDEST -> {} //丟棄最舊的數據,這里暫不處理
            }
        }
//將數據加入到緩存數組中,這里不會掛起emit所在的協程
        enqueueLocked(value)
        bufferSize++ //緩存數組長度
        // 上面的緩存溢出策略,丟棄最老數據是沒做處理的,實際上延遲在這里處理
        if (bufferSize > bufferCapacity) dropOldestLocked()
        //  如果replayCache中數據的數量超過了最大容量
        if (replaySize > replay) {
// 更新replayIndex的值,replayIndex向前移動一位
            updateBufferLocked(replayIndex + 1, minCollectorIndex, bufferEndIndex, queueEndIndex)
        }
        return true
    }

有收集者,但沒有配置任何緩存

 runBlocking {
        //默認參數情況,先emit,再collect收不到數據
        val sharedFlow = MutableSharedFlow<Int>(replay = 0, extraBufferCapacity = 0)

        launch {
            sharedFlow.collect {
                println("collect: $it")
            }
        }

        delay(200) //確保已經訂閱
        sharedFlow.emit(1)
        sharedFlow.emit(2)
        sharedFlow.emit(3)
    }

上面demo,是能收到1,2,3的。
從源碼可以看出,tryEmitLocked方法中,有訂閱者的情況,即使沒有配置緩存也會執行enqueueLocked(value)方法把數據加入到緩存數組。

有訂閱者,且配置了replay或者extraBufferCapacity緩存,會多了一個緩存溢出策略。有3種策略,掛起協程,丟棄最老的數據,丟棄最新的數據。

emitSuspend掛起方式發數據

private suspend fun emitSuspend(value: T) = suspendCancellableCoroutine<Unit> sc@{ cont ->
        var resumes: Array<Continuation<Unit>?> = EMPTY_RESUMES
        val emitter = synchronized(this) lock@{
            //再次檢查,確保緩存區滿了,因為滿了才會執行下面的邏輯
            if (tryEmitLocked(value)) {
                cont.resume(Unit)
                resumes = findSlotsToResumeLocked(resumes)
                return@lock null
            }
            // 創建Emitter,加入到buffer里
            //可以去看下前面的那張圖,Emitter是加到緩存區的什么位置的
            Emitter(this, head + totalSize, value, cont).also {
                //加到之前的緩存數組
                enqueueLocked(it)
                queueSize++ // Emitter是掛起的,單獨記錄下數量
                // 如果buffered value緩存沒有數據,則收集已經掛起的訂閱者的續體,保存到局部變量resumes中
                if (bufferCapacity == 0) resumes = findSlotsToResumeLocked(resumes)
            }
        }
        // outside of the lock: register dispose on cancellation
        emitter?.let { cont.disposeOnCancellation(it) }
        // 恢復掛起的訂閱者
        for (r in resumes) r?.resume(Unit)
    }

這個方法比較簡單,緩存區滿了,創建Emitter加到buffer緩存區。

小結

閱讀emit發射數據的流程,可以分2部分完成,不掛起發射和掛起發射。

如果沒有收集者,emit永遠不會掛起。

如果有收集者,并且buffered values緩存容量已滿并且最舊的數據沒有被消費,則emit有機會被掛起,當然這取決于你的溢出策略。

collect消費數據

 override suspend fun collect(collector: FlowCollector<T>): Nothing {
         //分配槽位
        val slot = allocateSlot()
        try {
            if (collector is SubscribedFlowCollector) collector.onSubscription()
            val collectorJob = currentCoroutineContext()[Job]
            while (true) {
                var newValue: Any?
                while (true) {
                    //嘗試獲取值,獲取到了就跳出循環,獲取不到就掛起等待
                    newValue = tryTakeValue(slot) // attempt no-suspend fast path first
                    if (newValue !== NO_VALUE) break
                    awaitValue(slot) // await signal that the new value is available
                }
                //判斷訂閱者所在協程是否是存活
                collectorJob?.ensureActive()
              //回調到collect方法的lambda
                collector.emit(newValue as T)
            }
        } finally {
            freeSlot(slot)
        }
    }

關于slot的理解,可以看下圖


slot.png
  • 消費者開始collect,根據index找到buffer下標為0的元素即為可以消費的元素;
  • 拿到0號數據后,slot.index=1,找到buffer下標為1的元素
    index++,重復上訴步驟
 private fun tryTakeValue(slot: SharedFlowSlot): Any? {
        var resumes: Array<Continuation<Unit>?> = EMPTY_RESUMES
        val value = synchronized(this) {
            // 從slot中獲取index
           // index表示當前應該從緩存數組的index位置中獲取數據
            val index = tryPeekLocked(slot)
            if (index < 0) {
                //沒有數據,返回空數據的標識
                NO_VALUE
            } else {
                val oldIndex = slot.index
                //從緩存數組buffer中獲取index對應的數據
                val newValue = getPeekedValueLockedAt(index)
              //slot索引加1,表示獲取下個數據的位置
                slot.index = index + 1 // points to the next index after peeked one
                resumes = updateCollectorIndexLocked(oldIndex)
                newValue
            }
        }
      //恢復協程
        for (resume in resumes) resume?.resume(Unit)
        return value
    }

tryPeekLocked方法,是判斷數據所在的位置是否符合要求。

 private suspend fun awaitValue(slot: SharedFlowSlot): Unit = suspendCancellableCoroutine { cont ->
        synchronized(this) lock@{
            //再次檢查index
            val index = tryPeekLocked(slot) // recheck under this lock
            if (index < 0) {
            //保存續體cont到slot
                slot.cont = cont // Ok -- suspending
            } else {
                //說明有值,不需要再繼續掛起了,通過resume恢復協程
                cont.resume(Unit) // has value, no need to suspend
                return@lock
            }
            slot.cont = cont // suspend, waiting
        }
    }

suspendCancellableCoroutine 是一個掛起函數,用于創建可取消的協程。在協程中調用 suspendCancellableCoroutine 函數時,它會創建一個 CancellableContinuation 對象,并將其傳遞給一個lambda表達式。該lambda表達式中的代碼可以使用 suspend 關鍵字掛起當前協程,并在某個條件滿足時或協程被取消時恢復協程。

collect小結
collect方法會構造Slot對象,然后開啟死循環去不斷匹配緩存區的數據。具體是根據Slot 中的index匹配緩存區buffer中的數據,如果匹配到了,執行collect閉包;匹配不到就掛起協程,掛起的協程會在有新數據時被生產者所恢復。無論是否有生產者,只要沒拿到數據,collect都會被掛起。

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370

推薦閱讀更多精彩內容