iT邦幫忙

2021 iThome 鐵人賽

DAY 13
0
Software Development

Coroutine 停看聽系列 第 13

Day13:內建的 suspend 函式,好函式不用嗎? (2)

withContext

suspend fun<T> withContext(context: CoroutineContext,
						   block: suspend CoroutineScope.()->T):T

withContext 是用來在現有的 coroutine 中,使用新的 CoroutineContext 來建立一個執行區塊。我們最常看到的是拿來做更新畫面使用,也就是說當 coroutine 執行的任務完成之後,我們可以使用 withContext(Dispatchers.Main) 將執行緒切回主執行緒並更新。

這邊我們看一個範例:

fun main() = runBlocking {
    val job = launch {
        println("inside: ${Thread.currentThread().name}")
        withContext(Dispatchers.Default) {
            println("Default dispatcher: ${Thread.currentThread().name}")
        }
    }
    println("outer: ${Thread.currentThread().name}")
}
outer: main
inside: main
Default dispatcher: DefaultDispatcher-worker-1

→ 根據我們前面所說的巢狀架構,這段程式碼會先列印最外側的 outer: main ,接著才會是執行 launch 所建立出來的 coroutine,並依序執行 coroutine 裏面的程式。

→ 在 launch 中,我們使用 withContext(Dispatchers.Default) ,也就是說在這個區塊中我們將使用 Dispatchers.Default,而從 log 看來的確也已經切換到 DefaultDispatcher。

withContext 是可取消的

withContext 所建立出來的區間是可以被取消的,當調用 withContext 的 coroutine 被取消之後,withContext 也就會被取消。如下例:

fun main() = runBlocking {
    val job = launch {
        println("inside: ${Thread.currentThread().name}")
        withContext(Dispatchers.Default) {
            delay(200)
            println("Default: ${Thread.currentThread().name}")
        }
    }
    delay(100)
    job.cancel()
    job.join()
    println("outer: ${Thread.currentThread().name}")
}
inside: main
outer: main

→ 外側的 coroutine 因為呼叫了 delay() 之後,所以把外側的 coroutine 切換至等待狀態,並尋找下一個可執行的 coroutine。那麼這邊我們看到列印出 inside: main ,緊接者進入 withContext(Dispatchers.Default) ,在 withContext 中立刻就呼叫一個 delay() ,所以此 coroutine 又被切換成等待狀態,並且找尋下一個可執行的 coroutine。此時,外側的 coroutine 已經結束它的延時,所以呼叫到了 job.cancel() ,這個時候因為 launch 裏面尚有任務還在等待,所以就直接被取消。最後則是列印出 outer: main

讓 withContext 不可取消

在前面的範例中,我們知道 withContext 的區塊會隨著啟動它的 coroutine 被取消也跟著被取消,what if 我們不希望讓 withContext 被取消呢?

我們可以在 withContext 的 CoroutineContext 中加上 NonCancellable ,加上 NonCancellable 之後,被 withContext 包住的區塊就不會因為外側的 coroutine 取消而跟著被取消。

如下面的範例:

fun main() = runBlocking {
    val job = launch {
        println("inside: ${Thread.currentThread().name}")
        withContext(Dispatchers.Default + NonCancellable) {
            delay(200)
            println("Default: ${Thread.currentThread().name}")
        }
    }
    delay(100)
    job.cancel()
    job.join()
    println("outer: ${Thread.currentThread().name}")

}

→ 這個範例跟前面一個幾乎相同,唯一不同的地方在於我加上 NonCancellable 在 withContext 的 CoroutineContext 中,結果會是如何呢?我們看一下:

inside: main
Default: DefaultDispatcher-worker-1
outer: main

雖然 job 的 cancel() 被呼叫,但是 withContext 裏面的區塊仍然會執行。

在最前面有說,我們經常使用 withContext 在更新畫面上,也就是說,我們可以讓更新畫面這段程式碼無論如何都會執行,而不會因為外側的 coroutine 被取消而跟著被取消。

withTimeout

withTimeout 顧名思義就是跟 timeout 有關係,我們先看他是怎麼使用的:

suspend fun <T> withTimeout(timeMillis: Long, block: suspend CoroutineScope.() -> T): T

→ 它有包含兩個參數,一個是時間,另一個是 CoroutineScope 也就是執行的區塊。

要如何使用呢?我們看一下下面的範例:

fun main() = runBlocking {
    val job = launch() {
        println("inside: ${Thread.currentThread().name}")
	       withTimeout(200) {
	            repeat(10) {
	                println("delay $it times")
	                delay(50)
	                yield()
	            }
	            println("withTimeout")
	        }
    }
    delay(100)
    println("outer: ${Thread.currentThread().name}")
}

→ 在上面這個範例中,同樣的我們在 runBlocking 裏面使用了 launch 建立了一個 coroutine,而在 launch 的下方則是有幾行程式碼。

→ 首先,會先執行最外側的 delay(100) ,這時最外側的 Coroutine 就被設定為暫停,此時會尋找下一個適當的 coroutine 來執行。在這個範例中,適當的 coroutine 是 launch 的區塊。launch 內部有一重複 10 次,在每一次執行的時候,都會將 coroutine 暫停 50 毫秒,在暫停完成之後,會把執行緒使用權切換至外側,但是外側的 coroutine 還在等待,所以使用權又切回來執行下一次。如果要完成重複十次的任務,需要花費 10*50 = 500 毫秒。

