1. 事前準備
回應式 UI 是優異應用程式的關鍵要素。您過去建構應用程式時可能都採用此做法並視為標準程序,但隨著您開始新增更多進階功能 (例如網路或資料庫功能),可能會越來越難撰寫可正常運作又兼具效能的程式碼。以下範例說明若在長時間執行工作 (例如從網際網路下載圖片) 時未正確處理,可能會發生什麼情況。當圖片功能運作時,捲動操作會變得不穩定而導致使用者介面 UI 沒有回應 (且欠缺專業!)。
為避免上述應用程式發生問題,您必須對執行緒有一些瞭解。執行緒是一種略為抽象的概念,但您可以將其視為應用程式中程式碼的單一執行路徑。您撰寫的每一行程式碼皆是要在同一執行緒上依序執行的指令。
您已在 Android 中使用執行緒。每個 Android 應用程式都有預設的「主要」執行緒。這 (通常) 是 UI 執行緒。您到目前為止撰寫的所有程式碼皆位於主要執行緒。每個指令 (亦即一行程式碼) 都會接續先前的指令,直到執行下一行為止。
但在執行中的應用程式中,除了主要執行緒之外還有更多執行緒。就技術原理而言,處理者實際上不會操作個別執行緒,而是在各個不同系列的指令之間來回切換,以呈現多工處理樣貌。執行緒是一種抽象概念,可在編寫程式碼時用來判斷每個指令的執行路徑。使用主執行緒以外的執行緒,可讓應用程式執行諸如在背景運作下載圖片等複雜的工作,並讓應用程式的使用者介面保持回應。這就是所謂的並行程式碼。
在本程式碼研究室中,您將瞭解關於執行緒的資訊,以及如何使用稱為協同程式的 Kotlin 功能撰寫明確、非阻塞的並行程式碼。
必要條件
- 關於迴圈與函式等 Kotlin 程式設計概念知識,請參閱「課程 1:Kotlin 簡介」
- 關於如何在 Kotlin 中使用 lambda 函式,請參閱「課程 3:Kotlin 收藏」
課程內容
- 何謂並行,以及其重要性
- 如何使用協同程式和執行緒,撰寫非阻塞並行程式碼
- 如何在執行背景工作時存取主要執行緒,安全地執行使用者介面更新
- 不同並行模式 (範圍/調度工具/延遲) 的使用方式與使用時機
- 如何撰寫與網路資源互動的程式碼
建構項目
- 在本程式碼研究室中,您將會撰寫一些小程式,以瞭解如何在 Kotlin 中處理執行緒和協同程式
需求條件
- 搭載新版網路瀏覽器的電腦,例如最新版 Chrome
- 電腦具備網際網路連線
2. 簡介
多執行緒與並行
截至目前為止,我們已將 Android 應用程式視為具備單一執行路徑的程式。只要透過單一執行路徑即可完成大量工作,但隨著應用程式增長,您必須思考的是並行問題。
並行功能可讓多個程式碼單元執行時跳脫順序或看似平行執行,提高資源使用效率。作業系統可運用系統特性、程式設計語言和並行單元來管理多工處理能力。
為什麼需要使用並行?隨著應用程式越發複雜,程式碼也必須處於非阻塞狀態。也就是說,執行諸如網路要求等長時間執行的工作,並不會導致應用程式停止執行其他工作。若未正確實作並行,可能會導致應用程式無法回應使用者。
以下為您提供幾個範例,在 Kotlin 中演示並行程式設計。所有範例皆可在 Kotlin Playground 中執行:
https://developer.android.com/training/kotlinplayground
執行緒是程式碼的最小單位,可在程式的限定範圍內執行。以下是可讓我們執行並行程式碼的小範例。
您可透過提供 lambda 來建立簡易執行緒。在 Playground 中嘗試下列做法。
fun main() {
val thread = Thread {
println("${Thread.currentThread()} has run.")
}
thread.start()
}
系統不會在函式觸及 start()
函式呼叫時執行執行緒。畫面會顯示類似以下的輸出內容。
Thread[Thread-0,5,main] has run.
請注意,currentThread()
會傳回一個 Thread
執行個體,其會轉換為字串格式,並傳回執行緒的名稱、優先順序和執行緒群組。以上的輸出內容可能會略有不同。
建立與執行多個執行緒
為了示範簡易的並行,我們可以建立兩個執行緒來執行。程式碼會建立 3 個執行緒,並輸出上一個範例的資訊行。
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 中的輸出內容:
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) 中的輸出內容:
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
執行程式碼數次。畫面上會顯示不同的輸出內容。執行緒有時會以連續順序執行,有時內容會散置。
3. 執行緒相關挑戰
使用執行緒可讓您輕鬆開始處理多項工作和並行,但並非完美無缺。當您直接在程式碼中使用 Thread
時,可能會發生一些問題。
執行緒需要大量資源
建立、切換和管理執行緒會佔用系統資源,而可同時管理的原始執行緒數量會受時間所限。建立成本著實可能會激增。
執行中的應用程式會有多個執行緒,而每個應用程式皆有一個專屬執行緒,專供應用程式的使用者介面來使用。此執行緒通常稱為主要執行緒或 UI 執行緒。
這個執行緒負責執行應用程式的 UI,因此主要執行緒必須維持高效能,確保應用程式能順暢運作。所有長時間執行的工作在完成之前皆會將其封鎖,致使應用程式無法回應。
作業系統會盡力維持使用者的回應式體驗。目前的手機嘗試將 UI 更新至每秒 60 至 120 次 (至少 60 次)。準備及繪製 UI 需要一小段時間 (每秒 60 個影格,每個畫面更新不超過 16 毫秒)。Android 會捨棄影格或或取消嘗試完成單一更新週期,以嘗試掌握運作情況。有些影格發生流失和波動是正常現象,但影格過多會導致應用程式無法回應。
競爭狀況與無法預測的行為
如上所述,執行緒是一種關於處理者如何一次處理多項工作的抽象概念。由於處理者會針對不同執行緒切換指令組合,因此執行緒的確切執行時間與暫停時間不在控制範圍內。當您直接處理執行緒時,系統未必會產生可預期的輸出內容。
舉例來說,下列程式碼使用簡易迴圈來計算 1 至 50 的次數,但每計數一次,系統就會建立新執行緒。請設想您所希望的輸出內容外觀,然後執行數次程式碼。
fun main() {
var count = 0
for (i in 1..50) {
Thread {
count += 1
println("Thread: $i count: $count")
}.start()
}
}
輸出內容是否符合您的預期?是否每次皆相同?以下是我們收到的輸出內容範例。
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
有別於此程式碼的呈現結果,我們似乎優先執行最後一個執行緒,其他部分執行緒的執行順序也不正確。若查看某些疊代的「計數」,會發現多個執行緒仍維持不變。更弔詭的是,即使輸出內容表明這只是第二個執行的執行緒,執行緒 43 的計數仍達到 50。單就輸出內容判斷,無法得知 count
的最終值。
這只是執行緒可能導致發生無法預測行為的其中一種形式。在使用多個執行緒時,您也可以執行所謂的「競爭狀況」。這會導致多個執行緒嘗試同時存取記憶體中的相同值。競爭狀況可能會導致系統難以重現隨機查詢錯誤,進而導致應用程式異常終止。
建議您不要直接使用執行緒,以免出現效能問題、競爭狀況,或是難以重現錯誤的狀況。您將會學習一項名為「協同程式」的 Kotlin 功能,以協助您撰寫並行程式碼。
4. Kotlin 中的協同程式
您可在 Android 上直接建立和使用執行緒處理背景工作,但 Kotlin 亦提供「協同程式」,讓您以更靈活彈性的方式管理並行作業。
協同程式可讓您處理多工作業,但也提供超越處理執行緒的另一層抽象概念。協同程式的其中一項主要功能是可儲存狀態,以便暫停和繼續運作。協同程式不一定會執行。
以「連續」呈現的狀態,可讓部分程式碼指出何時需要移交控制權,或等待其他協同程式完成工作後再繼續。此流程稱為「合作多工處理」。Kotlin 實作協同程式後,新增了眾多功能來協助執行多工處理工作。除了連續作業外,建立協同程式包括 Job
中的作業,以及在 CoroutineScope
中具生命週期的可取消作業。CoroutineScope
是一種結構定義,可針對其子項以及子項當中的子項,週期性地強制執行取消和其他規則。Dispatcher
會管理協同程式用於執行的備用執行緒,讓開發人員無須處理新執行緒的使用時間和位置。
工作 | 可取消的工作單位,例如使用 |
CoroutineScope | 用來建立新協同程式的函式,例如 |
調度工具 | 決定協同程式將要使用的執行緒。 |
您可以在稍後深入瞭解這些資訊,但 Dispatchers
是能讓協同程式展現高效能的其中一種方法。其可避免初始化新執行緒產生的效能成本。
讓我們舉個更早的協同程式使用範例。
import kotlinx.coroutines.*
fun main() {
repeat(3) {
GlobalScope.launch {
println("Hi from ${Thread.currentThread()}")
}
}
}
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]
以上程式碼片段使用預設「調度工具」,在「全域範圍」中建立三個協同程式。GlobalScope
允許在應用程式處於執行中狀態期間,執行其中的任何協同程式。由於我們在此討論的是主執行緒,因此不建議在超出範例程式碼範圍下採取此做法。在應用程式中使用協同程式時,我們會使用其他範圍。
launch()
函式會建立包裝在可取消工作物件的封閉程式碼。若傳回值無須超出協同程式限制範圍,請使用 launch()
。
讓我們來看看 launch()
的完整特徵,瞭解關於協同程式的下一個重要概念。
fun CoroutineScope.launch() {
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
}
您在幕後傳遞啟動的程式碼區塊會標有 suspend
關鍵字。停用可暫停或繼續執行程式碼或函式區塊的訊號。
關於 runBlocking
接下來的範例會使用 runBlocking()
,如名稱所示,其會啟動新的協同程式,並在完成之前封鎖目前的執行緒。其主要用於連結主函式與測試中的阻塞與非阻塞程式碼。您不常在一般 Android 程式碼中使用此項目。
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()
並傳回總和。
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()
函式 (保留其他所有程式碼) 替換為下列函式。
fun main() {
runBlocking {
val num1 = async { getValue() }
val num2 = async { getValue() }
println("result of num1 + num2 is ${num1.await() + num2.await()}")
}
}
getValue()
的兩次呼叫各自獨立,且無須停用協同程式。Kotlin 採用的非同步函式與啟動作業類似。async()
函式的定義如下。
fun CoroutineScope.async() {
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
}: Deferred<T>
async()
函式會傳回 Deferred
類型的值。Deferred
是一個可取消的 Job
,其可保留未來值的參考。使用 Deferred
時,您還是可以呼叫函式,就如同函式會立即傳回值一般,但由於您無法確定非同步工作的傳回時間,因此 Deferred
只是預留位置。Deferred
(在其他語言中亦稱為 Promise 或 Future) 會確保稍後對此物件傳回值。另一方面,非同步工作依預設不會封鎖或等待執行。如要啟動目前這行程式碼,必須等候 Deferred
(可在其上呼叫 await()
) 的輸出內容。其會傳回原始值。
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
函式。
若確實如此,範例中的 main()
函式為何未標示為 suspend
?畢竟,其確實呼叫了 getValue()
。
不一定。getValue()
函式實際上是在傳遞至 runBlocking()
的 lambda 中呼叫,而 lambda 是 suspend
函式,類似傳遞至 launch()
和 async()
的函式。不過,runBlocking()
本身並不是 suspend
函式。並未在 main()
本身當中呼叫 getValue()
函式,runBlocking()
也不是 suspend
函式,因此未將 main()
標示為 suspend
。若函式未呼叫 suspend
函式,則其不必然會是 suspend
函式本身。
5. 自行練習
您已在本程式碼研究室的開頭,看到下列使用多個執行緒的範例。您可運用習得的協同程式知識,重新撰寫程式碼來使用協同程式而非 Thread
。
注意:即使 println()
陳述式參照 Thread
,也無須進行編輯。
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()
}
}
6. 演練解決方案
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")
}
}
}
}
7. 摘要
您已學習以下內容
- 為何需要採用並行
- 什麼是執行緒,以及為何執行緒對並行作業如此重要
- 如何使用協同程式在 Kotlin 中撰寫並行程式碼
- 將函式標示為「停用」的時機
- CoroutineScope、工作和調度工具的角色
- 「延遲」與「等待」的差異