Using Kotlin Coroutines

Using Kotlin Coroutines

Overview

  • With the advent of Virtual Thread in Java 19, synchronous programming continues to dominate programming paradigms with its readability as a weapon. Meanwhile, Kotlin offers its own unique approach to asynchronous programming with Coroutines, which has many strengths in solving bottlenecks and parallel processing. The first impression of coroutines was not that of a completely newly invented secret weapon, but rather it made using threads really easy. It even felt liberating from the conscious callbacks hell. This article summarizes the basic usage of Kotlin coroutines from a server-side perspective using Kotlin/JVM and will be updated continuously.

build.gradle.kts

  • Add the following content to the build.gradle.kts at the project root.
val kotlinCoroutinesVersion by extra { "1.8.0" }

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinVersion")
    runtimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:$kotlinVersion")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:$kotlinVersion")
}

Switching CoroutineDispatcher to Virtual Thread

  • Kotlin has long provided developers with a similar suspend function through coroutines, even before the advent of Virtual Threads. By writing the code below and using Dispatchers.LOOM, which applies virtual threads instead of the traditionally used Dispatchers.IO, coroutines can be executed on virtual threads.
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import java.util.concurrent.Executors

val Dispatchers.LOOM: CoroutineDispatcher
    get() = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()

runBlocking

  • The start of a coroutine is the creation of a runBlocking block. It acts as the first gateway to create coroutine blocks on the current thread.
fun main() {

    // [1] Transition to a state where coroutines can be executed on the current thread
    runBlocking {

        // Still using the current thread within the block
        // [2] Writing 0 or more coroutine blocks
    }

    // [3] Resuming the current thread after all the logic inside the runBlocking coroutine block has finished
}
  • Or, it can be used directly as a block for a specific function.
fun doSomeCoroutine() = runBlocking {

    // Writing 0 or more coroutine blocks
}
  • In the runBlocking block, the current thread continues as it is. It's important to know that even if any coroutine execution within the runBlocking block runs in parallel or asynchronously, the runBlocking block does not conclude until those blocks are completely finished.

  • From the example above, the runBlocking block might seem insignificant, but only within this block can coroutineScope, launch, async, and other coroutine blocks be written. More on this below.

launch

runBlocking {

    // [1] Writing a coroutine block that uses the current thread
    launch {
        // Write execution logic
    }

    // [2] Writing a coroutine block using a new thread optimized for IO requests
    launch(Dispatchers.LOOM) {
        // Executed on a thread named DefaultDispatcher-worker-{thread-number}
        // Write execution logic
    }

    // [3] Writing a coroutine block using a new thread optimized for CPU operations
    launch(Dispatchers.Default) {
        // Executed on a thread named DefaultDispatcher-worker-{thread-number}
        // Write execution logic
    }

    // [4] If applied, specific logic can be executed in parallel on different threads
    (1..1000).forEach {
        launch(Dispatchers.LOOM) {
            // Executed on a thread named DefaultDispatcher-worker-{thread-number}
            doSomething(it)
        }
    }

    // [5] The execution order of each launch block is determined randomly
}

suspend

  • suspend is the true star that shines in coroutines and the reason for writing this article. By simply adding the suspend keyword in front of a regular function, it can be executed without pausing the current thread. This revolutionary method allows multiple threads to handle the execution based on the system's conditions without requiring special attention from developers.
// Add the suspend keyword in front of fun
suspend fun doSomething(number: Int): String {

    // [1] Logic 1
    // [2] IO logic 2 taking 100ms, at this point, other suspend functions can take over the idle current thread.
    // [3] Logic 3, likely to be executed by a different thread.

    return "something"
}

// To preserve the caller thread's MDC fields, add withContext(MDCContext())
suspend fun doSomething(number: Int): String = withContext(MDCContext()) {
    ...
    return@withContext "foobar"
}
  • It's crucial to understand that suspend does not speed up the execution of the function itself. The execution of the function is no different from any regular function. However, the true strength of suspend lies in its use during operations that block threads, such as remote database queries. In such cases, a regular function would block the current thread until the operation is completed, essentially creating idle threads in the system temporarily. In contrast, a suspend function, when the thread becomes idle, immediately reassigns the thread to other tasks by the Dispatcher. So, while the execution speed of the function remains unchanged, it allows threads to be lent to other important functions that need to be executed simultaneously. Consequently, using suspend functions extensively can significantly enhance the overall performance of the system.

  • For suspend to be executed by multiple threads as intended, a Dispatcher must be declared in the parent coroutine block as follows.

// The current thread works solely for doSomething().
runBlocking {
    doSomething(100)
}

// Multiple IO threads work for doSomething().
runBlocking(Dispatchers.LOOM) {
    doSomething(100)
}

// Multiple Default threads work for doSomething().
runBlocking(Dispatchers.Default) {
    doSomething(100)
}

Calling suspend functions in Spring MVC

  • The best environment to use coroutines is within Spring WebFlux. However, it's not impossible in Spring MVC, and suspend functions can be called as follows.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.slf4j.MDCContext
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class FooController(
    private val fooService: FooService
) {
    @GetMapping("/foos")
    fun getFoo(): ResponseEntity<Foo> {

        return runBlocking(Dispatchers.LOOM + MDCContext()) {
            // Call the suspend function FooService#getFoo(), a @Service spring bean
            val result = async { fooService.getFoo({id}) }
            ResponseEntity.ok(result.await())
        }
    }
}

Calling suspend functions in @Scheduled

  • Below is an example of calling a suspend function in a @Scheduled logic.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.slf4j.MDCContext
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock
import org.springframework.context.annotation.Profile
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component

@Profile("batch")
@Component
class BatchScheduledService(
    private val fooService: FooService
) {
    @Scheduled(cron = "0 0 * * * *")
    @SchedulerLock(name = "batchUpdateFoos", lockAtLeastFor = "30s", lockAtMostFor = "1h")
    fun batchUpdateFoos() {

        runBlocking(Dispatchers.LOOM + MDCContext()) {

            val result = async { fooService.getFoo({id}) }
            result.await()
        }
    }
}

References