Cafedev tự hào giới thiệu với cộng đồng lập trình viên về chủ đề “Kotlin với Xử lý Ngoại lệ Coroutines”. Trong hành trình khám phá sức mạnh của Kotlin, chúng ta sẽ đắm chìm vào cách xử lý ngoại lệ trong môi trường coroutine. Tính năng này không chỉ làm cho mã nguồn trở nên linh hoạt hơn mà còn mang lại trải nghiệm lập trình hiệu quả. Hãy cùng Cafedev khám phá cách Kotlin với coroutine giúp chúng ta xử lý ngoại lệ một cách thông minh và mạnh mẽ!

Phần này bao gồm việc xử lý ngoại lệ và hủy bỏ khi có ngoại lệ. Chúng ta đã biết rằng một coroutine bị hủy bỏ sẽ ném ra CancellationException tại các điểm tạm ngưng và được bỏ qua bởi máy chủ của coroutine. Ở đây, chúng ta sẽ xem xét điều gì xảy ra nếu một ngoại lệ được ném ra trong quá trình hủy bỏ hoặc nếu nhiều child của cùng một coroutine ném ra một ngoại lệ.

1. Truyền ngoại lệ

Có hai loại coroutine builder: tự động truyền ngoại lệ launch hoặc hiển thị chúng cho người dùng async và produce. Khi những builder này được sử dụng để tạo một coroutine gốc, không phải là con của một coroutine khác, những builder trước đều xử lý ngoại lệ như là ngoại lệ chưa được bắt, tương tự như Thread.uncaughtExceptionHandler trong Java, trong khi những builder sau phụ thuộc vào người dùng để xử lý ngoại lệ cuối cùng, ví dụ như thông qua await hoặc receive của phần produce.


Điều này có thể được minh họa bằng một ví dụ đơn giản tạo ra các coroutine gốc bằng cách sử dụng GlobalScope:

GlobalScope là một API nhạy cảm có thể có những hậu quả không trích dẫn. Tạo một coroutine gốc cho toàn bộ ứng dụng là một trong những ứng dụng hợp lệ hiếm hoi của GlobalScope, vì vậy bạn phải chọn sử dụng GlobalScope một cách rõ ràng với @OptIn(DelicateCoroutinesApi::class).

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val job = GlobalScope.launch { // root coroutine with launch
        println("Throwing exception from launch")
        throw IndexOutOfBoundsException() // Will be printed to the console by Thread.defaultUncaughtExceptionHandler
    }
    job.join()
    println("Joined failed job")
    val deferred = GlobalScope.async { // root coroutine with async
        println("Throwing exception from async")
        throw ArithmeticException() // Nothing is printed, relying on user to call await
    }
    try {
        deferred.await()
        println("Unreached")
    } catch (e: ArithmeticException) {
        println("Caught ArithmeticException")
    }
}


Bạn có thể xem mã nguồn đầy đủ tại đây.


Kết quả của đoạn mã này là (với debug):

Throwing exception from launch
Exception in thread "DefaultDispatcher-worker-2 @coroutine#2" java.lang.IndexOutOfBoundsException
Joined failed job
Throwing exception from async
Caught ArithmeticException

2. Hiểu về CoroutineExceptionHandler

Có thể tùy chỉnh hành vi mặc định của việc in ngoại lệ chưa được bắt ra console. Nguyên tắc CoroutineExceptionHandler trên một coroutine gốc có thể được sử dụng như một khối catch chung cho coroutine gốc và tất cả các con của nó, nơi xử lý ngoại lệ tùy chỉnh có thể diễn ra. Điều này tương tự Thread.uncaughtExceptionHandler). Bạn không thể phục hồi từ ngoại lệ trong CoroutineExceptionHandler. Coroutine đã hoàn thành với ngoại lệ tương ứng khi người xử lý được gọi. Thông thường, người xử lý được sử dụng để ghi log ngoại lệ, hiển thị một loại thông báo lỗi, kết thúc và/hoặc khởi động lại ứng dụng.


