Hello, today I will talk about an important topic of coroutine: exception handling. We will learn different methods for error handling with coroutines and know when to use which method.
Coroutine error handling mechanism.
When an exception is thrown in a coroutine, instead of handling the application crash right where the error occurred, the exception is propagated to the parent coroutine. The parent coroutine will be responsible for handling exceptions through the Coroutine Exception handler (if any). In case there is no Exception handler, the application will crash.
Thus, if a parent coroutine starts many child coroutines, errors arising from the child coroutines will be handled centrally at the parent coroutine. This helps us easily debug the cause of errors, instead of having to handle errors in every child coroutine.
What happens when an exception is thrown in a coroutine.
Coroutine has an automatic cancel mechanism when encountering errors. That is, when the parent receives a propagated exception, it will cancel all child coroutines. Knowing this and handling exceptions properly will help our application limit memory leaks,
OK, that’s the theory, now let’s learn how to handle exceptions in coroutine.
Try/Catch block
This is probably the simplest way to handle it. We are still using this method even without using coroutines. For example:
GlobalScope.launch {
try {
val result = 10/0
}catch (e: Exception) {
Log.d("Coroutine", "$e")
}
}
Output:
java.lang.ArithmeticException: divide by zero
This is the simplest way to handle errors, but also the most difficult. Imagine when your coroutine starts many child coroutines, we will have to handle errors for all of those coroutines. For example:
GlobalScope.launch {
launch {
try {
val numberResult = 10/0
}catch (e: ArithmeticException) {
Log.d("Coroutine", "$e")
}
}
launch {
try {
val stringResult = "".substring(10)
} catch (e: StringIndexOutOfBoundsException) {
Log.d("Coroutine", "$e")
}
}
}
Output:
java.lang.ArithmeticException: divide by zero
java.lang.StringIndexOutOfBoundsException: length=0;
As you can see in the example above, we have to handle the exception twice for 2 child coroutines. So this method is not very convenient and is rarely used.
Coroutine exception handler
This is a simpler way of processing than the above method. When defining CoroutineContext, we declare CoroutineExceptionHandler to handle exceptions. For example:
GlobalScope.launch (Dispatchers.Default + CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("Coroutine", "$throwable")
}) {
launch {
delay(1000)
val numberResult = 10/0
}
launch {
delay(500)
val stringResult = "".substring(10)
}
}
Output:
java.lang.StringIndexOutOfBoundsException: length=0;
There are 2 things to analyze here:
We have declared CoroutineExceptionHandler to handle exceptions. Thanks to it, we know which error was thrown, and from which coroutineContext.
For example, both child coroutines can throw exceptions, but only one error will be printed. The reason is that as soon as StringIndexOutOfBoundsException is thrown, the parent coroutine will cancel all of its child coroutines. So in this case ArithmeticException cannot be thrown.
What if we use CoroutineExceptionHandler on child coroutines.
GlobalScope.launch (Dispatchers.Default + CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("Coroutine", "Parent coroutine: $throwable")
}) {
launch (CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("Coroutine", "Child coroutine 1: $throwable")
}) {
delay(1000)
val numberResult = 10/0
}
launch (CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("Coroutine", "Child coroutine 2: $throwable")
}) {
delay(500)
val stringResult = "".substring(10)
}
}
Output:
Parent coroutine: java.lang.StringIndexOutOfBoundsException: length=0; index=10
We see that the exception is still handled by the parent coroutine, even though we have declared the CoroutineExceptionHandler in the child coroutines.
So, in coroutines, any exceptions thrown in child coroutines will be propagated to the root coroutine, and will be handled by the root coroutine.
SupervisorJob
As stated in the previous section, the parent coroutine will automatically cancel all child coroutines when an exception is thrown. If we don’t want that to happen, we will need to use SupervisorJob. For example
val job = SupervisorJob()
GlobalScope.launch (Dispatchers.Default + CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("Coroutine", "Parent coroutine: $throwable")
}) {
launch {
delay(500)
Log.d("Coroutine", "Completed")
}
launch (job) {
"".substring(10)
}
}
Output:
Parent coroutine: java.lang.StringIndexOutOfBoundsException: length=0; index=10
Completed
So we see that the first child coroutine is still executed even when the second child coroutine throws an exception.
So when a coroutine uses SupervisorJob, it prevents its coroutine siblings from being canceled when it throws an exception.
This is really useful when you don’t want all child coroutines to be canceled when one of them has a problem. For example:
val job = SupervisorJob()
lifecycleScope.launch (Dispatchers.Default + CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("Coroutine", "Parent coroutine: $throwable")
}) {
val job1 = launch (Dispatchers.Main + job) {
//Update UI part 1
}
val job2 = launch (Dispatchers.Main + job) {
//Update UI part 2
}
val job3 = launch (Dispatchers.Main + job) {
//Update UI part 3
}
}
In this case, you need to update multiple UI components, and even if one component has an error, the other components will not be affected.
Supervisor Scope
In addition to using SupervisorJob, we can also use supervisorScope to achieve the same purpose. For example:
GlobalScope.launch (Dispatchers.Default + CoroutineExceptionHandler { coroutineContext, throwable ->
Log.d("Coroutine", "Parent coroutine: $throwable")
}) {
supervisorScope {
launch {
delay(500)
Log.d("Coroutine", "Completed")
}
launch () {
"".substring(10)
}
}
}
Output:
Parent coroutine: java.lang.StringIndexOutOfBoundsException: length=0; index=10
Completed
Thus, when we start coroutines in a supervisorScope, these coroutines will not be canceled when an exception is thrown.
supervisorScope is an extension function of coroutineScope, it creates a separate coroutineScope and when the child coroutine throws an exception, the sibling coroutine and parent coroutine are not canceled. In other words, supervisorScope moves the exception to the parent coroutine, but requires the parent coroutine not to cancel active coroutines, thereby keeping these coroutines from being canceled.
Some practical use cases of supervisorScope
- Critical Sections
supervisorScope {
launch {
// Perform a critical task that should not be affected by other failures
}
launch {
// Another critical task within the supervisor scope
}
}
- Batch Processing:
val tasks = listOf(/* List of tasks to be processed */)
supervisorScope {
tasks.forEach { task ->
launch {
try {
processTask(task)
} catch (e: Exception) {
handleTaskFailure(e)
}
}
}
}
- Multiple Network Requests
supervisorScope {
launch {
val result1 = makeNetworkRequest1()
handleNetworkResult1(result1)
}
launch {
val result2 = makeNetworkRequest2()
handleNetworkResult2(result2)
}
}
When you need to handle jobs and they need to be guaranteed to complete, supervisorScope ensures that when something goes wrong with one coroutine, other coroutines are not affected.
Conclude
This article has presented the basic ways to handle exceptions in coroutine.
- Use try/catch block as the most basic way
- Use coroutine exception handler for parent coroutine to handle exceptions centrally.
- Use SupervisorJob and supervisorScope to protect sibling coroutines from cancellation.
Hope it will be helpful to you.
Leave a Reply