Tina Tang's Blog

在哪裡跌倒了,就在哪裡躺下來

0%

Android筆記(33)-Coroutines簡介

瞭解關於執行緒(threads)的資訊,以及如何使用稱為協程(coroutines)的 Kotlin 功能撰寫乾淨(clear)非阻塞(non-blocking)並行(concurrent)程式碼。

學習目標

  • 何謂並行(concurrency),以及其重要性
  • 如何使用協程(coroutines)執行緒(threads),撰寫非阻塞(non-blocking)並行(concurrent)程式碼
  • 如何在 background 工作(performing tasks)時存取 main thread,安全地更新UI
  • 不同並行模式(concurrency pattern) 範圍(Scope) / 調度工具(Dispatchers) / 延遲(Deferred) 」的使用方式與使用時機
  • 如何撰寫與網路資源(network resources)互動(interacts)的程式碼

Responsive UI 是 great app 的關鍵要素。您過去建構 app 時可能都採用此做法並視為標準程序,但隨著您開始新增更多進階功能 (例如網路(networking)資料庫(database)功能),可能會越來越難撰寫可正常運作又兼具效能(performant)的程式碼

以下範例說明若在長時間執行 tasks (例如從 Internet 下載圖片) 時未正確處理,可能會發生什麼情況。當圖片功能運作時,scrolling 操作會變得不穩定而導致 UI 沒有回應。

為避免上述 app 發生問題,您必須對執行緒(threads)有一些瞭解。Thread 是一種略為抽象(abstract)的概念,但您可以將其視為 app 中程式碼的單一執行路徑(single path of execution)。您撰寫的每一行程式碼皆是要在同一 thread 上依序執行的指令(instruction)。

您已在 Android 中使用 thread。每個 Android app 都有預設的 main thread。這通常是 UI thread。您到目前為止撰寫的所有程式碼皆位於 main thread。每個指令 (亦即一行程式碼) 都會接續先前的指令,直到執行下一行為止。

但在執行中的 app 中,除了 main thread 之外還有更多 thread。就技術原理而言,處理者實際上不會操作個別(separate) thread,而是在各個不同序列(series)的指令(instructions)之間來回切換,以呈現多工(multitasking)處理樣貌。

★Thread 是一種抽象概念,可在編寫程式碼時用來判斷每個指令的執行路徑(path of execution)使用 main thread 以外的 thread,可讓 app thread 如背景(background)運作下載圖片等複雜的工作,並讓 app 的 UI 保持回應(responsive)。這就是所謂的並行(concurrent/concurrency)程式碼。


簡介(Intoduction)

多執行緒(multithreading)與並行(concurrency)

截至目前為止,我們已將 Android app 視為具備單一執行路徑(single path of execution)的程式。只要透過單一執行路徑(single path of execution)即可完成大量工作,但隨著 app 增長,您必須思考的是並行(concurrency)問題。

並行(Concurrency)可讓多個程式碼單元執行時跳脫順序(order)看似平行(parallel)執行,提高資源(resources)使用效率。作業系統(operating system)可運用系統特性程式語言和並行單元(concurrency unit)來管理多工處理(multitasking)

為什麼需要使用並行(concurrency)?隨著 app 越發複雜,程式碼也必須處於非阻塞(non-blocking)狀態。也就是說,thread 如 network request長時間執行的 task,並不會讓 app 停止執行其他工作。若未正確實作 concurrency,可能會導致 app 無法回應使用者

以下為您提供幾個範例,在 Kotlin 中演示 concurrent 程式設計。所有範例皆可在 Kotlin Playground 中執行。

Thread 是程式碼的最小單位,可在程式的限定範圍(confines)內執行。以下是可讓我們執行 concurrent 程式碼的小範例。
您可透過提供 lambda 來建立簡易 thread。在 Playground 中嘗試下列做法。

1
2
3
4
5
6
fun main() {
val thread = Thread {
println("${Thread.currentThread()} has run.")
}
thread.start()
}
  • 系統不會在呼叫 start() function 之前執行 thread。