CoroutineExceptionHandler chỉ được gọi trên các ngoại lệ chưa được bắt – những ngoại lệ không được xử lý bằng bất kỳ cách nào khác. Đặc biệt, tất cả các coroutine con (coroutine được tạo trong ngữ cảnh của một [Job] khác) chuyển giao xử lý ngoại lệ của chúng cho coroutine cha, mà cũng chuyển giao cho cha, và cứ như vậy cho đến root, vì vậy CoroutineExceptionHandler được cài đặt trong ngữ cảnh của chúng không bao giờ được sử dụng. Ngoài ra, builder [async] luôn bắt tất cả các ngoại lệ và biểu diễn chúng trong đối tượng Deferred kết quả, vì vậy CoroutineExceptionHandler của nó cũng không có tác dụng.

Coroutines chạy trong phạm vi giám sát không truyền ngoại lệ cho cha của chúng và được loại trừ khỏi quy tắc này.

val handler = CoroutineExceptionHandler { _, exception -> 
    println("CoroutineExceptionHandler got $exception") 
}
val job = GlobalScope.launch(handler) { // root coroutine, running in GlobalScope
    throw AssertionError()
}
val deferred = GlobalScope.async(handler) { // also root, but async instead of launch
    throw ArithmeticException() // Nothing will be printed, relying on user to call deferred.await()
}
joinAll(job, deferred)


Bạn có thể xem mã nguồn đầy đủ tại đây.


Kết quả của đoạn mã này là:

CoroutineExceptionHandler got java.lang.AssertionError

3. Hủy bỏ và ngoại lệ

Hủy bỏ có mối liên quan chặt chẽ với ngoại lệ. Coroutine sử dụng ngoại lệ CancellationException nội bộ cho việc hủy bỏ, những ngoại lệ này bị bỏ qua bởi tất cả các người xử lý, vì vậy chúng nên chỉ được sử dụng như nguồn thông tin debug bổ sung, có thể được nhận bằng khối catch. Khi một coroutine bị hủy bỏ bằng cách sử dụng [Job.cancel], nó kết thúc, nhưng không hủy bỏ cha của nó.

val job = launch {
    val child = launch {
        try {
            delay(Long.MAX_VALUE)
        } finally {
            println("Child is cancelled")
        }
    }
    yield()
    println("Cancelling child")
    child.cancel()
    child.join()
    yield()
    println("Parent is not cancelled")
}
job.join()

Bạn có thể xem mã nguồn đầy đủ tại đây.


Kết quả của đoạn mã này là:

Cancelling child
Child is cancelled
Parent is not cancelled

Nếu một coroutine gặp phải một ngoại lệ khác với CancellationException, nó sẽ hủy bỏ cha của mình với ngoại lệ đó. Hành vi này không thể được ghi đè và được sử dụng để cung cấp các cấu trúc coroutine ổn định cho cấu trúc concurrency. Implementasi CoroutineExceptionHandler không được sử dụng cho các coroutine con.

Trong những ví dụ này, [CoroutineExceptionHandler] luôn được cài đặt cho một coroutine được tạo trong [GlobalScope]. Không có ý nghĩa gì khi cài đặt một người xử lý ngoại lệ cho một coroutine được khởi chạy trong phạm vi của [runBlocking] chính, vì coroutine chính sẽ luôn bị hủy bỏ khi con của nó kết thúc với ngoại lệ mặc dù có người xử lý được cài đặt.


Ngoại lệ gốc chỉ được xử lý bởi cha khi tất cả các con của nó kết thúc, điều này được minh họa bằng ví dụ sau.

val handler = CoroutineExceptionHandler { _, exception -> 
    println("CoroutineExceptionHandler got $exception") 
}
val job = GlobalScope.launch(handler) {
    launch { // the first child
        try {
            delay(Long.MAX_VALUE)
        } finally {
            withContext(NonCancellable) {
                println("Children are cancelled, but exception is not handled until all children terminate")
                delay(100)
                println("The first child finished its non cancellable block")
            }
        }
    }
    launch { // the second child
        delay(10)
        println("Second child throws an exception")
        throw ArithmeticException()
    }
}
job.join()


