Chào mừng bạn đến với Cafedev – nguồn cảm hứng và kiến thức vững về lập trình. Trong chuyên mục mới, chúng ta sẽ khám phá sức mạnh của Kotlin khi kết hợp các hàm tạm dừng(suspending) giữa nhau. Những kỹ thuật này không chỉ giúp tối ưu hóa mã nguồn mà còn tạo ra các ứng dụng linh hoạt và hiệu quả. Hãy cùng Cafedev trải nghiệm hành trình đầy thú vị với “Kotlin với việc Kết hợp các hàm tạm dừng(suspending)” và khám phá cách mà chúng có thể nâng cao chất lượng mã nguồn của bạn.

Phần này bao gồm các phương pháp khác nhau để kết hợp các hàm tạm dừng(suspending).

1. Chạy Tuần tự theo mặc định

Giả sử chúng ta có hai hàm treo được định nghĩa ở đâu đó thực hiện một số công việc hữu ích như gọi dịch vụ từ xa hoặc tính toán. Chúng ta chỉ giả vờ rằng chúng có ý nghĩa, nhưng thực tế mỗi hàm chỉ trì hoãn một giây cho mục đích của ví dụ này:

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // pretend we are doing something useful here
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // pretend we are doing something useful here, too
    return 29
}


Chúng ta làm gì nếu chúng ta cần chúng được gọi theo thứ tự tuần tự — trước doSomethingUsefulOne rồi sau đó doSomethingUsefulTwo, và tính tổng của kết quả của chúng? Trong thực tế, chúng ta làm điều này nếu chúng ta sử dụng kết quả của hàm đầu tiên để đưa ra quyết định về việc chúng ta cần gọi hàm thứ hai hay quyết định cách gọi nó.


Chúng ta sử dụng một cuộc gọi tuần tự thông thường, bởi vì mã trong coroutine, giống như trong mã thông thường, mặc định là tuần tự. Ví dụ sau minh họa điều này bằng cách đo thời gian tổng cộng để thực hiện cả hai hàm tạm dừng(suspending):

val time = measureTimeMillis {
    val one = doSomethingUsefulOne()
    val two = doSomethingUsefulTwo()
    println("The answer is ${one + two}")
}
println("Completed in $time ms")

Bạn có thể lấy mã đầy đủ tại đây.


Nó tạo ra một kết quả giống như sau:

The answer is 42
Completed in 2017 ms

2. Chạy Song song bằng cách sử dụng async

Điều gì sẽ xảy ra nếu không có sự phụ thuộc giữa việc gọi doSomethingUsefulOnedoSomethingUsefulTwo và chúng ta muốn nhận câu trả lời nhanh hơn, bằng cách thực hiện cả hai đồng thời? Đây là nơi async đến để giúp đỡ.


Về mặt khái niệm, async hoàn toàn giống với launch. Nó bắt đầu một coroutine riêng biệt, đó là một luồng nhẹ hoạt động đồng thời với tất cả các coroutine khác. Sự khác biệt là launch trả về một Job và không mang theo giá trị kết quả nào, trong khiasync trả về một Deferred — một tương lai nhẹ không chặn đang đại diện cho một hứa hẹn cung cấp kết quả sau này. Bạn có thể sử dụng .await() trên một giá trị deferred để nhận kết quả cuối cùng của nó, nhưng Deferred cũng là một Job, nên bạn có thể hủy nó nếu cần.

val time = measureTimeMillis {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    println("The answer is ${one.await() + two.await()}")
}
println("Completed in $time ms")

Bạn có thể lấy mã đầy đủ tại đây.


Nó tạo ra một kết quả giống như sau:

The answer is 42
Completed in 1017 ms

Điều này nhanh gấp đôi, bởi vì hai coroutine thực thi đồng thời. Lưu ý rằng tính đồng thời với coroutine luôn được thể hiện rõ ràng.

