What is suspend and how it works?

Abhishek Srivastava
7 min readApr 27, 2023

A function suspend gives it the capability to start, pause and resume the coroutine, but it will only actually suspend the coroutine if it internally calls another suspend function that suspends the coroutine. The suspend functions that directly suspend a coroutine are in the standard library. Among these are suspendCoroutine() and suspendCancellableCoroutine(). You will not often use these. They are most commonly used to convert non-coroutine APIs into suspend functions.

Some of the commonly used functions that indirectly suspend a coroutine are withContext(), delay(), Job.join() and Deferred.await()

Most important points to remember about the suspend functions is that they are only allowed to be called from a coroutine or another suspend function.

Suspending vs. non-suspending

Up until now, you’ve learned that coroutines rely on the concept of suspending code and suspending functions. Suspended code is based on the same concepts as regular code, except the system has the ability to pause its execution and continue it later on. But when you’re using two functions, a suspendable and a regular one, the calls seem pretty much the same.

If you go a step further and duplicate a function you use, but add the suspend modifier keyword at the start, you could call both of the functions with the same parameters. You’d have to wrap the suspendable function in a launch block, because the Kotlin coroutines API is built like that, but the actual function call doesn’t change.

The system differentiates these two functions by the suspend modifier at compile time, but where and how do these functions work differently, and how do both functions work with respect to the suspension mechanism in Kotlin coroutines? The answer can be found by analyzing the bytecode each of the functions generate, and by explaining how the call-stack works in both of the cases. You’ll start by analyzing the non-suspendable, regular, variant first.

Analyzing a suspendable function

Analyze what happens when you just add the suspend modifier to any existing function. Add another function to the Main.kt file, with the following signature:

suspend fun getUserSuspend(userId: String): User {
delay(1000)

return User(userId, "ABHI")
}

This function is very similar to the normal function except you added the suspend modifier, and you don’t sleep the thread but call delay() - a suspendable function which suspends coroutines for a given amount of time. Given these changes, you’re probably thinking the difference in bytecode cannot be that big, right?

Well, the bytecode, which you can get using the Decompile button in the kotlin bytecode decompiler is the following:

@Nullable
public static final Object getUserSuspend(
@NotNull String userId,
@NotNull Continuation var1) {
Object $continuation;
label28: {
if (var1 instanceof < undefinedtype >) {
$continuation = (<undefinedtype>)var1;
if ((((<undefinedtype>)$continuation).label & Integer.MIN_VALUE) != 0) {
((<undefinedtype>)$continuation).label -= Integer.MIN_VALUE;
break label28;
}
}

$continuation = new ContinuationImpl(var1) {
// $FF: synthetic field
Object result;
int label;
Object L $0;

@Nullable
public final Object invokeSuspend (@NotNull Object result) {
this.result = result;
this.label | = Integer.MIN_VALUE;
return MainKt.getUserSuspend((String)null, this);
}
};
}

Object var2 =((<undefinedtype>)$continuation).result;
Object var4 = IntrinsicsKt . getCOROUTINE_SUSPENDED ();
switch(((<undefinedtype>)$continuation).label) {
case 0:
if (var2 instanceof Failure) {
throw ((Failure) var2).exception;
}

((<undefinedtype>)$continuation).L$0 = userId;
((<undefinedtype>)$continuation).label = 1;
if (DelayKt.delay(1000L, (Continuation)$continuation) == var4) {
return var4;
}
break;
case 1:
userId = (String)((<undefinedtype>)$continuation).L$0;
if (var2 instanceof Failure) {
throw ((Failure) var2).exception;
}
break;
default:
throw new IllegalStateException ("call to ’resume’ before ’invoke’ with coroutine");
}

return new User (userId, "ABHI");
}
  • One of the first things you’ll notice is the extra parameter to the function — the Continuation. It forms the entire foundation of coroutines, and it is the most important thing by which suspendable functions are different from regular ones. Continuations allow functions to work in the suspended mode. They allow the system to go back to the originating call site of a function, after it has suspended them. You could say that Continuations are just callbacks for the system or the program currently running, and that by using continuations, the system knows how to navigate the execution of functions and the call stack.
  • That being said, all functions actually have a hidden, internal, Continuation they are tied to. The system uses it to navigate around the call stack and the code in general. However, suspendable functions have an additional instance which they use, so that they can be suspended, and that the program can continue with execution, finally using the second Continuation, to navigate back to the suspendable function call site or receive its result.
  • The rest of the code first checks which continuation we’re in. Since each suspendable function can create multiple Continuation objects. Each continuation would describe one flow the function can take. For example, if you call delay(1000) on a suspendable function, you’re actually creating another instance of execution, which finishes in one second, and returns back to the originating point — the line at which delay was called.
  • The code wraps the continuation arguments, and calls the function from within. Once that is finished, it checks on the label for the currently active continuation. If the label has reached zero, it means it’s at the end of the latest execution — the delay(). In that case it just returns the result from that execution, which is in turn the rest of the function call. In the end, it also increases the label, to one, to notify that it’s past delay(), and should continue on with the code.
  • Finally, if the label is one, which is the largest index in the continuation-stack, so to speak, it means the function has resumed after delay(), and that it’s ready to serve you the value — the User. If anything went wrong up until that point, the system throws an exception.

There’s another, default, case, which just throws an exception if the system tries to resume() with a continuation or execution flow, but hasn’t actually invoked the function call. This can sometimes happen when a child Job finishes after its parent. It’s a default, fallback mechanism, for cases which are extremely rare. If you use your coroutines carefully and the way they are supposed to be used, parent Jobs should always wait for their children, and this shouldn’t happen.

Briefly, the system uses continuations for small state-machines, and internal callbacks, so that it knows how to navigate through the code, and which execution flows exist, and at which points it should suspend, and resume later on. The state is described using the label, and it can have as many states as there are suspension points in the function.

To call the newly created function, you can use the next snippet of code:

fun main() {
GlobalScope.launch {
val user = getUserSuspend("101")

println(user)
}

Thread.sleep(1500)
}

The function call is just like the normal function. The difference is it’s suspendable, so you can push it in a coroutine, offloading the main thread. You also rely on the internal threads from the Coroutine API, so there’s no additional overhead. The code is sequential, even though it could be asynchronous. And you can use try/catch blocks, at the call site, even though the value could be produced asynchronously. You can call the different — 2 type of thread based on user need.

Dispatchers.Main - single thread

Dispatchers.Default - number of cores

Dispatchers.IO - at most 64.

Living in the stack

When a program first starts, its call-stack has only one entry — the initial function, usually called main(). This is because within it, no other functions have been called yet. The initial function is important, because when the program reaches its end, it calls back to the continuation of main(), which completes the program, and notifies the system to release it from memory.

As the program lives, it calls other functions, adding them to the stack.

So if you had this code fun main() {}, the lifecycle of the program-level continuation is contained within the brackets of the main function. But when another function is called, the first thing the system does is create a new Continuation for the new function. It adds some information to the new continuation, like what is the parent function and its Continuation object — in this case main(). It additionally passes the information about which line of code the function was called at, and with which arguments, and what its return type should be.

Thanks for reading…

--

--

Abhishek Srivastava

Senior Software Engineer | Android | Java | Kotlin | Xamarin Native Android | Flutter | Go