Output 應該看起來像這樣。

1
Thread[Thread-0,5,main] has run.
  • 請注意,currentThread() 會 returns 一個 Thread instance,其會轉換為字串(string)格式,並傳回 thread 的名稱(name)優先順序(priority)執行緒群組(thread group)。以上的 output 可能會略有不同。

建立與執行多執行緒(multiple threads)

為了示範簡易的 concurrency,我們可以建立兩個 thread 來執行。程式碼會建立 3 個 thread,並 printing 上一個範例的 information line。

1
2
3
4
5
6
7
8
9
10
11
12
fun main() {
val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
repeat(3) {
Thread {
println("${Thread.currentThread()} has started")
for (i in states) {
println("${Thread.currentThread()} - $i")
Thread.sleep(50)
}
}.start()
}
}

Playground 中的 output:

1
2
3
4
Thread[Thread-2,5,main] has started Thread[Thread-2,5,main] - Starting Thread[Thread-0,5,main] - Doing Task 1 Thread[Thread-1,5,main] - Doing Task 1 Thread[Thread-2,5,main] - Doing Task 1 Thread[Thread-0,5,main] - Doing Task 2 Thread[Thread-1,5,main] - Doing Task 2 Thread[Thread-2,5,main] - Doing Task 2 Thread[Thread-0,5,main] - Ending Thread[Thread-2,5,main] - Ending Thread[Thread-1,5,main] - Ending Thread[Thread-0,5,main] has started
Thread[Thread-0,5,main] - Starting
Thread[Thread-1,5,main] has started
Thread[Thread-1,5,main] - Starting