3. Dùng async để khởi động lazy

Theo tùy chọn, async có thể trở nên lười biếng bằng cách đặt tham số start của nó thành CoroutineStart.LAZY. Trong chế độ này, nó chỉ bắt đầu coroutine khi kết quả của nó được yêu cầu bởi [await][Deferred.await], hoặc nếu hàm [start][Job.start] của Job của nó được gọi. Chạy ví dụ sau:

val time = measureTimeMillis {
    val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
    val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
    // some computation
    one.start() // start the first one
    two.start() // start the second one
    println("The answer is ${one.await() + two.await()}")
}
println("Completed in $time ms")

Bạn có thể lấy mã đầy đủ tại đây.


Nó tạo ra một kết quả giống như sau:

The answer is 42
Completed in 1017 ms

Vì vậy, ở đây, hai coroutine được định nghĩa nhưng không được thực thi như trong ví dụ trước, nhưng quyền kiểm soát được chuyển đến người lập trình về khi nào chính xác bắt đầu thực hiện bằng cách gọi start. Đầu tiên, chúng ta bắt đầu one, sau đó bắt đầu two, và sau đó chờ đợi từng coroutine cá nhân hoàn thành.


Lưu ý rằng nếu chúng ta chỉ gọi await trong println mà không gọi trước đó start trên từng coroutine riêng lẻ, điều này sẽ dẫn đến hành vi tuần tự, vì await bắt đầu thực hiện coroutine và đợi nó hoàn thành, điều này không phải là trường hợp sử dụng lười biếng mong muốn. Trường hợp sử dụng của async(start = CoroutineStart.LAZY) là một thay thế cho hàm tiêu chuẩn lazy trong các trường hợp khi tính toán giá trị liên quan đến các hàm treo.

4. Hàm theo kiểu Async

Kiểu lập trình này với các hàm async chỉ mang tính minh họa, vì nó là một kiểu phổ biến trong các ngôn ngữ lập trình khác. Sử dụng kiểu lập trình này với Kotlin coroutines được không khuyến khích mạnh mẽ vì các lý do được giải thích dưới đây.


Chúng ta có thể định nghĩa các hàm theo kiểu async mà gọi doSomethingUsefulOnedoSomethingUsefulTwo bất đồng bộ bằng cách sử dụng coroutine builder async và sử dụng một tham chiếu [GlobalScope] để thoát khỏi quy trình cấu trúc. Chúng ta đặt tên cho các hàm như vậy với hậu tố “…Async” để làm nổi bật việc chúng chỉ bắt đầu tính toán bất đồng bộ và người dùng cần sử dụng giá trị deferred kết quả.

GlobalScope là một API tinh tế có thể gây ra vấn đề phức tạp, trong số đó một trong số đó sẽ được giải thích dưới đây, vì vậy bạn phải rõ ràng chấp nhận sử dụng GlobalScope với @OptIn(DelicateCoroutinesApi::class).

// The result type of somethingUsefulOneAsync is Deferred<Int>
@OptIn(DelicateCoroutinesApi::class)
fun somethingUsefulOneAsync() = GlobalScope.async {
    doSomethingUsefulOne()
}

// The result type of somethingUsefulTwoAsync is Deferred<Int>
@OptIn(DelicateCoroutinesApi::class)
fun somethingUsefulTwoAsync() = GlobalScope.async {
    doSomethingUsefulTwo()
}


Lưu ý rằng những hàm xxxAsync này không phải là các hàm tạm dừng. Chúng có thể được sử dụng từ bất cứ đâu. Tuy nhiên, việc sử dụng của chúng luôn áp đặt thực thi bất đồng bộ (ở đây có nghĩa là song song) với mã gọi.
Ví dụ sau đây thể hiện cách chúng ta sử dụng chúng bên ngoài coroutine:

// note that we don't have `runBlocking` to the right of `main` in this example
fun main() {
    val time = measureTimeMillis {
        // we can initiate async actions outside of a coroutine
        val one = somethingUsefulOneAsync()
        val two = somethingUsefulTwoAsync()
        // but waiting for a result must involve either suspending or blocking.
        // here we use `runBlocking { ... }` to block the main thread while waiting for the result
        runBlocking {
            println("The answer is ${one.await() + two.await()}")
        }
    }
    println("Completed in $time ms")
}

Bạn có thể lấy mã đầy đủ tại đây.

Xem xét điều gì xảy ra nếu giữa dòng val one = somethingUsefulOneAsync() và biểu thức one.await(), có một lỗi logic trong mã nguồn, và chương trình ném ra một ngoại lệ, và thao tác đang được thực hiện bởi chương trình bị hủy bỏ. Thông thường, một trình xử lý lỗi toàn cầu có thể bắt lỗi này, đăng nhập và báo cáo lỗi cho nhà phát triển, nhưng chương trình vẫn có thể tiếp tục thực hiện các thao tác khác. Tuy nhiên, ở đây chúng ta có somethingUsefulOneAsync vẫn đang chạy ở nền, mặc dù thao tác khởi tạo nó đã bị hủy bỏ. Vấn đề này không xảy ra với concurrency có cấu trúc, như được thể hiện trong phần dưới đây.

5. Cấu trúc Concurrenct với async

Hãy xem xét ví dụ Concurrent theo kiểu async ở trên và trích xuất một hàm thực hiện đồng thời doSomethingUsefulOnedoSomethingUsefulTwo và trả về tổng kết quả của chúng. Bởi vì hàm xây dựng [async] coroutine được định nghĩa như một phần mở rộng trên CoroutineScope, chúng ta cần có nó trong phạm vi và đó là điều mà hàm coroutineScope cung cấp:

suspend fun concurrentSum(): Int = coroutineScope {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    one.await() + two.await()
}


Như vậy, nếu có điều gì đó sai trong mã của hàm concurrentSum, và nó ném ra một ngoại lệ, tất cả các coroutine được khởi chạy trong phạm vi của nó sẽ bị hủy bỏ.

val time = measureTimeMillis {
    println("The answer is ${concurrentSum()}")
}
println("Completed in $time ms")

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


Chúng ta vẫn có thực hiện đồng thời cả hai thao tác, như có thể thấy từ kết quả của hàm main ở trên:

The answer is 42
Completed in 1017 ms

Hủy bỏ luôn được truyền qua cấu trúc coroutines:

import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
    try {
        failedConcurrentSum()
    } catch(e: ArithmeticException) {
        println("Computation failed with ArithmeticException")
    }
}

suspend fun failedConcurrentSum(): Int = coroutineScope {
    val one = async<Int> { 
        try {
            delay(Long.MAX_VALUE) // Emulates very long computation
            42
        } finally {
            println("First child was cancelled")
        }
    }
    val two = async<Int> { 
        println("Second child throws an exception")
        throw ArithmeticException()
    }
    one.await() + two.await()
}

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


Lưu ý cách cả async đầu tiên và cha đang chờ đều bị hủy bỏ khi một trong số các con (cụ thể là two) thất bại:

Second child throws an exception
First child was cancelled
Computation failed with ArithmeticException

Chúng tôi hy vọng rằng bài viết về “Kotlin với việc Kết hợp các hàm treo” đã mang đến cho bạn những kiến thức mới và động lực trong việc xây dựng ứng dụng hiện đại. Cafedev luôn cam kết chia sẻ kiến thức và thông tin chất lượng nhất để hỗ trợ sự phát triển của cộng đồng lập trình viên. Hãy tiếp tục đồng hành cùng chúng tôi, đọc thêm về các chủ đề thú vị và đặt câu hỏi tại Cafedev để cùng nhau khám phá sâu hơn về vũ trụ của lập trình Kotlin.”

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!