Cùng học Kotlin Coroutine, phần 4: Job, Join, Cancellation and Timeouts

1. Job - một element trong coroutine context

Như chúng ta đã biết ở phần 3: Trong coroutine context có một element là Job giữ nhiệm vụ nắm giữ thông tin về lifecycle của coroutine, cancel coroutine, .... Mỗi khi chúng ta launch một coroutine thì nó trả về một đối tượng Job này.

val job = GlobalScope.launch { // launch a new coroutine and keep a reference to its Job
       delay(5000L)
       println("World!")
   }

Ở những mục tiếp theo của bài viết này, chúng ta sẽ được giới thiệu một số property và method hay dùng liên quan đến đối tượng job này.

2. Hàm join() - hãy đợi coroutine chạy xong đã!

Chúng ta có thể sử dụng đối tượng Job để thực hiện một số method có sẵn trong mỗi coroutine. Ví dụ ở đây mình sử dụng hàm join(). Khi một coroutine gọi hàm join() này thì tiến trình phải đợi coroutine này chạy xong task của mình rồi mới chạy tiếp. Ví dụ:

fun main() = runBlocking {
   val job = GlobalScope.launch { // launch a new coroutine and keep a reference to its Job
       delay(5000L)
       println("World!")
   }
   println("Hello,")
   job.join() // wait until child coroutine completes
   println("Kotlin")
}

Output:

22:07:20 I/System.out: Hello
22:07:25 I/System.out: World
22:07:25 I/System.out: Kotlin

Nhìn output ta có thể dễ dàng thấy khi tiến trình chạy xong dòng code in ra từ "Hello," thì nó gặp lệnh join() và nó không tiếp tục chạy xuống dòng code bên dưới để in tiếp từ "Kotlin" mà chờ coroutine chạy xong task để in ra từ "World" trước cái đã. Đó là công dụng của hàm join()

3. Hàm cancel() - hủy bỏ một coroutine

Để dừng và hủy bỏ một coroutine đang chạy. Ta có thể dùng method cancel() của biến Job

fun main() = runBlocking {
    val job = launch {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancel() // cancels the job
    println("main: Now I can quit.")    
}

Output:

I'm sleeping 0 …
I'm sleeping 1 …
I'm sleeping 2 …
main: I'm tired of waiting!
main: Now I can quit.

Ở đoạn code trên, mình cho phóng một coroutine và bảo nó in ra câu "I'm sleeping ..." cứ mỗi 500 ms và in đủ 1000 lần như vậy. Và đoạn code dưới, mình cho tiến trình delay 1300 ms trước khi cancel con coroutine mình đã phóng. Kết quả là sau 1300 ms, nó mới chỉ in được có 3 câu "I'm sleeping ..." mà nó đã bị hủy bỏ nên không in tiếp được nữa 😄

4. Những lưu ý khi hủy bỏ một coroutine

4.1. Coroutine cancellation is cooperative

Thử dùng hàm cancel() để hủy bỏ coroutine trong đoạn code sau:

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    val job = launch(Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) {
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("job: I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancel() // cancels the job
    println("main: Now I can quit.")
}

Output:

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.
job: I'm sleeping 3 ...
job: I'm sleeping 4 ...

Ôi, thật bất ngờ!. Đoạn code trên, mình cũng cho phóng một coroutine và bảo nó in ra câu "I'm sleeping ..." cứ mỗi 500 ms và in đủ 5 lần như vậy. Tuy nhiên sau 1300 ms, mình đã gọi hàm cancel() để hủy bỏ corotine đó, tức là nó chỉ có đủ thời gian để in ra được 3 câu "I'm sleeping ..." nhưng thực tế output cho thấy nó vẫn chạy bất chấp và in ra đủ 5 câu "I'm sleeping ..." =))

