Kotlin 協程之取消與異常處理探索之旅(上)

前言

協程系列文章:

我們知道線程可以被終止,線程里可以拋出異常,類似的協程也會遇到此種情況。本篇將從線程的終止與異常處理分析開始,逐漸引入協程的取消與異常處理。
通過本篇文章,你將了解到:

  1. 線程的終止
  2. 線程的異常處理
  3. 協程的Job 結構

1. 線程的終止

如何終止一個線程

阻塞狀態下終止

先看個Demo:

class ThreadDemo {
    fun testStop() {
        //構造線程
        var t1 = thread {
            println("thread start")
            Thread.sleep(2000)
            println("thread end")
        }
        //1s后中斷線程
        Thread.sleep(1000)
        t1.interrupt()
    }
}

fun main(args : Array<String>) {
    var threadDemo = ThreadDemo()
    threadDemo.testStop()
}

結果如下:


image.png

可以看出,"thread end" 沒有打印出來,說明線程被成功中斷了。
上述Demo里線程能夠被中斷的本質是:

Thread.sleep(xx)方法會檢測中斷狀態,若是發現發生了中斷,則拋出異常。

非阻塞狀態下終止

改造一下Demo:

class ThreadDemo {
    fun testStop() {
        //構造線程
        var t1 = thread {
            var count = 0
            println("thread start")
            while (count < 100000000) {
                count++
            }
            println("thread end count:$count")
        }
        //等待線程運行
        Thread.sleep(10)
        println("interrupt t1 start")
        t1.interrupt()
        println("interrupt t1 end")
    }
}

運行結果如下:


image.png

可以看出,線程啟動后,中斷線程,而最后線程依然正常運行到結束,說明此時線程并沒有被中斷。
本質原因:

interrupt() 方法僅僅只是喚醒線程與設置中斷標記位。

此種場景下如何終止一個線程呢?我們繼續改造一下Demo:

class ThreadDemo {
    fun testStop() {
        //構造線程
        var t1 = thread {
            var count = 0
            println("thread start")
            //檢測是否被中斷
            while (count < 100000000 && !Thread.interrupted()) {
                count++
            }
            println("thread end count:$count")
        }
        //等待線程運行
        Thread.sleep(10)
        println("interrupt t1 start")
        t1.interrupt()
        println("interrupt t1 end")
    }
}

對比之前的Demo,僅僅只是添加了中斷標記檢測:Thread.interrupted()。
該方法返回true表示該線程被中斷了,于是我們手動停止計數。
結果如下:


image.png

由此可見,線程被成功終止了。

綜上所述,如何終止一個線程我們有了結論:


image.png

更加深入的分析原理以及兩者的結合使用請移步:Java “優雅”地中斷線程(實踐篇)

2. 線程的異常處理

不論在Java 還是Kotlin里,異常都是可以通過try...catch 捕獲。
典型如下:

    fun testException() {
        try {
            1/0
        } catch (e : Exception) {
            println("e:$e")
        }
    }

結果:


image.png

成功捕獲了異常。

改造一下Demo:

    fun testException() {
        try {
            //開啟線程
            thread {
                1/0
            }
        } catch (e : Exception) {
            println("e:$e")
        }
    }

大家先猜測一下結果,能夠捕獲異常嗎?
接著來看結果:


image.png

很遺憾,無法捕獲。
根本原因:

異常的捕獲是針對當前線程的堆棧。而上述Demo是在main(主)線程里進行捕獲,而異常時發生在子線程里。

你可能會說,簡單我直接在子線程里進行捕獲即可。

    fun testException() {
        thread {
            try {
                1/0
            } catch (e : Exception) {
                println("e:$e")
            }
        }
    }

這么做沒毛病,很合理也很剛。
考慮另一種場景:若是主線程想要獲取子線程異常的原因,進而做不同的處理。
這時候就引入了:UncaughtExceptionHandler。
繼續改造Demo:

    fun testException3() {
        try {
            //開啟線程
            var t1 = thread(false){
                1/0
            }
            t1.name = "myThread"
            //設置
            t1.setUncaughtExceptionHandler { t, e ->
                println("${t.name} exception:$e")
            }
            t1.start()
        } catch (e : Exception) {
            println("e:$e")
        }
    }

其實就是注冊了個回調,當線程發生異常時會調用uncaughtException(xx)方法。
結果如下:


image.png

說明成功捕獲了異常。

3. 協程的Job 結構

Job 基礎

Job 的創建

在分析協程的取消與異常之前,先要弄清楚父子協程的結構。

class JobDemo {
    fun testJob() {
        //父Job
        var rootJob: Job? = null
        runBlocking {
            //啟動子Job
            var job1 = launch {
                println("job1")
            }
            //啟動子Job
            var job2 = launch {
                println("job2")
            }
            rootJob = coroutineContext[Job]
            job1.join()
            job2.join()
        }
    }
}

