We've worked with CoroutineScopes in the previous article to control the work of children tasks. You may have noticed that passing additional parameters to the scope builder changes its behavior. We've seen how to change the error propagation by passing SupervisorJob to the scope builder. What else can we do? Let's take a look at some solutions.
Context
The CoroutineScope interface has only one property – coroutineContext, which contains all the information the coroutine needs for execution. It's similar to a map or dictionary — all the values are stored there along with their keys. If we take our previous example with a supervised scope, we can see what's inside the context.
fun main() {
val supervisedScope = CoroutineScope(SupervisorJob())
println(supervisedScope)
}
It will print something like CoroutineScope(coroutineContext=SupervisorJobImpl{Active}@4534b60d). In our case, the context contains only one value — the job, which we can access using the Job interface's companion Key like that: supervisedScope.coroutineContext[Job]. We can add more things to the context – for example, an error handler:
val handler = CoroutineExceptionHandler { _, exception ->
println("Caught ${exception.message}")
}
fun main() {
val supervisedScope = CoroutineScope(SupervisorJob() + handler)
println(supervisedScope)
}
The output will look like this: CoroutineScope(coroutineContext=[SupervisorJobImpl{Active}@7e32c033, MainKt$special$$inlined$CoroutineExceptionHandler$1@7ab2bfe1]). Now we have two elements in the context. But what's the handler and what else can we put there?
Jobs, ExceptionHandlers, Dispatchers
The most important elements we can add to the context are Job, ExceptionHandler, and Dispatcher (the list is not exhaustive).
- Job represents the work itself, including the hierarchy of children jobs. It has a state (Active/Completed/Cancelled) and defines how a failure or cancellation propagates to parent and children jobs.
- ExceptionHandler handles uncaught exceptions in a coroutine or its children. It can be very helpful for logging, but note that it doesn't provide a way to catch the exception, so it cannot be used to recover from failures – we have
try/catchfor that. Also, it has to be installed on the root coroutine only, because children automatically propagate uncaught errors to the parent all the way up to the root. - Dispatcher defines which thread or threads will be used to run the coroutine code.
It's important to note that even though we can add multiple jobs, handlers, or dispatchers to the coroutine builder, a coroutine can only have one element of each type, so the last one always overrides all previous. For example, CoroutineScope(SupervisorJob() + handler1 + handler2) will use handler2.
Another important fact is that if we don't provide an element of each type, the default one will be used. There is a default error handler and a default dispatcher for each platform; as for the job, if it's not provided, a new one will be created for us. There can't be a single default job, because each one represents its own coroutine.
More about Dispatchers
Dispatcher is just an interface and we can implement our own, if needed. In fact, most platforms (like JS, JVM/Android, Native), have their own specific dispatchers in addition to the three standard ones available everywhere.
- Dispatchers.Default – as the name implies, this dispatcher will be used if none is specified explicitly. It schedules coroutine execution in a background thread from a shared thread pool. It's a good choice for all types of computations that consume CPU.
- Dispatchers.IO – it uses another shared thread pool and is designed to handle I/O load with a lot of operations that can block on waiting for data. Use it for disk read/write and network requests.
- Dispatchers.Unconfined — it starts execution of a coroutine in the current thread and runs until the first suspension; after that, it resumes in a different suitable thread. Normally, this dispatcher should not be used.
In case we need to use our own pool of one or multiple threads, there are helper functions newSingleThreadContext() and newFixedThreadPoolContext(), which take care of creating a thread pool, and the dispatcher that uses it for execution. They may be handy if we need to run a fixed number of long-running background jobs.
However, to simply limit the number of parallel threads allowed for execution, coroutines v1.6 offer a better option — limitedParallelism(). This function can be applied to any multi-threaded dispatcher to guarantee that no more than N coroutines will run in parallel. For instance, Dispatchers.IO.limitedParallelism(2) uses the same shared IO pool but allows to use two threads only. For example, it can be useful if we are allowed to open only a limited number of DB connections. Any number of limited parallelism views can be created on top of the dispatcher.
Run the following example and see what it prints:
fun main() {
runBlocking {
launch { // context of the parent, main runBlocking coroutine
println("main : ${Thread.currentThread()}")
}
launch(Dispatchers.Unconfined) { // will work on main thread, then on another one
println("Unconfined A : ${Thread.currentThread()}")
delay(1) // suspension point
println("Unconfined B : ${Thread.currentThread()}")
}
launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher
println("Default : ${Thread.currentThread()}")
}
launch(newSingleThreadContext("MyThread1")) { // will get its own new thread
println("new thread 1 : ${Thread.currentThread()}")
}
launch(newSingleThreadContext("MyThread2")) { // will get a different new thread
println("new thread 2 : ${Thread.currentThread()}")
}
}
}Context inheritance
An important thing to know about the context is that everything except for the job is inherited by default unless explicitly overridden. That's very handy because we usually don't want to specify the same dispatcher over and over again for all children. See how that works:
val handler = CoroutineExceptionHandler { _, exception ->
println("Caught ${exception.message}")
}
fun main(): Unit = runBlocking(handler) {
println("root : ${this.coroutineContext}")
launch { // uses context of the parent, creates a new job
println("first : ${this.coroutineContext}")
launch { // still same context, another new job
println("first->same : ${this.coroutineContext}")
}
launch(Dispatchers.Default) { // overrides the dispatcher
println("first->default : ${this.coroutineContext}")
launch(Dispatchers.IO) { // overrides the dispatcher once more
println("first->default->IO: ${this.coroutineContext}")
}
}
}
}
It will print something like that:
root : [MainKt$special$$inlined$CoroutineExceptionHandler$1@73a8dfcc, BlockingCoroutine{Active}@1c655221, BlockingEventLoop@58d25a40]
first : [MainKt$special$$inlined$CoroutineExceptionHandler$1@73a8dfcc, StandaloneCoroutine{Active}@626b2d4a, BlockingEventLoop@58d25a40]
first->same : [MainKt$special$$inlined$CoroutineExceptionHandler$1@73a8dfcc, StandaloneCoroutine{Active}@14899482, BlockingEventLoop@58d25a40]
first->default : [MainKt$special$$inlined$CoroutineExceptionHandler$1@73a8dfcc, StandaloneCoroutine{Active}@1c1817d6, Dispatchers.Default]
first->default->IO: [MainKt$special$$inlined$CoroutineExceptionHandler$1@73a8dfcc, StandaloneCoroutine{Active}@6809a6a3, Dispatchers.IO]
Notice how error handler once installed is inherited by all the children (even though we know it only takes effect in root); similarly, root's dispatcher called BlockingEventLoop propagates to all children that don't override it. And it's possible to override the same thing on different levels if necessary.
Context switching
The example above shows how to run multiple concurrent jobs in different contexts, but in real life we often need something much simpler. For example, consider a UI application that needs to load some data from disk to be displayed, which can take some time and therefore can not be executed in the main thread without freezing the UI. We don't really want to launch an async job, wait for its execution, and then render the result. How can we do it in a simpler way? Kotlin offers a solution for that problem, too. It's called withContext. As the name implies, it executes a block of code with a new context. As shown before, it inherits the context and, if the dispatcher is specified, switches the execution to a new thread. But now the execution order is sequential. See the example:
fun main() = runBlocking {
// starts in main thread
println("root : ${Thread.currentThread()} ${this.coroutineContext}")
withContext(CoroutineName("A")) { // continues in the same thread, but overrides coroutine name in context
println("coroutine A : ${Thread.currentThread()} ${this.coroutineContext}")
withContext(Dispatchers.IO) { // jumps to IO thread pool
println("coroutine A->IO : ${Thread.currentThread()} ${this.coroutineContext}")
}
}
// returns back to the main thread, after IO operation is done
println("root again : ${Thread.currentThread()} ${this.coroutineContext}")
}
Unlike in the previous example, the execution order is guaranteed in this case – you can prove that by adding random delays before each print and comparing the output.
Conclusion
Dispatchers allow scheduling different types of work to different threads. We can use them for launching background work with launch/async builders or for moving consecutive operations between threads. All the rules of structured concurrency still apply in both cases, and the parent context is inherited in children.