Giới thiệu về coroutine trong Kotlin Playground

1. Trước khi bắt đầu

Lớp học lập trình này giới thiệu cho bạn cơ chế xử lý đồng thời, một kỹ năng quan trọng mà nhà phát triển Android cần nắm được để mang lại trải nghiệm tuyệt vời cho người dùng. Cơ chế xử lý đồng thời liên quan đến việc cùng lúc thực hiện nhiều tác vụ trong ứng dụng. Ví dụ: ứng dụng của bạn có thể nhận dữ liệu qua máy chủ web hoặc lưu dữ liệu người dùng trên thiết bị, đồng thời phản hồi sự kiện nhập của người dùng cũng như cập nhật giao diện người dùng cho phù hợp.

Để xử lý đồng thời trong ứng dụng, bạn sẽ dùng coroutine trong Kotlin. Coroutine cho phép thực thi một khối mã để tạm ngưng rồi tiếp tục sau đó, giúp bạn có thể thực hiện tác vụ khác trong thời gian chờ đợi. Coroutine giúp bạn viết mã không đồng bộ dễ dàng hơn, tức là một tác vụ không cần phải xong hẳn trước khi bắt đầu tác vụ tiếp theo, cho phép nhiều tác vụ chạy đồng thời.

Lớp học lập trình này hướng dẫn bạn một số ví dụ cơ bản trong Kotlin Playground, nơi bạn có thể thực hành với coroutine để lập trình không đồng bộ dễ dàng hơn sau này.

Điều kiện tiên quyết

  • Có khả năng tạo một chương trình Kotlin cơ bản có hàm main()
  • Có kiến thức cơ bản về ngôn ngữ Kotlin, bao gồm cả hàm và lambda

Sản phẩm bạn sẽ tạo ra

  • Chương trình Kotlin ngắn để tìm hiểu và thử nghiệm các hoạt động cơ bản của coroutine

Kiến thức bạn sẽ học được

  • Cách coroutine trong Kotlin có thể giúp đơn giản hoá quy trình lập trình không đồng bộ
  • Mục đích của cơ chế xử lý đồng thời có cấu trúc và tại sao cơ chế này lại quan trọng

Bạn cần có

2. Đoạn mã đồng bộ

Chương trình đơn giản

Trong mã đồng bộ, mỗi thời điểm chỉ tiến hành được một tác vụ khái niệm. Bạn có thể coi đó là một lộ trình tuyến tính tuần tự. Một tác vụ phải hoàn tất trước khi bắt đầu tác vụ tiếp theo. Sau đây là một ví dụ về mã đồng bộ.

  1. Mở Kotlin Playground.
  2. Thay thế mã trong đó bằng mã sau đây cho một chương trình đưa ra thông tin dự báo thời tiết là trời nắng. Trong hàm main(), trước tiên chúng ta in văn bản này: Weather forecast. Sau đó, chúng ta in: Sunny.
fun main() {
    println("Weather forecast")
    println("Sunny")
}
  1. Chạy mã. Sau đây là kết quả khi chạy mã trên:
Weather forecast
Sunny

println() là lệnh gọi đồng bộ vì tác vụ in văn bản đã hoàn tất trước khi quy trình thực thi có thể chuyển sang dòng mã tiếp theo. Vì từng lệnh gọi hàm trong main() đều đồng bộ, nên toàn bộ hàm main() sẽ đồng bộ. Bất kể một hàm đồng bộ hay không đồng bộ có được xác định bằng các phần của hàm đó hay không.

Hàm đồng bộ chỉ trả về khi tác vụ của bạn hoàn tất. Vì vậy, sau khi câu lệnh in cuối cùng trong main() được thực thi, tất cả tác vụ đều đã hoàn tất. Hàm main() trả về và chương trình kết thúc.

Thêm độ trễ

Bây giờ, giả sử rằng để nhận được dự báo thời tiết là trời nắng, bạn cần đưa ra một yêu cầu mạng cho một máy chủ web từ xa. Hãy mô phỏng yêu cầu mạng đó bằng cách thêm độ trễ trong mã trước khi in dự báo thời tiết là trời nắng.

  1. Trước tiên, hãy thêm import kotlinx.coroutines.* vào phần đầu mã trước hàm main(). Thao tác này nhập các hàm mà bạn sẽ sử dụng trên thư viện coroutine Kotlin.
  2. Sửa đổi mã để thêm lệnh gọi đến delay(1000). Mã này trì hoãn việc thực thi phần còn lại của hàm main() thêm 1000 mili giây hay 1 giây. Sau đó, hãy chèn lệnh gọi delay() này vào trước câu lệnh in cho Sunny.
import kotlinx.coroutines.*

fun main() {
    println("Weather forecast")
    delay(1000)
    println("Sunny")
}

delay() thực sự là một hàm tạm ngưng đặc biệt do thư viện coroutine Kotlin cung cấp. Việc thực thi hàm main() sẽ tạm ngưng (hoặc tạm dừng) ở thời điểm này, rồi tiếp tục khi kết thúc thời lượng trễ đã chỉ định (trong trường hợp này là một giây).

Nếu bạn cố gắng chạy chương trình của mình ở thời điểm này, sẽ có lỗi biên dịch: Suspend function 'delay' should be called only from a coroutine or another suspend function.

Để tìm hiểu coroutine trong Kotlin Playground, bạn có thể gói mã hiện tại bằng lệnh gọi đến hàm runBlocking() qua thư viện coroutine. runBlocking() chạy một vòng lặp sự kiện có thể xử lý nhiều tác vụ cùng lúc bằng cách tiếp tục từ nơi từng tác vụ dừng lại, khi tác vụ đó đã sẵn sàng tiếp tục.

  1. Di chuyển nội dung hiện tại của hàm main() vào phần nội dung của lệnh gọi runBlocking {}. Phần nội dung của runBlocking{} được thực thi trong một coroutine mới.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        delay(1000)
        println("Sunny")
    }
}

runBlocking() có tính chất đồng bộ; hàm này sẽ không trả về cho đến khi tất cả công việc trong khối lambda của hàm hoàn tất. Tức là mã này sẽ đợi tác vụ trong lệnh gọi đến delay() hoàn tất (cho đến khi một giây trôi qua), rồi mới tiếp tục thực thi câu lệnh in Sunny. Sau khi toàn bộ tác vụ trong hàm runBlocking() hoàn tất, hàm này trả về rồi kết thúc chương trình.

  1. Chạy chương trình. Sau đây là kết quả:
Weather forecast
Sunny

Kết quả vẫn như trước. Mã này vẫn đồng bộ, tức là chạy theo đường thẳng và mỗi lần chỉ thực hiện một việc. Tuy nhiên, lúc này, điểm khác biệt là mã này chạy trong một khoảng thời gian dài hơn do có độ trễ.

Phần "co-" trong coroutine có nghĩa là phối hợp. Mã này phối hợp để chia sẻ vòng lặp sự kiện cơ bản khi tạm ngưng để chờ tác vụ nào đó, cho phép tác vụ khác chạy trong thời gian chờ đợi. (Phần "-routine" trong "coroutine" có nghĩa là một tập hợp hướng dẫn giống như một hàm.) Trong ví dụ này, coroutine tạm ngưng khi gặp lệnh gọi delay(). Bạn có thể thực hiện tác vụ khác sau một giây khi coroutine bị tạm ngưng (mặc dù trong chương trình này, bạn không cần làm gì khác). Khi hết thời gian trễ, coroutine sẽ tiếp tục thực thi và có thể tiếp tục in Sunny.

Hàm tạm ngưng

