Search code examples
kotlinkotlin-coroutines

Why does my withContext block wait for a coroutine launched within suspend function?


I am facing an issue with a Kotlin coroutine. When I run the suspend function performTask I notice that the log message "Step 1: Task within withContext" is printed first, and only after 10 seconds, the message "Step 2: Task outside withContext" is printed.

Here's the code:

suspend fun weirdFunction() {
    CoroutineScope(coroutineContext).launch {
        delay(10_000)
    }
}

suspend fun performTask() {
    withContext(Dispatchers.Default) {
        weirdFunction()
        Log.d("MyTag", "Step 1: Task within withContext")
    }
    Log.d("MyTag", "Step 2: Task outside withContext")
}

I believe the issue is caused by using CoroutineScope(coroutineContext).launch inside weirdFunction, but I don't understand why this usage leads to this behavior. My intention was to launch a child coroutine within the current coroutine scope without passing the scope as a parameter. I thought using coroutineContext to obtain the current coroutine scope was an elegant way to achieve this.

However, it seems my understanding might be incorrect. This issue made me think about a more concerning scenario: if weirdFunction were part of a third-party library, it could cause withContext to hang and not return as expected, which would make debugging very difficult.

Could someone explain why this approach causes the withContext block to wait for the launched coroutine to complete? Is there a better way to launch a non-blocking coroutine within a suspend function that doesn't affect the caller's coroutine scope? Any insights or explanations would be greatly appreciated.


Solution

  • The following happens when you run performTask:

    1. withContext creates a new job to run block synchronously and waits for it to finish. Let's call this job job1. It doesn't matter what you pass for the parameter of withContext.
    2. Now weirdFunction is called and a new coroutine scope is created. You always need to associate a coroutine scope with a job. If you don't provide one a new one is created under the hood. In this case, however, you did provide a job, because job1 is part of the coroutineContext.
    3. The new scope is used to launch a new coroutine. By this a new job is created that can be used to control that coroutine. Let's call this job job2. Its parent job is taken from the coroutine scope, which is job1.
    4. While the new coroutine waits for 10 seconds weirdFunction returns and "Step 1: Task within withContext" is logged.
    5. Now the withContext block is finished... almost! job1 that was used to run the block is still running, because one of its children is still running: job2 is not finished yet, it ist still waiting for 10 seconds. So job1 is also running and withContext will only return when that is finished.
    6. 10 seconds later job2 finishes and with it job1 now finishes too. withContext returns and "Step 2: Task outside withContext" is logged.

    If you hadn't attached job1 to the new coroutine scope you would have achieved the desired behavior:

    CoroutineScope(Job()).launch {
        delay(10_000)
    }
    

    However, suspend functions shouldn't try to launch new coroutines that outlive the function itself. The reason for that is that you won't have a way to control that coroutine, for example, to cancel it when it isn't needed anymore.

    If you need to launch a new coroutine you should pass a CoroutineScope instance as a parameter. Inside the suspend function it can be used to launch a new coroutine, outside the suspend function it can be used to cancel the coroutine. You can also specify the parameter as the receiver of an extension function, like this:

    fun CoroutineScope.weirdFunction() {
        launch {
            delay(10_000)
        }
    }
    

    Please note that this isn't even a suspend function anymore because everything coroutine-related is applied to the coroutine scope.

    This makes the behavior of the function more transparent: It isn't a suspend function so you don't need a coroutine to call it, but you do need to provide a coroutine scope.

    The expectation of a suspend function on the other hand is that it performs what it needs to do; it may suspend in between, but when that function returns it also finished what it set out to do and there is no dangling coroutine left that you wouldn't have any control over.

    When you call this new weirdFunction from withContext it would exhibit the same behavior as before, but this is expected from just looking at the function signature because you know that you pass the current coroutine scope with the withContext's context (and its job), so whatever happens inside weirdFunction it is clear that withContext will wait for it to finish.

    When this is not the desired behavior you can always provide another coroutine scope that is not dependent on withContext:

    scope.weirdFunction()
    

    Just make sure to keep a reference to that scope around so you cancel it when it isn't needed anymore.