iT邦幫忙

2021 iThome 鐵人賽

DAY 13
0
Software Development

Coroutine 停看聽系列 第 13 篇

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

  • 分享至 

  • xImage
  •  

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 讀書會


由本系列文改編的《Kotlin 小宇宙:使用 Coroutine 優雅的執行非同步任務》已經上市囉。

有興趣的讀者歡迎參考:https://coroutine.kotlin.tips/
天瓏書局


上一篇
Day12:內建的 suspend 函式,好函式不用嗎?(1)
下一篇
Day14:內建的 suspend 函式,好函式不用嗎? (3)
系列文
Coroutine 停看聽 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言