preface
Translated articles are also a way of learning
Original title:Coroutines in Kotlin 1.3 explained: Suspending functions, contexts, builders and scopes
Original author:Antonio Leiva
Coroutines profile
Coroutines are a feature of Kotlin. With coroutines, you can simplify asynchronous programming and make your code more readable and easier to understand.
With coroutines, you can write asynchronous code synchronously, rather than with traditional callbacks. The result returned by the synchronous method is the result of the asynchronous request.
What’s the magic of coroutines? We’ll find out soon. Before we do that, we need to know why coroutines are so important.
Since Kotlin 1.1 as an experimental feature, coroutines have been available in production environments since Kotlin 1.3 released the final API.
Goal of coroutines: Let’s look at some of the existing problems
To get a full example of the article click here
Suppose you want to create a login interface: the user enters a username and password, and then clicks login.
Assume the following flow: The App first requests the server to verify the user name and password, and after successful verification, requests the user’s friends list.
The pseudocode is as follows:
progress.visibility = View.VISIBLE
userService.doLoginAsync(username, password) { user ->
userService.requestCurrentFriendsAsync(user) { friends ->
val finalUser = user.copy(friends = friends)
toast("User ${finalUser.name} has ${finalUser.friends.size} friends")
progress.visibility = View.GONE
}
}
Copy the code
The steps are as follows:
- Displays a progress bar.
- Request server to verify user name and password;
- After the verification is successful, request the server to obtain the friend list.
- Finally, hide the progress bar;
The situation can be even more complicated. Imagine asking not only for a list of friends, but also for a list of recommended friends, and combining the two results into one list.
There are two options:
- The easiest way to do this is to request a list of recommended friends after requesting the list of friends, but this is not efficient because the latter does not depend on the results of the former request.
- This is a bit more complicated, asking for and recommending friends at the same time and synchronizing the results of both requests.
More often than not, slackers might opt for the first option:
progress.visibility = View.VISIBLE
userService.doLoginAsync(username, password) { user ->
userService.requestCurrentFriendsAsync(user) { currentFriends ->
userService.requestSuggestedFriendsAsync(user) { suggestedFriends ->
val finalUser = user.copy(friends = currentFriends + suggestedFriends)
toast("User ${finalUser.name} has ${finalUser.friends.size} friends")
progress.visibility = View.GONE
}
}
}
Copy the code
At this point, the code starts to get complicated, with the dreaded callback hell: the latter request is always nested inside the result callback of the previous request, and the indentation gets bigger and bigger.
With Kotlin’s Lambdas, it probably doesn’t look so bad. But as the number of requests increases, the code becomes harder to manage.
Remember, we still use a relatively simple but inefficient method.
What is a coroutine (Coroutine
)
In short, coroutines are like lightweight threads, but not exactly threads.
First, coroutines allow you to write asynchronous code sequentially, greatly reducing the burden of asynchronous programming.
Second, coroutines are more efficient. Multiple coroutines can share a thread. The number of threads an App can run is limited, but the number of coroutines it can run is almost unlimited;
And that suspending functions is the foundation of coroutine realization. A interruptible method can interrupt execution of a coroutine anywhere until the interruptible method returns a result or execution is complete.
Interruptible methods running in coroutines (in general) do not block the current thread, in general because it depends on how we use them. We’ll talk about that later.
coroutine {
progress.visibility = View.VISIBLE
val user = suspended { userService.doLogin(username, password) }
val currentFriends = suspended { userService.requestCurrentFriends(user) }
val finalUser = user.copy(friends = currentFriends)
toast("User ${finalUser.name} has ${finalUser.friends.size} friends")
progress.visibility = View.GONE
}
Copy the code
The example above is a common usage paradigm for coroutines. First, a coroutine is created using a Coroutine Builder. Then, one or more interruptible methods run in the coroutine, which will interrupt the execution of the coroutine until they return a result.
After the interruptible method returns the results, we can use them on the next line of code, much like sequential programming. Note that the keywords coroutine and SUSPENDED do not actually exist in Kotlin; the examples above are intended to demonstrate the usage paradigm of coroutines.
Interruptible methods (suspending functions
)
Interruptible methods have the ability to interrupt the execution of coroutines, and when interruptible methods have completed their execution, the results they return can then be used.
val user = suspended { userService.doLogin(username, password) }
val currentFriends = suspended { userService.requestCurrentFriends(user) }
Copy the code
Interruptible methods can run on the same or different threads, depending on how you use them. Interruptible methods can only run in coroutines or other interruptible methods.
To declare a breakable method, just use the suspend reserve word:
suspend fun suspendingFunction(a) : Int {
// Long running task
return 0
}
Copy the code
Going back to the original example, you might ask which thread the above code runs on, but let’s start with this line:
coroutine {
progress.visibility = View.VISIBLE
...
}
Copy the code
Which thread do you think this line of code is running on? Are you sure it’s running on the UI thread? If not, the App will crash, so it’s important to figure out which thread it’s running on.
The answer is that it depends on the setting of the coroutine context.
Coroutine context (Coroutine Context
)
A coroutine context is a set of rules and configurations that determine how a coroutine should operate. It can also be understood as containing a series of key-value pairs.
For now, all you need to know is that Dispatcher is one of the configurations that specifies which thread the coroutine runs on.
Dispatcher can be configured in two ways:
- Explicitly specify what needs to be used
dispatcher
; - Scoped by coroutines (
coroutine scope
) decision. I’m not going to do that, I’ll do that later;
Specifically, the Coroutine Builder takes a Coroutine context as its first argument, and we can pass in the dispatcher to use. Because the Dispatcher implements the coroutine context, it can be passed in as a parameter:
coroutine(Dispatchers.Main) {
progress.visibility = View.VISIBLE
...
}
Copy the code
The code that changes the visibility of the progress bar now runs in the UI thread. Not only that, but all the code inside the coroutine runs in the UI thread. So the question is, how do interruptible methods work?
coroutine {
...
val user = suspended { userService.doLogin(username, password) }
val currentFriends = suspended { userService.requestCurrentFriends(user) }
...
}
Copy the code
Is the service request code also running on the main thread? If so, they block the main thread. Whether it is, or not, depends on how you use it.
Interruptible methods There are several ways to configure the Dispatcher to use, the most common of which is withContext.
withContext
Within coroutines, this method can easily change the context in which the code is running. It is an interruptible method, so calling it interrupts the execution of the coroutine until the method completes.
Thus, we can make the interruptible methods in the example run in different threads:
suspend fun suspendLogin(username: String, password: String) =
withContext(Dispatchers.Main) {
userService.doLogin(username, password)
}
Copy the code
The above code will run on the main thread, so it will still block the UI. But now we can easily specify a different dispatcher:
suspend fun suspendLogin(username: String, password: String) =
withContext(Dispatchers.IO) {
userService.doLogin(username, password)
}
Copy the code
Now that we are using IO Dispatcher, the above code will run on the child thread. Also, withContext is itself an interruptible method, so we don’t have to run it in another interruptible method. So we could also write:
val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
val currentFriends = withContext(Dispatchers.IO) { userService.requestCurrentFriends(user) }
Copy the code
So far, we have known two dispatchers. Now we will introduce the usage scenarios of all dispatchers in detail.
-
Default: dispatcher is used by Default when we do not specify it. Of course, we can also specify it explicitly. It is generally used for CPU intensive tasks, especially scenarios involving computation and algorithms. It can use as many threads as the number of CPU cores. Because the task is so intensive, it doesn’t make sense to run multiple threads at the same time because the CPU will be busy.
-
IO: This is used in input/output scenarios. Typically, it can be used for tasks that involve blocking threads and waiting for another system to respond, such as network requests, database operations, file reads and writes, and so on. Because it does not use CPU, it can run multiple threads at the same time, with a pool of 64 threads by default. There are a lot of web requests in the Android App, so you’ll probably use it a lot.
-
UnConfined: If you don’t care how many threads are started, use it. The threads it uses are uncontrollable and are not recommended unless you know exactly what you are doing.
-
Main: This is a dispatcher from the UI-related coroutine library, which in Android programming uses the UI thread.
Now, you should have a lot of flexibility with the dispatcher.
Coroutine constructor (Coroutine Builders
)
Now you can switch threads easily. Next, we’ll learn how to start a new coroutine: with the coroutine constructor, of course.
Depending on the situation, we can choose to use a different coroutine constructor, or we can create a custom coroutine constructor. But in general, the coroutine library provides enough for our use. Details are as follows:
runBlocking
The coroutine constructor blocks the current thread until all tasks in the coroutine are completed. That kind of defeats the purpose of using coroutines, so when do we use it?
RunBlocking is very useful for testing interruptible methods. When testing, run the interruptible methods inside the runBlocking built coroutine so that the current test thread will not terminate until the interruptible methods return results, so that we can verify the test results.
fun testSuspendingFunction(a) = runBlocking {
val res = suspendingTask1()
assertEquals(0, res)
}
Copy the code
However, except for this scenario, you probably won’t use runBlocking anymore.
launch
This coroutine constructor is important because it makes it easy to create a coroutine, and you’ll probably use it a lot. As opposed to runBlocking, it does not block the current thread (provided we use the appropriate dispatcher).
This coroutine constructor usually requires a scope. The concept of scope will be covered later, but we’ll use GlobalScope for the moment:
GlobalScope.launch(Dispatchers.Main) {
...
}
Copy the code
The launch method returns a Job that inherits the CoroutineContext context.
Job provides many useful methods. A Job can have a parent Job, and the parent Job can control the child Job. Here are the methods for Job:
job.join
This method breaks the coroutine associated with the current Job until all child jobs have been executed. All interruptible methods in the coroutine are associated with the current Job. The coroutine associated with the current Job cannot continue to execute until all sub-jobs are executed.
val job = GlobalScope.launch(Dispatchers.Main) {
doCoroutineTask()
val res1 = suspendingTask1()
val res2 = suspendingTask2()
process(res1, res2)
}
job.join()
Copy the code
Job.join () is a interruptible method, so it should be called inside the coroutine.
job.cancel
Res1 is not returned and suspendingTask2() is not executed if the Job calls Cancel () while suspendingTask1() is executing.
val job = GlobalScope.launch(Dispatchers.Main) {
doCoroutineTask()
val res1 = suspendingTask1()
val res2 = suspendingTask2()
process(res1, res2)
}
job.cancel()
Copy the code
Job.cancel () is a normal method, so it doesn’t have to run inside the coroutine.
async
This coroutine constructor will solve some of the problems we mentioned at the beginning of the example.
Async allows multiple subthreaded tasks to run in parallel. It is not a breakable method, so when async is called to initiate a subcoroutine, subsequent code is immediately executed. Async usually needs to run inside another coroutine, which returns a special Job called Deferred.
Deferred has a new method called await(), which is a breakable method and calls await() when we need to get async results. After calling the await() method, the current coroutine is interrupted until it returns a result.
In the following example, the second and third requests depend on the results of the first request. The request friends list and recommend friends list could have been requested in parallel, but using withContext would obviously have wasted time:
GlobalScope.launch(Dispatchers.Main) {
val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
val currentFriends = withContext(Dispatchers.IO) { userService.requestCurrentFriends(user) }
val suggestedFriends = withContext(Dispatchers.IO) { userService.requestSuggestedFriends(user) }
val finalUser = user.copy(friends = currentFriends + suggestedFriends)
}
Copy the code
If each request takes 2 seconds, a total of 6 seconds is needed. What if we use async instead:
GlobalScope.launch(Dispatchers.Main) {
val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
val currentFriends = async(Dispatchers.IO) { userService.requestCurrentFriends(user) }
val suggestedFriends = async(Dispatchers.IO) { userService.requestSuggestedFriends(user) }
val finalUser = user.copy(friends = currentFriends.await() + suggestedFriends.await())
}
Copy the code
At this point, the second and third requests run in parallel, so the total time is reduced to 4 seconds.
Scope (Scope
)
So far, we have easily implemented complex operations in a simple way. However, one problem remains unresolved.
If we need to use RecyclerView to display the friends list, and the client closes the Activity while the request is still in progress, then the Activity is in isFinishing state, and any UI update operation will cause the App to crash.
How do we handle this scenario? Scope, of course. Let’s take a look at the scopes:
Global scope
It is a global scope and can be used when creating coroutines if they run for as long as the App life cycle. So it should not be bound to any component that can be destroyed.
Here’s how it works:
GlobalScope.launch(Dispatchers.Main) {
...
}
Copy the code
When you use it, make sure that the coroutine you are creating needs to run with the entire App life cycle and that it is not tied to interfaces, components, etc.
Custom coroutine scope
Any class can inherit CoroutineScope as a scope. The only thing you need to do is override the coroutineContext property.
Before you do that, you need to clarify two important concepts: dispatcher and Job.
If you remember, a context can be a combination of multiple contexts. The context of the composition needs to be of a different type. So, you need to do two things:
- a
dispatcher
: used to specify the default use of coroutinesdispatcher
; - a
job
: used to remove the coroutine whenever needed;
class MainActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
private lateinit var job: Job
}
Copy the code
The operator symbol + is used to combine contexts. If two different types of contexts are combined, a CombinedContext is generated, and the new context has the properties of the CombinedContext.
If two contexts of the same type are combined, the new context is equivalent to the second. IO == dispatchers.io
We can create a Job using lateinit. This way we can initialize it in onCreate() and cancel it in onDestroy().
override fun onCreate(savedInstanceState: Bundle?). {
super.onCreate(savedInstanceState)
job = Job()
...
}
override fun onDestroy(a) {
job.cancel()
super.onDestroy()
}
Copy the code
This makes it much easier to use coroutines. We just create coroutines, regardless of the context in which they are used. Because we have declared the context in our custom scope, that is, the one containing the main Dispatcher:
launch {
...
}
Copy the code
If all your activities require coroutines, it is necessary to extract the above code into a parent class.
Appendix 1 – Callbacks to coroutines
If you’re already considering using coroutines for existing projects, you might want to consider how to convert existing callback-style code into coroutines:
suspend fun suspendAsyncLogin(username: String, password: String): User =
suspendCancellableCoroutine { continuation ->
userService.doLoginAsync(username, password) { user ->
continuation.resume(user)
}
}
Copy the code
SuspendCancellableCoroutine () this method returns a continuation object, continuation can be used to return the result of the callback. The result of this callback can be returned to the coroutine as the result of the interruptible method simply by calling the continuation.resume() method.
Appendix 2 – Coroutines andRxJava
Every time coroutines are mentioned, people ask, can coroutines replace RxJava? The short answer is: no.
Objectively speaking, depending on the situation:
- If you use
RxJava
It’s just used to switch from the main thread to the child thread. As you can see, coroutines can easily do this. In this case, it’s totally replaceableRxJava
. - If you use
RxJava
Used for streaming programming, merging streams, transforming streams, etc.RxJava
Still more advantageous. There’s one in the coroutineChannels
The concept can be replacedRxJava
Implement some simple scenarios, but in general, you might prefer to useRxJava
Streaming programming.
It is worth mentioning that there is an open source library for using RxJava in coroutines that you may be interested in.
conclusion
Coroutines open up a world of possibilities and make asynchronous programming easier. Previously, this would have been unthinkable.
It is highly recommended to use coroutines for your existing projects. If you want to see the full sample code, click here.
Start your coroutine journey!