Cracking Kotlin Coroutines (5) - Coroutine Cancellation
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 stop
method 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 stop
it is deprecated. What if we start a thread and let it do some work, but soon we regret it and stop
don'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 sleep
this , 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 runBlocking
start 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. runBlocking
will 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 sleep
The 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 delay
not yet is canceled, because delay
can respond to cancellation, so delay
the code behind will not be scheduled again, and the reason for not scheduling is also very simple. The at ② delay
will 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 getUser
callback version to support cancellation. Let's see getUser
how 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 bennyhuo
called . We know that OkHttp supportsCall
this. After canceling, if the canceled status is read during the network request, the request will be sent to cancel
stop falling. In this case, we simply modify getUser
it , which can save our own Callback
callback 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 suspendCancellableCoroutine
instead of the previous one suspendCoroutine
, this is to make our suspend function support the cancellation of the coroutine. This method Continuation
wraps the into one CancellableContinuation
, and by calling its invokeOnCancellation
method you can set a callback for canceling the event. Once this callback is called, it means that getUserCoroutine
the 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 getUserCoroutine
that 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.resume
or 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.resumeWith
continuation.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 invokeOnCompletion
triggered 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 Call
to
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, getUserCoroutine
the returned Deferred
can 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 invokeOnCompletion
from the callback results of subsequent , getUserCoroutine
the Deferred
returned 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 CoroutineCallAdapterFactory
of , 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 CompletableDeferred
itself is an Job
implementation of , and its construction can accept an Job
instance 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.launch
started a coroutine with . If you use it on Android MainScope
, then because of the reason mentioned above, there CompletableDeferred
is 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 supervisorScope
for one-way cancellation, that is, all child coroutines are canceled after the parent coroutine is canceled. In Android, MainScope
it is dispatched to the UI thread supervisorScope
; coroutineScope
the The logic is the logic of mutual cancellation of parent and child; and GlobalScope
will 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 CompletableDeferred
the 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 adapt
the method is not a suspend method, should we create coroutines elsewhere?
In fact, when we talked about getUserCoroutine
it kept showing you how to convert a callback into a coroutine call:
suspend fun getUserCoroutine() = suspendCancellableCoroutine<User> { continuation ->
...
}
suspendCancellableCoroutine
suspendCoroutine
As we mentioned at the beginning , it is necessary to obtain Continuation
the 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 getUserCoroutine
ours , 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
runCatching
can be encapsulated into aResult
type. Kotlin 1.3 has addedContinuation.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
Continuation
by , there is no need to callcontinuation.resumeWithException(t)
to throw the exception back at this time. Although we actually mentioned earlier thatcontinuation.resumeWithException(t)
there , there will still be some overhead in terms of performance. - At ③, although
Call.cancel
the call of is relatively safe, the network environment and state are inevitably complicated, so capturing exceptions will make this code more robust. If an exceptioncancel
is 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!
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
Chapter 2 Preliminary Explanation of Key Knowledge Points of Kotlin Coroutine
● Coroutine Scheduler
● Coroutine context
● Coroutine startup mode
● Coroutine scope
● suspend function
Chapter 3 Exception Handling of Kotlin Coroutines
● Generation process of coroutine exception
● Exception handling for coroutines
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
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