Note: see the link below for the English version of this article.

https://duongnt.com/launch-exception-handling

 Xử lý lỗi trong launch builder của Kotlin

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.

Việc dừng coroutine là quá trình 2 chiều

Để 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 successfailure đượ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ủa success).
  • 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.

A software developer from Vietnam and is currently living in Japan.

One Thought on “Xử lý lỗi trong launch builder của Kotlin”

Leave a Reply