協同程式簡介

1. 事前準備

回應式 UI 是優異應用程式的關鍵要素。您過去建構應用程式時可能都採用此做法並視為標準程序,但隨著您開始新增更多進階功能 (例如網路或資料庫功能),可能會越來越難撰寫可正常運作又兼具效能的程式碼。以下範例說明若在長時間執行工作 (例如從網際網路下載圖片) 時未正確處理,可能會發生什麼情況。當圖片功能運作時,捲動操作會變得不穩定而導致使用者介面 UI 沒有回應 (且欠缺專業!)。

fcf3738b61270a1f.gif

為避免上述應用程式發生問題,您必須對執行緒有一些瞭解。執行緒是一種略為抽象的概念,但您可以將其視為應用程式中程式碼的單一執行路徑。您撰寫的每一行程式碼皆是要在同一執行緒上依序執行的指令。

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

但在執行中的應用程式中,除了主要執行緒之外還有更多執行緒。就技術原理而言,處理者實際上不會操作個別執行緒,而是在各個不同系列的指令之間來回切換,以呈現多工處理樣貌。執行緒是一種抽象概念,可在編寫程式碼時用來判斷每個指令的執行路徑。使用主執行緒以外的執行緒,可讓應用程式執行諸如在背景運作下載圖片等複雜的工作,並讓應用程式的使用者介面保持回應。這就是所謂的並行程式碼。

在本程式碼研究室中,您將瞭解關於執行緒的資訊,以及如何使用稱為協同程式的 Kotlin 功能撰寫明確、非阻塞的並行程式碼。

必要條件

課程內容

  • 何謂並行,以及其重要性
  • 如何使用協同程式和執行緒,撰寫非阻塞並行程式碼
  • 如何在執行背景工作時存取主要執行緒,安全地執行使用者介面更新
  • 不同並行模式 (範圍/調度工具/延遲) 的使用方式與使用時機
  • 如何撰寫與網路資源互動的程式碼

建構項目

  • 在本程式碼研究室中,您將會撰寫一些小程式,以瞭解如何在 Kotlin 中處理執行緒和協同程式

需求條件

  • 搭載新版網路瀏覽器的電腦,例如最新版 Chrome
  • 電腦具備網際網路連線

2. 簡介

多執行緒與並行

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

並行功能可讓多個程式碼單元執行時跳脫順序或看似平行執行,提高資源使用效率。作業系統可運用系統特性、程式設計語言和並行單元來管理多工處理能力。

966e300fad420505.png

為什麼需要使用並行?隨著應用程式越發複雜,程式碼也必須處於非阻塞狀態。也就是說,執行諸如網路要求等長時間執行的工作,並不會導致應用程式停止執行其他工作。若未正確實作並行,可能會導致應用程式無法回應使用者。

以下為您提供幾個範例,在 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 會管理協同程式用於執行的備用執行緒,讓開發人員無須處理新執行緒的使用時間和位置。

工作

可取消的工作單位,例如使用 launch() 函式建立的單位。

CoroutineScope

用來建立新協同程式的函式,例如 launch()async() 延伸 CoroutineScope

調度工具

決定協同程式將要使用的執行緒。Main 調度工具會一律在主執行緒上執行協同程式,而 DefaultIOUnconfined 等調度工具會使用其他執行緒。

您可以在稍後深入瞭解這些資訊,但 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、工作和調度工具的角色
  • 「延遲」與「等待」的差異

8. 瞭解詳情