Nếu logic thực tế thực hiện yêu cầu mạng để giúp dữ liệu thời tiết trở nên phức tạp hơn, thì bạn nên trích xuất logic đó vào hàm riêng tương ứng. Hãy cùng tái cấu trúc mã để xem hiệu ứng của mã này.

  1. Trích xuất mã mô phỏng yêu cầu mạng cho dữ liệu thời tiết rồi di chuyển dữ liệu đó vào hàm riêng tên là printForecast(). Gọi printForecast() qua mã runBlocking().
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

fun printForecast() {
    delay(1000)
    println("Sunny")
}

Nếu chạy chương trình ngay bây giờ thì bạn sẽ thấy cùng một lỗi biên dịch như đã thấy trước đó. Hệ thống chỉ có thể gọi hàm tạm ngưng qua một coroutine hoặc một hàm tạm ngưng khác, vậy nên hãy định nghĩa printForecast() là một hàm suspend.

  1. Thêm đối tượng sửa đổi suspend ngay trước từ khoá fun trong phần khai báo hàm printForecast() để biến đối tượng này thành một hàm tạm ngưng.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

Hãy nhớ rằng delay() là một hàm tạm ngưng. Giờ đây, bạn cũng đã thiết lập printForecast() làm một hàm tạm ngưng.

Hàm tạm ngưng giống như hàm thông thường, nhưng có thể được tạm ngưng rồi tiếp tục sau đó. Để làm như vậy, bạn chỉ có thể gọi hàm tạm ngưng qua các hàm tạm ngưng khác có thể sử dụng chức năng này.

Một hàm tạm ngưng có thể không chứa hoặc chứa nhiều điểm tạm ngưng. Điểm tạm ngưng là vị trí trong hàm nơi quá trình thực thi hàm có thể tạm ngưng. Khi quá trình thực thi tiếp tục, hàm này tiếp tục từ điểm tạm ngưng gần nhất trong mã rồi tiếp tục phần còn lại của hàm.

  1. Thực hành bằng cách thêm một hàm tạm ngưng khác vào mã của bạn bên dưới phần khai báo của hàm printForecast(). Gọi hàm tạm ngưng mới này là printTemperature(). Bạn có thể giả định rằng thao tác này thực hiện một yêu cầu mạng để lấy dữ liệu nhiệt độ cho thông tin dự báo thời tiết.

Trong hàm này, hãy trì hoãn quá trình thực thi khoảng 1000 mili giây, rồi in một giá trị nhiệt độ ở đầu ra, chẳng hạn như 30 độ C. Bạn có thể sử dụng trình tự thoát "\u00b0" để in biểu tượng độ °.

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
}
  1. Gọi hàm printTemperature() mới qua mã runBlocking() trong hàm main(). Sau đây là mã đầy đủ:
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
        printTemperature()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 
  1. Chạy chương trình. Kết quả đầu ra sẽ là:
Weather forecast
Sunny
30°C

Trong mã này, coroutine bị tạm ngưng trước tiên theo độ trễ trong hàm tạm ngưng printForecast(), rồi tiếp tục sau độ trễ một giây đó. Văn bản Sunny được in ra. Hàm printForecast() trở lại phương thức gọi.

Tiếp theo, hàm printTemperature() được gọi. Coroutine đó tạm ngưng khi gặp lệnh gọi delay(), rồi tiếp tục một giây sau đó để hoàn tất việc in giá trị nhiệt độ ra kết quả. Hàm printTemperature() hoàn tất mọi tác vụ rồi trả về.

Không có thêm tác vụ nào cần thực thi trong phần nội dung runBlocking(). Do đó, hàm runBlocking() trả về và chương trình kết thúc.

Như đã đề cập trước đó, runBlocking() có tính chất đồng bộ và mỗi lệnh gọi trong phần nội dung sẽ được gọi tuần tự. Xin lưu ý rằng hàm tạm ngưng được thiết kế phù hợp chỉ trả về sau khi tất cả tác vụ đã hoàn tất. Do đó, những hàm tạm ngưng này sẽ lần lượt chạy.

  1. (Không bắt buộc) Nếu muốn xem khoảng thời gian để thực thi chương trình này theo độ trễ, thì bạn có thể gói mã trong lệnh gọi đến measureTimeMillis() để trả về thời gian tính bằng mili giây cần thiết để chạy khối mã được truyền vào. Thêm câu lệnh nhập (import kotlin.system.*) để có quyền truy cập vào hàm này. In thời gian thực thi rồi chia cho 1000.0 để chuyển đổi mili giây thành giây.
import kotlin.system.*
import kotlinx.coroutines.*

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            println("Weather forecast")
            printForecast()
            printTemperature()
        }
    }
    println("Execution time: ${time / 1000.0} seconds")
}
suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 

Kết quả:

Weather forecast
Sunny
30°C
Execution time: 2.128 seconds

Kết quả cho thấy mất khoảng 2,1 giây để thực thi. (Thời gian thực thi chính xác có thể hơi khác trong trường hợp của bạn.) Điều này có vẻ hợp lý vì mỗi hàm tạm ngưng có độ trễ là một giây.

Tới đây, bạn đã thấy mã trong coroutine được gọi tuần tự theo mặc định. Bạn phải nêu rõ nếu muốn mọi thứ chạy đồng thời. Bạn sẽ tìm hiểu cách thực hiện việc đó trong phần tiếp theo. Bạn sẽ tận dụng vòng lặp sự kiện phối hợp để thực hiện nhiều tác vụ cùng lúc, nhờ đó đẩy nhanh thời gian thực thi chương trình.

3. Mã không đồng bộ

launch()

Bạn có thể dùng hàm launch() trong thư viện coroutine để chạy coroutine mới. Để thực thi đồng thời các tác vụ, hãy thêm nhiều hàm launch() vào mã để nhiều coroutine có thể được xử lý cùng lúc.

Coroutine trong Kotlin tuân theo một khái niệm chủ chốt gọi là cơ chế xử lý đồng thời có cấu trúc, trong đó mã của bạn chạy tuần tự theo mặc định và phối hợp với vòng lặp sự kiện cơ bản, trừ trường hợp bạn yêu cầu thực thi đồng thời một cách rõ ràng (ví dụ: sử dụng launch()). Giả sử bạn gọi một hàm, thì hàm đó phải hoàn tất công việc trước thời điểm trả về, bất kể số lượng coroutine có thể đã sử dụng trong chi tiết triển khai. Ngay cả khi hàm đó bị lỗi với một ngoại lệ, sau khi trả về ngoại lệ, hàm đó sẽ không còn tác vụ nào đang chờ xử lý. Do đó, tất cả công việc đều hoàn tất sau khi luồng điều khiển trả về từ hàm đó, cho dù luồng đó trả về một ngoại lệ hay hoàn tất công việc thành công.

  1. Bắt đầu bằng mã của bạn trong các bước trước. Dùng hàm launch() để chuyển lần lượt từng lệnh gọi đến printForecast()printTemperature() vào coroutine riêng.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 
  1. Chạy chương trình. Sau đây là kết quả:
Weather forecast
Sunny
30°C

Kết quả vẫn như cũ nhưng bạn có thể nhận thấy thời gian chạy chương trình này nhanh hơn. Trước đó, bạn phải đợi hàm tạm ngưng printForecast() hoàn tất trước khi chuyển sang hàm printTemperature(). Giờ đây, printForecast()printTemperature() có thể chạy đồng thời vì chúng nằm trong coroutine riêng biệt.

Câu lệnh println (Dự báo thời tiết) nằm trong một hộp ở đầu biểu đồ. Bên dưới câu lệnh là mũi tên thẳng đứng chỉ thẳng xuống dưới. Bên ngoài mũi tên thẳng đứng đó, có một nhánh đi sang bên phải với một mũi tên trỏ vào hộp chứa câu lệnh printForecast(). Bên ngoài mũi tên thẳng đứng ban đầu, còn có một nhánh khác đi sang bên phải với một mũi tên trỏ vào hộp chứa câu lệnh printTemperature().

