Kotlin: withContext () so với Async-await


91

Tôi đã đọc tài liệu về kotlin và nếu tôi hiểu đúng thì hai hàm Kotlin hoạt động như sau:

  1. withContext(context): chuyển ngữ cảnh của chương trình đăng quang hiện tại, khi khối đã cho thực thi, chương trình đăng quang sẽ chuyển về ngữ cảnh trước đó.
  2. async(context): Bắt đầu một chương trình đăng quang mới trong ngữ cảnh đã cho và nếu chúng ta gọi tác vụ được .await()trả về Deferred, nó sẽ tạm dừng chương trình đăng quang đang gọi và tiếp tục khi khối thực thi bên trong chương trình đăng quang được tạo ra trở lại.

Bây giờ cho hai phiên bản sau của code:

Phiên bản 1:

  launch(){
    block1()
    val returned = async(context){
      block2()
    }.await()
    block3()
  }

Phiên bản 2:

  launch(){
    block1()
     val returned = withContext(context){
      block2()
    }
    block3()
  }
  1. Trong cả hai phiên bản block1 (), block3 () thực thi trong ngữ cảnh mặc định (commonpool?) Trong khi block2 () thực thi trong ngữ cảnh nhất định.
  2. Việc thực thi tổng thể là đồng bộ với thứ tự block1 () -> block2 () -> block3 ().
  3. Điểm khác biệt duy nhất mà tôi thấy là version1 tạo ra một quy trình đăng ký khác, trong đó phiên bản 2 chỉ thực thi một quy trình đăng ký trong khi chuyển đổi ngữ cảnh.

Câu hỏi của tôi là:

  1. Không phải lúc nào sử dụng cũng tốt hơn withContextthay async-awaitvì nó giống về mặt chức năng, nhưng không tạo ra một quy trình khác. Số lượng lớn các coroutines, mặc dù nhẹ, vẫn có thể là một vấn đề trong các ứng dụng đòi hỏi khắt khe.

  2. Có trường hợp nào async-awaitthích hợp hơn withContextkhông?

Cập nhật: Kotlin 1.2.50 hiện có kiểm tra mã nơi nó có thể chuyển đổi async(ctx) { }.await() to withContext(ctx) { }.


Tôi nghĩ rằng khi bạn sử dụng withContext, một quy trình đăng ký mới luôn được tạo bất kể. Đây là những gì tôi có thể thấy từ mã nguồn.
stdout

@stdout Cũng không async/awaittạo quy trình đăng quang mới, theo OP?
IgorGanapolsky

Câu trả lời:


126

Một số lượng lớn các coroutines, mặc dù nhẹ, vẫn có thể là một vấn đề trong các ứng dụng đòi hỏi

Tôi muốn xóa tan lầm tưởng về "quá nhiều điều tra" là một vấn đề bằng cách định lượng chi phí thực tế của chúng.

Đầu tiên, chúng ta nên gỡ các coroutine bản thân từ bối cảnh coroutine mà nó được đính kèm. Đây là cách bạn chỉ tạo một quy trình đăng ký với chi phí tối thiểu:

GlobalScope.launch(Dispatchers.Unconfined) {
    suspendCoroutine<Unit> {
        continuations.add(it)
    }
}

Giá trị của biểu thức này là Jobgiữ một quy trình đăng quang bị đình chỉ. Để giữ lại phần tiếp theo, chúng tôi đã thêm nó vào danh sách trong phạm vi rộng hơn.

Tôi đã đánh giá tiêu chuẩn cho mã này và kết luận rằng nó phân bổ 140 byte và mất 100 nano giây để hoàn thành. Vì vậy, đó là mức độ nhẹ của một quy trình đăng ký.

Để có thể tái tạo, đây là mã tôi đã sử dụng:

fun measureMemoryOfLaunch() {
    val continuations = ContinuationList()
    val jobs = (1..10_000).mapTo(JobList()) {
        GlobalScope.launch(Dispatchers.Unconfined) {
            suspendCoroutine<Unit> {
                continuations.add(it)
            }
        }
    }
    (1..500).forEach {
        Thread.sleep(1000)
        println(it)
    }
    println(jobs.onEach { it.cancel() }.filter { it.isActive})
}

class JobList : ArrayList<Job>()

