Ali P7 boss teaches you to crack the Kotlin coroutine (5) - coroutine cancellation

Cracking Kotlin Coroutines (5) - Coroutine Cancellation

insert image description here

Keywords: Kotlin coroutine coroutine cancel task stop

The cancellation of the task of the coroutine needs to rely on the cooperation support of the internal call of the coroutine, which is similar to our thread interruption and the response to the interrupt status.

1. Thread interruption

Let's start with a topic that everyone is familiar with. The thread has a deprecated stopmethod that causes the thread to die immediately and releases the lock it holds, which leaves the storage it is reading and writing in an unsafe state, so stopit is deprecated. What if we start a thread and let it do some work, but soon we regret it and stopdon't let it go?

val thread = thread {
    ...
}
thread.stop() // !!! Deprecated!!!

We should find a way to let the running tasks inside the thread cooperate with us to stop the tasks, so that there is still a chance to clean up some resources before the tasks inside the thread stop, such as closing the stream and so on.

val thread = thread {
    try {
        Thread.sleep(10000)
    } catch (e: InterruptedException) {
        log("Interrupted, do cleaning stuff.")
    }
}
thread.interrupt()

For method calls like sleepthis , the documentation clearly states that it supports InterruptedException, so when the thread is marked as interrupted, it will be thrown InterruptedException, then we can naturally catch the exception and do resource cleanup.

So please pay attention to the so-called cooperative task termination, and the cancellation of the coroutine is the same as the idea of cancel​​the mechanism .

2. Similar examples of coroutines

Let's look at an example of coroutine cancellation:

fun main() = runBlocking {
    val job1 = launch { // ①
        log(1)
        delay(1000) // ②
        log(2)
    }
    delay(100)
    log(3)
    job1.cancel() // ③
    log(4)
}

This time we used a different way of writing, we did not use suspend main, but directly used to runBlockingstart coroutine, this method also exists on Native, it is based on the current thread to start an infinite loop similar to Android's Looper, or It is called a message queue, and messages can be continuously sent to it for processing. runBlockingwill start one Job, so there is also a default scope here, but this does not have much impact on our discussion today.

This code starts a sub-coroutine at ①, which first outputs 1, and then starts delay. Unlikedelay the thread , it will not block the thread. You can think that it actually triggers a delayed task, telling the coroutine to schedule sleepThe system executes the following code (that is, log(2)) after 1000ms; during this period, we trigger the cancellation of the coroutine just started at ③, so the coroutine at ② has delaynot yet is canceled, because delaycan respond to cancellation, so delaythe code behind will not be scheduled again, and the reason for not scheduling is also very simple. The at ② delaywill throw one CancellationException:

...
log(1)
try {
    delay(1000)
} catch (e: Exception) {
    log("cancelled. $e")
}
log(2)
...

Then the output will be different:

06:54:56:361 [main] 1
06:54:56:408 [main] 3
06:54:56:411 [main] 4
06:54:56:413 [main] cancelled. kotlinx.coroutines.JobCancellationException: Job was cancelled; job=StandaloneCoroutine{Cancelling}@e73f9ac
06:54:56:413 [main] 2

You see, is this very similar to the interrupt logic of threads?

3. Completing our previous example

We had an example before. The exception handling logic has been added in the previous article, so this time we will add cancellation logic to it. Before it was like this:

suspend fun getUserCoroutine() = suspendCoroutine<User> { continuation ->
    getUser(object : Callback<User> {
        override fun onSuccess(value: User) {
            continuation.resume(value)
        }

        override fun onError(t: Throwable) {
            continuation.resumeWithException(t)
        }
    })
}

Adding cancellation logic requires our getUsercallback version to support cancellation. Let's see getUserhow our is implemented:

fun getUser(callback: Callback<User>) {
    val call = OkHttpClient().newCall(
            Request.Builder()
                    .get().url("https://api.github.com/users/bennyhuo")
                    .build())

    call.enqueue(object : okhttp3.Callback {
        override fun onFailure(call: Call, e: IOException) {
            callback.onError(e)
        }

        override fun onResponse(call: Call, response: Response) {
            response.body()?.let {
                try {
                    callback.onSuccess(User.from(it.string()))
                } catch (e: Exception) {
                    callback.onError(e) // 这里可能是解析异常
                }
            }?: callback.onError(NullPointerException("ResponseBody is null."))
        }
    })
}