Lệnh gọi đến launch { printForecast() } có thể trả về trước khi tất cả công việc trong printForecast() hoàn tất. Đó là tác dụng của coroutine. Bạn có thể chuyển sang lệnh gọi launch() tiếp theo để bắt đầu coroutine tiếp theo. Tương tự, launch { printTemperature() } cũng trả về ngay cả trước khi hoàn tất mọi công việc.

  1. (Không bắt buộc) Nếu muốn xem chương trình lúc này đã nhanh đến mức nào thì bạn có thể thêm mã measureTimeMillis() để kiểm tra thời gian thực thi.
import kotlin.system.*
import kotlinx.coroutines.*

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            println("Weather forecast")
            launch {
                printForecast()
            }
            launch {
                printTemperature()
            }
        }
    }
    println("Execution time: ${time / 1000.0} seconds")
}

...

Kết quả:

Weather forecast
Sunny
30°C
Execution time: 1.122 seconds

Bạn có thể thấy thời gian thực thi đã giảm từ khoảng 2,1 giây xuống còn khoảng 1,1 giây. Do đó, chương trình sẽ thực thi nhanh hơn sau khi bạn thêm các hoạt động đồng thời! Bạn có thể xoá mã đo lường thời gian này trước khi chuyển sang các bước tiếp theo.

Điều gì sẽ xảy ra nếu bạn thêm một câu lệnh in khác sau lệnh gọi launch() thứ hai trước khi kết thúc mã runBlocking()? Thông báo đó sẽ xuất hiện ở đâu trong kết quả?

  1. Sửa đổi mã runBlocking() để thêm một câu lệnh in bổ sung trước khi khối đó kết thúc.
...

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
        println("Have a good day!")
    }
}

...
  1. Chạy chương trình rồi xem kết quả:
Weather forecast
Have a good day!
Sunny
30°C

Từ kết quả này, bạn có thể quan sát thấy sau khi khởi chạy hai coroutine mới cho printForecast()printTemperature(), bạn có thể tiếp tục với lệnh tiếp theo để in Have a good day!. Điều này thể hiện bản chất "fire and forget" (kích hoạt rồi quên) của launch(). Bạn kích hoạt một coroutine mới bằng launch() rồi không cần lo lắng về thời điểm hoàn tất tác vụ.

Sau đó, các coroutine sẽ hoàn tất tác vụ của chúng rồi in các câu lệnh đầu ra còn lại. Sau khi tất cả tác vụ (bao gồm cả toàn bộ coroutine) trong phần nội dung của lệnh gọi runBlocking() hoàn tất, runBlocking() trả về và chương trình kết thúc.

Lúc này, bạn đã thay đổi mã đồng bộ thành mã không đồng bộ. Khi một hàm không đồng bộ trả về, có thể tác vụ chưa hoàn tất. Đây là điều bạn đã thấy khi sử dụng launch(). Hàm này trả về nhưng tác vụ vẫn chưa hoàn tất. Khi sử dụng launch(), nhiều tác vụ có thể chạy đồng thời trong mã của bạn. Đây là một khả năng mạnh mẽ mà bạn có thể sử dụng trong các ứng dụng Android mà bạn phát triển.

async()

Trên thực tế, bạn sẽ không biết mất bao lâu để thực hiện các yêu cầu mạng đối với việc dự báo và nhiệt độ. Việc chỉ sử dụng launch() theo phương thức hiện tại là chưa đủ nếu bạn muốn cho thấy báo cáo thời tiết hợp nhất khi cả hai tác vụ đã hoàn tất. Đây là lúc async() xuất hiện.

Hãy sử dụng hàm async() trong thư viện coroutine nếu bạn quan tâm đến thời điểm coroutine kết thúc và cần một giá trị trả về qua coroutine đó.

Hàm async() trả về một đối tượng thuộc loại Deferred, giống như lời hứa hẹn rằng kết quả sẽ ở đó khi sẵn sàng. Bạn có thể truy cập vào kết quả trên đối tượng Deferred bằng await().

  1. Trước tiên, hãy thay đổi các hàm tạm ngưng của bạn để trả về String thay vì in dữ liệu dự báo và nhiệt độ. Cập nhật tên hàm từ printForecast()printTemperature() thành getForecast()getTemperature().
...

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. Sửa đổi mã runBlocking() để sử dụng async() thay vì launch() cho hai coroutine. Lưu trữ giá trị trả về của mỗi lệnh gọi async() trong các biến tên là forecasttemperature, cũng là các đối tượng Deferred chứa kết quả loại String. (Bạn không bắt buộc phải chỉ định kiểu vì đã có suy luận kiểu trong Kotlin, nhưng thuộc tính này được đưa vào dưới đây để bạn có thể thấy rõ hơn dữ liệu được lệnh gọi async() trả về.)
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        ...
    }
}

...
  1. Tiếp đó trong coroutine, sau hai lệnh gọi async(), bạn có thể truy cập kết quả của các coroutine đó bằng cách gọi await() trên các đối tượng Deferred. Trong trường hợp này, bạn có thể in giá trị của từng coroutine bằng forecast.await()temperature.await().
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        println("${forecast.await()} ${temperature.await()}")
        println("Have a good day!")
    }
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. Chạy chương trình để xem kết quả:
Weather forecast
Sunny 30°C
Have a good day!

Tuyệt vời! Bạn đã tạo xong hai coroutine chạy đồng thời để nhận dữ liệu dự báo và nhiệt độ. Khi từng coroutine hoàn tất, chúng sẽ trả về một giá trị. Sau đó, bạn kết hợp hai giá trị trả về thành một câu lệnh in duy nhất: Sunny 30°C.

Phân ly song song

Chúng ta có thể nâng cấp ví dụ này về thời tiết để xem coroutine có thể hữu ích như thế nào trong quá trình phân ly song song tác vụ. Quá trình phân ly song song bao gồm cả việc nhận biết một vấn đề rồi chia vấn đề đó thành các tác vụ phụ nhỏ hơn để giải quyết song song. Khi kết quả của các tác vụ phụ đã sẵn sàng, bạn có thể kết hợp các tác vụ phụ đó thành một kết quả cuối cùng.

Trong mã, hãy trích xuất logic của báo cáo thời tiết trong phần nội dung của runBlocking() thành một hàm getWeatherReport() duy nhất trả về chuỗi Sunny 30°C kết hợp.

  1. Xác định hàm tạm ngưng mới getWeatherReport() trong mã.
  2. Thiết lập hàm bằng với kết quả của lệnh gọi đến hàm coroutineScope{} có khối lambda trống. Khối này cuối cùng sẽ chứa logic để nhận báo cáo thời tiết.
...

suspend fun getWeatherReport() = coroutineScope {
    
}

...

coroutineScope{} tạo ra phạm vi cục bộ cho tác vụ báo cáo thời tiết này. Coroutine khởi chạy trong phạm vi này được nhóm lại cũng trong phạm vi này, nơi ảnh hưởng đến việc huỷ và các ngoại lệ mà bạn sắp tìm hiểu.

  1. Trong phần nội dung của coroutineScope(), hãy tạo hai coroutine mới bằng cách sử dụng async() để tìm nạp dữ liệu dự báo và nhiệt độ tương ứng. Bạn có thể tạo chuỗi báo cáo thời tiết bằng cách kết hợp các kết quả này của hai coroutine. Hãy làm việc này bằng cách gọi await() trên từng đối tượng Deferred do lệnh gọi async() trả về. Việc này đảm bảo rằng mỗi coroutine hoàn tất tác vụ của mình rồi trả về kết quả trước khi chúng ta trả về qua hàm này.