class ContinuationList : ArrayList<Continuation<Unit>>()

Mã này bắt đầu một loạt các coroutines và sau đó ngủ để bạn có thời gian phân tích đống bằng một công cụ giám sát như VisualVM. Tôi đã tạo các lớp chuyên biệt JobListContinuationListvì điều này giúp phân tích kết xuất đống dễ dàng hơn.


Để có được một câu chuyện hoàn chỉnh hơn, tôi đã sử dụng đoạn mã dưới đây để đo lường chi phí withContext()async-await:

import kotlinx.coroutines.*
import java.util.concurrent.Executors
import kotlin.coroutines.suspendCoroutine
import kotlin.system.measureTimeMillis

const val JOBS_PER_BATCH = 100_000

var blackHoleCount = 0
val threadPool = Executors.newSingleThreadExecutor()!!
val ThreadPool = threadPool.asCoroutineDispatcher()

fun main(args: Array<String>) {
    try {
        measure("just launch", justLaunch)
        measure("launch and withContext", launchAndWithContext)
        measure("launch and async", launchAndAsync)
        println("Black hole value: $blackHoleCount")
    } finally {
        threadPool.shutdown()
    }
}

fun measure(name: String, block: (Int) -> Job) {
    print("Measuring $name, warmup ")
    (1..1_000_000).forEach { block(it).cancel() }
    println("done.")
    System.gc()
    System.gc()
    val tookOnAverage = (1..20).map { _ ->
        System.gc()
        System.gc()
        var jobs: List<Job> = emptyList()
        measureTimeMillis {
            jobs = (1..JOBS_PER_BATCH).map(block)
        }.also { _ ->
            blackHoleCount += jobs.onEach { it.cancel() }.count()
        }
    }.average()
    println("$name took ${tookOnAverage * 1_000_000 / JOBS_PER_BATCH} nanoseconds")
}

fun measureMemory(name:String, block: (Int) -> Job) {
    println(name)
    val jobs = (1..JOBS_PER_BATCH).map(block)
    (1..500).forEach {
        Thread.sleep(1000)
        println(it)
    }
    println(jobs.onEach { it.cancel() }.filter { it.isActive})
}

val justLaunch: (i: Int) -> Job = {
    GlobalScope.launch(Dispatchers.Unconfined) {
        suspendCoroutine<Unit> {}
    }
}

val launchAndWithContext: (i: Int) -> Job = {
    GlobalScope.launch(Dispatchers.Unconfined) {
        withContext(ThreadPool) {
            suspendCoroutine<Unit> {}
        }
    }
}

val launchAndAsync: (i: Int) -> Job = {
    GlobalScope.launch(Dispatchers.Unconfined) {
        async(ThreadPool) {
            suspendCoroutine<Unit> {}
        }.await()
    }
}

Đây là đầu ra điển hình mà tôi nhận được từ đoạn mã trên:

Just launch: 140 nanoseconds
launch and withContext : 520 nanoseconds
launch and async-await: 1100 nanoseconds

Có, async-awaitmất khoảng thời gian dài gấp đôi withContext, nhưng nó vẫn chỉ là một micro giây. Bạn sẽ phải khởi chạy chúng trong một vòng lặp chặt chẽ, gần như không làm gì ngoài việc đó trở thành "vấn đề" trong ứng dụng của bạn.

Sử dụng, measureMemory()tôi thấy chi phí bộ nhớ sau cho mỗi cuộc gọi:

Just launch: 88 bytes
withContext(): 512 bytes
async-await: 652 bytes

Chi phí của async-awaitchính xác cao hơn 140 byte so với withContext, con số chúng tôi nhận được là trọng lượng bộ nhớ của một chương trình đăng quang. Đây chỉ là một phần nhỏ của chi phí hoàn chỉnh cho việc thiết lập CommonPoolbối cảnh.

Nếu hiệu suất / tác động bộ nhớ là tiêu chí duy nhất để quyết định giữa withContextasync-await, thì kết luận sẽ là không có sự khác biệt liên quan giữa chúng trong 99% trường hợp sử dụng thực tế.

