iT邦幫忙

2021 iThome 鐵人賽

DAY 12
0
Software Development

Coroutine 停看聽系列 第 12

Day12:內建的 suspend 函式,好函式不用嗎?(1)

Coroutine 的三大要素不知道大家還記得嗎?CoroutineScope、Suspend function、Dispatchers。

CoroutineScope 是定義 coroutine 執行的範圍,我們可以使用 launchasync 來建立範圍。

Suspend function 是用來處理非同步的執行,所以我們可以在這邊放上耗時任務,Coroutine 執行到這邊的時候,就會將此 coroutine 暫停(suspend/掛起),等到完成之後,才又會把此 coroutine 恢復運行。

而 Dispatchers 是用來指定該 coroutine 使用不同的調度器來執行,所謂調度器指的是 coroutine 根據不同的應用會在背景會建立不同的執行緒池/執行緒,使用者可以根據使用的情境來選擇適當的 Dispatchers。如 Dispatchers.IO、Dispatchers.Default...

今天要來介紹的是那些內建的 suspend function,如我們之前經常使用的 delay() 就是其中一個成員喔。


delay()

第一個當然就是我們的 delay() 囉,以前我們使用執行緒的時候,如果我們需要在執行緒上暫停一下,我們會使用 Thread.sleep() 來把目前的執行緒停止,不過這樣做的缺點就是整個執行緒就停起來了,這個執行緒就什麼事都不能做。

delay() 則是讓這個 coroutine 進入等待的狀態,coroutine 進入等待之後,就會去找尋在同一個 Dispatchers 的任務來執行。

fun main() = runBlocking<Unit> {
    launch {
        Thread.sleep(100L)
        println("Thread 1 ${Thread.currentThread().name}")
    }

    launch {
        println("Thread 2 ${Thread.currentThread().name}")
    }
}
Coroutine 1 main
Coroutine 2 main

→ 在第一個 launch 中呼叫了 Thread.sleep(100L) 之後,執行緒就會暫停 100 毫秒,當暫停時間結束過後,才會開始執行後面的內容。所以輸出會是 Coroutine 1 main → Coroutine 2 main

Inappropriate blocking method call

如果在 coroutine 裏面使用 Thread.sleep() ,會顯示一個警告。

就是在告訴你 coroutine 不要使用會阻塞的方法啦。
Inappropriate blocking method call

同樣的程式碼,我們改用 delay()

fun main() = runBlocking<Unit> {
    launch {
        delay(100L)
        println("Coroutine 1 ${Thread.currentThread().name}")
    }

    launch {
        println("Coroutine 2 ${Thread.currentThread().name}")
    }
}
Coroutine 2 main
Coroutine 1 main

→ 第一個 launch 呼叫 delay() 之後,這一個 launch 的 coroutine 的狀態被切換成等待,然後就會執行下一段程式。當 delay() 結束之後,就會從暫停的地方恢復,所以就會繼續往下執行。最後看到的輸出結果就是 Coroutine 2 main -> Coroutine 1 main


yield()

如果直接查字典,可能會得到一個不太貼切的翻譯:屈服。
屈服?

根據韋氏辭典的解釋,我認為比較貼切的是這個解釋

to give up possession of on claim or demand - 根據主張或需求放棄權利。

那麼,到底 yield() 到底是什麼用途呢?

Yields the thread (or thread pool) of the current coroutine dispatcher to other coroutines on the same dispatcher to run if possible. - Ref

如果可能,放棄目前 coroutine 調度程序的執行緒/執行緒池到另一個在同一個調度器的 coroutine 。

還是很茫嗎?看下面的範例:

  • 一個巢狀的 coroutine 由兩個 launch 產生兩個 coroutine - Ref
fun main() = runBlocking {
    val job = launch {
        val child = launch {
            try {
                println("run child")
                delay(Long.MAX_VALUE)
            } finally {
                println("Child is cancelled")
            }
        }
        println("run parent")
        yield()
        println("Cancelling child")
        child.cancel()
        child.join()
        yield()
        println("Parent is not cancelled")
    }
    job.join()
}
run parent
run child
Cancelling child
Child is cancelled
Parent is not cancelled