...

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

...
  1. Gọi hàm getWeatherReport() mới này qua runBlocking(). Sau đây là mã đầy đủ:
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. Chạy chương trình rồi xem kết quả:
Weather forecast
Sunny 30°C
Have a good day!

Kết quả vẫn như cũ, nhưng có một số điểm cần chú ý ở đây. Như đã đề cập trước đó, coroutineScope() sẽ chỉ trả về sau khi tất cả tác vụ (bao gồm cả mọi coroutine mà công cụ này khởi chạy) đã hoàn tất. Trong trường hợp này, cả hai coroutine getForecast()getTemperature() đều cần hoàn tất rồi trả về kết quả tương ứng. Sau đó, văn bản Sunny30°C được kết hợp rồi trả về qua phạm vi. Báo cáo thời tiết này của Sunny 30°C được in ra kết quả và lệnh gọi có thể tiếp tục xử lý đến câu lệnh in cuối cùng của Have a good day!.

Tuy coroutineScope() làm việc đồng thời trong cục bộ, nhưng lệnh gọi sẽ thấy hàm này dưới dạng hoạt động đồng bộ vì coroutineScope sẽ không trả về cho đến khi tất cả tác vụ hoàn tất.

Thông tin chuyên sâu quan trọng ở đây về cơ chế xử lý đồng thời có cấu trúc là bạn có thể thực hiện nhiều hoạt động đồng thời rồi đưa chúng vào một hoạt động đồng bộ duy nhất, trong đó cơ chế xử lý đồng thời là một chi tiết triển khai. Yêu cầu duy nhất đối với mã gọi là phải ở trong một hàm tạm ngưng hoặc coroutine. Ngoài ra, cấu trúc của mã gọi không cần đưa vào chi tiết đồng thời.

4. Trường hợp ngoại lệ và huỷ

Bây giờ, hãy nói về một số trường hợp có thể xảy ra lỗi hoặc một số công việc có thể bị huỷ.

Giới thiệu về các trường hợp ngoại lệ

Một trường hợp ngoại lệ là một sự kiện không mong muốn xảy ra trong quá trình thực thi mã của bạn. Bạn nên triển khai những cách thức thích hợp để xử lý các trường hợp ngoại lệ này nhằm ngăn ứng dụng của bạn gặp sự cố và ảnh hưởng tiêu cực đến trải nghiệm người dùng.

Dưới đây là ví dụ về chương trình chấm dứt sớm do một trường hợp ngoại lệ. Chương trình này nhằm tính số lượng pizza mà mỗi người sẽ ăn, bằng cách chia numberOfPizzas / numberOfPeople. Giả sử bạn vô tình quên đặt giá trị của numberOfPeople thành một giá trị thực tế.

fun main() {
    val numberOfPeople = 0
    val numberOfPizzas = 20
    println("Slices per person: ${numberOfPizzas / numberOfPeople}")
}

Khi chạy, chương trình sẽ gặp sự cố với một ngoại lệ số học vì bạn không thể chia một số cho số 0.

Exception in thread "main" java.lang.ArithmeticException: / by zero
 at FileKt.main (File.kt:4)
 at FileKt.main (File.kt:-1)
 at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0 (:-2)

Vấn đề này có cách khắc phục đơn giản, trong đó bạn có thể thay đổi giá trị ban đầu của numberOfPeople thành một số khác 0. Tuy nhiên, khi mã của bạn trở nên phức tạp hơn, bạn không thể dự đoán và ngăn chặn mọi ngoại lệ xảy ra trong một số trường hợp nhất định.

Điều gì xảy ra khi một trong các coroutine bị lỗi với một ngoại lệ? Hãy sửa đổi mã của chương trình thời tiết để tìm hiểu.

Trường hợp ngoại lệ với coroutine

  1. Bắt đầu từ chương trình thời tiết ở phần trước.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}

Từ một trong các hàm tạm ngưng, hãy chủ ý trả về một ngoại lệ để xem ảnh hưởng ra sao. Trường hợp này mô phỏng một lỗi không mong muốn xảy ra khi tìm nạp dữ liệu từ máy chủ (điều này là hợp lý).

  1. Trong hàm getTemperature(), hãy thêm một dòng mã trả về một ngoại lệ. Viết một biểu thức gửi bằng cách sử dụng từ khoá throw trong Kotlin, theo sau là một thực thể mới của trường hợp ngoại lệ mở rộng từ Throwable.

Ví dụ: bạn có thể trả về một AssertionError và chuyển vào một chuỗi thông báo mô tả lỗi chi tiết hơn: throw AssertionError("Temperature is invalid"). Khi trả về ngoại lệ này, quá trình thực thi hàm getTemperature() sẽ dừng.

...

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}

Bạn cũng có thể thay đổi độ trễ thành 500 mili giây cho phương thức getTemperature() để biết rằng trường hợp ngoại lệ này sẽ xảy ra trước khi hàm getForecast() khác có thể hoàn tất công việc đó.

  1. Hãy chạy chương trình để xem kết quả.
Weather forecast
Exception in thread "main" java.lang.AssertionError: Temperature is invalid
 at FileKt.getTemperature (File.kt:24)
 at FileKt$getTemperature$1.invokeSuspend (File.kt:-1)
 at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)

Để hiểu rõ hành vi này, bạn cần phải biết mối quan hệ mẹ-con giữa các coroutine. Bạn có thể chạy một coroutine (còn gọi là con) từ một coroutine khác (mẹ). Khi chạy thêm coroutine khác từ những coroutine đó, bạn có thể tạo nên toàn bộ hệ phân cấp coroutine.

Coroutine thực thi getTemperature() và coroutine thực thi getForecast() là các coroutine con của cùng một coroutine mẹ. Hành vi bạn thấy với các trường hợp ngoại lệ trong coroutine là do cơ chế xử lý đồng thời có cấu trúc. Khi một trong các coroutine con bị lỗi với một ngoại lệ, coroutine đó sẽ được truyền lên trên. Coroutine mẹ bị huỷ, theo đó sẽ huỷ mọi coroutine con khác (ví dụ trong trường hợp này là coroutine chạy getForecast()). Cuối cùng, lỗi được truyền lên trên và chương trình gặp lỗi với AssertionError.

Trường hợp ngoại lệ với try-catch

Nếu biết rằng một số phần nhất định của mã có thể trả về một ngoại lệ, thì bạn có thể gói mã đó bằng khối try-catch. Bạn có thể phát hiện và xử lý ngoại lệ dễ dàng hơn trong ứng dụng của mình, chẳng hạn như bằng cách cho người dùng thấy một thông báo lỗi hữu ích. Đoạn mã sau đây cho thấy cách hiển thị thông báo này:

try {
    // Some code that may throw an exception
} catch (e: IllegalArgumentException) {
    // Handle exception
}

Phương pháp này cũng áp dụng cho mã không đồng bộ khi sử dụng với coroutine. Bạn vẫn có thể sử dụng biểu thức try-catch để phát hiện và xử lý các ngoại lệ trong coroutine. Lý do là vì với cơ chế xử lý đồng thời có cấu trúc, mã tuần tự vẫn là mã đồng bộ nên khối try-catch sẽ vẫn hoạt động theo cách tương tự như dự kiến.

...

fun main() {
    runBlocking {
        ...
        try {
            ...
            throw IllegalArgumentException("No city selected")
            ...
        } catch (e: IllegalArgumentException) {
            println("Caught exception $e")
            // Handle error
        }
    }
}

...

