iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
Software Development

異步程式設計修煉:Kotlin Coroutines 完全指南系列 第 5

Day 04:launch、async、runBlocking,三種 Coroutine Builder 該選誰

  • 分享至 

  • xImage
  •  

Day 03:Continuation 是什麼,編譯器在背後偷偷做了什麼事》留下一個問題。suspend function 內部已經具備暫停與恢復的能力,這套機制確實存在,但它終究要有個起點才會真正運作起來。一段程式碼要怎麼發動出一個協程,讓這整套暫停恢復機制開始運轉。今天要正式回答這個問題。

協程要怎麼被發動

先用一句話重述問題:知道了 suspend function 內部具備暫停恢復的能力,但這一切需要一個起點才能真正跑起來,而這個起點目前還沒有名字。

答案是,Kotlin 協程提供幾個用來實際啟動一個協程的函式,統稱 Coroutine Builder。今天要定案這個詞,全系列後續一律使用它來指稱這類函式。本篇聚焦其中最常用的三個,launchasyncrunBlocking,分別對應不同的使用情境。

呼叫這些 Builder 的時候,必須處於某個可以啟動協程的環境下,這件事目前先留一個印象就好,這個環境的完整定義留給《Day 06:Coroutine Scope,協程住在哪裡、活多久》。今天只需要知道,寫下 launchasync 這幾個字之前,總要先站在某個地方才能呼叫它們。

launch:發射出去就不等你了

launch 的核心行為很直接:啟動一個新的協程去執行一段程式碼,呼叫之後,呼叫端會立刻繼續往下執行,不會停下來等待這個新協程完成。

fun startDownloadLogging(url: String) {
    launch {
        val bytes = downloadImage(url)
        println("下載完成,大小為 ${bytes.size} bytes")
    }
    println("已送出下載請求,繼續處理其他事情")
}

這段程式碼裡,launch 呼叫完畢的瞬間,函式並不會等大括號裡的內容跑完才往下走。println("已送出下載請求...") 很可能在圖片還沒下載完成時就先被印出來。launch 回傳的是一個 Job 物件,今天先點到為止,不展開它的細節,Job 的完整定義留給系列談 Job 錨點時再展開。

拿示範情境具體化一次。如果只是想觸發一次圖片下載,並不特別在意下載完成後要拿到什麼回傳值,只在意下載完成之後去更新畫面或記錄一筆結果,這種只關心「副作用」而不關心「回傳值」的情境,就適合用 launch。一句話記住這個選擇,只是要觸發一個動作、不需要它的回傳值,就用 launch

async:我要結果,但等一下再要

async 的核心行為和 launch 有一個關鍵差異:它同樣會啟動一個新的協程去執行一段程式碼,但會回傳一個代表「未來會有結果」的物件。呼叫端可以先把這個物件放著,在真正需要這個結果的那一刻,才對它呼叫等待取值的操作,此時才真正暫停下來等待結果到位。這個回傳物件的完整型別與細節今天先點到為止,需要用到時再說明。

suspend fun downloadAndAggregate(urls: List<String>) {
    val deferredList = urls.map { url ->
        async { downloadImage(url) }
    }
    val allBytes = deferredList.map { it.await() }
    println("共取得 ${allBytes.size} 張圖片,開始聚合")
}

這裡的關鍵在於兩個步驟被拆開了。第一步先用 async 把每個網址的下載動作都各自啟動出去,這時候多個下載已經同時在進行,彼此不互相等待。第二步才呼叫 await,逐一取出結果,也就是在這一刻才真正暫停,等待對應的下載完成。如果需要同時從多個不同網址下載圖片,並且要等所有圖片都下載完成才能進行聚合,這種既需要取得結果、又可能需要同時進行多個操作的情境,就適合用 async。同樣一句話記住,需要回傳值,而且可能要同時做好幾件事,就用 async

runBlocking:名字裡帶著 blocking,是不是很矛盾

看到 runBlocking,可能會冒出一個疑問。前三天一直在強調暫停不等於阻塞,怎麼會有一個 Coroutine Builder 的名字裡直接寫著 blocking。這個疑惑完全合理,值得正面回應。