如上,通過runBlocking 啟動一個協程,此時它作為父協程,在父協程里又依次啟動了兩個協程作為子協程。
launch()函數為CoroutineScope 的擴展函數,它的作用是啟動一個協程:

#Builders.common.kt
fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    //構造新的上下文
    val newContext = newCoroutineContext(context)
    //協程
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    //開啟
    coroutine.start(start, coroutine, block)
    //返回協程
    return coroutine
}

以返回StandaloneCoroutine 為例,它繼承自AbstractCoroutine,進而繼承自JobSupport,而JobSupport 實現了Job接口,具體實現類即為JobSupport。

我們知道協程是比較抽象的事物,而Job 作為協程具象性的表達,表示協程的作業。
通過Job,我們可以控制、監控協程的一些狀態,如:

    //屬性
     job.isActive //協程是否活躍
     job.isCancelled //協程是否被取消
     job.isCompleted//協程是否執行完成
     ...
    //函數
    job.join()//等待協程完成
    job.cancel()//取消協程
    job.invokeOnCompletion()//注冊協程完成回調
    ...

Job 的存儲

Demo里通過launch()啟動了兩個子協程,暴露出來兩個子Job,而它們的父Job 在哪呢?
從runBlocking()里尋找答案:

#Builers.kt
fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T {
    //...
    //創建BlockingCoroutine,它也是個Job
    val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
    coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
    return coroutine.joinBlocking()
}

BlockingCoroutine 繼承自AbstractCoroutine,AbstractCoroutine里有個成員變量:

#AbstractCoroutine.kt
    //this 指代AbstractCoroutine 本身,也就是BlockingCoroutine
    public final override val context: CoroutineContext = parentContext + this

不僅是BlockingCoroutine,StandaloneCoroutine 也繼承自AbstractCoroutine,由此可見:

Job實例索引存儲在對應的Context(上下文)里,通過context[Job]即可索引到具體的Job對象。

父子Job 關聯

綁定關系初步建立

我們通常說的協程是結構化并發,它的狀態比如異常可以在協程之間傳遞,怎么理解結構化這概念呢?重點在于理解父子協程、平級子協程之間是如何關聯的。
還是上面的Demo,稍微改造:

    fun testJob2() {
        runBlocking {//父Job==rootJob
            //啟動子Job
            var job1 = launch {
                println("job1")
            }
        }
    }

從job1的創建開始分析,先看AbstractCoroutine 的實現:

#AbstractCoroutine.kt
abstract class AbstractCoroutine<in T>(
    parentContext: CoroutineContext,//父協程的上下文
    initParentJob: Boolean,//是否需要關聯父子Job,默認true
    active: Boolean //默認true
) : JobSupport(active), Job, Continuation<T>, CoroutineScope {

    init {
        //關聯父子Job
        //parentContext[Job] 即為從父Context里取出父Job
        if (initParentJob) initParentJob(parentContext[Job])
    }
}

#JobSupport.kt
protected fun initParentJob(parent: Job?) {
    if (parent == null) {
        //沒有父Job,根Job 沒有父Job
        parentHandle = NonDisposableHandle
        return
    }
    parent.start() // make sure the parent is started
    //綁定父子Job      ①
    val handle = parent.attachChild(this)
    //返回父Handle,指向鏈表 ②
    parentHandle = handle
    //...
}

分兩個點 ①和 ②,先看①:

#JobSupport.kt
//ChildJob 為接口,接口里的函數是用來給父Job取消其子Job用的
//JobSupport 實現了ChildJob 接口
public final override fun attachChild(child: ChildJob): ChildHandle {
    //ChildHandleNode(child) 構造ChildHandleNode 對象
    return invokeOnCompletion(onCancelling = true, handler = ChildHandleNode(child).asHandler) as ChildHandle
}

#JobSupport.kt
public final override fun invokeOnCompletion(
    onCancelling: Boolean,
    invokeImmediately: Boolean,
    handler: CompletionHandler
): DisposableHandle {
    //創建
    val node: JobNode = makeNode(handler, onCancelling)
    loopOnState { state ->
        when (state) {
            //根據state,組合為一個ChildHandleNode 的鏈表
            //比較繁瑣,忽略
            //返回鏈表頭
        }
    }
}

最終的目的是返回ChildHandleNode,它可能是個鏈表。
再看②,將返回的結果記錄在子Job的parentHandle 成員變量里。
小結一下:

  1. 父Job 構造ChildHandleNode 節點放入到鏈表里,每個節點存儲的是子Job以及父Job 本身,而該鏈表可以與父Job里的state 互轉。
  2. 子Job 的成員變量parentHandle 指向該鏈表。

由1.2 步驟可知,子Job 通過parentHandle 可以訪問父Job,而父Job 通過state可以找出其下關聯的子Job,如此父子Job就建立起了聯系。


image.png

Job 鏈構建