Để xử lý ngoại lệ dễ dàng hơn, hãy sửa đổi chương trình thời tiết để phát hiện ngoại lệ mà bạn thêm trước đó rồi in ngoại lệ ra kết quả.

  1. Trong hàm runBlocking(), hãy thêm một khối try-catch quanh mã gọi getWeatherReport(). In lỗi phát hiện được, đồng thời in thông báo cho biết không có báo cáo thời tiết.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        try {
            println(getWeatherReport())
        } catch (e: AssertionError) {
            println("Caught exception in runBlocking(): $e")
            println("Report unavailable at this time")
        }
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}
  1. Chạy chương trình và giờ đây lỗi được xử lý dễ dàng. Do vậy, chương trình có thể hoàn tất việc thực thi thành công.
Weather forecast
Caught exception in runBlocking(): java.lang.AssertionError: Temperature is invalid
Report unavailable at this time
Have a good day!

Trong kết quả, bạn có thể thấy rằng getTemperature() trả về một ngoại lệ. Trong phần nội dung của hàm runBlocking(), bạn sẽ bao quanh lệnh gọi println(getWeatherReport()) trong một khối try-catch. Bạn phát hiện loại ngoại lệ theo dự kiến (trong ví dụ này là AssertionError). Sau đó, bạn in kết quả ngoại lệ ở dạng "Caught exception", theo sau là chuỗi thông báo lỗi. Để xử lý lỗi, bạn thông báo cho người dùng biết rằng báo cáo thời tiết không có sẵn với câu lệnh println() khác: Report unavailable at this time.

Xin lưu ý rằng hành vi này có nghĩa là nếu quá trình xác định nhiệt độ bị lỗi, thì sẽ không có báo cáo thời tiết nào (ngay cả khi dự báo hợp lệ được truy xuất).

Tuỳ thuộc vào cách bạn muốn chương trình hoạt động, bạn có thể xử lý ngoại lệ trong chương trình thời tiết bằng cách khác.

  1. Di chuyển phần xử lý lỗi sao cho hành vi try-catch thực sự diễn ra trong coroutine do async() chạy để tìm nạp nhiệt độ. Bằng cách đó, báo cáo thời tiết vẫn có thể in dự báo, ngay cả khi không xác định được nhiệt độ. Dưới đây là mã:
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async {
        try {
            getTemperature()
        } catch (e: AssertionError) {
            println("Caught exception $e")
            "{ No temperature found }"
        }
    }

    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}
  1. Chạy chương trình.
Weather forecast
Caught exception java.lang.AssertionError: Temperature is invalid
Sunny { No temperature found }
Have a good day!

Từ kết quả, bạn có thể thấy rằng hoạt động gọi getTemperature() bị lỗi với một ngoại lệ, nhưng mã trong async() có thể phát hiện và xử lý ngoại lệ đó dễ dàng bằng cách để coroutine vẫn trả về một String cho biết không tìm thấy nhiệt độ. Bạn vẫn có thể in báo cáo thời tiết có thông tin dự báo thành công về Sunny. Thiếu thông tin nhiệt độ trong báo cáo thời tiết, nhưng sẽ có thông báo giải thích rằng không tìm thấy thông tin nhiệt độ. Đây là trải nghiệm người dùng chất lượng hơn so với chương trình gặp lỗi.

Một cách hữu ích để xem xét phương pháp xử lý lỗi này là async() là ứng dụng sản xuất khi một coroutine được bắt đầu từ mã này. await() là ứng dụng tiêu dùng vì đang chờ sử dụng kết quả từ coroutine. Ứng dụng sản xuất thực hiện công việc và tạo ra kết quả. Ứng dụng tiêu dùng sẽ sử dụng kết quả. Nếu có một ngoại lệ với ứng dụng sản xuất, thì ứng dụng tiêu dùng sẽ nhận được ngoại lệ đó (nếu ngoại lệ chưa được xử lý) và coroutine sẽ bị lỗi. Tuy nhiên, nếu ứng dụng sản xuất có thể phát hiện và xử lý ngoại lệ, thì ứng dụng tiêu dùng sẽ không thấy ngoại lệ đó và sẽ thấy kết quả hợp lệ.

Dưới đây là mã getWeatherReport() để tham khảo:

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async {
        try {
            getTemperature()
        } catch (e: AssertionError) {
            println("Caught exception $e")
            "{ No temperature found }"
        }
    }

    "${forecast.await()} ${temperature.await()}"
}

Trong trường hợp này, ứng dụng sản xuất (async()) có thể phát hiện và xử lý ngoại lệ nhưng vẫn trả về kết quả String"{ No temperature found }". Ứng dụng tiêu dùng (await()) nhận được kết quả String này và thậm chí không cần biết rằng đã xảy ra ngoại lệ. Đây là một cách khác để dễ dàng xử lý ngoại lệ mà bạn dự kiến có thể xảy ra trong mã của mình.

Giờ đây, bạn đã biết được rằng các ngoại lệ sẽ truyền lên trên cây coroutine, trừ phi được xử lý. Ngoài ra, bạn cũng cần lưu ý rằng khi ngoại lệ lan truyền hoàn toàn đến gốc của hệ phân cấp, điều này có thể khiến toàn bộ ứng dụng gặp lỗi. Hãy tìm hiểu thêm thông tin về cách xử lý ngoại lệ trong bài đăng Exceptions in coroutines (Ngoại lệ trong coroutine) trên blog và bài viết Coroutine exceptions handling (Xử lý ngoại lệ trong coroutine).

Huỷ

Chủ đề tương tự với ngoại lệ là huỷ coroutine. Trường hợp này thường do người dùng thực hiện khi một sự kiện khiến ứng dụng huỷ công việc mà ứng dụng đã bắt đầu trước đó.

Ví dụ: giả sử người dùng đã chọn một lựa chọn ưu tiên trong ứng dụng mà họ không muốn thấy giá trị nhiệt độ trong ứng dụng đó nữa. Họ chỉ muốn biết dự báo thời tiết (ví dụ: Sunny) mà không muốn biết nhiệt độ chính xác. Do đó, hãy huỷ coroutine đang lấy dữ liệu về nhiệt độ.

  1. Trước tiên, hãy bắt đầu bằng mã ban đầu dưới đây (không cần huỷ).
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. Sau một khoảng thời gian trễ, hãy huỷ coroutine đang tìm nạp thông tin nhiệt độ để báo cáo thời tiết của bạn chỉ hiển thị thông tin dự báo. Thay đổi giá trị trả về của khối coroutineScope thành chỉ chuỗi dự báo thời tiết.
...

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    
    delay(200)
    temperature.cancel()

    "${forecast.await()}"
}

...
  1. Chạy chương trình. Hiện tại, kết quả như sau. Báo cáo thời tiết chỉ bao gồm dự báo thời tiết Sunny, mà không bao gồm thông tin nhiệt độ vì coroutine đó đã bị huỷ.
Weather forecast
Sunny
Have a good day!

Những kiến thức bạn đã học được ở đây là một coroutine có thể bị huỷ, nhưng sẽ không ảnh hưởng đến các coroutine khác trong cùng phạm vi và coroutine mẹ sẽ không bị huỷ.

Trong phần này, bạn đã biết cách thức hoạt động của ngoại lệ và quy trình huỷ trong coroutine cũng như mối quan hệ với hệ phân cấp coroutine. Hãy tìm hiểu thêm về các khái niệm chính thức đằng sau coroutine để bạn có thể hiểu cách thức tất cả các thành phần quan trọng kết hợp với nhau.

5. Các khái niệm về coroutine

Khi thực thi tác vụ một cách không đồng bộ hoặc đồng thời, có những câu hỏi bạn cần phải trả lời về cách thực thi tác vụ, thời gian tồn tại của coroutine, điều gì sẽ xảy ra nếu coroutine bị huỷ hoặc không thành công và gặp lỗi, v.v. Coroutine tuân theo nguyên tắc đối với cơ chế xử lý đồng thời có cấu trúc, buộc bạn phải trả lời những câu hỏi này khi sử dụng coroutine trong mã bằng cách sử dụng kết hợp các cơ chế.