We sent a network request to Github and asked it to return a user information bennyhuocalled . We know that OkHttp supportsCall this. After canceling, if the canceled status is read during the network request, the request will be sent to cancelstop falling. In this case, we simply modify getUserit , which can save our own Callbackcallback process:

suspend fun getUserCoroutine() = suspendCancellableCoroutine<User> { continuation ->
    val call = OkHttpClient().newCall(...)

    continuation.invokeOnCancellation { // ①
        log("invokeOnCancellation: cancel the request.")
        call.cancel()
    }

    call.enqueue(object : okhttp3.Callback {
        override fun onFailure(call: Call, e: IOException) {
            log("onFailure: $e")
            continuation.resumeWithException(e)
        }

        override fun onResponse(call: Call, response: Response) {
            log("onResponse: ${response.code()}")
            response.body()?.let {
                try {
                    continuation.resume(User.from(it.string()))
                } catch (e: Exception) {
                    continuation.resumeWithException(e)
                }
            } ?: continuation.resumeWithException(NullPointerException("ResponseBody is null."))
        }
    })
}

We use it here suspendCancellableCoroutineinstead of the previous one suspendCoroutine, this is to make our suspend function support the cancellation of the coroutine. This method Continuationwraps the into one CancellableContinuation, and by calling its invokeOnCancellationmethod you can set a callback for canceling the event. Once this callback is called, it means that getUserCoroutinethe coroutine where the call is located is cancelled. At this time, we have to do it accordingly Send a cancellation response, that is, cancel the request sent by OkHttp.

So what happens if we encounter cancellation when we call it?

val job1 = launch { //①
    log(1)
    val user = getUserCoroutine()
    log(user)
    log(2)
}
delay(10)
log(3)
job1.cancel()
log(4)

Note that we canceled it after only a 10ms delay after starting ①. Generally speaking, the speed of network requests is not so fast, so there is a high probability getUserCoroutinethat it will be

