Lesson 23. Suspend Functions and Coroutine Builders
Mission link: The mission names writing and reasoning about coroutines. This is the entry point: what the keyword does, what starts a coroutine, and why concurrent Kotlin still reads sequentially.
Primary source: Docs: "Coroutines basics", Kotlin
Prerequisites: Lesson 22, Lesson 15
Warm-up
- ▢ A field is
@Volatile var counter = 0. Iscounter++safe from two threads, and why?
Check
No. @Volatile gives visibility, and atomicity of a single read or a single write of the backing field. counter++ is a read and then a write, so two threads can read the same value and write the same increment, losing one. Visibility and atomicity are separate guarantees, and only one of them is on offer here.
- ▢ Which thread-safety mode does
by lazyuse when you do not name one, and what does it promise?
Check
LazyThreadSafetyMode.SYNCHRONIZED. It uses a lock so that only a single thread initialises the value, and the initialised value is then visible to all threads. PUBLICATION and NONE are the opt-outs, and NONE gives no locks and unspecified behaviour across threads.
- ▢ In a lambda with receiver, of type
A.(B) -> C, what isthisinside the lambda body, and what does that let the lambda call without qualifying it?
Check
this is the receiver, an instance of A, so the body can call A's members and any extension function on A as if it were inside A. That is the mechanism the scope functions are built on, and it is about to explain why launch can be called with no qualifier inside one block and not compile at all outside it.
Know this
A coroutine is a suspendable computation. The point of it is that concurrent code stays written in a clear, sequential style: coroutines can run concurrently with each other and potentially in parallel (Coroutines basics). Note where the line falls: suspend is part of the core language, while nearly everything else in this stage, the builders included, comes from the kotlinx.coroutines library.
What suspend changes. It marks a function that can pause and resume later without changing the shape of your code. The compiler then enforces one rule that shapes everything else: a suspending function can only be called from another suspending function. The entry point is suspend fun main(), and where a framework will not let you have one, runBlocking() is the way in.
That rule is why suspending code reads the way it does. There is no marker at the call site, no callback, no chained continuation: val user = loadUser(id) is a normal-looking line whose function happens to be able to suspend. Suspension is a property of the function, declared once, not a ceremony repeated by every caller.
A coroutine is not a thread, and the difference is the reason for all of this. A thread is managed by the operating system, needs a stack of a few megabytes, and a JVM typically handles a few thousand of them at once. A coroutine is not bound to a thread at all: it can suspend on one and resume on another, and when it suspends the thread is not blocked and stays free to run other work, which is why millions of coroutines fit in one process (Coroutines basics). Lesson 22's platform thread is still underneath, doing the running.
The immediate consequence is a rule you can act on: suspend, do not block. delay() suspends the coroutine and releases the thread; Thread.sleep() blocks the thread and takes it out of service for everything else scheduled on it. One blocking call inside a coroutine on a small pool stalls work that has nothing to do with it.
The three builders.
| Builder | Returns | Reach for it when |
|---|---|---|
runBlocking { } | The block's value | You must call suspending code from non-suspending code and have no other way in. It creates a scope and blocks the current thread until everything launched inside finishes |
CoroutineScope.launch { } | Job | You want work started alongside the rest of the scope and do not need its result. The Job is the handle for waiting on it or cancelling it |
CoroutineScope.async { } | Deferred | You want a result later. Deferred is a light-weight non-blocking future; await() gets the value, and since Deferred is also a Job it can be cancelled (Composing suspending functions) |
The docs are pointed about runBlocking: use it only when there is no other option, such as implementing a third-party interface you cannot change. Inside a suspending function you never need it, because there you can simply call the thing.
Suspending is sequential by default. Two suspending calls one after the other run one after the other, exactly as they read, so two operations of a second each take about two seconds. Nothing about suspend makes them concurrent. Concurrency is something you ask for: start both with async, then await both, and the same work takes about one second (Composing suspending functions). async can even be made lazy with start = CoroutineStart.LAZY, in which case it does not begin until await() or Job.start().
Why launch needs a receiver. launch and async are extension functions on CoroutineScope, which is the warm-up's third answer arriving in practice: inside a block whose receiver is a CoroutineScope, they resolve with no qualifier. Move such a call into a helper function and it stops compiling unless that function declares the receiver, as fun CoroutineScope.launchAll(). So a builder call is never floating free; it always belongs to some scope.
Which scope, and what that scope guarantees about waiting for its children and cancelling them, is lesson 24's subject. For now, one fact from it is enough to read the examples: coroutineScope { } runs its block and does not return until every coroutine launched inside it has finished.
Practice
- ▢ A plain
fun saveAll()callssuspend fun save(item: Item). It does not compile. Why, and what are the three ways out?
Check
A suspending function can only be called from another suspending function. The three ways out: mark the caller suspend too, which is usually right and pushes the question up to its caller; call it inside a coroutine builder's block, launch, async or withContext, if you have a scope; or, at the very edge of the program where non-suspending code has to call in and cannot be changed, runBlocking. Reaching for runBlocking first is the Java-shaped instinct, and it blocks the thread that could have been running the work.
- ▢ Two suspending functions each take about a second. Predict the elapsed time for calling them one after the other, and for
val a = async { one() }andval b = async { two() }followed bya.await() + b.await().
Check
About two seconds, then about one second. Suspending code is sequential by default: the second call does not start until the first returns, and suspend does nothing to change that. async is how you ask for concurrency, and the two await calls then just collect results that were being produced at the same time. Note the order of operations: starting both before awaiting either is what makes it concurrent. async { one() }.await() immediately followed by async { two() }.await() is the sequential version again, with extra machinery.
- ▢ Inside
launch { }, someone writesThread.sleep(1000)wheredelay(1000)was meant. It compiles and the timing looks similar in a small test. What has actually changed?
Hint
Ask what the thread is doing during each of the two calls, not what the coroutine is doing.
Check
delay suspends the coroutine and releases the thread, which is then free to run other coroutines. Thread.sleep blocks the thread: the coroutine still occupies it and nothing else scheduled there runs for a second. In a small test with an idle pool the wall-clock time looks the same, which is exactly why this survives review. Under load on a pool sized to the core count, a handful of blocking calls can hold every thread and stall work unrelated to them. This is the single most common way a Java habit quietly removes the benefit of coroutines.
- ▢ A suspending function's body wraps each of its own suspending calls in
runBlocking { }. It works. What is wrong with it?
Check
runBlocking blocks the current thread until the work inside finishes, so this converts every suspension point back into a blocked thread and gives up the whole mechanism while keeping the syntax. Inside a suspending function the calls need no wrapper at all: call them directly. runBlocking is the bridge from non-suspending code that cannot be changed into suspending code, which is why the docs restrict it to the case where there is no other option.
-
▢ Which claim about
launchis correct?- a)
launchreturns aJobcarrying the block's result, whichawaitthen unwraps - b)
launchreturns aJobwith no result, whileasyncreturns aDeferred - c)
launchreturns aDeferred, so a fire-and-forget call must discard it explicitly - d)
launchreturns nothing at all, so a launched coroutine cannot be cancelled
- a)
Check
b) launch gives you a Job, a handle for waiting and cancelling that carries no value, and async gives you a Deferred, which is a Job that also promises a result. (a) puts a result on the wrong type: there is no await on a plain Job. (c) has them swapped. (d) is false, and the Job is precisely how a launched coroutine is cancelled, which is lesson 27's material.
Real-world reps
- [ ] Find suspending code you have access to, or the coroutines documentation's own examples, and count the
runBlockingcalls. For each, decide whether non-suspending code really had to call in there, or whether the caller could have beensuspendinstead. - [ ] Take one function in your own work that does two independent slow things in a row. Write down the two-line change that would make them concurrent, and the elapsed time you would expect before and after.
- [ ] Tomorrow: search for blocking calls sitting inside coroutine code,
Thread.sleep, a synchronous HTTP client, a plain JDBC call. Pick the one with the widest reach and say which threads it takes out of service while it runs.
Going further
- Docs: "Coroutines basics", Kotlin
- Docs: "Composing suspending functions", Kotlin
- Docs: "Coroutines guide", Kotlin
- Resources
Not landing? Reread the primary source at the top, since this lesson compresses it and compression is where understanding leaks. Check the glossary for any term that felt slippery.
If the lesson itself is unclear rather than the material, that is a defect: open an issue.