答案是,runBlocking 的定位跟前兩者不一樣,它不是用來在協程世界裡再啟動一個協程,而是一座橋樑,讓非協程的一般程式碼,例如一個普通函式的 main 進入點,或是某些測試情境,能夠呼叫協程程式碼。

fun main() = runBlocking {
    val bytes = downloadImage("https://example.com/photo.jpg")
    println("下載完成,大小為 ${bytes.size} bytes")
}

main 本身是一個普通函式,不具備暫停恢復的能力,也不身處任何協程環境。如果想在裡面呼叫 downloadImage 這樣的 suspend function,就需要某個東西先把執行緒帶進協程世界。runBlocking 做的正是這件事,它會真正阻塞當下的執行緒,直到裡面的協程執行完成才返回。

它刻意設計成阻塞,是因為呼叫它的地方本來就不在協程環境裡,沒有其他協程可以把執行緒讓給,只能選擇等待。這跟 suspend function 暫停時把執行緒讓給別人用的行為完全不同,runBlocking 站在的位置本來就是協程世界的外面,它要負責的是把外面的執行緒鎖住,直到裡面的事情辦完。

正因為如此,使用時機有明確的邊界。runBlocking 通常只適合出現在程式的最外層進入點,或某些測試場景,用來銜接協程世界與非協程世界。如果已經身處協程環境,或是在正式應用程式的一般業務邏輯裡,並不適合隨意使用 runBlocking,濫用會讓原本應該保持非阻塞優勢的程式碼,重新變回阻塞,等於白白繞了一圈又走回 Day 01:為什麼你需要協程,從一個會卡住主執行緒的下載函式說起 一開始想解決的問題。

三者放在一起,該怎麼選

三個名字看起來各有語法,實際判斷時不需要死記語法差異,前面兩節其實已經分別給出各自的判斷依據,把它們接在一起問,就是一套完整的決策流程,靠兩個問題依序回答就能決定。

第一個問題,需不需要這個操作的回傳結果。不需要,選 launch,到這裡就結束,不用再問第二個問題。需要,才繼續往下看第二個問題。

第二個問題,呼叫的地方是不是已經身處協程或可啟動協程的環境。如果已經在協程世界裡,且需要結果,選 async。如果是在協程世界之外,例如程式的進入點,代表連第一步「啟動協程」都還做不到,這時候要先靠 runBlocking 開一個入口把執行緒帶進協程世界,之後才有資格再問要選 launch 還是 async

拿示範情境完整對照一次。多來源圖片下載與聚合工具裡,觸發個別下載進度的記錄行為,只是想留下一筆記錄、不特別在意回傳值,適合用 launch。需要聚合多個來源結果的下載操作,必須等所有結果到齊才能繼續,適合用 async。而如果整個範例程式的進入點是一個普通的 main 函式,需要銜接進協程世界才能開始呼叫這些 suspend function,這裡才會用到 runBlocking

把這幾天的內容放在一起跑一次

今天正式定案了 Coroutine Builder 這個詞,也拆解了 launchasyncrunBlocking 三者各自的行為與適用情境。回傳值決定第一層選擇,環境決定第二層選擇,這個判斷架構會反覆用在接下來的每一次協程程式碼撰寫上。

這幾天分別認識了暫停不等於阻塞、Continuation 如何維持暫停狀態,以及三種啟動協程的方式。但目前為止,這些都還是分開理解的知識點。如果把它們放進同一個情境裡,從頭到尾完整跑一次,會看到什麼樣的執行時間軸?下一篇會把這幾天的內容整合在一起,做一次完整的小結,詳見《Day 05:小結:把暫停機制與啟動方式放在一起跑一次》。


上一篇
Day 03:Continuation 是什麼,編譯器在背後偷偷做了什麼事
下一篇
Day 05:把暫停機制與啟動方式放在一起跑一次
系列文
異步程式設計修煉:Kotlin Coroutines 完全指南8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言