協程入門#

協程(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 通常只在最外層出現一次;裡面所有的並行工作都交給 launchasync


3. runBlocking:入口橋樑#

為什麼需要橋樑?#

協程不能在普通函數裡憑空啟動——launchasync 都需要「協程環境」才能呼叫。runBlocking 的作用就是在普通世界和協程世界之間搭一座橋。它的作用是:

  1. 建立一個協程環境(CoroutineScope)
  2. 在這個環境裡啟動一個協程
  3. 阻塞呼叫它的執行緒,直到區塊內所有協程都完成才繼續往下走

名稱裡的 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 只能在協程作用域內(runBlockingcoroutineScope 等)呼叫,用來再開一個子協程。它會立刻啟動子協程,然後 不等它跑完就繼續往下執行(非阻塞),最後回傳一個 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:在協程世界裡開子協程(有回傳值)#

asynclaunch 一樣,只能在協程作用域內呼叫,同樣是開一個不阻塞的子協程。差別在於 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#

launchasync
回傳值無(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 函數只能在兩個地方呼叫:

  1. 協程內(launch { } / async { } / runBlocking { } 等)
  2. 另一個 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.sleepdelay(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)內啟動,作用域負責管理其生命週期——當作用域結束時,它內部所有未完成的協程也會一併取消。

runBlockingGlobalScopeviewModelScope 等都實作了 CoroutineScope 介面,本質上都是協程作用域,差別在於 生命週期是否阻塞執行緒

常用作用域#

作用域是否阻塞執行緒生命週期適用場景
runBlocking✅ 阻塞區塊執行完畢學習、測試、main() 入口
GlobalScope整個應用程式(應避免使用)
CoroutineScope自行控制與元件生命週期綁定
viewModelScopeViewModel 存活期間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.DefaultCPU 核心數CPU 密集型:排序、計算、資料處理
Dispatchers.IO最多 64 條(可動態擴展)I/O 密集型:網路請求、檔案讀寫、資料庫查詢
Dispatchers.Main1(主執行緒)UI 更新(Android / JavaFX 專用)
Dispatchers.Unconfined不固定不建議使用,行為難以預測

指定調度器#

launchasync 的第一個參數傳入調度器:

 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