Bạn có thể xem mã nguồn đầy đủ tại đây.


Kết quả của đoạn mã này là:

Second child throws an exception
Children are cancelled, but exception is not handled until all children terminate
The first child finished its non cancellable block
CoroutineExceptionHandler got java.lang.ArithmeticException

4. Tổng hợp ngoại lệ

Khi nhiều con của một coroutine gặp lỗi với một ngoại lệ, quy tắc chung là “ngoại lệ đầu tiên chiến thắng”, vì vậy ngoại lệ đầu tiên được xử lý. Tất cả các ngoại lệ bổ sung xảy ra sau ngoại lệ đầu tiên được đính kèm vào ngoại lệ đầu tiên dưới dạng ngoại lệ bị đè.

import kotlinx.coroutines.*
import java.io.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception with suppressed ${exception.suppressed.contentToString()}")
    }
    val job = GlobalScope.launch(handler) {
        launch {
            try {
                delay(Long.MAX_VALUE) // it gets cancelled when another sibling fails with IOException
            } finally {
                throw ArithmeticException() // the second exception
            }
        }
        launch {
            delay(100)
            throw IOException() // the first exception
        }
        delay(Long.MAX_VALUE)
    }
    job.join()  
}

Bạn có thể xem mã nguồn đầy đủ tại đây.


Kết quả của đoạn mã này là:

CoroutineExceptionHandler got java.io.IOException with suppressed [java.lang.ArithmeticException]

Lưu ý rằng cơ chế này hiện chỉ hoạt động trên Java phiên bản 1.7 trở lên. Các hạn chế trên JS và Native là tạm thời và sẽ được nâng lên trong tương lai.


Ngoại lệ hủy bỏ là trong suốt và mặc định được mở gói:

val handler = CoroutineExceptionHandler { _, exception ->
    println("CoroutineExceptionHandler got $exception")
}
val job = GlobalScope.launch(handler) {
    val innerJob = launch { // all this stack of coroutines will get cancelled
        launch {
            launch {
                throw IOException() // the original exception
            }
        }
    }
    try {
        innerJob.join()
    } catch (e: CancellationException) {
        println("Rethrowing CancellationException with original cause")
        throw e // cancellation exception is rethrown, yet the original IOException gets to the handler  
    }
}
job.join()


Bạn có thể xem mã nguồn đầy đủ tại đây.


Kết quả của đoạn mã này là:

Rethrowing CancellationException with original cause
CoroutineExceptionHandler got java.io.IOException

5. Giám sát (Supervision)

Như chúng ta đã tìm hiểu trước đó, hủy bỏ là mối quan hệ hai chiều truyền qua toàn bộ cấu trúc hình phân của coroutine. Hãy xem xét trường hợp khi hủy bỏ một chiều là cần thiết.


Một ví dụ tốt cho yêu cầu như vậy là một thành phần UI với công việc được định nghĩa trong phạm vi của nó. Nếu bất kỳ công việc con nào của UI đã thất bại, không luôn cần thiết phải hủy bỏ (tức là giết) toàn bộ thành phần UI, nhưng nếu thành phần UI bị hủy bỏ (và công việc của nó bị hủy bỏ), thì cần phải hủy bỏ tất cả các công việc con vì kết quả của chúng không còn cần thiết nữa.


Một ví dụ khác là một tiến trình máy chủ tạo ra nhiều công việc con và cần “giám sát” thực hiện chúng, theo dõi sự thất bại và chỉ khởi động lại những công việc thất bại.

5.1 Công việc giám sát

[SupervisorJob] có thể được sử dụng cho những mục đích này. Nó tương tự như một [Job] thông thường với ngoại lệ duy nhất là hủy bỏ chỉ được truyền xuống. Điều này có thể dễ dàng được minh họa bằng ví dụ sau:

val supervisor = SupervisorJob()
with(CoroutineScope(coroutineContext + supervisor)) {
    // launch the first child -- its exception is ignored for this example (don't do this in practice!)
    val firstChild = launch(CoroutineExceptionHandler { _, _ ->  }) {
        println("The first child is failing")
        throw AssertionError("The first child is cancelled")
    }
    // launch the second child
    val secondChild = launch {
        firstChild.join()
        // Cancellation of the first child is not propagated to the second child
        println("The first child is cancelled: ${firstChild.isCancelled}, but the second one is still active")
        try {
            delay(Long.MAX_VALUE)
        } finally {
            // But cancellation of the supervisor is propagated
            println("The second child is cancelled because the supervisor was cancelled")
        }
    }
    // wait until the first child fails & completes
    firstChild.join()
    println("Cancelling the supervisor")
    supervisor.cancel()
    secondChild.join()

Bạn có thể xem mã nguồn đầy đủ tại đây.


Kết quả của đoạn mã này là:

The first child is failing
The first child is cancelled: true, but the second one is still active
Cancelling the supervisor
The second child is cancelled because the supervisor was cancelled

5.2 Phạm vi giám sát

Thay vì [coroutineScope], chúng ta có thể sử dụng [supervisorScope] cho cấu trúc concurrency có phạm vi (_scoped). Nó truyền tải hủy bỏ một chiều duy nhất và chỉ hủy bỏ tất cả các con của nó nếu nó thất bại. Nó cũng đợi tất cả các con trước khi hoàn thành, giống như [coroutineScope] làm.

try {
    supervisorScope {
        val child = launch {
            try {
                println("The child is sleeping")
                delay(Long.MAX_VALUE)
            } finally {
                println("The child is cancelled")
            }
        }
        // Give our child a chance to execute and print using yield 
        yield()
        println("Throwing an exception from the scope")
        throw AssertionError()
    }
} catch(e: AssertionError) {
    println("Caught an assertion error")
}

Bạn có thể xem mã nguồn đầy đủ tại đây.


Kết quả của đoạn mã này là:

The child is sleeping
Throwing an exception from the scope
The child is cancelled
Caught an assertion error

Ngoại lệ trong các coroutine được giám sát

Một sự khác biệt quan trọng khác giữa công việc thông thường và công việc giám sát là xử lý ngoại lệ. Mỗi công việc con nên xử lý ngoại lệ của mình thông qua cơ chế xử lý ngoại lệ. Sự khác biệt này xuất phát từ thực tế rằng sự thất bại của con không lan truyền lên cha. Điều này có nghĩa là coroutine được khởi chạy trực tiếp bên trong [supervisorScope] sử dụng [CoroutineExceptionHandler] được cài đặt trong phạm vi của chúng giống như root coroutine làm (xem phần CoroutineExceptionHandler trên để biết chi tiết).

val handler = CoroutineExceptionHandler { _, exception -> 
    println("CoroutineExceptionHandler got $exception") 
}
supervisorScope {
    val child = launch(handler) {
        println("The child throws an exception")
        throw AssertionError()
    }
    println("The scope is completing")
}
println("The scope is completed")

Bạn có thể xem mã nguồn đầy đủ tại đây.


Kết quả của đoạn mã này là:

The scope is completing
The child throws an exception
CoroutineExceptionHandler got java.lang.AssertionError
The scope is completed



Chân thành cảm ơn bạn đã đồng hành cùng Cafedev trong hành trình khám phá “Kotlin với Xử lý Ngoại lệ Coroutines”. Hy vọng rằng thông qua nội dung này, bạn đã có những cái nhìn mới về sức mạnh và linh hoạt mà Kotlin mang lại trong việc xử lý ngoại lệ. Hãy tiếp tục theo dõi và đồng hành cùng Cafedev để khám phá thêm nhiều điều thú vị khác về lập trình và công nghệ. Cảm ơn bạn vì sự quan tâm và đóng góp vào cộng đồng lập trình!”

Các nguồn kiến thức MIỄN PHÍ VÔ GIÁ từ cafedev tại đây

Nếu bạn thấy hay và hữu ích, bạn có thể tham gia các kênh sau của Cafedev để nhận được nhiều hơn nữa:

Chào thân ái và quyết thắng!

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!