上面分析了父子Job 之間是如何建立聯系的,接下來重點分析子Job之間是如何關聯的。
重點看看ChildHandleNode 的構造:

#JobSupport.kt
//主要有2個成員變量
//childJob: ChildJob 表示當前node指向的子Job
//parent: Job 表示當前node 指向的父Job
internal class ChildHandleNode(
    @JvmField val childJob: ChildJob
) : JobCancellingNode(), ChildHandle {
    override val parent: Job get() = job
    //父Job 取消其所有子Job
    override fun invoke(cause: Throwable?) = childJob.parentCancelled(job)
    //子Job向上傳遞,取消父Job
    override fun childCancelled(cause: Throwable): Boolean = job.childCancelled(cause)
}

可以看出,ChildHandleNode 里的invoke()、childCancelled()函數最終都依靠Job 實現其功能。
通過查找,很容易發現parentCancelled()/childCancelled()函數在JobSupport 均有實現。

ChildHandleNode 最終繼承自LockFreeLinkedListNode,該類是一個線程安全的雙向鏈表,雙向鏈表我們很容易想到其實現的核心是依賴前驅后驅指針。

#LockFreeLinkedList.kt
public actual open class LockFreeLinkedListNode {
    //后驅指針
    private val _next = atomic<Any>(this) // Node | Removed | OpDescriptor
    //前驅指針
    private val _prev = atomic(this) // Node to the left (cannot be marked as removed)
    private val _removedRef = atomic<Removed?>(null) // lazily cach
}

于是ChildHandleNode 鏈表如下圖:


image.png

這樣子Job 之間就通過前驅/后驅指針聯系起來了。
再結合實際的Demo來闡述Job 鏈構造過程。

    fun testJob2() {
        runBlocking {//父Job==rootJob
            //啟動子Job
            var job1 = launch {
                println("job1")
            }
            //啟動子Job
            var job2 = launch {
                println("job2")
            }
            cancel("")
        }
    }

第1步
runBlocking 創建一個協程,并構造Job,該Job為BlockingCoroutine,在創建Job的同時會嘗試綁定父Job,而此時它作為根Job,沒有父Job,因此parentHandle = NonDisposableHandle。
而這個時候,它還沒創建子Job,因此state 里沒有子Job。

image.png

第2步
創建第1個Job:Job1。
此時構造的Job為StandaloneCoroutine,在創建Job的同時會嘗試綁定父Job,從父Context里取出父Job,即為BlockingCoroutine,找到后就開始進行關聯綁定。
于是,現在的結構變為:

image.png

父Job 的state(指向鏈表頭)此時就是個鏈表,該鏈表里的節點為ChildHandleNode,而ChildHandleNode 里存儲了父Job與子Job。

第3步
創建第2個Job:Job2。
同樣的,構造的Job 為StandaloneCoroutine,綁定父Job,最終的結構變為:

image.png

小結來說:

  1. 創建Job 時嘗試關聯其父Job。
  2. 若父Job 存在,則構造ChildHandleNode,該Node 存儲了父Job以及子Job,并將ChildHandleNode 存儲在父Job 的State里,同時子Job 的parentHandle 指向ChildHandleNode。
  3. 再次創建Job,繼續嘗試關聯父Job,因為父Job 里已經關聯了一個子Job,因此需要將新的子Job 掛到前一個子Job 后面,這樣就形成了一個子Job鏈表。

簡單Job 示意圖:


image.png

如圖,類似一個樹結構。
當Job 鏈建立起來后,狀態的傳遞就簡單了。

  • 父Job 通過鏈表可以找到每個子Job。
  • 子Job 通過parentHandle 找到父Job。
  • 子Job 之間通過鏈表索引。

由于篇幅原因,協程的取消與異常將在下篇分析,敬請關注。

本文基于Kotlin 1.5.3,文中完整Demo請點擊

您若喜歡,請點贊、關注、收藏,您的鼓勵是我前進的動力

持續更新中,和我一起步步為營系統、深入學習Android/Kotlin

1、Android各種Context的前世今生
2、Android DecorView 必知必會
3、Window/WindowManager 不可不知之事
4、View Measure/Layout/Draw 真明白了
5、Android事件分發全套服務
6、Android invalidate/postInvalidate/requestLayout 徹底厘清
7、Android Window 如何確定大小/onMeasure()多次執行原因
8、Android事件驅動Handler-Message-Looper解析
9、Android 鍵盤一招搞定
10、Android 各種坐標徹底明了
11、Android Activity/Window/View 的background
12、Android Activity創建到View的顯示過
13、Android IPC 系列
14、Android 存儲系列
15、Java 并發系列不再疑惑
16、Java 線程池系列
17、Android Jetpack 前置基礎系列
18、Android Jetpack 易懂易學系列
19、Kotlin 輕松入門系列
20、Kotlin 協程系列全面解讀

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容