Tác vụ

Khi bạn chạy một coroutine bằng hàm launch(), hàm này sẽ trả về một thực thể của Job. Tác vụ (Job) lưu giữ một handle hoặc thông tin tham chiếu đến coroutine để bạn có thể quản lý vòng đời của coroutine đó.

val job = launch { ... }

Tác vụ này có thể được dùng để kiểm soát vòng đời hoặc thời gian tồn tại của coroutine, chẳng hạn như huỷ coroutine nếu bạn không cần thực hiện tác vụ nữa.

job.cancel()

Với mỗi tác vụ, bạn có thể kiểm tra xem tác vụ đó đang hoạt động, đã huỷ hay đã hoàn tất. Tác vụ được xem là hoàn tất nếu coroutine của nó và mọi coroutine do công cụ này khởi chạy đã hoàn tất mọi tác vụ của chúng. Xin lưu ý rằng coroutine có thể đã hoàn tất vì một lý do khác (chẳng hạn như bị huỷ hoặc bị lỗi với một ngoại lệ), nhưng tác vụ vẫn được coi là đã hoàn tất vào thời điểm đó.

Các tác vụ cũng theo dõi mối quan hệ mẹ con giữa các coroutine.

Phân cấp tác vụ

Khi một coroutine khởi chạy một coroutine khác, tác vụ trả về của coroutine mới sẽ gọi là con của tác vụ mẹ ban đầu.

val job = launch {
    ...            

    val childJob = launch { ... }

    ...
}

Mối quan hệ mẹ con này tạo thành một hệ phân cấp tác vụ, trong đó mỗi tác vụ có thể triển khai các tác vụ khác, v.v.

Sơ đồ này thể hiện hệ phân cấp cây của các tác vụ. Ở gốc của hệ phân cấp là parent job (tác vụ mẹ). Tác vụ mẹ có 3 tác vụ con: đó là Child 1 Job, Child 2 Job và Child 3 Job (Tác vụ con 1, Tác vụ con 2 và Tác vụ con 3). Tiếp đó, Tác vụ con 1 có hai tác vụ con khác: Child 1a Job và Child 1b Job (Tác vụ con 1a và Tác vụ con 1b). Ngoài ra, Tác vụ con 2 cũng có một tác vụ con gọi là Child 2a Job (Tác vụ con 2a). Cuối cùng, Tác vụ con 3 có hai tác vụ con khác: Child 3a Job và Child 3b Job (Tác vụ con 3a và Tác vụ con 3b).

Mối quan hệ mẹ con này rất quan trọng vì nó sẽ quy định một số hành vi nhất định cho tác vụ con và tác vụ mẹ, cũng như các tác vụ con khác thuộc cùng tác vụ mẹ. Bạn đã thấy hành vi này trong các ví dụ trước về chương trình thời tiết.

  • Nếu tác vụ mẹ bị huỷ thì các tác vụ con cũng bị huỷ.
  • Khi dùng job.cancel() để huỷ tác vụ con, tác vụ con đó sẽ chấm dứt nhưng không huỷ tác vụ mẹ.
  • Nếu tác vụ không thành công nhưng thuộc trường hợp ngoại lệ, nó sẽ huỷ tác vụ mẹ bằng ngoại lệ đó. Đây gọi là truyền lỗi lên trên (cho mẹ, mẹ của mẹ, v.v.). .

CoroutineScope

Thường thì coroutine được khởi chạy trong CoroutineScope. Điều này giúp đảm bảo rằng chúng ta không có coroutine nào không được quản lý hay bị mất, gây lãng phí tài nguyên.

launch()async()các hàm mở rộng trên CoroutineScope. Gọi launch() hoặc async() theo phạm vi để tạo coroutine mới trong phạm vi đó.

CoroutineScope liên kết với một vòng đời thiết lập giới hạn về thời lượng của coroutine trong phạm vi đó. Nếu phạm vi bị huỷ thì tác vụ trong phạm vi đó cũng bị huỷ, đồng thời việc huỷ phạm vi đó được truyền cho các tác vụ con. Nếu một tác vụ con trong phạm vi không thành công nhưng thuộc trường hợp ngoại lệ, thì các tác vụ con khác sẽ bị huỷ, tác vụ mẹ sẽ bị huỷ và trường hợp ngoại lệ được khai báo lại cho lệnh gọi.

CoroutineScope trong Kotlin Playground

Trong lớp học lập trình này, bạn đã sử dụng runBlocking() để cung cấp CoroutineScope cho chương trình của bạn. Bạn cũng đã tìm hiểu cách sử dụng coroutineScope { } để tạo phạm vi mới trong hàm getWeatherReport().

CoroutineScope trong ứng dụng Android

Android hỗ trợ phạm vi coroutine trong các thực thể có vòng đời được xác định rõ ràng, chẳng hạn như Activity (lifecycleScope) và ViewModel (viewModelScope). Những coroutine được bắt đầu trong các phạm vi này sẽ tuân thủ vòng đời của thực thể tương ứng, chẳng hạn như Activity hoặc ViewModel.

Ví dụ: giả sử bạn bắt đầu coroutine trong Activity có phạm vi coroutine được cung cấp gọi là lifecycleScope. Nếu hoạt động này bị huỷ thì lifecycleScope sẽ bị huỷ và tất cả coroutine con của nó cũng sẽ tự động bị huỷ. Bạn chỉ cần quyết định xem coroutine theo vòng đời của Activity có phải là hành vi bạn muốn hay không.

Trong ứng dụng Race Tracker trên Android, bạn sẽ tìm hiểu cách thiết lập phạm vi của coroutine trong vòng đời của thành phần kết hợp.

Thông tin triển khai CoroutineScope

Nếu kiểm tra mã nguồn để biết cách CoroutineScope.kt được triển khai qua thư viện coroutine trong Kotlin, thì bạn có thể thấy CoroutineScope được khai báo là giao diện và chứa CoroutineContext dưới dạng biến.

Các hàm launch()async() tạo coroutine con mới trong phạm vi đó. Đồng thời, thành phần con cũng kế thừa ngữ cảnh của phạm vi đó. Vậy có gì trong ngữ cảnh? Hãy cùng thảo luận về việc này trong phần tiếp theo.

CoroutineContext

CoroutineContext cung cấp thông tin về ngữ cảnh mà coroutine sẽ chạy. Về cơ bản, CoroutineContext là bản đồ lưu trữ các phần tử và mỗi phần tử có một khoá riêng. Tuy không phải là những trường bắt buộc nhưng sau đây là một số ví dụ về phần tử có thể có trong ngữ cảnh:

  • tên – tên để xác định từng coroutine
  • tác vụ – kiểm soát vòng đời của coroutine
  • trình điều phối – gửi tác vụ tới luồng phù hợp
  • trình xử lý ngoại lệ – xử lý ngoại lệ do mã được thực thi trong coroutine trả về

Bạn có thể nối các phần tử trong ngữ cảnh bằng toán tử +. Ví dụ: bạn có thể xác định một CoroutineContext như sau:

Job() + Dispatchers.Main + exceptionHandler

Do không cung cấp tên nên tên coroutine mặc định được sử dụng.

Trong coroutine, nếu bạn chạy một coroutine mới thì coroutine con sẽ kế thừa CoroutineContext của coroutine mẹ, nhưng thay thế tác vụ cụ thể cho coroutine vừa tạo. Bạn cũng có thể ghi đè mọi phần tử kế thừa bối cảnh mẹ bằng cách truyền đối số đến hàm launch() hoặc async() cho những phần ngữ cảnh mà bạn muốn thay đổi.

scope.launch(Dispatchers.Default) {
    ...
}

