協程入門#
協程(Coroutines) 是 Kotlin 處理非同步(Asynchronous)和並行(Concurrent)任務的核心機制。相較於傳統執行緒,協程更輕量、更易撰寫,讓非同步程式碼看起來像同步程式碼一樣直觀。
1. 加入依賴#
協程需要額外引入 kotlinx.coroutines 函式庫。
Gradle(build.gradle.kts):
1
2
3
| dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
}
|
2. 三個核心概念的關係#
學習協程前,先釐清三個核心概念的層級關係:
執行緒(Thread):OS 提供的工作者,真正執行程式碼的單位。建立成本高、數量有限。
協程(Coroutine):跑在執行緒上的輕量任務,由 Kotlin 管理。可以暫停讓出執行緒,再從暫停點恢復,不受 OS 執行緒數量限制。
調度器(Dispatcher):決定協程跑在哪條執行緒(池)上,負責把協程分配給合適的執行緒。
1
2
3
4
5
6
7
8
| 調度器(Dispatcher)— 分配協程到執行緒
├── 執行緒 1 — 執行程式碼的工作者
│ ├── 協程 A(執行中)
│ └── 協程 B(A 暫停時接手)
├── 執行緒 2
│ └── 協程 C(執行中)
└── 執行緒 3
└── 協程 D(執行中)
|
關鍵特性:
- 多個協程可共用一條執行緒(協程暫停時讓出,其他協程趁機執行)
- 一個協程可以切換執行緒(
withContext 切換調度器,對程式碼透明)
本文各節對應這三個概念:第 3–5 節學如何啟動協程、第 6 節學暫停機制、第 7 節學作用域、第 9 節學調度器。
執行緒 vs. 協程#
| 比較項目 | 執行緒 | 協程 |
|---|
| 建立成本 | 高(MB 等級記憶體) | 極低(KB 等級) |
| 數量限制 | 受 OS 限制 | 可建立數十萬個 |
| 阻塞行為 | 阻塞整條執行緒 | 只暫停協程,不阻塞執行緒 |
| 程式碼風格 | 回呼(Callback)較常見 | 循序(Sequential)風格 |
啟動協程的三種方式#
學習協程前,先建立一個整體概念:啟動協程有三種常用方式,各自扮演不同角色。
| 方式 | 定位 | 能在普通函數呼叫 | 阻塞執行緒 | 回傳值 |
|---|
runBlocking | 入口橋樑:從普通世界進入協程世界 | ✅ | ✅ | Lambda 結果 |
launch | 子協程:在協程世界裡再開一個協程,不需要結果 | ❌ | ❌ | Job |
async | 子協程:在協程世界裡再開一個協程,需要取回結果 | ❌ | ❌ | Deferred<T> |
普通函數(main)
│
└─ runBlocking { ← 入口橋樑,建立協程世界
│
├─ launch { … } ← 子協程,執行動作
├─ launch { … } ← 子協程,執行動作
└─ async { … } ← 子協程,計算並回傳結果
}
runBlocking 通常只在最外層出現一次;裡面所有的並行工作都交給 launch 和 async。
3. runBlocking:入口橋樑#
為什麼需要橋樑?#
協程不能在普通函數裡憑空啟動——launch 和 async 都需要「協程環境」才能呼叫。runBlocking 的作用就是在普通世界和協程世界之間搭一座橋。它的作用是:
- 建立一個協程環境(CoroutineScope)
- 在這個環境裡啟動一個協程
- 阻塞呼叫它的執行緒,直到區塊內所有協程都完成才繼續往下走
名稱裡的 Blocking 就是這個意思——runBlocking 本身會擋住執行緒,直到區塊內所有工作完成。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| import kotlinx.coroutines.*
fun main() { // 1. 普通函數
println("runBlocking 之前")
runBlocking { // 2. 建立協程環境,main 執行緒在此等待
println("協程開始")
println("協程結束")
} // 3. 所有協程完成,main 執行緒繼續
println("runBlocking 之後")
// 輸出:
// runBlocking 之前
// 協程開始
// 協程結束
// runBlocking 之後
}
|
使用時機#
runBlocking 主要用於:
- 學習和實驗:快速跑協程程式碼
- 單元測試:
runTest 的前身 main() 入口:讓 main 函數能夠等待協程完成
實際的 Android 或後端開發,會改用與元件生命週期綁定的作用域(見第 7 節),而非 runBlocking。
4. launch:在協程世界裡開子協程(無回傳值)#
launch 只能在協程作用域內(runBlocking、coroutineScope 等)呼叫,用來再開一個子協程。它會立刻啟動子協程,然後 不等它跑完就繼續往下執行(非阻塞),最後回傳一個 Job 物件供後續控制。適合「執行某件事、但不需要取回結果」的場景。
以下範例會使用 delay(ms) 來模擬耗時工作(例如網路請求)。現在只需把它理解為「讓協程等待指定毫秒數」,完整說明見 第 6 節。
執行順序說明#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch { // ① 啟動協程,立刻回到呼叫方
delay(500) // ③ 協程在這裡暫停 500ms
println("協程執行完畢") // ④ 500ms 後繼續,印出這行
}
println("主程式繼續執行") // ② launch 不阻塞,馬上執行這行
job.join() // ⑤ 等待 job 完成後才繼續
println("全部完成") // ⑥ job 結束後才印出
// 輸出:
// 主程式繼續執行
// 協程執行完畢
// 全部完成
}
|
launch 的核心特性:啟動後 立刻把控制權交還給呼叫方,所以「主程式繼續執行」比「協程執行完畢」先印出。
不呼叫 join() 會怎樣?#
1
2
3
4
5
6
7
8
9
10
11
12
13
| import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
delay(500)
println("協程執行完畢") // 這行會被印出嗎?
}
println("主程式繼續執行")
// 沒有 job.join()
// 輸出:
// 主程式繼續執行
// 協程執行完畢 ← runBlocking 會等所有子協程完成才退出,所以還是印得到
}
|
在 runBlocking 內不呼叫 join(),runBlocking 本身仍會等所有子協程完成再結束。但在其他作用域(如 GlobalScope)就不保證了,可能協程還沒跑完,程式就退出了。養成呼叫 join() 的習慣比較安全。
Job:控制協程的遙控器#
launch 回傳的 Job 物件提供幾個常用的方法和屬性:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(10) { i ->
println("執行中 $i")
delay(200)
}
}
println("isActive: ${job.isActive}") // 輸出:isActive: true
println("isCompleted: ${job.isCompleted}") // 輸出:isCompleted: false
delay(500)
job.cancel() // 取消協程
job.join() // 等待取消完成(確保清理工作結束)
println("isActive: ${job.isActive}") // 輸出:isActive: false
println("isCompleted: ${job.isCompleted}") // 輸出:isCompleted: true
// 完整輸出:
// isActive: true ← launch 後主協程繼續,子協程尚未執行
// isCompleted: false
// 執行中 0 ← delay(500) 讓出執行緒,子協程開始跑
// 執行中 1
// 執行中 2 ← 500ms 到,主協程恢復並 cancel()
// isActive: false
// isCompleted: true
}
|
Job 成員 | 說明 |
|---|
join() | 等待協程執行完畢 |
cancel() | 取消協程 |
isActive | 協程是否還在執行中 |
isCompleted | 協程是否已完成(正常或取消均算) |
isCancelled | 協程是否已被取消 |
同時啟動多個協程#
launch 每次呼叫都會啟動一個獨立的協程,多個協程可以同時等待,互不干擾。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| import kotlinx.coroutines.*
fun main() = runBlocking {
repeat(5) { i ->
launch {
delay((i * 200).toLong()) // 協程 0 等 0ms、1 等 200ms、2 等 400ms…
println("協程 $i 完成")
}
}
println("所有協程已啟動") // launch 不阻塞,5 個都啟動後馬上印這行
// 輸出:
// 所有協程已啟動 ← 5 個 launch 全部啟動後立刻印
// 協程 0 完成 ← 0ms 後
// 協程 1 完成 ← 200ms 後
// 協程 2 完成 ← 400ms 後
// 協程 3 完成 ← 600ms 後
// 協程 4 完成 ← 800ms 後
}
|
5. async:在協程世界裡開子協程(有回傳值)#
async 和 launch 一樣,只能在協程作用域內呼叫,同樣是開一個不阻塞的子協程。差別在於 async 會回傳 Deferred<T> 物件,呼叫 .await() 可以取得執行結果。適合「需要計算並取回結果」的場景。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| import kotlinx.coroutines.*
fun main() = runBlocking {
val deferred1 = async {
delay(500) // 模擬耗時工作(如網路請求)
"資料 #1" // async 區塊的最後一行即為回傳值
}
val deferred2 = async {
delay(500)
"資料 #2"
}
// 兩個 async 並行等待,總時間約 500ms(而非 1000ms)
val result1 = deferred1.await() // 等待並取得結果
val result2 = deferred2.await()
println("$result1, $result2")
// 輸出:資料 #1, 資料 #2
}
|
async { } 區塊的 最後一個表達式 就是回傳值,型別由編譯器自動推導(此例為 Deferred<String>)。
launch vs. async#
| launch | async |
|---|
| 回傳值 | 無(Job) | 有(Deferred<T>) |
| 取得結果 | 不適用 | .await() |
| 適用場景 | 執行動作(如寫入、更新) | 計算並取回結果 |
6. suspend 函數#
suspend 是什麼意思?#
suspend 的中文是「暫停」。加上這個關鍵字的函數,執行到需要等待的地方時,會 把執行緒讓出去,讓執行緒在等待期間去做別的事,等結果回來後再從暫停的地方繼續。
生活化比喻:就像廚師把菜放進烤箱後,不會站在烤箱前乾等,而是去備料、洗碗,烤好了再回來繼續。普通函數則是站著等烤箱,什麼都不做。
1
2
3
4
| 普通函數: ──執行── [等待中,執行緒空轉] ──繼續──▶
suspend 函數:──執行── [讓出執行緒] ──繼續──▶
↓
執行緒去做別的事
|
delay() 本身就是 suspend 函數#
前幾節一直在用的 delay(),其實就是一個 suspend 函數。它讓協程暫停指定時間,期間執行緒不會被佔用。這也是為什麼 delay() 只能在協程或 suspend 函數裡呼叫。
1
2
3
| // delay 的簽名(Kotlin 標準函式庫)
public suspend fun delay(timeMillis: Long)
// ↑ 就是這個關鍵字讓它能「暫停而不阻塞」
|
宣告自己的 suspend 函數#
在 fun 前加上 suspend,就能在函數內部使用 delay() 或其他 suspend 函數。
1
2
3
4
5
6
| import kotlinx.coroutines.*
suspend fun fetchUser(id: Int): String {
delay(300) // 模擬網路等待 300ms
return "User($id)" // 等待完成後回傳結果
}
|
suspend 函數只能在兩個地方呼叫:
- 協程內(
launch { } / async { } / runBlocking { } 等) - 另一個
suspend 函數內
在普通函數中呼叫會發生什麼?#
1
2
3
4
5
6
7
8
9
10
11
12
13
| import kotlinx.coroutines.*
suspend fun fetchUser(id: Int): String {
delay(300)
return "User($id)"
}
fun normalFunction() {
val user = fetchUser(1)
// 編譯錯誤:
// Suspend function 'fetchUser' should be called only
// from a coroutine or another suspend function
}
|
編譯器直接拒絕,這是刻意的保護——普通函數沒有「暫停能力」,強行呼叫會破壞執行緒模型。
suspend 的「暫停」和「blocking」有什麼不同?#
看到這裡你可能會問:循序呼叫兩個 suspend 函數,還是要等 300ms + 200ms = 500ms,跟用 Thread.sleep 有什麼差別?
差別在於 等待期間執行緒去哪了。
| Thread.sleep | delay(suspend) |
|---|
| 等待期間執行緒 | 被凍結,什麼都做不了 | 被釋放,去執行其他協程 |
| 對這個協程的耗時 | 一樣 | 一樣 |
| 對整個程式的吞吐量 | 低(一條執行緒同時只服務一個任務) | 高(一條執行緒同時服務多個協程) |
用程式碼驗證這個差別:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| import kotlinx.coroutines.*
// ❌ 用 Thread.sleep:執行緒被佔用,3 個協程被迫排隊
fun main() = runBlocking {
val start = System.currentTimeMillis()
val jobs = List(3) { i ->
launch {
Thread.sleep(300) // 凍結執行緒,其他協程無法插入
println("sleep 協程 $i 完成")
}
}
jobs.joinAll() // 等全部完成再計時
println("總耗時:${System.currentTimeMillis() - start}ms")
// 輸出:
// sleep 協程 0 完成
// sleep 協程 1 完成
// sleep 協程 2 完成
// 總耗時:約 900ms ← 3 個排隊,每個等 300ms
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| import kotlinx.coroutines.*
// ✅ 用 delay:執行緒在等待時被釋放,3 個協程同時在等
fun main() = runBlocking {
val start = System.currentTimeMillis()
val jobs = List(3) { i ->
launch {
delay(300) // 釋放執行緒,其他協程可以趁機執行
println("delay 協程 $i 完成")
}
}
jobs.joinAll() // 等全部完成再計時
println("總耗時:${System.currentTimeMillis() - start}ms")
// 輸出:
// delay 協程 0 完成
// delay 協程 1 完成
// delay 協程 2 完成
// 總耗時:約 300ms ← 3 個同時在等,一起完成
}
|
所以 suspend 的「讓出執行緒」,是對 整個執行緒的利用率 有幫助,而不是讓單一協程跑得更快。
suspend 函數的循序呼叫#
在同一個協程內循序呼叫兩個 suspend 函數,執行緒在每次等待時都會被釋放去服務其他協程,但這個協程本身仍然依序等待,總耗時是兩者相加。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| import kotlinx.coroutines.*
suspend fun fetchUser(id: Int): String {
delay(300)
return "User($id)"
}
suspend fun fetchPosts(userId: Int): List<String> {
delay(200)
return listOf("文章 A", "文章 B")
}
fun main() = runBlocking {
val start = System.currentTimeMillis()
val user = fetchUser(1) // 等 300ms(期間執行緒可服務其他協程)
val posts = fetchPosts(1) // 等 200ms(期間執行緒可服務其他協程)
println(user)
println(posts)
println("耗時:${System.currentTimeMillis() - start}ms")
// 輸出:User(1)
// 輸出:[文章 A, 文章 B]
// 輸出:耗時:約 500ms
}
|
如果需要這兩個呼叫 同時進行,改用 async 並行執行,總耗時可縮短為約 300ms(見第 5 節)。
核心優勢:取代 callback 巢狀結構#
沒有 suspend 時,多個非同步操作必須層層巢狀 callback,俗稱「callback 地獄」。
1
2
3
4
5
6
7
8
9
10
11
| // 傳統 callback 寫法的樣子(越來越深)
fetchUser(1) { user ->
fetchPosts(user) { posts ->
fetchComments(posts) { comments ->
fetchLikes(comments) { likes ->
// 真正的邏輯埋在第四層
println(likes)
}
}
}
}
|
suspend 讓同樣的邏輯可以用由上而下的循序方式寫,可讀性大幅提升:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| import kotlinx.coroutines.*
suspend fun fetchUser(id: Int): String { delay(100); return "User($id)" }
suspend fun fetchPosts(user: String): String { delay(100); return "Post of $user" }
suspend fun fetchComments(post: String): String { delay(100); return "Comments of $post" }
suspend fun fetchLikes(comment: String): Int { delay(100); return 42 }
fun main() = runBlocking {
val user = fetchUser(1)
val posts = fetchPosts(user)
val comments = fetchComments(posts)
val likes = fetchLikes(comments)
println("讚數:$likes")
// 輸出:讚數:42
}
|
每一步都等上一步完成,程式碼結構和執行順序完全一致,沒有縮排地獄。
程式碼由上而下,邏輯一目瞭然,背後仍是非同步執行。
7. 協程作用域(CoroutineScope)#
協程必須在作用域(CoroutineScope)內啟動,作用域負責管理其生命週期——當作用域結束時,它內部所有未完成的協程也會一併取消。
runBlocking、GlobalScope、viewModelScope 等都實作了 CoroutineScope 介面,本質上都是協程作用域,差別在於 生命週期 和 是否阻塞執行緒:
常用作用域#
| 作用域 | 是否阻塞執行緒 | 生命週期 | 適用場景 |
|---|
runBlocking | ✅ 阻塞 | 區塊執行完畢 | 學習、測試、main() 入口 |
GlobalScope | ❌ | 整個應用程式(應避免使用) | — |
CoroutineScope | ❌ | 自行控制 | 與元件生命週期綁定 |
viewModelScope | ❌ | ViewModel 存活期間 | Android ViewModel |
coroutineScope:結構化並行#
coroutineScope 建立一個子作用域,確保所有子協程完成後才繼續。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| import kotlinx.coroutines.*
suspend fun processAll(): String = coroutineScope {
val part1 = async {
delay(300)
"結果 A"
}
val part2 = async {
delay(200)
"結果 B"
}
"${part1.await()} + ${part2.await()}"
}
fun main() = runBlocking {
val result = processAll()
println(result) // 輸出:結果 A + 結果 B
}
|
8. 取消協程#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(10) { i ->
println("執行中 $i")
delay(300)
}
}
delay(1000)
println("取消協程")
job.cancel()
job.join()
println("協程已取消")
// 輸出:
// 執行中 0
// 執行中 1
// 執行中 2
// 執行中 3
// 取消協程
// 協程已取消
}
|
9. 協程調度器(Dispatchers)#
為什麼需要調度器?#
協程可以在不同的執行緒上執行。不同類型的工作對執行緒的需求不同:
- CPU 密集型(排序、影像處理、加密):需要大量運算,執行緒數量等於 CPU 核心數就夠,多了反而造成切換開銷
- I/O 密集型(網路請求、讀寫檔案、查詢資料庫):大部分時間在等待,需要更多執行緒同時處理,讓 CPU 不閒置
調度器(Dispatcher)就是負責把協程分配到適合的執行緒(池)上執行。
四種調度器#
| 調度器 | 執行緒池大小 | 適用場景 |
|---|
Dispatchers.Default | CPU 核心數 | CPU 密集型:排序、計算、資料處理 |
Dispatchers.IO | 最多 64 條(可動態擴展) | I/O 密集型:網路請求、檔案讀寫、資料庫查詢 |
Dispatchers.Main | 1(主執行緒) | UI 更新(Android / JavaFX 專用) |
Dispatchers.Unconfined | 不固定 | 不建議使用,行為難以預測 |
指定調度器#
在 launch 或 async 的第一個參數傳入調度器:
1
2
3
4
5
6
7
8
9
10
11
12
13
| import kotlinx.coroutines.*
fun main() = runBlocking {
launch(Dispatchers.Default) {
println("Default:${Thread.currentThread().name}")
// 輸出:Default:DefaultDispatcher-worker-1
}
launch(Dispatchers.IO) {
println("IO:${Thread.currentThread().name}")
// 輸出:IO:DefaultDispatcher-worker-2
}
}
|
withContext:在協程內切換調度器#
實際開發中,同一個協程常常需要先做 I/O(取資料),再做 CPU 運算(處理資料)。withContext 可以在不開新協程的情況下,臨時切換到另一個調度器,完成後自動切回來。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| import kotlinx.coroutines.*
suspend fun loadAndProcess(): String {
// 第一步:用 IO 調度器讀取資料(模擬網路請求)
val raw = withContext(Dispatchers.IO) {
println("讀取資料,執行緒:${Thread.currentThread().name}")
"原始資料 A,B,C,D,E" // 模擬從網路取回的字串
}
// 第二步:用 Default 調度器處理資料(模擬 CPU 運算)
val result = withContext(Dispatchers.Default) {
println("處理資料,執行緒:${Thread.currentThread().name}")
raw.split(",").map { it.lowercase() }
}
return result.toString()
}
fun main() = runBlocking {
val output = loadAndProcess()
println("結果:$output")
// 輸出:
// 讀取資料,執行緒:DefaultDispatcher-worker-1
// 處理資料,執行緒:DefaultDispatcher-worker-2
// 結果:[a, b, c, d, e]
}
|
withContext 是暫停函數,切換期間不阻塞執行緒,是比開新協程更輕量的切換方式。
不指定調度器時預設用哪個?#
在 runBlocking 內不指定調度器,協程會繼承父作用域的調度器,即在呼叫 runBlocking 的執行緒上執行(通常是 main 執行緒)。
Reference#
https://kotlinlang.org/docs/coroutines-overview.html