iTranslated by AI
My Attempt to Organize the Concepts and Mechanisms of Kotlin Coroutines
A common explanation of Kotlin Coroutines goes something like this:
Kotlin Coroutines are like lightweight threads that make it easy to implement asynchronous and concurrent processing.
With Coroutines, you can use thesuspendkeyword to pause and resume functions, allowing you to write asynchronous code simply.
I often see explanations like this. While I felt I understood it vaguely, I couldn't quite grasp the overall picture.
In particular, terms like Coroutine, suspend, CoroutineScope, CoroutineContext, CoroutineDispatcher were hard to remember, and it was unclear how they related to each other.
Then, I read the article below, and the overall picture of Kotlin Coroutine concepts and related terms gradually became clearer. So, I decided to write this article to organize my understanding. (If you want to understand deeply, I highly recommend reading the article below.)
In this article, I will explain the concepts and related terms of Kotlin Coroutines in the following structure:
- The mechanism supporting suspension and resumption of Coroutines (suspend functions and Continuation)
- The mechanism supporting the execution of Coroutines (CoroutineDispatcher)
- Several elements composing a Coroutine (CoroutineScope)
- Convenient functions for creating Coroutines
- Environment
Kotlin version 2.3.20 (JRE 25.0.2+10-LTS)kotlinx-coroutines-core:1.10.1
The mechanism supporting suspension and resumption of Coroutines (suspend functions and Continuation)
Here, I will explain the most fundamental concepts when using Coroutines: suspend functions and Continuation.
First, to write suspendable processing in Coroutines, you need what is called a suspend function. A suspend function is a function that can be paused and resumed, defined by adding the suspend keyword before the function. (It just makes it possible.)
The compiler transforms this suspend function into a function that takes a Continuation object as an argument.
- Before compilation
suspend fun main() {
withContext(Dispatchers.Default) {
hello()
}
}
suspend fun hello() {
println("Hello")
delay(1000)
println("World")
}
- After compilation (pseudo-code)
public static final Object hello(@NotNull Continuation $completion) {
// On the first call, create a ContinuationImpl and manage the resumption position with label.
$continuation = new ContinuationImpl($completion) {
Object result;
int label; // initial value is 0
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
...
// Call itself
return ContinuationKt.hello((Continuation)this);
}
};
switch ($continuation.label) {
case 0:
// Start here on the first call
System.out.println("Hello");
$continuation.label = 1; // Update the label
if (delay(1000, $continuation) == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED // Pause here and resume later
}
case 1:
// When resumed via invokeSuspend later, start here
...
break;
}
System.out.println("World");
return Unit.INSTANCE;
}
In this way, suspend functions are transformed to take a Continuation as an argument and are converted into a form that can be resumed after suspension. This compile-time transformation is a technique called CPS (Continuation-Passing Style) transformation, creating a state machine-like structure for executing asynchronous processing step by step. The delay function is also a suspend function, so it is compiled in the same way, taking a Continuation as an argument.
When a Coroutine is suspending, it saves the current execution state (local variables and the label indicating the resumption position) as a Continuation object and returns COROUTINE_SUSPENDED. (Incidentally, such suspendable points are called suspension points.)
In other words, it executes part of the function and temporarily ends. Later, at an arbitrary timing, invokeSuspend is called based on the saved state, and the processing resumes from the appropriate position according to the label.
In short, Continuation is like a "handler to resume a suspended suspend function." It may be easier to understand if you think of it as an object that acts like a callback for common asynchronous or delayed processing. When a suspend function calls another suspend function, Continuations are chained to return results to the caller.
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
Continuation is defined as an interface in the Kotlin repository. The reason Continuation holds a CoroutineContext is to use information such as the Dispatcher contained in the CoroutineContext to decide where and how to resume. (More on this later.)
On the other hand, just because a function is a suspend function does not mean it always runs asynchronously. A suspend function is simply capable of being paused and resumed; depending on the implementation, it may run synchronously.
For example, the following suspend function simply returns a value and does not run asynchronously:
// Before compilation
suspend fun hello() {
println("Hello")
println("World")
}
// After compilation
public static final Object hello(@NotNull Continuation $completion) {
System.out.println("Hello");
System.out.println("World");
return Unit.INSTANCE; // Immediately returns a result
}
Whether it becomes asynchronous depends on the implementation; specifically, whether it returns the marker COROUTINE_SUSPENDED to suspend and resumes later.
Also, even if marked as suspend, if you call a JVM blocking API like Thread.sleep() as shown below, it will block the calling thread, resulting in inefficient behavior.
// Before compilation
// Blocking suspend function
suspend fun hello() {
println("Hello")
Thread.sleep(1000) // Occupies the thread
println("World")
}
// After compilation
public static final Object hello(@NotNull Continuation $completion) {
System.out.println("Hello");
Thread.sleep(1000); // Occupies the thread
System.out.println("World");
return Unit.INSTANCE;
}
The mechanism supporting the execution of Coroutines (CoroutineDispatcher)
In the previous section, I explained how suspend functions suspend. In it, I mentioned that processing after the suspension point is temporarily saved in a Continuation object and resumed later.
On the other hand, I haven't yet explained "how the remaining processing after suspension is ultimately executed?", in other words, when and how the generated Continuation is resumed (and in what flow resumeWith is called).
Continuing from the previous part, let's look inside the delay function.
public suspend fun delay(timeMillis: Long) {
if (timeMillis <= 0) return // don't delay
return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
// if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
if (timeMillis < Long.MAX_VALUE) {
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
}
First, you can see that the delay function calls suspendCancellableCoroutine.
public suspend inline fun <T> suspendCancellableCoroutine(
crossinline block: (CancellableContinuation<T>) -> Unit
): T =
suspendCoroutineUninterceptedOrReturn { uCont ->
val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
/*
* For non-atomic cancellation we setup parent-child relationship immediately
* in case when `block` blocks the current thread (e.g. Rx2 with trampoline scheduler), but
* properly supports cancellation.
*/
cancellable.initCancellability()
block(cancellable)
cancellable.getResult()
}
suspendCancellableCoroutine is a builder function for implementing suspend functions. This function creates a CancellableContinuation within the suspend function and calls the block argument (in this case, the lambda passed inside the delay function). Then, it calls cancellable.getResult() and returns an appropriate value based on the Continuation's state (returning COROUTINE_SUSPENDED if suspending).
In other words, the delay function uses suspendCancellableCoroutine to create a Continuation (CancellableContinuation), passes it to scheduleResumeAfterDelay, and then calls cancellable.getResult() to decide whether to suspend. Incidentally, cont.context.delay uses the delay property of the CoroutineContext. Which Delay implementation is used depends on the Dispatcher and the runtime environment, but below we'll look at DefaultDelay in EventLoop.common.kt as an example. (More on CoroutineContext later.)
Next, let's look at the implementation of scheduleResumeAfterDelay.
override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
val timeNanos = delayToNanos(timeMillis)
if (timeNanos < MAX_DELAY_NS) {
val now = nanoTime()
DelayedResumeTask(now + timeNanos, continuation).also { task ->
/*
* Order is important here: first we schedule the heap and only then
* publish it to continuation. Otherwise, `DelayedResumeTask` would
* have to know how to be disposed of even when it wasn't scheduled yet.
*/
schedule(now, task)
continuation.disposeOnCancellation(task)
}
}
}
s scheduleResumeAfterDelay receives the suspendable Continuation (CancellableContinuation) created earlier. This function wraps the suspendable Continuation (CancellableContinuation) in DelayedResumeTask(now + timeNanos, continuation), an EventLoop task, and registers it in the EventLoop's queue with schedule(now, task). The implementation of the queue and task registration/retrieval is handled within the EventLoop class.
Once the task is registered in the queue, the DelayedResumeTask.run method will be called after the specified time by the scheduling mechanism of this Delay implementation.
private inner class DelayedResumeTask(
nanoTime: Long,
private val cont: CancellableContinuation<Unit>
) : DelayedTask(nanoTime) {
override fun run() { with(cont) { resumeUndispatched(Unit) } }
override fun toString(): String = super.toString() + cont.toString()
}
internal fun <T> DispatchedTask<T>.dispatch(mode: Int) {
assert { mode != MODE_UNINITIALIZED } // invalid mode value for this method
val delegate = this.delegate
val undispatched = mode == MODE_UNDISPATCHED
if (!undispatched && delegate is DispatchedContinuation<*> && mode.isCancellableMode == resumeMode.isCancellableMode) {
// dispatch directly using this instance's Runnable implementation
val dispatcher = delegate.dispatcher
val context = delegate.context
if (dispatcher.safeIsDispatchNeeded(context)) {
dispatcher.safeDispatch(context, this)
} else {
resumeUnconfined()
}
} else {
// delegate is coming from 3rd-party interceptor implementation (and does not support cancellation)
// or undispatched mode was requested
resume(delegate, undispatched)
}
}
internal fun CoroutineDispatcher.safeDispatch(context: CoroutineContext, runnable: Runnable) {
try {
dispatch(context, runnable)
} catch (e: Throwable) {
throw DispatchException(e, this, context)
}
}
When the DelayedResumeTask.run method is called, it invokes resumeUndispatched on the suspended CancellableContinuation, and the resumption process begins. At this point, the CancellableContinuation calls the dispatch method of its base class, DispatchedTask. In the dispatch method, the scheduling method for the resumption process is switched based on whether the Continuation implementation is DispatchedContinuation and whether the mode and resumeMode are cancellable modes.
In this case, since withContext(Dispatchers.Default) is specified, the Continuation implementation becomes DispatchedContinuation (because it's wrapped by uCont.intercepted() as mentioned earlier). It then obtains the corresponding dispatcher and context and calls the dispatcher.safeDispatch extension function, which ultimately dispatches the task to the specified Dispatcher.
This is where CoroutineScheduler comes into play. CoroutineScheduler is the execution infrastructure used internally by Dispatchers like Dispatchers.Default. It's a class that manages a thread pool, registers tasks in queues, and executes tasks on threads. In this example, since Dispatchers.Default is used, the resumption process is ultimately dispatched to the execution infrastructure of DefaultScheduler / CoroutineScheduler.
fun dispatch(block: Runnable, taskContext: TaskContext = NonBlockingContext, fair: Boolean = false) {
trackTask() // this is needed for virtual time support
val task = createTask(block, taskContext)
val isBlockingTask = task.isBlocking
// Invariant: we increment counter **before** publishing the task
// so executing thread can safely decrement the number of blocking tasks
val stateSnapshot = if (isBlockingTask) incrementBlockingTasks() else 0
// try to submit the task to the local queue and act depending on the result
val currentWorker = currentWorker()
val notAdded = currentWorker.submitToLocalQueue(task, fair)
if (notAdded != null) {
if (!addToGlobalQueue(notAdded)) {
// Global queue is closed in the last step of close/shutdown -- no more tasks should be accepted
throw RejectedExecutionException("$schedulerName was terminated")
}
}
// Checking 'task' instead of 'notAdded' is completely okay
if (isBlockingTask) {
// Use state snapshot to better estimate the number of running threads
signalBlockingWork(stateSnapshot)
} else {
signalCpuWork()
}
}
The dispatch method of CoroutineScheduler is a method for registering a task in a queue. For Dispatchers.Default, its actual implementation is DefaultScheduler, which internally holds a CoroutineScheduler. Therefore, the dispatched Continuation is wrapped in a CoroutineDispatcher task and submitted to the CoroutineScheduler's queue. When Dispatchers.Default is specified, a thread pool sized according to the number of CPU cores is used. On the other hand, Dispatchers.IO shares threads with Dispatchers.Default but is a Dispatcher that allows additional parallelism for blocking I/O, with a default parallelism limit of max(64, CPU core count).
private fun executeTask(task: Task) {
terminationDeadline = 0L // reset deadline for termination
if (state == WorkerState.PARKING) {
assert { task.isBlocking }
state = WorkerState.BLOCKING
}
if (task.isBlocking) {
// Always notify about new work when releasing CPU-permit to execute some blocking task
if (tryReleaseCpu(WorkerState.BLOCKING)) {
signalCpuWork()
}
runSafely(task)
decrementBlockingTasks()
val currentState = state
// Shutdown sequence of blocking dispatcher
if (currentState !== WorkerState.TERMINATED) {
assert { currentState == WorkerState.BLOCKING } // "Expected BLOCKING state, but has $currentState"
state = WorkerState.DORMANT
}
} else {
runSafely(task)
}
}
fun runSafely(task: Task) {
try {
task.run()
} catch (e: Throwable) {
val thread = Thread.currentThread()
thread.uncaughtExceptionHandler.uncaughtException(thread, e)
} finally {
unTrackTask()
}
}
The task registered this time is retrieved by one of the threads in the thread pool managed by the CoroutineScheduler, and eventually task.run() is called.
final override fun run() {
assert { resumeMode != MODE_UNINITIALIZED } // should have been set before dispatching
try {
val delegate = delegate as DispatchedContinuation<T>
val continuation = delegate.continuation
withContinuationContext(continuation, delegate.countOrElement) {
val context = continuation.context
val state = takeState() // NOTE: Must take state in any case, even if cancelled
val exception = getExceptionalResult(state)
/*
* Check whether continuation was originally resumed with an exception.
* If so, it dominates cancellation, otherwise the original exception
* will be silently lost.
*/
val job = if (exception == null && resumeMode.isCancellableMode) context[Job] else null
if (job != null && !job.isActive) {
val cause = job.getCancellationException()
cancelCompletedResult(state, cause)
continuation.resumeWithStackTrace(cause)
} else {
if (exception != null) {
continuation.resumeWithException(exception)
} else {
continuation.resume(getSuccessfulResult(state))
}
}
}
} catch (e: DispatchException) {
handleCoroutineException(delegate.context, e.cause)
} catch (e: Throwable) {
handleFatalException(e)
}
}
When the Task's run method is called, the process to resume the Continuation held within the task is executed. In this example, continuation.resume(getSuccessfulResult(state)) is called on the DispatchedContinuation, which triggers resumeWith(result) on the Continuation, ultimately calling invokeSuspend to resume the remaining parts of the suspend function step by step.
Several elements that compose a Coroutine (CoroutineScope)
In the previous section, I explained how a Continuation is resumed. In it, I mentioned that a Continuation has a CoroutineContext.
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
You can see that the Continuation interface defines a property called context: CoroutineContext. The CoroutineContext contains elements such as the Dispatcher and Job, which provide the information needed for the execution and management of the Continuation.
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
CoroutineScope is a simple interface that only has a coroutineContext, but CoroutineBuilders like launch and async (explained later) create the context of a new Coroutine based on this coroutineContext.
Note that inside a suspend function, you can directly obtain the CoroutineContext without going through a CoroutineScope by using the coroutineContext property from the kotlin.coroutines package.
suspend fun example() {
val ctx = coroutineContext // Get the CoroutineContext directly inside a suspend function
val dispatcher = ctx[CoroutineDispatcher]
}
CoroutineContext
CoroutineContext is an immutable collection that holds the information needed to execute a Coroutine. Each element is identified by a CoroutineContext.Key, and only one element can exist for the same key.
The main elements are as follows:
| Element | Role |
|---|---|
CoroutineDispatcher |
Controls the thread on which the Coroutine runs |
Job |
Manages the Coroutine's lifecycle and parent-child relationships |
CoroutineName |
A debug name for the Coroutine |
CoroutineExceptionHandler |
Handler for uncaught exceptions in the Coroutine |
CoroutineDispatcher
CoroutineDispatcher is an element for controlling the thread on which a Coroutine runs.
By specifying a dispatcher, you can choose which dispatcher's execution infrastructure (e.g., Dispatchers.Default or Dispatchers.IO with their thread pools) will execute and resume the coroutine.
The threads in the thread pool are actual JVM/OS threads, so the actual CPU assignment is handled by the OS scheduler. On the other hand, since a coroutine does not occupy a real thread while suspended, the dispatcher can execute another coroutine's continuation on the same thread.
- Types of Dispatchers
There are several types of Dispatchers, and they can be used according to the purpose.
-
Dispatchers.Default: Suitable for CPU-bound tasks; uses a thread pool sized according to the number of CPU cores. -
Dispatchers.IO: Suitable for I/O-bound tasks; shares threads withDispatchers.Defaultbut allows additional parallelism, with a default limit ofmax(64, CPU core count)(configurable via thekotlinx.coroutines.io.parallelismsystem property). -
Dispatchers.Main: Suitable for UI threads in Android, JavaFX, etc.
fun main() = runBlocking {
launch(Dispatchers.Default) {
// CPU-bound task
}
}
Job
Job is an element that manages the lifecycle and parent-child structure of a Coroutine.
For example, when you call launch within a CoroutineScope, a new child Coroutine is created. At this time, a new child Job is created with the Job contained in the parent coroutineContext as its parent. Furthermore, if you call launch within that child Coroutine, a grandchild Job is created with the child Coroutine's Job as its parent. This creates a structure where cancelling the parent Job also cancels the child and grandchild Jobs.
fun main() = runBlocking {
launch {
// Child Coroutine
launch {
// Grandchild Coroutine
}
}
}
In this way, Job manages the lifecycle and parent-child structure of Coroutines, enabling structured management of Coroutines.
- Structured Concurrency
The parent-child structure management via Job is designed based on an important concept called Structured Concurrency.
Structured Concurrency is a principle in Coroutines that guarantees child Coroutines are properly scoped and managed by their parent Coroutine.
Without Structured Concurrency, the parent could finish before its child Coroutines complete. For example, in a web application launching a Coroutine per request, if the parent Coroutine completes mid-request, a child Coroutine could be left running (a Coroutine leak). Also, if the parent doesn't wait for its children to complete, exceptions in child coroutines might not propagate to the parent, potentially preventing proper error handling.
Therefore, when launching a new Coroutine with launch, async, coroutineScope, etc., a new child Job is created with the parent context's Job as its parent. This forms a parent-child tree of Jobs, guaranteeing the following properties:
- Cancellation propagation: Cancelling the parent cancels all child and grandchild Jobs.
- The parent waits for children to complete: The parent waits until all child and grandchild Jobs have completed.
- Error propagation: Uncaught exceptions in a child Job propagate to the parent Job.
However, there are different types of Scopes.
Using the default coroutineScope, you can create a structure where errors in children propagate to the parent.
suspend fun example() = coroutineScope {
launch {
// If an exception occurs here, the parent `coroutineScope` and sibling `launch` are also cancelled.
}
launch {
// If an exception occurs here, the parent `coroutineScope` and sibling `launch` are also cancelled.
}
}
On the other hand, using supervisorScope creates a structure where a failure in one child Coroutine does not affect the cancellation of sibling Coroutines.
suspend fun example() = supervisorScope {
launch {
// Even if an exception occurs here, the sibling `launch` is not cancelled.
// However, it is treated as an uncaught exception from `launch`.
...
}
launch {
// The sibling Coroutine can continue executing.
...
}
}
However, this is not a mechanism to swallow exceptions. Unhandled exceptions from launch are treated as uncaught exceptions, and exceptions from async are held in the Deferred and re-thrown when await() is called.
You can choose between coroutineScope (default) and supervisorScope based on your needs.
Convenient Functions for Creating Coroutines
So far, I've explained the mechanisms that support the creation and execution of Coroutines. From here, I'll explain the convenient functions used to actually create Coroutines.
Since only a small part of Coroutine functionality is built into the language, it's common to use an extension library called kotlinx.coroutines for actual usage. The withContext and CoroutineScope implementations mentioned earlier are also features included in the kotlinx.coroutines library.
Additionally, extension functions on CoroutineScope, known as CoroutineBuilders, are also included in the kotlinx.coroutines library. CoroutineBuilders are convenient functions for launching Coroutines, such as launch and async.
runBlocking Function (Coroutine Builder)
runBlocking creates a new CoroutineScope and blocks the current thread until all Coroutines launched within it complete. Since it blocks the thread, it's typically used only in limited places like the main function or test code.
- Example
fun main() = runBlocking {
// The thread is blocked until all Coroutines launched here complete.
launch {
delay(1000)
println("Hello")
}
}
launch Function (Coroutine Builder)
launch is an extension function on CoroutineScope that creates a new Coroutine and starts asynchronous processing within it. It returns a Job object, which can be used to manage the state of the launched Coroutine.
- Example
suspend fun hello() = coroutineScope {
launch {
println("Hello")
delay(1000)
println("World")
}
}
- Internal Implementation of launch
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}
In launch, it first calls newCoroutineContext on the CoroutineScope to create a new CoroutineContext for the child Coroutine based on the parent scope's context. Then, it creates a Coroutine object using that CoroutineContext. This object acts as a Job, managing the Coroutine's state and parent-child relationships. After that, it calls the start method on this object to begin executing the block, and returns the created object as a Job.
launch itself is not a suspend function, but the block it receives is a suspend lambda of type suspend CoroutineScope.() -> Unit. Therefore, what is subject to suspension and resumption is not launch itself, but the block executed as a child Coroutine. The block is designed to be suspendable and resumable using Continuations, and in most cases, it's executed on the appropriate execution context according to the Dispatcher at the start or after resumption. Thus, launch delegates the subsequent processing to the Dispatcher after starting the child Coroutine, and returns a Job to the caller immediately.
async Function (Coroutine Builder)
async is also an extension function on CoroutineScope for executing asynchronous processing, but unlike launch, async allows retrieving results via await(). In other words, use launch when you don't need the result, and use async when you do – that's the common usage. async is also convenient when combining results from multiple asynchronous operations.
- Example
suspend fun hello() = coroutineScope {
val deferred = async {
println("Hello")
delay(1000)
"World" // The last expression in the async block becomes the Deferred's result.
}
println(deferred.await()) // Use deferred.await() to get the result of the async block.
}
- Internal Implementation of async
public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T> {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyDeferredCoroutine(newContext, block) else
DeferredCoroutine<T>(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}
The internal implementation is basically the same as launch, but it returns a Deferred object, allowing the future result to be retrieved using the await function.
Also, the behavior of exception handling differs between launch and async.
In a Coroutine launched with launch, if an unhandled exception occurs, it propagates to the parent Job. On the other hand, in a Coroutine launched with async, if an exception occurs, it's stored inside the Deferred object and re-thrown when await() is called.
However, if async is launched within a coroutineScope, simply wrapping await() in a try-catch is not enough to prevent the cancellation of the parent Scope. If you don't want the failure of async to affect other Coroutines or the parent Scope, you need to use supervisorScope (explained earlier).
suspend fun hello() = supervisorScope {
val a = async {
// Essential processing
...
}
val b = async {
// Processing that may fail
...
}
val safeB = try {
b.await()
} catch (e: CancellationException) {
// Swallowing CancellationException breaks structured cancellation, so always rethrow it.
throw e
} catch (e: Exception) {
null
}
println(a.await())
println(safeB)
}
withContext Function
withContext is a suspend function that suspends the current Coroutine, executes a block with a specified CoroutineContext, and returns the result after its completion. It's not an API for launching a child Coroutine that operates independently from the caller, like launch or async, but rather for switching the execution context of the current coroutine.
Since withContext returns the result of the block, it can be used as an alternative to async { }.await() in sequential contexts. However, because withContext executes processing sequentially, you need to use async if you want to execute multiple tasks in parallel.
// Sequential execution with different contexts
suspend fun example() {
val a = withContext(Dispatchers.IO) { fetchA() }
val b = withContext(Dispatchers.IO) { fetchB() } // b starts only after a completes.
}
// Concurrent execution with async
suspend fun exampleParallel() = coroutineScope {
val a = async(Dispatchers.IO) { fetchA() }
val b = async(Dispatchers.IO) { fetchB() } // b starts without waiting for a to complete.
a.await() + b.await()
}
delay Function
delay is a suspend function that suspends a Coroutine for a specified duration.
Unlike Thread.sleep(), it does not block the thread; it only suspends the Coroutine. During the suspension, the thread can be used for other Coroutines' processing.
suspend fun example() {
println("A")
delay(1000) // Suspends only the coroutine, not the thread
println("B") // Resumes after 1 second
}
yield Function
yield is a suspend function that suspends the current Coroutine and yields execution to other Coroutines waiting on the same Dispatcher.
Unlike the yield() used in generator functions within sequence {} blocks, here it's intended as an explicit yield to the scheduler. When implementing CPU-bound processing with Coroutines, inserting yield gives other Coroutines a chance to execute.
suspend fun heavyComputation() {
for (i in 0..1_000_000) {
// yield() suspends the Coroutine, yields to other Coroutines on the same Dispatcher, and also serves as a cancellation check.
if (i % 1_000 == 0) yield()
}
}
For example, calling yield every 1000 iterations as above allows other Coroutines to run during heavy computation.
Summary
In this article, I've organized the mechanisms of Kotlin's Coroutines, including convenient functions for creating them and several elements that compose a Coroutine.
Coroutines are a powerful feature for writing asynchronous code concisely, but internally they rely on complex mechanisms like Continuations and CoroutineContext. Understanding these mechanisms will help you use Coroutines more effectively.
Discussion