Note: see the link below for the English version of this article.
https://duongnt.com/launch-exception-handling
Kotlin hỗ trợ lập trình không đồng bộ thông qua coroutine. Tuy nhiên, xử lý exception trong lập trình không đồng bộ là khó hơn trong lập trình đồng bộ. Với những coroutine tạo bởi hàm launch
thì điều này đặc biệt đúng, bởi vì chúng thường có tính fire-and-forget. Hôm nay, chúng ta sẽ tìm hiểu cách xử lý exception trong những coroutine tạo bởi hàm launch
.
Code minh hoạ
Trong bài hôm nay, chúng ta sẽ sử dụng 2 hàm suspending để mô phỏng những request I/O. Hàm đầu tiên đợi một thời gian rồi in dòng thông báo ra console; còn hàm thứ hai đợi một thời gian rồi sinh exception. Thời gian đợi trong cả 2 hàm đều có thể được tuỳ chỉnh.
suspend fun success(delayInMs: Long) {
delay(delayInMs)
println("Success!")
}
suspend fun failure(delayInMs: Long) {
delay(delayInMs)
throw Exception("This is a dummy error!")
}
Chúng ta sẽ gọi 2 hàm này trong 2 coroutine riêng biệt.
runBlocking {
val successJob = launch(Dispatchers.IO) {
success(2000)
}
val failedJob = launch(Dispatchers.IO) {
failure(1000)
}
joinAll(successJob, failedJob)
}
Đúng như dự tính, ta sẽ gặp lỗi như dưới đây.
Exception in thread "main" java.lang.Exception: This is a dummy error!
Trong những phần sau, ta sẽ tìm cách xử lý exception sinh bởi hàm failure
, đồng thời đảm bảo hàm success
vẫn chạy.
Try-catch không thể xử lý exception này
Nếu đã quen với lập trình đồng bộ, có thể chúng ta sẽ thử đặt 2 lệnh launch
ở trên vào trong khối try-catch
.
runBlocking {
try {
// code to launch two coroutines
}
catch(ex: Exception) {
println("An error occurred: ${ex.message}")
}
}
Nhưng kết quả là ta vẫn gặp exception.
Exception in thread "main" java.lang.Exception: This is a dummy error!
Hơn nữa, ta không thấy dòng thông báo Success
trong kết quả đầu ra. Điều này có vẻ kỳ lạ vì mặc dù failedJob
gặp lỗi, lẽ ra successJob
vẫn phải chạy bình thường. Chúng ta gọi 2 hàm này trong 2 coroutine, mỗi coroutine chạy trên thread riêng biệt mà?
Job và SupervisorJob trong Kotlin
Lý do successJob
không thể chạy thành công là vì failedJob
chỉ là Job
thông thường. Khi failedJob
bị lỗi, nó sẽ dừng cả coroutine cha tạo bởi runBlocking
. Và khi coroutine cha bị dừng thì nó sẽ dừng tất cả các coroutine con còn lại, bao gồm cả successJob
.
Để lỗi trong coroutine con không làm dừng coroutine cha, ta phải sử dụng SupervisorJob
thay vì Job
. Nếu một coroutine là SupervisorJob
thì kể cả khi nó bị lỗi, coroutine cha và các coroutine anh em của nó sẽ vẫn chạy. Ta có 2 cách để tạo SupervisorJob
.
Tạo từng coroutine con với context SupervisorJob
Như đã thấy trong code ở trên, ta tạo các coroutine con với context Dispatchers.IO
. Để tạo SupervisorJob
, ta chỉ cần thêm context SupervisorJob vào Dispatchers.IO
.
runBlocking {
val successJob = launch(Dispatchers.IO + SupervisorJob()) {
success(2000)
}
val failedJob = launch(Dispatchers.IO + SupervisorJob()) {
failure(1000)
}
joinAll(successJob, failedJob)
}
Chú ý là hàm success
thực ra không cần context SupervisorJob
. Tôi thêm context vào cả hàm này để code được đồng nhất.
Để tất cả coroutine con trong supervisorScope
Nếu ta có nhiều coroutine còn thì việc thêm SupervisorJob
vào context của từng cái một là tương đối tốn công. Nhưng ta lại không thể thêm SupervisorJob
vào context của coroutine cha tạo bởi runBlocking
. Nguyên nhân vì mỗi coroutine con khi được tạo đều sẽ được cấp một Job
mới. Job
này sẽ ghi đè lên SupervisorJob
kế thừa từ coroutine cha.
// Đoạn code dưới sẽ gặp lỗi
runBlocking(SupervisorJob()) {
val successJob = launch(Dispatchers.IO) {
success(2000)
}
val failedJob = launch(Dispatchers.IO) {
failure(1000)
}
joinAll(successJob, failedJob)
}
Thay vào đó, ta có thể đặt tất cả những coroutine con vào trong một supervisorScope. Hàm này tạo một scope mới kế thừa context của coroutine cha, nhưng nó ghi đè Job
trong context bằng SupervisorJob
.
runBlocking {
supervisorScope {
val successJob = launch(Dispatchers.IO) {
success(2000)
}
val failedJob = launch(Dispatchers.IO) {
failure(1000)
}
joinAll(successJob, failedJob)
}
}
Thử sử dụng SupervisorJob
Dưới đây là kết quả của 2 phiên bản code ở trên.
Exception in thread "DefaultDispatcher-worker-3 @coroutine#3" java.lang.RuntimeException: Exception while trying to handle coroutine exception
at kotlinx.coroutines.CoroutineExceptionHandlerKt.handlerException(CoroutineExceptionHandler.kt:38)
at kotlinx.coroutines.CoroutineExceptionHandlerImplKt.handleCoroutineExceptionImpl(CoroutineExceptionHandlerImpl.kt:52)
at kotlinx.coroutines.CoroutineExceptionHandlerKt.handleCoroutineException(CoroutineExceptionHandler.kt:33)
<omitted>
Success!
Như đã thấy, hàm success
đã chạy thành công. Tuy nhiên ta vẫn gặp phải exception từ hàm failure
. Exception này được lan truyền lên tới main thread, và làm main thread bị dừng. Ta sẽ tìm cách xử lý exception này trong phần dưới.
Dùng CoroutineExceptionHandler để xử lý lỗi trong coroutines
Trong phần đầu của bài hôm nay ta đã thấy rằng try-catch
không thể xử lý lỗi trong coroutine tạo bởi hàm launch
. Thay vào đó, ta phải định nghĩa và sử dụng CoroutineExceptionHandler.
val handler = CoroutineExceptionHandler { _, ex ->
// Code để xử lý exception
println("From handler: An error occurred: ${ex.message}")
}
Cách tạo coroutine với context CoroutineExceptionHandler
Handler này cũng implement interface CoroutineContext
, vì thế ta có thể dùng nó như một context.
runBlocking {
val successJob = launch(Dispatchers.IO + SupervisorJob() + handler) {
success(2000)
}
val failedJob = launch(Dispatchers.IO + SupervisorJob() + handler) {
failure(1000)
}
joinAll(successJob, failedJob)
}
Hoặc nếu như ta muốn sử dụng supervisorScope
.
runBlocking {
supervisorScope {
val successJob = launch(Dispatchers.IO + handler) {
success(2000)
}
val failedJob = launch(Dispatchers.IO + handler) {
failure(1000)
}
joinAll(successJob, failedJob)
}
}
Ta cũng có thể thêm handler
vào context của coroutine cha.
runBlocking(handler) {
supervisorScope {
val successJob = launch(Dispatchers.IO) {
success(2000)
}
val failedJob = launch(Dispatchers.IO) {
failure(1000)
}
joinAll(successJob, failedJob)
}
}
Thử sử dụng CoroutineExceptionHandler
Dưới đây là kết quả của đoạn code ở trên.
From handler: An error occurred: This is a dummy error!
Success!
Đoạn code đó thực hiện những việc sau.
- Hàm
success
vàfailure
được gọi cùng lúc và chạy trong 2 coroutine khác nhau, trên 2 thread riêng biệt. - Sau 1000ms,
failure
sinh exception và khiến coroutine của nó bị dừng. Nhưng vì coroutine đó làSupervisorJob
, exception này không làm dừng coroutine cha hay coroutine anh em (trong trường hợp này là coroutine củasuccess
). - Exception nay sau đó được
CoroutineExceptionHandler
xử lý và in thông báo lỗi ra console. - Sau 2000ms,
success
chạy xong và in một thông báo khác ra console.
Chuyện gì xảy ra nếu ta dùng CoroutineExceptionHandler với Job thay vì SupervisorJob?
Có lẽ sẽ có người hỏi vì sao ta cần SupervisorJob
nếu như ta có thể dùng CoroutineExceptionHandler
để xử lý exception trong coroutine con. Ta sẽ thử phương án đó trong thí nghiệm dưới đây.
runBlocking {
val successJob = launch(Dispatchers.IO + handler) {
success(2000)
}
val failedJob = launch(Dispatchers.IO + handler) {
failure(1000)
}
joinAll(successJob, failedJob)
}
Kết quả của đoạn code trên như sau.
Exception in thread "main" java.lang.Exception: This is a dummy error!
Handler của ta không có tác dụng gì cả. Theo như tài liệu này, nguyên nhân là như sau.
CoroutineExceptionHandler
chỉ có thể xử lý exception uncaught.- Exception uncaught chỉ có thể xảy ra tại coroutine root.
- Tất cả exception trong các coroutine con sẽ được lan truyền qua coroutine cha, cho tới khi nó tới root.
- Ngoại lệ duy nhất là trong
SupervisorJob
coroutines. Lúc này coroutine đó được coi là coroutine root và có thể có uncaught exceptions.
Vì những điểm trên, khi ta xoá supervisorScope
(hoặc context SupervisorJob
), exception từ bên trong hàm failure
sẽ lan truyền ra bên ngoài coroutine con mà không bị CoroutineExceptionHandler
xử lý.
Kết thúc
Coroutine tạo bởi launch
builder có nhiều điểm giống với Task
không generic trong C#. Nhưng nếu tôi await một Task
thì tôi có thể đặt nó bên trong khối try-catch
một cách bình thường. Vì sự khác biệt này, phải mất một thời gian tôi mới quen được với cách xử lý trong Kotlin. Nhưng việc tìm hiểu cách xử lý exception đã giúp tôi hiểu thêm về coroutine và lập trình không đồng bộ trong Kotlin.
One Thought on “Xử lý lỗi trong launch builder của Kotlin”