1. Long operations and Thread.sleep
Before rushing into new terms, let’s admit it honestly: the word “asynchrony” sounds like it’s going to be either magic or pain. Most often — both, just in different proportions. We’ll start with a very down-to-earth observation: some actions take time, and if at that moment you “clamp down” on the only execution thread, the program simply stops moving forward.
Imagine your code is a line in a coffee shop. If the barista (the thread) disappears into the back room for 20 seconds to wait for a milk delivery, the line doesn’t “parallelize” just because the people in line are trying really hard. The line stands still. In programs the same thing happens, except instead of milk — it’s the network, disk, database, or just a timer.
The most straightforward example of blocking is Thread.sleep(...). It pauses, but the cost of that pause is that the thread cannot do anything else.
fun main() {
println("A") // A
Thread.sleep(300)
println("B") // B
}
This example isn’t “bad.” It simply demonstrates the behavior: between "A" and "B" execution completely stopped.
Sometimes blockings accumulate so quietly that you end up surprised: “Why is the program so slow? I’m not doing anything special.” But you are. You’re waiting. And you’re waiting in a blocking way.
fun main() {
Thread.sleep(200)
Thread.sleep(200)
println("Done") // Done
}
What exactly is blocked: the thread
The word “thread” sounds like something from plumbing: something flows somewhere, somewhere a valve got shut off. In fact, the analogy is almost accurate. A thread is the “execution lane” for code: your main walks along it step by step. And if you put a concrete block on that lane (Thread.sleep, blocking reads, a long request), movement along the lane stops until the block is removed.
In Kotlin/JVM, a regular console program starts with a thread that is often called "main". We can see it like this:
fun main() {
println(Thread.currentThread().name) // main
}
Now the important part: it’s not “your function,” not “a line of code,” not “a variable” that gets blocked. The thread that runs it gets blocked. If you have one thread — the freeze is total. If you have multiple threads — the freeze is local, but you pay a different price (we’ll get to that).
Let’s look at an example that imitates a “long profile load.” This isn’t the network and isn’t a database — just a delay, so you can feel the model.
fun loadProfileBlocking(id: Int): String {
Thread.sleep(200) // imitate waiting
return "User#$id"
}
fun main() {
val name = loadProfileBlocking(1)
println(name) // User#1
}
While Thread.sleep runs, the "main" thread is busy waiting. It doesn’t “switch” to other tasks, because it has… no other tasks, and it has “fallen asleep” itself.
Why “just more threads” is not a cure-all
After the previous section, an idea usually appears that sounds like a plan: “OK, if one thread blocks — we’ll make a second, third, hundredth!” And that sometimes really works. But then reality kicks in: threads are an operating system resource, and they are relatively “heavy.” Creating them in bulk is expensive, and keeping thousands of threads waiting is even more expensive.
In the official Kotlin documentation, this is stated very bluntly: a common approach to concurrency is threads, but threads are “heavy,” and creating a lot of them can lead to performance problems. This is exactly where the motivation for coroutines as a lighter-weight unit of execution comes from.
Let’s try to feel the “heaviness” of threads without profilers and ritual drums. If you write a program that starts many tasks, and each one “hangs” in sleep, then threads will be tied up waiting. From a human point of view it looks like: “They’re not doing anything, they’re just waiting.” But from the system’s point of view you’re keeping many active entities that must be managed.
In this lecture we won’t build a multithreaded hell (that’s a separate horror genre), but it’s important to remember the thought: threads are not free. So we need a way to wait without holding the thread “hostage.”
2. Suspension: what coroutines are
A coroutine as a suspendable task
Here comes the key idea of the day. A coroutine is a lightweight unit of execution that you can suspend and later resume. And suspension is designed so that it does not have to hold on to the thread for the entire waiting time.
If we continue the coffee analogy, the thread is the barista, and the coroutine is an order. If the order “waits for the oven for 2 minutes,” the barista doesn’t have to stand there staring at the oven with a hypnotic gaze. They can work on another order. Then the oven “beeps,” and the barista returns to the first order and continues from the same spot.
Here is a very rough sketch of the difference:
flowchart TD
A[Main thread executes code] --> B{Need to wait?}
B -- Yes, Thread.sleep --> C[Thread is blocked and does nothing]
C --> D[Waiting is over]
D --> E[Continue execution]
B -- Yes, suspend/delay --> F[Coroutine is suspended]
F --> G[Thread can do other work]
G --> H[Waiting is over]
H --> I[Coroutine resumes from the same spot]
So far this sounds almost like “magical multitasking,” but an important correction: a coroutine is not a thread. A coroutine can run on a thread, then suspend, then resume (depending on configuration and environment) — all without allocating a separate thread for each wait.
suspend as a contract
Now we get to the word you will see constantly: suspend. In Kotlin, coroutines are built around suspending functions. The documentation phrases it like this: coroutines let you write asynchronous code in a sequential style, and suspend marks functions that can suspend execution.
A super-important thought for a beginner: suspend is not a “speed-up” and not “run it on another thread.” It’s a contract marker: inside the function there may be suspension points. That is, the function can say: “I’m going to wait now, but I’ll wait correctly — not necessarily blocking the thread.”
An example of a “prepared” function that in theory could be long-running:
suspend fun loadUserName(): String {
return "Alice"
}
Yes, the example is too simple. But it shows the shape: suspend was added, and the return type stayed a normal String. This is an important difference of the Kotlin approach compared to approaches where instead of String you get a “promise wrapper” (like Future/Promise). Kotlin tries to preserve the feel of ordinary code.
There’s also a flip side: you can’t just call a suspend function from anywhere. If you try to do this, the compiler will stop you:
suspend fun loadUserName(): String = "Alice"
fun main() {
// val name = loadUserName() // COMPILATION ERROR: suspend function should be called only from a coroutine or another suspend function
}
This isn’t Kotlin being picky. It’s protecting meaning: if a function can suspend, it can’t be called from the “regular world” that doesn’t know how to handle that suspension.
Suspension points and delay
So far we have the word suspend, but it’s unclear where exactly the pause happens. For that, there is the concept of a suspension point. This is a place where a coroutine can temporarily stop and give the thread to other tasks, and then resume.
The classic example is delay(...) from the kotlinx.coroutines library. It is similar in meaning to Thread.sleep(...) (“wait”), but differs in mechanics: delay is a suspending function, and it does not have to block the thread during the wait. This idea is directly related to the fact that coroutines can “suspend without blocking system resources.”
We haven’t yet learned how to run coroutine code properly (that’s the next lecture), but you can already see the shape:
import kotlinx.coroutines.delay
suspend fun slowHello(): String {
delay(100) // suspension point
return "Hello"
}
Notice the discipline: the function is honestly marked as suspend, because it contains delay. This is a very healthy habit: if there is real waiting inside, the contract should be honest. If there is no waiting — don’t slap suspend on “just in case,” otherwise you’ll make your own life harder.
3. Tools: kotlinx.coroutines and the entry point into the coroutine world
Now an important anti-myth. Coroutines in Kotlin are not “compiler magic that is always enabled.” Most practical coroutine tools live in the kotlinx.coroutines library. The documentation states directly that most coroutine capabilities are provided by this library.
Two everyday consequences follow from this.
First: you will need dependencies (Gradle/Maven) and imports. This is normal: the Kotlin standard library doesn’t drag in everything under the sun.
Second: to execute suspend code, you need an “entry” into the coroutine world. The minimal example that just shows the skeleton (without details; we’ll break them down later) looks like this:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
println("We are inside coroutine world") // We are inside coroutine world
}
Here runBlocking is literally a “blocking bridge” from a regular main into a coroutine block. It blocks the current thread until the block completes, but inside the block you can already call suspend functions. We’ll go through this in detail in the next lecture — today it’s important for us to understand simply why such a bridge is needed at all.
4. Mini practical example: expense sync
To avoid staying at the level of terms, let’s create a small fragment of a “practical application” that we can improve in the next lectures of the day. Imagine we have a console expense tracker (we already know how to store data, print reports, work with collections), and now we want to add a “sync data” command. Sync is almost always waiting (network, disk, server), which makes it an ideal candidate for coroutines.
First we’ll make a bad, but understandable blocking version: it honestly “hangs” while the sync is happening.
data class Expense(val title: String, val amount: Int)
fun syncExpensesBlocking(): List<Expense> {
Thread.sleep(400) // imitate a long sync
return listOf(
Expense("Coffee", 250),
Expense("Pizza", 700)
)
}
Now let main call this function and print the result:
fun main() {
println("Sync started...") // Sync started...
val expenses = syncExpensesBlocking()
println("Sync done: ${expenses.size}") // Sync done: 2
}
From the console’s point of view everything is “OK.” But in terms of behavior, this is exactly the case where our thread does nothing except wait. And if this were not an educational console app, but, for example, a UI app, the user would see “application not responding.” That’s why the documentation often explains coroutines as a way to run tasks concurrently and not block each other.
What would an “honest contract” look like in coroutine style (still without launching)? We would make the sync a suspend function:
import kotlinx.coroutines.delay
suspend fun syncExpenses(): List<Expense> {
delay(400) // instead of Thread.sleep: suspension, not blocking
return listOf(
Expense("Coffee", 250),
Expense("Pizza", 700)
)
}
Notice that here we’re not changing the “return a list” logic, but the way we wait and the call contract. This is exactly why (and for the ability to run multiple such operations properly) we go to coroutines: we want to be able to wait while keeping the code readable, but not turning waiting into a “freeze of everything around.”
5. Typical mistakes when getting acquainted with coroutines
Mistake #1: thinking that “coroutine = thread.”
This is a very popular trap in the “looks similar, so it must be the same thing” category. A thread is an OS resource, fairly heavy. A coroutine is a lighter unit of execution that can be suspended and resumed without creating a separate thread per task. This contrast — “threads are heavy” vs “coroutines are lightweight” — is one of the main motivators for coroutines.
Mistake #2: expecting suspend to automatically make code parallel and fast.
Suspend is not a “speed booster.” It’s a contract marker: a function can suspend. Parallelism appears not because of the word suspend, but because of how and where you launch coroutines. Kotlin specifically tries to preserve the normal, sequential code model so that you write “top to bottom,” but wait correctly.
Mistake #3: treating coroutines as a “built-in part of the language without libraries.”
In Kotlin there really is the keyword suspend, but most practical tools — delay, launch, async, and so on — come from kotlinx.coroutines. So if an “import doesn’t compile,” it’s not divine punishment, but a reminder: you need the dependency and the right imports.
Mistake #4: confusing “suspension” and “blocking” at the level of how it feels.
From the outside it looks like both Thread.sleep(1000) and delay(1000) “waited a second.” But the meaning is different: Thread.sleep blocks the thread, while delay suspends the coroutine, leaving the thread a chance to do other work. This difference is especially important when there are many tasks and waits are frequent.
Mistake #5: trying to call a suspend function from a regular main and getting mad at the compiler.
The compiler is your ally here: it prevents you from accidentally writing code that cannot correctly live in a world of suspensions. To call suspend code, you need a coroutine context (for example, a bridge via runBlocking, which we’ll cover in the next lecture).