Bạn có thể tìm hiểu thêm về CoroutineContext và cách kế thừa ngữ cảnh của tác vụ mẹ trong buổi trò chuyện qua hội nghị truyền hình KotlinConf.

Chúng ta vừa đề cập đến trình điều phối vài lần. Vai trò của nó là điều phối hoặc giao tác vụ cho luồng. Hãy cùng thảo luận chi tiết hơn về các luồng và trình điều phối.

Trình điều phối

Coroutine sử dụng trình điều phối (dispatcher) để xác định luồng (thread) cần sử dụng cho quá trình thực thi. Một luồng có thể được bắt đầu, thực hiện tác vụ nào đó (thực thi một số mã) rồi chấm dứt khi không cần thực hiện thêm tác vụ nào nữa.

Khi người dùng khởi động ứng dụng của bạn, hệ thống Android tạo một quy trình mới và một luồng thực thi duy nhất cho ứng dụng (còn gọi là luồng chính). Luồng chính xử lý nhiều hoạt động quan trọng cho ứng dụng của bạn, bao gồm cả sự kiện hệ thống của Android, vẽ giao diện người dùng trên màn hình, xử lý sự kiện nhập của người dùng, v.v. Do đó, hầu hết mã bạn viết cho ứng dụng có khả năng sẽ chạy trên luồng chính.

Có 2 thuật ngữ cần phải hiểu rõ khi nói đến hành vi trong luồng của mã: chặnkhông chặn. Một hàm thông thường chặn luồng lệnh gọi cho đến khi hoàn tất công việc. Điều đó có nghĩa là hàm này không tạo ra luồng lệnh gọi cho đến khi hoàn tất công việc. Vì vậy, không có công việc nào khác có thể được thực hiện trong thời gian chờ đợi. Ngược lại, mã không chặn tạo ra luồng lệnh gọi cho đến khi đáp ứng một điều kiện nhất định. Vì vậy, bạn có thể thực hiện công việc khác trong thời gian chờ đợi. Bạn có thể sử dụng hàm không đồng bộ để thực hiện công việc không chặn vì hàm này sẽ trả về trước khi hoàn tất công việc.

Nếu là ứng dụng Android, bạn chỉ nên gọi mã chặn trên luồng chính nếu luồng đó thực thi khá nhanh. Mục tiêu là đảm bảo luồng chính không bị chặn để có thể thực thi công việc ngay lập tức nếu một sự kiện mới được kích hoạt. Luồng chính này là luồng giao diện người dùng cho các hoạt động của bạn, đồng thời chịu trách nhiệm vẽ giao diện người dùng và sự kiện liên quan đến giao diện người dùng. Khi có thay đổi trên màn hình, bạn cần vẽ lại giao diện người dùng. Đối với nội dung như ảnh động trên màn hình, giao diện người dùng cần được vẽ lại thường xuyên để có thể chuyển đổi mượt mà. Nếu luồng chính cần thực thi một khối tác vụ chạy trong thời gian dài, thì màn hình sẽ không cập nhật thường xuyên và người dùng sẽ thấy phần chuyển đổi đột ngột (gọi là "giật") hoặc ứng dụng có thể bị treo hoặc phản hồi chậm.

Do đó, chúng ta cần di chuyển mọi mục tác vụ chạy trong thời gian dài khỏi luồng chính để xử lý trong một luồng khác. Ứng dụng của bạn bắt đầu bằng một luồng chính duy nhất, nhưng bạn có thể chọn tạo nhiều luồng để thực hiện thêm tác vụ. Các luồng bổ sung này có thể được gọi là luồng worker. Hoàn toàn không có vấn đề gì khi một tác vụ chạy trong thời gian dài chặn một luồng worker trong thời gian dài, vì trong thời gian chờ đợi, luồng chính không bị chặn và có thể chủ động phản hồi người dùng.

Kotlin cung cấp một số trình điều phối tích hợp:

  • Dispatchers.Main – Sử dụng trình điều phối này để chạy coroutine trên luồng Android chính. Trình điều phối này chủ yếu dùng để xử lý nội dung cập nhật và hoạt động tương tác trên giao diện người dùng cũng như thực hiện tác vụ nhanh.
  • Dispatchers.IO – Trình điều phối này được tối ưu hoá để thực hiện I/O đĩa hoặc I/O mạng bên ngoài luồng chính. Ví dụ: đọc hoặc ghi vào tệp và thực thi hoạt động bất kỳ về mạng.
  • Dispatchers.Default: Đây là trình điều phối mặc định được dùng khi gọi launch()async(), khi không có trình điều phối nào được chỉ định trong ngữ cảnh của chúng. Bạn có thể sử dụng trình điều phối này để thực hiện các tác vụ nặng về điện toán bên ngoài luồng chính. Ví dụ: xử lý tệp hình ảnh bitmap.

Hãy thử ví dụ sau trong Kotlin Playground để hiểu rõ hơn về trình điều phối coroutine.

  1. Thay thế mã bất kỳ mà bạn có trong Kotlin Playground bằng mã sau:
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        launch {
            delay(1000)
            println("10 results found.")
        }
        println("Loading...")
    }
}
  1. Lúc này, bạn có thể gói nội dung của coroutine đã khởi chạy bằng một lệnh gọi tới withContext() để thay đổi CoroutineContext mà coroutine được thực thi trong đó và đặc biệt là ghi đè trình điều phối. Chuyển sang sử dụng Dispatchers.Default (thay vì Dispatchers.Main đang dùng cho phần còn lại của mã coroutine trong chương trình).
...

fun main() {
    runBlocking {
        launch {
            withContext(Dispatchers.Default) {
                delay(1000)
                println("10 results found.")
            }
        }
        println("Loading...")
    }
}

Bạn có thể chuyển đổi trình điều phối vì bản chất của withContext() là hàm tạm ngưng. Hàm này thực thi khối mã được cung cấp bằng cách sử dụng CoroutineContext mới. Ngữ cảnh mới xuất phát từ ngữ cảnh của tác vụ mẹ (khối launch() bên ngoài), ngoại trừ trường hợp ngữ cảnh mới ghi đè trình điều phối dùng trong ngữ cảnh mẹ bằng ngữ cảnh được chỉ định tại đây: Dispatchers.Default. Đây là cách chúng ta có thể chuyển từ thực thi tác vụ bằng Dispatchers.Main sang Dispatchers.Default.

  1. Chạy chương trình. Kết quả đầu ra sẽ là:
Loading...
10 results found.
  1. Thêm câu lệnh in để xem bạn đang dùng luồng nào bằng cách gọi Thread.currentThread().name.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("${Thread.currentThread().name} - runBlocking function")
                launch {
            println("${Thread.currentThread().name} - launch function")
            withContext(Dispatchers.Default) {
                println("${Thread.currentThread().name} - withContext function")
                delay(1000)
                println("10 results found.")
            }
            println("${Thread.currentThread().name} - end of launch function")
        }
        println("Loading...")
    }
}
  1. Chạy chương trình. Kết quả đầu ra sẽ là:
main @coroutine#1 - runBlocking function
Loading...
main @coroutine#2 - launch function
DefaultDispatcher-worker-1 @coroutine#2 - withContext function
10 results found.
main @coroutine#2 - end of launch function