Đó là vì quá trình hủy bỏ coroutine có tính hợp tác (Coroutine cancellation is cooperative). Một coroutine khi bị cancel thì nó sẽ chỉ set lại một property có tên là isActive trong đối tượng Job từ true thành false (job.isActive = false), còn tiến trình của nó đang chạy thì sẽ vẫn chạy bất chấp cho đến hết mà không bị dừng lại. Vậy tại sao, ở đoạn code trong phần 2, tiến trình của coroutine lại được hủy bỏ thành công. Đó là vì hàm delay(500L) ngoài chức năng delay thì bản thân nó cũng có một chức năng có thể check coroutine này còn sống hay không, nếu không còn sống (job.isActive == false) nó sẽ hủy bỏ tiến trình của coroutine đó ngay và luôn. Không chỉ riêng hàm delay() mà tất cả các hàm suspend function trong package kotlinx.coroutines đều có khả năng check này.

Vậy chúng ta đã biết thêm một property tuyệt vời của đối tượng JobisActive. Nó giúp chúng ta kiểm tra xem coroutine đã bị cancel hay chưa. Thử áp dụng nó vào code để kịp thời ngăn chặn tiến trình của coroutine khi đã có lệnh hủy bỏ coroutine đó xem nào 😄

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    val job = launch(Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (isActive) {   // Điều kiện i < 5 đã được thay bằng isActive để ngăn chặn coroutine khi nó đã bị hủy
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("job: I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancel() // cancels the job
    println("main: Now I can quit.")
}

Output:

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

Tuyệt vời!. Nếu như không có biến isActive thì vòng lặp while sẽ làm cho coroutine in ra vô số câu "I'm sleeping ...". Nhờ có điều kiện isActive nên chúng ta đã ngăn chặn được coroutine sau khi nó đã bị hủy bỏ, khiến nó chỉ có thể in ra 3 câu "I'm sleeping ...".

4.2. Sử dụng khối finally để close resource ngay cả khi coroutine đã bị hủy bỏ.

Nếu tiến trình của một coroutine bị hủy bỏ thì ngay lập tức nó sẽ tìm đến khối finally để chạy code trong đó. Chúng ta có thể sử dụng đặc điểm này để tranh thủ close hết các resource trước khi coroutine đó chính thức bị khai tử 😄

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("I'm sleeping $i ...")
                delay(500L)
            }
        } finally {
            // Tranh thủ close resource trong này đi nha :D
            println("I'm running finally")
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancel() // cancels the job
    println("main: Now I can quit.")
}

Output:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.
I'm running finally

Như chúng ta thấy trong kết quả output, ngay cả khi coroutine bị dừng không thể tiếp tục in ra những câu "I'm sleeping ..." và đã chạy đến dòng code cuối để in ra câu "main: Now I can quit." mà nó vẫn cố gắng chạy vào khối finally để in ra câu "I'm running finally" trước khi trút hơi thở cuối cùng 😄

4.3. Coroutine vẫn có thể chết trong khối finally

Bây giờ, thử để hàm delay() bên trong khối finally của đoạn code trên thử xem:

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("I'm sleeping $i ...")
                delay(500L)
            }
        } finally {
            println("I'm running finally")
            delay(1000L)                      // hàm delay được thêm vào khối finally
            println("Print me please!")
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancel() // cancels the job
    println("main: Now I can quit.")
}

Output:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.
I'm running finally

What!. Tại sao coroutine chạy vào khối finally in ra được câu "I'm running finally" nhưng lại không thể tiếp tục chạy xuống code dưới để in ra câu "Print me please!". Tất cả tại thằng hàm delay(). Như mình đã nói ở trên, hàm delay() nó riêng hay tất cả hàm suspend function nói chung có khả năng check xem coroutine còn sống không. Nếu nó đã chết thì tiến trình lập tức bị dừng lại ngay khi chạy vào hàm delay() này. Vậy thì câu "Print me please!" tất nhiên sẽ không được in ra rồi =))

4.4. Làm cho coroutine bất tử

Vậy giả sử bây giờ chúng ta muốn nó thực thi bất chấp tất cả dòng code trong khối finally thì làm cách nào?. Vẫn có cách nhé. Một element thuộc coroutine context có tên là NonCancellable sẽ giúp ta thực hiện điều này.

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("I'm sleeping $i ...")
                delay(500L)
            }
        } finally {
            withContext(NonCancellable) {  // Nhờ có em NonCancellable mà anh được phép chạy bất chấp đấy
                println("I'm running finally")
                delay(1000L)
                println("I'm non-cancellable")
            }
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancel() // cancels the job
    println("main: Now I can quit.")    
}