07:31:30:751 [main] 1
07:31:31:120 [main] 3
07:31:31:124 [main] invokeOnCancellation: cancel the request.
07:31:31:129 [main] 4
07:31:31:131 [OkHttp https://api.github.com/...] onFailure: java.io.IOException: Canceled

We found that the cancellation callback was called. After OkHttp received our cancellation instruction, it did stop the network request and called back an IO exception. At this time, our coroutine has been cancelled. Calling Continuation.resumeor on the coroutine willContinuation.resumeWithException be ignored, so in the OkHttp callback we call after receiving an IO exception will not have any side effects.Continuation.resumeWithcontinuation.resumeWithException(e)

4. Let’s talk about Retrofit’s coroutine extension

4.1 Problems with Jake Wharton's Adapter

I mentioned the Coroutine Adapter written by Jake Wharton for Retrofit when I cracked the Kotlin Coroutine - Getting Started (https://www.bennyhuo.com/2019/04/01/basic-coroutines/).

implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2'

It can indeed complete the network request, but a careful friend discovered its problem: how to cancel it? Let's post the code that uses it:

interface GitHubServiceApi {
    @GET("users/{login}")
    fun getUserCoroutine(@Path("login") login: String): Deferred<User>
}

Define the interface and pass in the corresponding Adapter when creating a Retrofit instance:

val gitHubServiceApi by lazy {
    val retrofit = retrofit2.Retrofit.Builder()
            .baseUrl("https://api.github.com")
            .addConverterFactory(GsonConverterFactory.create())
            .addCallAdapterFactory(CoroutineCallAdapterFactory()) // 这里添加 Adapter
            .build()

    retrofit.create(GitHubServiceApi::class.java)
}

When used like this:

val deferred = gitHubServiceApi.getUserCoroutine("bennyhuo")
try {
    showUser(deferred.await())
} catch (e: Exception) {
    showError(e)
}

If we want to cancel, we can call directly deferred.cancel(), for example:

log("1")
val deferred = gitHubServiceApi.getUserCoroutine("bennyhuo")
log("2")
withContext(Dispatchers.IO){
    deferred.cancel()
}
try {
    showUser(deferred.await())
} catch (e: Exception) {
    showError(e)
}

The result of the operation is as follows:

12:59:54:185 [DefaultDispatcher-worker-1] 1
12:59:54:587 [DefaultDispatcher-worker-1] 2
kotlinx.coroutines.JobCancellationException: Job was cancelled; job=CompletableDeferredImpl{Cancelled}@36699211

In this case, the network request is actually canceled. We can look at the processing of the source code:

...
override fun adapt(call: Call<T>): Deferred<T> {
      val deferred = CompletableDeferred<T>()

      deferred.invokeOnCompletion { // ①
        if (deferred.isCancelled) {
          call.cancel()
        }
      }

      call.enqueue(object : Callback<T> {
        ...
      }     
}
...

Note that ① is invokeOnCompletiontriggered when the coroutine enters the completion state, including exception and normal completion, then if it is found that its state has been canceled at this time, then the result can be directly called Callto

This looks really normal~ But @阿永 mentioned a case in the comments of the official account. If you look closely, there is really a problem. We give an example to reproduce this Case:

val job = GlobalScope.launch {
    log("1")
    val deferred = gitHubServiceApi.getUserCoroutine("bennyhuo")
    log("2")
    deferred.invokeOnCompletion {
        log("invokeOnCompletion, $it, ${deferred.isCancelled}")
    }
    try {
        showUser(deferred.await())
    } catch (e: Exception) {
        showError(e)
    }
    log(3)
}
delay(10)
job.cancelAndJoin()

We start a coroutine and execute the network request in it. Normally, getUserCoroutinethe returned Deferredcan be regarded as a child coroutine. It should follow the default scope rules and be canceled when the parent scope is cancelled, but the reality is Not so:

13:06:54:332 [DefaultDispatcher-worker-1] 1
13:06:54:829 [DefaultDispatcher-worker-1] 2
kotlinx.coroutines.JobCancellationException: Job was cancelled; job=StandaloneCoroutine{Cancelling}@19aea38c
13:06:54:846 [DefaultDispatcher-worker-1] 3
13:06:56:937 [OkHttp https://api.github.com/...] invokeOnCompletion, null, false

We see that a cancellation exception is thrown when deferred.await()calling , which is mainly because await()the coroutine in which has been cancelAndJoin()canceled , but invokeOnCompletionfrom the callback results of subsequent , getUserCoroutinethe Deferredreturned has not been cancelled, and a closer look shows that in terms of time This callback is 2s later than the previous operation, so it must be called after the network request returns.

So what exactly is the problem? In the implementation CoroutineCallAdapterFactoryof , in order to implement an asynchronous conversion, one is manually created CompletableDeferred:

override fun adapt(call: Call<T>): Deferred<T> {
  val deferred = CompletableDeferred<T>() // ①
  ...
}

This CompletableDeferreditself is an Jobimplementation of , and its construction can accept an Jobinstance as its parent coroutine, then the problem comes, it does not tell who the parent coroutine is, so there is no scope thing , as if we GlobalScope.launchstarted a coroutine with . If you use it on Android MainScope, then because of the reason mentioned above, there CompletableDeferredis no way to cancel it.

@阿永 mentioned this problem in the comments of the official account, and mentioned a better solution. Let's introduce it in detail below. Thanks @Ayong.

Having said that, let’s briefly review that the main scopes are GlobalScope, coroutineScope, supervisorScope, and for cancellation, except supervisorScopefor one-way cancellation, that is, all child coroutines are canceled after the parent coroutine is canceled. In Android, MainScopeit is dispatched to the UI thread supervisorScope; coroutineScopethe The logic is the logic of mutual cancellation of parent and child; and GlobalScopewill start a brand new scope, which is isolated from its outside and follows the default coroutine scope rules internally.

So is there any way to solve this problem?

It is difficult to solve it directly, because the calling environment where CompletableDeferredthe constructor is located is not the suspend function, so there is no way to get (probably not at all!) the parent coroutine.

4.2 How to correctly convert callbacks into coroutines

We mentioned earlier that since adaptthe method is not a suspend method, should we create coroutines elsewhere?

In fact, when we talked about getUserCoroutineit kept showing you how to convert a callback into a coroutine call:

suspend fun getUserCoroutine() = suspendCancellableCoroutine<User> { continuation ->
    ...
}

suspendCancellableCoroutinesuspendCoroutineAs we mentioned at the beginning , it is necessary to obtain Continuationthe instance of the current coroutine, which is actually equivalent to inheriting the context of the current coroutine, so we only need to do this conversion when we really need to switch the coroutine. Can:

public suspend fun <T : Any> Call<T>.await(): T {
    return suspendCancellableCoroutine { continuation ->
        enqueue(object : Callback<T> {
            override fun onResponse(call: Call<T>?, response: Response<T?>) {
                continuation.resumeWith(runCatching { // ①
                    if (response.isSuccessful) {
                        response.body()
                            ?: throw NullPointerException("Response body is null: $response")
                    } else {
                        throw HttpException(response)
                    }
                })
            }

            override fun onFailure(call: Call<T>, t: Throwable) {
                if (continuation.isCancelled) return // ②
                continuation.resumeWithException(t)
            }
        })

        continuation.invokeOnCancellation {
            try {
                cancel()
            } catch (ex: Throwable) {  // ③
                //Ignore cancel exception 
            }
        }
    }
}

Does this code look familiar to everyone? This is almost exactly the same as getUserCoroutineours , but there are a few details worth noting, and I have marked their positions with numbers:

  • ① The running result of a piece of code or the thrown exception runCatchingcan be encapsulated into a Resulttype. Kotlin 1.3 has added Continuation.resumeWith(Result)this method, which is more Kotlin-style than our previous writing method.
  • ② When an exception is thrown, it is judged whether it has been cancelled. In fact, if the network request is canceled, this callback will indeed be called. Since the cancellation operation is initiated Continuationby , there is no need to call continuation.resumeWithException(t)to throw the exception back at this time. Although we actually mentioned earlier that continuation.resumeWithException(t)there , there will still be some overhead in terms of performance.
  • At ③, although Call.cancelthe call of is relatively safe, the network environment and state are inevitably complicated, so capturing exceptions will make this code more robust. If an exception cancelis thrown but not caught, it is equivalent to throwing an exception inside the coroutine body. How to propagate depends on the relevant definition of the scope.

It should be pointed out that this code snippet comes from gildor/kotlin-coroutines-retrofit (https://github.com/gildor/kotlin-coroutines-retrofit), and you can also directly add dependencies for use:

compile 'ru.gildor.coroutines:kotlin-coroutines-retrofit:1.1.0'

The code volume of this framework is very small, but after being tempered by various Kotlin coroutine experts, the logical techniques are very delicate, and it is worth learning.

5. Summary
Kotlin coroutine learning materials can be obtained for free by scanning the QR code below!

# "**The most detailed introduction to Android version kotlin coroutine advanced actual combat in history** **"**

Chapter 1 Introduction to the Basics of Kotlin Coroutines

​ ● What is a coroutine

​ ● What is Job, Deferred, and coroutine scope

​ ● Basic usage of Kotlin coroutines

img

Chapter 2 Preliminary Explanation of Key Knowledge Points of Kotlin Coroutine

​ ● Coroutine Scheduler

​ ● Coroutine context

​ ● Coroutine startup mode

​ ● Coroutine scope

​ ● suspend function

img

Chapter 3 Exception Handling of Kotlin Coroutines

​ ● Generation process of coroutine exception

​ ● Exception handling for coroutines

img

Chapter 4 Basic application of kotlin coroutines in Android

​ ● Android uses kotlin coroutines

​ ● Use coroutines in Activity and Framgent

​ ● Use coroutines in ViewModel

​ ● Use coroutines in other environments

img

Chapter 5 Network request encapsulation of kotlin coroutine

​ ● Common environments for coroutines

​ ● Encapsulation and use of coroutines under network requests

​ ● Higher-order function method

​ ● Multi-state function return value method

Guess you like

Origin blog.csdn.net/Android_XG/article/details/130170884