Lý do thực sự là withContext()một API đơn giản hơn và trực tiếp hơn, đặc biệt là về mặt xử lý ngoại lệ:

  • Một ngoại lệ không được xử lý bên trong async { ... }sẽ khiến công việc chính của nó bị hủy. Điều này xảy ra bất kể cách bạn xử lý các ngoại lệ từ kết hợp await(). Nếu bạn chưa chuẩn bị coroutineScopecho nó, nó có thể làm hỏng toàn bộ ứng dụng của bạn.
  • Một ngoại lệ không được xử lý bên trong withContext { ... }chỉ đơn giản là bị ném bởi withContextcuộc gọi, bạn xử lý nó giống như bất kỳ ngoại lệ nào khác.

withContext cũng sẽ được tối ưu hóa, tận dụng thực tế là bạn đang tạm dừng quy trình đăng ký của cha mẹ và chờ đợi đứa trẻ, nhưng đó chỉ là một phần thưởng thêm.

async-awaitnên dành riêng cho những trường hợp bạn thực sự muốn đồng thời, để bạn khởi chạy một số quy trình trong nền và chỉ sau đó chờ đợi chúng. Nói ngắn gọn:

  • async-await-async-await - đừng làm vậy, sử dụng withContext-withContext
  • async-async-await-await - đó là cách sử dụng nó.

Về chi phí bộ nhớ bổ sung async-await: Khi chúng tôi sử dụng withContext, một quy trình đăng ký mới cũng được tạo (theo như tôi có thể thấy từ mã nguồn), vậy bạn có nghĩ sự khác biệt có thể đến từ một nơi khác không?
stdout

1
@stdout Thư viện đã phát triển kể từ khi tôi chạy các thử nghiệm này. Mã trong câu trả lời được cho là hoàn toàn độc lập, hãy thử chạy lại để xác thực. asynctạo một Deferredđối tượng, điều đó cũng có thể giải thích một số khác biệt.
Marko Topolnik

~ " Để giữ lại sự tiếp nối ". Khi nào chúng ta cần giữ lại điều này?
IgorGanapolsky

1
@IgorGanapolsky Nó luôn được giữ lại nhưng thường không được người dùng nhìn thấy. Mất sự tiếp diễn tương đương với Thread.destroy()- sự thực hiện biến mất vào không khí loãng.
Marko Topolnik

23

Không phải lúc nào sử dụng withContext cũng tốt hơn là asynch-await vì nó tương tự về mặt vật lý, nhưng không tạo ra một quy trình đăng quang khác. Các coroutines số lớn, mặc dù nhẹ vẫn có thể là một vấn đề trong các ứng dụng đòi hỏi

Có trường hợp asynch-await thích hợp hơn withContext không

Bạn nên sử dụng async / await khi bạn muốn thực thi nhiều tác vụ đồng thời, ví dụ:

runBlocking {
    val deferredResults = arrayListOf<Deferred<String>>()

    deferredResults += async {
        delay(1, TimeUnit.SECONDS)
        "1"
    }

    deferredResults += async {
        delay(1, TimeUnit.SECONDS)
        "2"
    }

    deferredResults += async {
        delay(1, TimeUnit.SECONDS)
        "3"
    }

    //wait for all results (at this point tasks are running)
    val results = deferredResults.map { it.await() }
    println(results)
}

Nếu bạn không cần chạy nhiều tác vụ đồng thời, bạn có thể sử dụng withContext.


14

Khi nghi ngờ, hãy nhớ điều này như một quy tắc ngón tay cái:

  1. Nếu nhiều nhiệm vụ phải diễn ra song song và kết quả cuối cùng phụ thuộc vào việc hoàn thành tất cả chúng, thì hãy sử dụng async.

  2. Để trả về kết quả của một tác vụ duy nhất, hãy sử dụng withContext.


1
Cả hai asyncwithContextchặn có nằm trong phạm vi tạm ngưng không?
IgorGanapolsky

3
@IgorGanapolsky Nếu bạn đang nói về việc chặn luồng chính asyncwithContextsẽ không chặn luồng chính, họ sẽ chỉ tạm ngưng nội dung của quy trình đăng quang trong khi một số tác vụ dài đang chạy và chờ kết quả. Để biết thêm thông tin và ví dụ, hãy xem bài viết này trên Phương tiện: Hoạt động không đồng bộ với Kotlin Coroutines .
Yogesh Umesh Vaity
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.