Output:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.
I'm running finally
I'm non-cancellable

Ở đoạn code trên, có 2 kiến thức lạ là NonCancellable và hàm withContext().

  • Hàm withContext() có tác dụng điều chỉnh lại context của coroutine. Cụ thể trước đó coroutine lúc mới được sinh ra thì bản thân nó default là Cancellable (có thể hủy bỏ được) nhưng khi coroutine chạy được một lúc rồi mình lại muốn nó đổi context thành NonCancellable (không thể hủy bỏ được). Khi đó hàm withContext() sẽ giúp chúng ta thực hiện việc điều chỉnh đó. Công dụng khác của hàm withContext() có thể kể đến như một coroutine thực thi task dưới background thread (Dispatchers.IO) và sau khi xong task thì cho nó chạy tiếp trên main thread withContext(Dispatchers.Main) để update UI chẳng hạn. Mình sẽ nói nhiều hơn về hàm withContext() ở các bài sau nhé 😄.
  • NonCancellable là một element trong tập context của coroutine. Công dụng của nó là khiến cho coroutine trở nên bất tử, không thứ gì có thể khiến nó dừng lại cho đến khi nó hoàn thành xong task nhé =))

5. Timeout - cho coroutine chết bằng cách hẹn giờ

Chúng ta có thể ra lệnh cho coroutine: "Nhà ngươi hãy làm task này cho ta trong vòng 10 giây, nếu hết 10 giây mà ngươi vẫn làm chưa xong thì hãy chết đi!". Hàm withTimeout(truyền_vào_khoảng_thời_gian_đơn_vị_ms) sẽ cho ta cái quyền lực như vậy.

fun main() = runBlocking {
    withTimeout(1300L) {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
    }
}
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms

WTF!. Sao lại gặp Exception!. Đúng vậy. Hàm withTimeout() khá là gắt khi nó thấy hết thời gian timeout mà vẫn chưa thấy coroutine xong task nó sẽ throw TimeoutCancellationException. Điều này đồng nghĩa với việc sẽ không có Exception nào xảy ra nếu coroutine hoàn thành task trước khi hết thời gian timeout.

Chúng ta có hàm withTimeoutOrNull(truyền_vào_khoảng_thời_gian_đơn_vị_ms) có công dụng như hàm withTimeout() nhưng bớt gắt hơn. Thay vì throw TimeoutCancellationException thì bản thân hàm withTimeoutOrNull() sẽ return về một biến null khi hết thời gian timeout rồi mà coroutine vẫn chưa xong task.

fun main() = runBlocking {
    val result = withTimeoutOrNull(1300L) {
        repeat(1000) { i ->
                println("I'm sleeping $i ...")
            delay(500L)
        }
        "Done" // will get cancelled before it produces this result
    }
    println("Result is $result")                // Biến result sẽ null
}

Output:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Result is null

Kết luận

Kết thúc phần 4, hy vọng bạn đã nắm rõ các kiến thức liên quan đến việc cancel một coroutine. Cảm ơn các bạn đã theo dõi bài viết này. Hy vọng các bạn sẽ tiếp tục theo dõi những phần tiếp theo. 😄

Nguồn tham khảo:

https://kotlinlang.org/docs/reference/coroutines/basics.html

https://kotlinlang.org/docs/reference/coroutines/cancellation-and-timeouts.html

Đọc lại những phần trước:

Cùng học Kotlin Coroutine, phần 1: Giới thiệu Kotlin Coroutine và kỹ thuật lập trình bất đồng bộ

Cùng học Kotlin Coroutine, phần 2: Build first coroutine with Kotlin

Cùng học Kotlin Coroutine, phần 3: Coroutine Context và Dispatcher

Đọc tiếp phần 5: Cùng học Kotlin Coroutine, phần 5: Async & Await


All Rights Reserved