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ó
- Quyền truy cập Internet để sử dụng Kotlin Playground
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ộ.
- Mở Kotlin Playground.
- 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")
}
- 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.
- Trước tiên, hãy thêm
import kotlinx.coroutines.*
vào phần đầu mã trước hàmmain()
. 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. - 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àmmain()
thêm1000
mili giây hay 1 giây. Sau đó, hãy chèn lệnh gọidelay()
này vào trước câu lệnh in choSunny
.
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.
- 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ọirunBlocking {}
. Phần nội dung củarunBlocking{}
đượ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.
- 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.
- 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ọiprintForecast()
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
.
- Thêm đối tượng sửa đổi
suspend
ngay trước từ khoáfun
trong phần khai báo hàmprintForecast()
để 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.
- 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")
}
- Gọi hàm
printTemperature()
mới qua mãrunBlocking()
trong hàmmain()
. 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")
}
- 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.
- (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 cho1000.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.
- 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 đếnprintForecast()
và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")
}
- 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()
và printTemperature()
có thể chạy đồng thời vì chúng nằm trong coroutine riêng biệt.
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.
- (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ả?
- 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!")
}
}
...
- 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()
và 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()
.
- 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()
vàprintTemperature()
thànhgetForecast()
vàgetTemperature()
.
...
suspend fun getForecast(): String {
delay(1000)
return "Sunny"
}
suspend fun getTemperature(): String {
delay(1000)
return "30\u00b0C"
}
- Sửa đổi mã
runBlocking()
để sử dụngasync()
thay vìlaunch()
cho hai coroutine. Lưu trữ giá trị trả về của mỗi lệnh gọiasync()
trong các biến tên làforecast
vàtemperature
, cũng là các đối tượngDeferred
chứa kết quả loạiString
. (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ọiasync()
trả về.)
import kotlinx.coroutines.*
fun main() {
runBlocking {
println("Weather forecast")
val forecast: Deferred<String> = async {
getForecast()
}
val temperature: Deferred<String> = async {
getTemperature()
}
...
}
}
...
- 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ọiawait()
trên các đối tượngDeferred
. Trong trường hợp này, bạn có thể in giá trị của từng coroutine bằngforecast.await()
và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"
}
- 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.
- Xác định hàm tạm ngưng mới
getWeatherReport()
trong mã. - 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.
- Trong phần nội dung của
coroutineScope()
, hãy tạo hai coroutine mới bằng cách sử dụngasync()
để 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ọiawait()
trên từng đối tượngDeferred
do lệnh gọiasync()
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()}"
}
...
- Gọi hàm
getWeatherReport()
mới này quarunBlocking()
. 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"
}
- 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()
và getTemperature()
đều cần hoàn tất rồi trả về kết quả tương ứng. Sau đó, văn bản Sunny
và 30°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
- 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ý).
- 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 đó.
- 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ả.
- Trong hàm
runBlocking()
, hãy thêm một khối try-catch quanh mã gọigetWeatherReport()
. 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"
}
- 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.
- 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"
}
- 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
là "{ 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 độ.
- 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"
}
- 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()}"
}
...
- 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.
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()
và async()
là 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()
và 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ặn và khô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()
và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.
- 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...")
}
}
- 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 đổiCoroutineContext
mà coroutine được thực thi trong đó và đặc biệt là ghi đè trình điều phối. Chuyển sang sử dụngDispatchers.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
.
- Chạy chương trình. Kết quả đầu ra sẽ là:
Loading... 10 results found.
- 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...")
}
}
- 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
, Default
và IO
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
, CoroutineDispatcher
và Jobs
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:
- 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 đó.
- 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.
- 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ỷ.
- 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ặcasync()
). 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ặcasync()
trênCoroutineScope
. - 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
- Coroutine của Kotlin trên Android
- Tài nguyên khác về coroutine và luồng trong Kotlin
- Coroutines guide (Hướng dẫn về coroutine)
- Coroutine context and dispatchers (Ngữ cảnh và trình điều phối coroutine)
- Cancellations and exceptions in Coroutines (Việc huỷ và ngoại lệ trong coroutine)
- Coroutines on Android (Coroutine trên Android)
- Kotlin coroutines 101 (Kiến thức cơ bản về coroutine trong Kotlin)
- KotlinConf 2019: Coroutines! Gotta catch ‘em all! (KotlinConf 2019: Coroutine! Đừng bỏ lỡ!)