→ 假設我們希望執行的時間能夠在 200 毫秒,在這個 repeat(10){ ... } 的外側,我們加上了 withTimeout(200) ,當 200 毫秒結束之後,我們就會取消這個區塊裏面的所有任務。所以這段程式碼的輸出會是:

inside: main
delay 0 times
delay 1 times
outer: main
delay 2 times
delay 3 times

→ 可以發現, repeat 區塊的程式只有跑了四次,符合我們的需求。

TimeoutCancellationException

其實當 withTimeout 的時間到了之後,是會拋出 TimeoutCancellationException ,只不過由於 TimeoutCancellationException 是 CancellationException 的子類別,所以這個例外會被 coroutine 給吃掉。

但是,如果我們真的需要處理這個例外,我們可以使用 try-catch 來攔截 TimeoutCancellationException。如下:

fun main() = runBlocking {
    val job = launch() {
        println("inside: ${Thread.currentThread().name}")
        try {
           withTimeout(200) {
                repeat(10) {
                    println("delay $it times")
                    delay(50)
                    yield()
                }
                println("withTimeout")
            }
        } catch (e: TimeoutCancellationException) {
            println(e.message)
        }

    }
    delay(100)
    println("outer: ${Thread.currentThread().name}")
}
inside: main
delay 0 times
delay 1 times
outer: main
delay 2 times
delay 3 times
Timed out waiting for 200 ms

將上例的 withTimeout 用 try-catch 包起來之後,我們就可以接收到這個例外了。

可取消

如同 withContext,withTimeout 也同樣是可以取消的。如下例:

fun main() = runBlocking {
    val job = launch() {
        println("inside: ${Thread.currentThread().name}")
        try {
           withTimeout(200) {
                repeat(10) {
                    println("delay $it times")
                    delay(50)
                    yield()
                }
                println("withTimeout")
            }
        } catch (e: TimeoutCancellationException) {
            println(e.message)
        }

    }
    delay(100)
    job.cancel()
    job.join()
    println("outer: ${Thread.currentThread().name}")
}

→ 我們在外側 coroutine delay(100) 之後加上了 job.cancel() ,所以當外側 coroutine 的暫停時間結束後,就會把內側的 coroutine 給取消掉。哪麼這個結果會是如何呢?

inside: main
delay 0 times
delay 1 times
outer: main

→ 你應該有猜到,因為 withTimeout 是可以取消的,所以當外側取消了內側的 coroutine ,那麼連帶 withTimeout 也一並被取消了,所以 repeat 第三次、第四次就被取消沒做了。

withTimeoutOrNull

withTimeout 非常的像,只不過一個是會拋出 TimeoutCancellationException,而另一個是回傳 null,我們看下面的範例:

fun main() = runBlocking {
    val job = launch() {
        println("inside: ${Thread.currentThread().name}")
        val timeout = withTimeoutOrNull(600) {
            repeat(10) {
                println("delay $it times")
                delay(50)
                yield()
            }
            return@withTimeoutOrNull 10
        }
        println("timeout= $timeout")
    }
    delay(100)
    println("outer: ${Thread.currentThread().name}")
}

→ 跟上面的範例很相像,差異只在於我們在 withTimeoutOrNull(){...} 的最後加上了一個回傳值,所以當程式碼順利在時限內完成,就會回傳這個值,否則就會回傳 null

→ 如果 timeout 是 600 毫秒,那這段程式碼可以正常跑完,所以結果會是

inside: main
delay 0 times
delay 1 times
outer: main
delay 2 times
delay 3 times
delay 4 times
delay 5 times
delay 6 times
delay 7 times
delay 8 times
delay 9 times
timeout= 10

將 timeout 改成 200

將 timeout 改成 200 之後,這段程式就沒有辦法執行 10 次,最多就只能跑 4 次,我們看看結果會是如何?

fun main() = runBlocking {
    val job = launch() {
        println("inside: ${Thread.currentThread().name}")
        val timeout = withTimeoutOrNull(200) {
            repeat(10) {
                println("delay $it times")
                delay(50)
                yield()
            }
            return@withTimeoutOrNull 10
        }
        println("timeout= $timeout")
    }
    delay(100)
    println("outer: ${Thread.currentThread().name}")
}
inside: main
delay 0 times
delay 1 times
outer: main
delay 2 times
delay 3 times
timeout= null

→ 原本輸出應該是 10,但是因為 timeout 的關係,所以只能完成四次,所以最後 withTimeoutOrNull 會輸出 null

小記

本篇文章介紹的三個 suspend 函式, withContext、withTimeout、withTimeoutOrNull。

withContext 適合使用在執行完非同步的呼叫後,需要切換成主執行緒並更新畫面的情境,而另外兩個與 timeout 有關的函式,主要就是要看使用者的需求,如果超時之後就不管他,可以使用 withTimeout,如果執行的區塊是有一個回傳值,withTimeoutOrNull 或許就比較適合了。

參考資料

withContext

withTimeout.html

withTimeoutOrNull

特別感謝

Kotlin Taiwan User Group

Kotlin 讀書會


上一篇
Day12:內建的 suspend 函式,好函式不用嗎?(1)
下一篇
Day14:內建的 suspend 函式,好函式不用嗎? (3)
系列文
Coroutine 停看聽30

尚未有邦友留言

立即登入留言