協程基礎概念
本節涵蓋協程的基本概念。
你的第一個協程
協程 (coroutine) 是一個可暫停計算的實例。它在概念上類似於執行緒 (thread),因為它接收一個程式碼區塊來運行,並且與其餘程式碼並行工作。然而,協程不受任何特定執行緒的約束。它可以在一個執行緒中暫停 (suspend) 執行,並在另一個執行緒中恢復 (resume) 執行。
協程可以被視為輕量級執行緒,但它們之間存在一些重要的差異,使得它們在實際應用中的用法與執行緒截然不同。
運行以下程式碼來實現你的第一個工作協程:
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
launch { // launch a new coroutine and continue
delay(1000L) // non-blocking delay for 1 second (default time unit is ms)
println("World!") // print after delay
}
println("Hello") // main coroutine continues while a previous one is delayed
}
NOTE
你可以在這裡獲取完整程式碼。
你將看到以下結果:
Hello
World!
讓我們剖析這段程式碼的功能。
launch 是一個_協程建構器 (coroutine builder)_。它啟動一個新的協程,與其餘程式碼同時執行,並獨立工作。這就是為什麼 Hello
會先被印出來。
delay 是一個特殊的_暫停函數 (suspending function)_。它會讓協程_暫停_特定時間。暫停協程並不會_阻塞_底層執行緒,而是允許其他協程運行並使用底層執行緒執行它們的程式碼。
runBlocking 也是一個協程建構器,它橋接了常規 fun main()
的非協程世界與 runBlocking { ... }
大括號內的協程程式碼。這在 IDE 中會透過 this: CoroutineScope
提示在 runBlocking
開啟大括號之後顯示。
如果你在這段程式碼中移除或忘記使用 runBlocking
,你將在 launch 呼叫處得到一個錯誤,因為 launch
只在 CoroutineScope 上聲明:
Unresolved reference: launch
runBlocking
的名稱意味著運行它的執行緒(在此例中為主要執行緒)會被_阻塞_,直到 runBlocking { ... }
內的所有協程完成執行。你經常會在應用程式的最頂層看到 runBlocking
如此使用,但在實際程式碼中卻很少見,因為執行緒是昂貴的資源,阻塞它們效率低下且通常不被期望。
結構化並發
協程遵循_結構化並發 (structured concurrency)_ 的原則,這意味著新的協程只能在特定的 CoroutineScope 中啟動,該作用域劃定了協程的生命週期。上述範例顯示 runBlocking 建立了相應的作用域,這就是為什麼前一個範例會等待 World!
在一秒的延遲後印出,然後才退出。
在實際應用中,你會啟動大量協程。結構化並發確保它們不會丟失且不會洩漏。外部作用域在其所有子協程完成之前不能完成。結構化並發還確保程式碼中的任何錯誤都能被正確報告且永不丟失。
提取函數重構
讓我們將 launch { ... }
內部的程式碼區塊提取到一個單獨的函數中。當你對這段程式碼執行「提取函數 (Extract function)」重構時,你會得到一個帶有 suspend
修飾符的新函數。這是你的第一個_暫停函數_。暫停函數可以在協程內部像常規函數一樣使用,但它們的額外特性是它們可以轉而使用其他暫停函數(如本例中的 delay
)來_暫停_協程的執行。
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
launch { doWorld() }
println("Hello")
}
// this is your first suspending function
suspend fun doWorld() {
delay(1000L)
println("World!")
}
NOTE
你可以在這裡獲取完整程式碼。
作用域建構器
除了不同建構器提供的協程作用域之外,還可以使用 coroutineScope 建構器聲明你自己的作用域。它創建一個協程作用域,並且只有當所有啟動的子協程完成後才會完成。
runBlocking 和 coroutineScope 建構器可能看起來很相似,因為它們都等待其主體及其所有子協程完成。主要區別在於 runBlocking 方法會_阻塞_當前執行緒進行等待,而 coroutineScope 僅僅是暫停,釋放底層執行緒供其他用途使用。由於這個差異,runBlocking 是一個常規函數,而 coroutineScope 是一個暫停函數。
你可以在任何暫停函數中使用 coroutineScope
。例如,你可以將 Hello
和 World
的並發列印移到 suspend fun doWorld()
函數中:
import kotlinx.coroutines.*
fun main() = runBlocking {
doWorld()
}
suspend fun doWorld() = coroutineScope { // this: CoroutineScope
launch {
delay(1000L)
println("World!")
}
println("Hello")
}
NOTE
你可以在這裡獲取完整程式碼。
這段程式碼也印出:
Hello
World!
作用域建構器和並發
coroutineScope 建構器可以在任何暫停函數內部使用,以執行多個並行操作。讓我們在 doWorld
暫停函數內部啟動兩個並行協程:
import kotlinx.coroutines.*
// Sequentially executes doWorld followed by "Done"
fun main() = runBlocking {
doWorld()
println("Done")
}
// Concurrently executes both sections
suspend fun doWorld() = coroutineScope { // this: CoroutineScope
launch {
delay(2000L)
println("World 2")
}
launch {
delay(1000L)
println("World 1")
}
println("Hello")
}
NOTE
你可以在這裡獲取完整程式碼。
launch { ... }
程式碼區塊內的兩段程式碼是_並行地 (concurrently)_ 執行,World 1
會在開始後一秒先被印出,而 World 2
會在開始後兩秒接著被印出。doWorld
中的 coroutineScope 只有在兩者都完成後才會完成,因此 doWorld
會返回並允許 Done
字串在那之後被印出:
Hello
World 1
World 2
Done
明確的 Job
launch 協程建構器返回一個 Job 物件,它是對已啟動協程的句柄 (handle),可以用來明確地 (explicitly) 等待其完成。例如,你可以等待子協程完成,然後印出 "Done" 字串:
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch { // launch a new coroutine and keep a reference to its Job
delay(1000L)
println("World!")
}
println("Hello")
job.join() // wait until child coroutine completes
println("Done")
}
NOTE
你可以在這裡獲取完整程式碼。
這段程式碼產生:
Hello
World!
Done
協程是輕量級的
協程比 JVM 執行緒耗用的資源更少。使用執行緒時會耗盡 JVM 可用記憶體的程式碼,可以使用協程來表達而不會達到資源限制。例如,以下程式碼啟動 50,000 個獨立的協程,每個協程等待 5 秒然後印出一個點 ('.'),同時消耗極少記憶體:
import kotlinx.coroutines.*
fun main() = runBlocking {
repeat(50_000) { // launch a lot of coroutines
launch {
delay(5000L)
print(".")
}
}
}
NOTE
你可以在這裡獲取完整程式碼。
如果你使用執行緒編寫相同的程式(移除 runBlocking
,將 launch
替換為 thread
,將 delay
替換為 Thread.sleep
),它將消耗大量記憶體。根據你的作業系統、JDK 版本及其設定,它要麼會拋出記憶體不足錯誤,要麼會緩慢啟動執行緒,使得同時運行的執行緒數量永遠不會過多。