Qua kết quả này, bạn có thể quan sát thấy rằng hầu hết mã được thực thi trong coroutine trên luồng chính. Tuy nhiên, phần mã của bạn trong khối withContext(Dispatchers.Default) được thực thi trong một coroutine trên luồng worker trình của điều phối mặc định (không phải là luồng chính). Xin lưu ý rằng sau khi withContext() trả về, coroutine sẽ chạy lại trên luồng chính (như được chứng minh bằng câu lệnh đầu ra: main @coroutine#2 - end of launch function). Ví dụ này cho thấy rằng bạn có thể chuyển đổi trình điều phối bằng cách sửa đổi ngữ cảnh được dùng cho coroutine.

Nếu đã bắt đầu coroutine trên luồng chính và muốn di chuyển một số hoạt động cụ thể ra khỏi luồng chính, thì bạn có thể sử dụng withContext để chuyển đổi trình điều phối đang được sử dụng cho tác vụ đó. Chọn trong số các trình điều phối hiện có: Main, DefaultIO tuỳ thuộc vào loại hoạt động. Sau đó, bạn có thể chỉ định tác vụ đó cho luồng (hoặc nhóm luồng) hỗ trợ mục đích đó. Coroutine có thể tự tạm ngưng và trình điều phối cũng ảnh hưởng đến cách coroutine tiếp tục.

Xin lưu ý rằng khi dùng các thư viện phổ biến như Room và Retrofit (trong bài này và bài tiếp theo), có thể bạn không cần tự chuyển đổi trình điều phối một cách rõ ràng nếu mã thư viện đã xử lý quy trình này bằng cách sử dụng trình điều phối coroutine thay thế như Dispatchers.IO.. Trong những trường hợp đó, hàm suspend mà các thư viện đó sử dụng có thể đã an toàn cho luồng chính, cũng như có thể được gọi qua một coroutine đang chạy trên luồng chính. Thư viện sẽ tự xử lý quy trình chuyển trình điều phối sang trình điều phối sử dụng luồng worker.

Giờ đây, bạn đã có được thông tin tổng quan cấp cao về các phần quan trọng trong coroutine cũng như vai trò của CoroutineScope, CoroutineContext, CoroutineDispatcherJobs trong việc định hình vòng đời và hành vi của coroutine.

6. Kết luận

Bạn đã hoàn thành rất tốt trong chủ đề đầy thách thức này về coroutine! Bạn biết rằng coroutine rất hữu ích vì quy trình thực thi coroutine có thể được tạm ngưng, giải phóng luồng cơ bản để thực hiện tác vụ khác, rồi coroutine này sau đó có thể chạy tiếp. Việc này tạo điều kiện để bạn chạy các hoạt động đồng thời trong mã của mình.

Mã coroutine Kotlin tuân theo nguyên tắc đối với cơ chế xử lý đồng thời có cấu trúc. Cơ chế này diễn ra theo thứ tự mặc định, do đó, bạn cần chỉ rõ nếu muốn chạy đồng thời (ví dụ: sử dụng launch() hoặc async()). Với cơ chế xử lý đồng thời có cấu trúc, bạn có thể thực hiện nhiều hoạt động đồng thời cũng như đưa chúng vào một hoạt động đồng bộ duy nhất, trong đó cơ chế xử lý đồng thời là một chi tiết triển khai. Yêu cầu duy nhất đối với mã gọi là phải ở trong một hàm tạm ngưng hoặc coroutine. Ngoài ra, cấu trúc của mã gọi không cần đưa vào chi tiết đồng thời. Việc này giúp mã không đồng bộ dễ đọc và dễ hiểu hơn.

Cơ chế xử lý đồng thời có cấu trúc theo dõi từng coroutine đã khởi chạy trong ứng dụng của bạn cũng như đảm bảo rằng chúng không bị mất. Coroutine có thể có hệ phân cấp: các tác vụ có thể chạy các tác vụ phụ và các tác vụ phụ có thể chạy các tác vụ phụ khác. Tác vụ duy trì mối quan hệ mẹ con giữa các coroutine và hỗ trợ bạn kiểm soát vòng đời của coroutine.

Khởi chạy, hoàn tất, huỷ và không thành công là 4 hoạt động phổ biến trong quy trình thực thi coroutine. Để giúp việc duy trì các chương trình đồng thời trở nên dễ dàng hơn, cơ chế xử lý đồng thời có cấu trúc xác định các nguyên tắc làm cơ sở cho cách quản lý các hoạt động phổ biến trong hệ phân cấp:

  1. Khởi chạy: Khởi chạy coroutine vào một phạm vi có giới hạn cụ thể về thời lượng tồn tại của coroutine đó.
  2. Hoàn tất: tác vụ chưa hoàn tất cho đến khi các tác vụ con của nó hoàn tất.
  3. Huỷ: Hoạt động này cần truyền xuống dưới. Khi một coroutine bị huỷ, các coroutine con cũng phải bị huỷ.
  4. Không thành công: Hoạt động này sẽ truyền lên trên. Khi coroutine khai báo trường hợp ngoại lệ, tác vụ mẹ sẽ huỷ tất cả tác vụ con của nó, huỷ chính nó rồi truyền trường hợp ngoại lệ lên tác vụ mẹ bên trên. Quá trình này tiếp tục cho đến khi tác vụ không thành công được phát hiện và xử lý. Điều này đảm bảo rằng mọi lỗi trong mã đều được báo cáo chính xác và không bao giờ bị mất.

Sau khi thực hành trên coroutine và nắm được các khái niệm đằng sau coroutine, giờ đây, bạn đã có thể viết mã đồng thời trong ứng dụng Android. Khi sử dụng coroutine để lập trình không đồng bộ, mã của bạn sẽ dễ đọc và dễ hiểu hơn, mạnh mẽ hơn trong các trường hợp huỷ và ngoại lệ, đồng thời mang lại trải nghiệm tối ưu hơn và phản hồi nhanh hơn cho người dùng cuối.

Tóm tắt

  • Với coroutine, bạn có thể viết mã chạy trong thời gian dài để chạy đồng thời mà không cần tìm hiểu kiểu lập trình mới. Quá trình thực thi coroutine diễn ra tuần tự theo thiết kế.
  • Coroutine tuân theo nguyên tắc đối với cơ chế xử lý đồng thời có cấu trúc, giúp đảm bảo rằng tác vụ không bị mất và gắn liền với một phạm vi có giới hạn cụ thể về thời gian tồn tại. Mã của bạn chạy tuần tự theo mặc định và phối hợp với vòng lặp sự kiện cơ bản, trừ trường hợp bạn yêu cầu rõ ràng để thực thi đồng thời (ví dụ: sử dụng launch() hoặc async()). Giả sử nếu bạn gọi một hàm, thì hàm đó phải hoàn tất tác vụ của nó (trừ trường hợp tác vụ không thành công nhưng thuộc trường hợp ngoại lệ) trước thời điểm trả về, bất kể số lượng coroutine có thể đã sử dụng trong chi tiết triển khai.
  • Đối tượng sửa đổi suspend dùng để đánh dấu một hàm có thể tạm ngưng rồi tiếp tục thực thi sau đó.
  • Bạn chỉ có thể gọi một hàm suspend qua một hàm tạm ngưng khác hoặc qua một coroutine.
  • Bạn có thể bắt đầu một coroutine mới bằng cách sử dụng các hàm mở rộng launch() hoặc async() trên CoroutineScope.
  • Tác vụ đóng vai trò quan trọng trong việc đảm bảo cơ chế xử lý đồng thời có cấu trúc bằng cách quản lý vòng đời của coroutine và duy trì mối quan hệ mẹ con.
  • CoroutineScope kiểm soát toàn bộ thời gian của coroutine thông qua tác vụ, đồng thời thực thi việc huỷ và các quy tắc khác đối với các tác vụ con, cũng như tác vụ con của các tác vụ con đó theo cách đệ quy.
  • CoroutineContext xác định hành vi của coroutine và có thể bao gồm cả nội dung tham chiếu đến tác vụ và trình điều phối coroutine.
  • Coroutine sử dụng CoroutineDispatcher để xác định luồng cần sử dụng cho quá trình thực thi.

Tìm hiểu thêm