→ 從 log 的輸出我們可以發現,我們一開始會先從外側的 coroutine 開始執行 ,所以印出了第一行 run parent ,當我們呼叫 yield() 時,此時執行緒的使用權就會切換至子 coroutine,所以列印出 run child ,接著在子 coroutine 中呼叫 delay() ,內部的 coroutine 切換至等待狀態,並把執行緒使用權切回外層的 coroutine 並列印 Cancelling child 。接著,呼叫 child.cancel() 來把

子 coroutine 裏面的任務給停掉,於是子 coroutine 的 delay() 被取消,列印出 Child is cancelled 。子 coroutine 被取消之後,調用 child.join() 就只會把子 coroutine 切回父 coroutine。下面的 yield() 則因為沒有其他的任務等待,所以沒有作用,最後列印 Parent is not cancelled 結束。

我們換另一個例子來看看:


fun main() = runBlocking{
    val job1 = launch {
        repeat(10) {
            println("coroutine1: $it")
            yield()
        }
    }
    val job2 = launch {
        repeat(10) {
            println("coroutine2: $it")
            yield()
        }
    }
    job1.join()
    job2.join()
}

猜猜看,這段程式碼會怎麼輸出呢?

如果按照我們前面所說的, yield() 會把放棄目前 coroutine 的執行緒到另一個 coroutine,所以當執行到 yield() 時,就會把執行的權利交給下一個 coroutine 來執行。所以答案會是

coroutine1: 0
coroutine2: 0
coroutine1: 1
coroutine2: 1
coroutine1: 2
coroutine2: 2
coroutine1: 3
coroutine2: 3
coroutine1: 4
coroutine2: 4
coroutine1: 5
coroutine2: 5
coroutine1: 6
coroutine2: 6
coroutine1: 7
coroutine2: 7
coroutine1: 8
coroutine2: 8
coroutine1: 9
coroutine2: 9

另外,假如 coroutine 在暫停的時候被取消,那麼縱使呼叫了 yield() 也沒有辦法回去,畢竟都被取消了。

fun main() = runBlocking {
    val job1 = launch {
        repeat(10) {
            println("coroutine1: $it")
            yield()
        }
    }
    val job2 = launch {
        repeat(10) {
            println("coroutine2: $it")
            job1.cancel() //<- Add this line
            yield()
        }
    }
    job1.join()
    job2.join()
}
coroutine1: 0
coroutine2: 0
coroutine2: 1
coroutine2: 2
coroutine2: 3
coroutine2: 4
coroutine2: 5
coroutine2: 6
coroutine2: 7
coroutine2: 8
coroutine2: 9

job2 裏面調用 job1.cancel() 取消 Job1,當在 job2 呼叫 yield() 也沒有辦法切回 Job1。

小記

delay() 與 yield() 使用上的結果看起來有點相像,不過實際的內容是不太一樣的, delay() 是會讓目前的 coroutine 切換成等待狀態,接著就會去尋找下一個等待執行的 coroutine ,因為它只是讓 coroutine 等待,所以執行緒並沒有被停止下來,跟 Thread.sleep() 是不一樣的, Thread.sleep() 會阻塞執行緒,所以後面就算有任務需要執行,也會因為執行緒被卡住的關係而無法執行。

yield() 則是放棄目前執行的權利,讓下一個 coroutine 可以執行(需要同一個調度器),所以就實現來說, yield()delay() 都可以做到暫停目前 coroutine 的任務,不過實際運用上還是有些不同。

內建的 suspend 函式就先介紹這兩個,其他的往後幾篇再繼續介紹。


上一篇
Day11:調度器(Dispatchers),我跳進來了,又跳出去了
下一篇
Day13:內建的 suspend 函式,好函式不用嗎? (2)
系列文
Coroutine 停看聽30

尚未有邦友留言

立即登入留言