AS(console) 中的 output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Thread[Thread-0,5,main] has started
Thread[Thread-1,5,main] has started
Thread[Thread-2,5,main] has started
Thread[Thread-1,5,main] - Starting
Thread[Thread-0,5,main] - Starting
Thread[Thread-2,5,main] - Starting
Thread[Thread-1,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 1
Thread[Thread-2,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 2
Thread[Thread-1,5,main] - Doing Task 2
Thread[Thread-2,5,main] - Doing Task 2
Thread[Thread-0,5,main] - Ending
Thread[Thread-2,5,main] - Ending
Thread[Thread-1,5,main] - Ending
  • 執行程式碼數次。畫面上會顯示不同的 output。Thread 有時會以連續順序(sequence)執行,有時內容會散置(interspersed)。

注意:不變性(invariability)是由於 thread 的執行方式所致。排程器(scheduler)會為每個 thread 分配一段時間,並會在指定時間範圍內完成,或是在收到另一個時間片段(time slice)前遭到停用。


Threads 相關挑戰

使用 threads 可讓您輕鬆開始處理多項 tasksconcurrency,但並非完美無缺。當您直接在程式碼中使用 thread 時,可能會發生一些問題。

Threads 需要大量資源(resources)

建立、切換和管理 threads 會佔用系統資源(system resources),而可同時管理的原始 threads 數量會受時間所限。建立成本著實可能會激增。

執行中的 app 會有多個 threads,而每個 app 皆有一個專屬 thread,專供 app 的 UI 來使用。此 thread 通常稱為 main threadUI thread

注意:在某些情況下,UI threadmain thread 可能會有所不同。

這個 thread 負責執行 app 的 UI,因此 main thread 必須維持高效能,確保 app 能順暢運作。所有長時間執行的 tasks 在完成之前都會 block,導致 app 無法回應。

作業系統(operating system)會盡力維持使用者的響應式(responsive)體驗。目前的手機嘗試將 UI 更新至每秒 60 至 120 次 (至少 60 次)。準備及繪製 UI 需要一小段時間 (每秒 60 個影格(frames)每個畫面更新不超過 16 毫秒(ms))。Android 會捨棄影格(frames)取消嘗試完成單一更新週期,以嘗試掌握運作情況。有些影格(frames)發生流失和波動是正常現象,但影格(frames)過多會導致 app 無法回應

競爭狀況與無法預測的行為

如上所述,thread 是一種關於處理者如何一次處理多項工作(multiple tasks)的抽象概念。由於處理者會針對不同 threads 切換指令組合,因此 thread 的確切執行時間與暫停時間不在控制範圍內。當您直接處理 thread 時,系統可能會產生非預期的 output

舉例來說,下列程式碼使用簡易迴圈來計算 1 至 50 的次數,但每計數一次,系統就會建立新 thread。請設想您所希望的 output,然後執行數次程式碼。

1
2
3
4
5
6
7
8
9
fun main() {
var count = 0
for (i in 1..50) {
Thread {
count += 1
println("Thread: $i count: $count")
}.start()
}
}
  • Output 是否符合您的預期?是否每次皆相同?

以下是我們收到的 output 範例。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
Thread: 50 count: 49 Thread: 43 count: 50 Thread: 1 count: 1
Thread: 2 count: 2
Thread: 3 count: 3
Thread: 4 count: 4
Thread: 5 count: 5
Thread: 6 count: 6
Thread: 7 count: 7
Thread: 8 count: 8
Thread: 9 count: 9
Thread: 10 count: 10
Thread: 11 count: 11
Thread: 12 count: 12
Thread: 13 count: 13
Thread: 14 count: 14
Thread: 15 count: 15
Thread: 16 count: 16
Thread: 17 count: 17
Thread: 18 count: 18
Thread: 19 count: 19
Thread: 20 count: 20
Thread: 21 count: 21
Thread: 23 count: 22
Thread: 22 count: 23
Thread: 24 count: 24
Thread: 25 count: 25
Thread: 26 count: 26
Thread: 27 count: 27
Thread: 30 count: 28
Thread: 28 count: 29
Thread: 29 count: 41
Thread: 40 count: 41
Thread: 39 count: 41
Thread: 41 count: 41
Thread: 38 count: 41
Thread: 37 count: 41
Thread: 35 count: 41
Thread: 33 count: 41
Thread: 36 count: 41
Thread: 34 count: 41
Thread: 31 count: 41
Thread: 32 count: 41
Thread: 44 count: 42
Thread: 46 count: 43
Thread: 45 count: 44
Thread: 47 count: 45
Thread: 48 count: 46
Thread: 42 count: 47
Thread: 49 count: 48

有別於此程式碼的呈現結果,我們似乎優先執行最後一個 thread,其他部分 ,threads 的執行順序也不正確。若查看某些迭代的 count,會發現多個 threads 仍維持不變。更弔詭的是,即使 ouput 表明這只是第二個執行的 thread,thread 43 的計數仍達到 50。單就 output 判斷,無法得知 count 的最終值

這只是 thread 可能導致發生無法預測行為的其中一種形式。在使用多個 threads 時,您也可以執行所謂的「競爭狀況(race condition)」。這會導致多個 threads 嘗試同時存取記憶體中的相同值。競爭狀況(Race conditions)可能會導致系統難以重現隨機查詢錯誤,進而導致 app 異常終止

建議您不要直接使用 thread,以免出現效能問題競爭狀況(Race conditions),或是難以重現錯誤的狀況。您將會學習一項名為「協程(coroutines)」的 Kotlin 功能,以協助您撰寫 並行(concurrent) 程式碼。


Kotlin 中的協程(coroutines)

您可在 Android 上直接建立和使用 threads 處理 background tasks,但 Kotlin 亦提供協程(coroutines),讓您以更靈活彈性的方式管理並行(concurrency)

Coroutines 可讓您處理 multitasking,但也提供超越處理 threads 的另一層抽象概念。Coroutines 的其中一項主要功能是可儲存狀態(state),以便暫停(halted)繼續運作(resumed)。Coroutine 不一定會執行。

連續(continuations)呈現的狀態,可讓部分程式碼指出何時需要移交控制權(control),或等待(wait)其他 coroutine 完成工作後再繼續。此流程(flow)稱為合作多工(cooperative multitasking)。Kotlin 實作 coroutines 後,新增了眾多功能來協助執行 multitasking。除了連續作業(continuations)外,建立 coroutine 包括 Job 中的作業(work),以及在 CoroutineScope 中具生命週期(lifecycle)可取消(cancelable)作業(work)

CoroutineScope 是一種 context,可針對其子項(children)以及子項(children)當中的子項(children),週期性地強制(enforces)執行取消(cancellation)和其他規則(rules)Dispatcher管理 coroutine 用於執行的備用執行緒(backing thread),讓開發人員無須處理新 thread 的使用時間和位置

Job 可取消(cancelable)的作業單元(unit of work),例如使用 launch() function 建立的作業單元。
CoroutineScope 用來建立新 coroutines 的 function,例如 launch()async() 繼承(extend) CoroutineScope
Dispatcher 決定 coroutine 將要使用的 threadMain dispatcher 會一律在 main thread 上執行 coroutine,而 DefaultIOUnconfined 等 dispatcher 會使用其他 threads。

您可以在稍後深入瞭解這些資訊,但 Dispatchers 是能讓 coroutines 展現高效能的其中一種方法。其可避免初始化(initializing)新 thread 產生的效能成本

讓我們舉個更早的 coroutines 使用範例。

1
2
3
4
5
6
7
8
9
import kotlinx.coroutines.*

fun main() {
repeat(3) {
GlobalScope.launch {
println("Hi from ${Thread.currentThread()}")
}
}
}
1
2
3
Hi from Thread[DefaultDispatcher-worker-2@coroutine#2,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#1,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#3`,5,main]

以上程式碼片段(snippet)使用預設(default) dispatcher,在 GlobalScope 中建立三個 coroutines。GlobalScope 允許在 app 處於執行中(running)狀態期間,執行其中的任何 coroutines。由於我們在此討論的是 main thread,因此不建議在超出範例程式碼範圍下採取此做法。在 app 中使用 coroutines 時,我們會使用其他範圍(scopes)。

launch() function 會建立包裝成可取消(cancelable)的 Job object 的封閉程式碼(enclosed code)。若 return value 不需超出 coroutine 限制範圍,請使用 launch()

讓我們來看看 launch() 的 full signature,瞭解關於 coroutines 的下一個重要概念。

1
2
3
4
5
fun CoroutineScope.launch() {
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
}
  • 您在幕後傳遞 launch 的,程式碼區塊(code block)會標有 suspend 關鍵字。Suspend 訊號(signals)可以暫停(paused)或繼續(resumed)執行程式碼或函式區塊

關於 runBlocking

接下來的範例會使用 runBlocking(),如名稱所示,其會啟動新的 coroutine,並在完成之前封鎖目前的 thread。其主要用於連結主函式(main functions)與測試中的阻塞(blocking)非阻塞(non-blocking)程式碼。您不常在一般 Android 程式碼中使用此項目。

阻塞 (Blocking) 與非阻塞 (Non-Blocking)

阻塞 (Blocking) 與非阻塞 (Non-Blocking) 描述的是 請求 在等待結果時的 狀態

  • 阻塞 (Blocking):調用的程序或者應用程式發起請求,在獲得結果之前,調用方的程序會懸 (Hang) 住不動,無法回應,直到獲得結果。
  • 非阻塞 (Non-Blocking):概念與阻塞相同,但是調用方不會因為等待結果,而懸著不動。後續通常透過輪詢機制 (Polling) 取得結果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import kotlinx.coroutines.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

val formatter = DateTimeFormatter.ISO_LOCAL_TIME
val time = { formatter.format(LocalDateTime.now()) }

suspend fun getValue(): Double {
println("entering getValue() at ${time()}")
delay(3000)
println("leaving getValue() at ${time()}")
return Math.random()
}

fun main() {
runBlocking {
val num1 = getValue()
val num2 = getValue()
println("result of num1 + num2 is ${num1 + num2}")
}
}

getValue() 會在設定的延遲時間後傳回隨機數字。其使用 DateTimeFormatter。說明適當的進入與離開時間。主函式會呼叫兩次 getValue() 並傳回總和。

1
2
3
4
5
entering getValue() at 17:44:52.311
leaving getValue() at 17:44:55.319
entering getValue() at 17:44:55.32
leaving getValue() at 17:44:58.32
result of num1 + num2 is 1.4320332550421415

如要實際查看,請將 main() 函式 (保留其他所有程式碼) 替換為下列函式。

1
2
3
4
5
6
7
fun main() {
runBlocking {
val num1 = async { getValue() }
val num2 = async { getValue() }
println("result of num1 + num2 is ${num1.await() + num2.await()}")
}
}
  • getValue() 的兩次呼叫各自獨立,且無須停用 coroutine。

Kotlin 採用的非同步函式與啟動作業類似。async() 函式的定義如下。

1
2
3
4
5
fun CoroutineScope.async() {
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
}: Deferred<T>
  • async() 函式會傳回 Deferred 類型的值。

Deferred(延遲) 是一個可取消的 Job,其可保留未來值的引用。使用 Deferred 時,您還是可以呼叫函式,就如同函式會立即傳回值一般,但由於您無法確定非同步工作(asynchronous task)的傳回時間,因此 Deferred 只是預留位置。

Deferred (在其他語言中亦稱為 PromiseFuture) 會確保稍後對此物件傳回值。另一方面,非同步工作(asynchronous task)依預設不會封鎖或等待執行。如要啟動目前這行程式碼,必須等候 Deferred (可在其上呼叫 await()(等待)) 的輸出內容。其會傳回原始值。

1
2
3
4
5
entering getValue() at 22:52:25.025
entering getValue() at 22:52:25.03
leaving getValue() at 22:52:28.03
leaving getValue() at 22:52:28.032
result of num1 + num2 is 0.8416379026501276

何時將函式標示為 suspend

在上述範例中,您可能會發現 getValue() 函式亦定義了 suspend 關鍵字。原因在於呼叫 delay(),其也為 suspend 函式。每當函式呼叫另一個 suspend 函式時,其也應為 suspend 函式

Q: 若確實如此,範例中的 main() 函式為何未標示為 suspend?畢竟,其確實呼叫了 getValue()

A: 不一定。getValue() 函式實際上是在傳遞至 runBlocking() 的 lambda 中呼叫,而 lambda 是 suspend 函式,類似傳遞至 launch()async() 的函式。不過,runBlocking() 本身並不是 suspend 函式

由於並未在 main() 本身當中呼叫 getValue() 函式runBlocking() 也不是 suspend 函式,因此未將 main() 標示為 suspend若函式未呼叫 suspend 函式,則其不必然會是 suspend 函式本身


自行練習

您已在本程式碼研究室的開頭,看到下列使用多個執行緒(multiple threads)的範例。您可運用習得的 coroutines 知識,重新撰寫程式碼來使用 coroutines 而非 Thread

注意:即使 println() 陳述式引用 Thread,也無須進行編輯。

1
2
3
4
5
6
7
8
9
10
11
12
fun main() {
val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
repeat(3) {
Thread {
println("${Thread.currentThread()} has started")
for (i in states) {
println("${Thread.currentThread()} - $i")
Thread.sleep(50)
}
}.start()
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
import kotlinx.coroutines.*

fun main() {
val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
repeat(3) {
GlobalScope.launch {
println("${Thread.currentThread()} has started")
for (i in states) {
println("${Thread.currentThread()} - $i")
}
}
}
}

總結

  • 為何需要採用並行(concurrency)
  • 什麼是執行緒(thread),以及為何 thread 對並行作業(concurrency)如此重要
  • 如何使用協程(coroutines)在 Kotlin 中撰寫並行concurrent程式碼
  • 將函式標示為 suspend 的時機
  • CoroutineScopeJobDispatcher 扮演的角色
  • 延遲(Deferred)等待(Await)的差異