Kotlin's delay() Never Blocks a Thread — Here's the Four-Stage Mechanism That Runs It
In development, we often write code like this:
mainScope.launch {
log("start")
delay(1000)
log("end")
}
This code looks very simple, but it hides a very classic question:
Which thread does this 1-second delay actually happen on? The main thread executes before and after, so who is "waiting for time" in the middle?
Conclusion
delay never occupies a thread to wait; it is a process of "suspension + time registration + dispatch recovery + state machine advancement."
No business thread is sleeping in the middle, and no thread is blocking to count time.
You can run my Demo first:
👉 Delay Implementation Principle (Custom EventLoop) (Non-Android environment)
🧩 1. The Four-Stage Model of delay
To thoroughly understand delay, we break it down into four consecutive stages of underlying operation:
🧩 Stage 1: Suspend
When the code executes to delay(1000), the coroutine immediately triggers suspension:
1. Save Continuation
The subsequent business code (like log("end")) is encapsulated as the remaining logic of the suspension point into a Continuation (i.e., a state machine object).
2. Thread is immediately released
What happens to the current thread (e.g., the UI main thread) is:
- ❌ No
Thread.sleep() - ❌ No infinite loop blocking
- ❌ No CPU spinning
The current coroutine directly "saves state and releases the execution right of the current thread." If it's the Android main thread, it will immediately return to the underlying message loop to handle other UI drawing or click events, absolutely without causing any lag.
🧩 Stage 2: Schedule (Time Registration)
Since the thread is released, someone has to be responsible for "remembering this 1 second." In this stage, the coroutine registers the just-encapsulated resume task into the corresponding environment's time queue:
🌟 Case 1: Android Environment (Dispatchers.Main)
The coroutine ultimately hands the task over to Android's underlying message mechanism:
handler.sendMessageDelayed(msg, 1000)
- Who is waiting? It's not the coroutine waiting, but the Android system's
MessageQueuecooperating with the underlyingnativePollOnce()(epoll mechanism) managing the time.
🌟 Case 2: Pure JVM / Non-Android Environment
If there is no Android Looper, delay will fallback to the coroutine's global:
- DefaultExecutor: A global delayed daemon thread. It manages delays uniformly through
LockSupport.parkNanos()combined with an internal delayed task queue (timing wheel or min-heap structure).
Essence: Regardless of the environment, the task is always entrusted to the underlying scheduling system/daemon thread to advance time.
🧩 Stage 3: Dispatch (Dispatch Recovery)
When the 1000ms time is up, the underlying timer (Looper or DefaultExecutor) triggers a callback. But note: it does not blindly run log("end") directly at this point!
The task first passes through:
Dispatcher.dispatch()
- Why is this step needed? Because the thread where the timer triggered might not be the thread the coroutine originally required (for example,
DefaultExecutoris an independent daemon thread). - Core Responsibility: The
Dispatcheris responsible for re-checking the coroutine's context and deciding "on which thread to resume this coroutine." It will re-post(dispatch) the resume task back into the target thread's task queue (e.g., re-dispatch back to Android's main thread queue).
🧩 Stage 4: Resume (Resume State Machine)
After the target thread consumes this dispatch task, the coroutine framework retakes control.
1. Resume Continuation
Call continuation.resume(Unit) to re-awaken the previously saved coroutine state machine.
2. Execute remaining code
The coroutine advances from the suspension point, finally executing:
log("end")
The entire lifecycle is perfectly closed-loop at this point.
2. Seeing the Essence of delay with a Custom EventLoop
To thoroughly verify and see the four stages above, I wrote this Demo to perfectly reproduce a "coroutine delay scheduling system."
1. Looper (Event Loop)
Looper.prepare()
Looper.loop()
- Essence: A
while(true)message loop, used to simulate task consumption when a thread is not blocked.
2. Handler (Task Dispatch)
fun sendMessageDelayed(msg: Message, delayMillis: Long)
- Essence: Responsible for putting tasks into a queue and specifying a future execution time.
3. Message (Task Carrier)
class Message {
var whenTime: Long = 0L
var callback: Runnable? = null
var target: Handler? = null
}
- Essence: A "wrapper for an action to be executed in the future."
4. CoroutineDispatcher (Coroutine Bridge)
class HandlerDispatcher(
private val handler: Handler
) : CoroutineDispatcher(), Delay
3. The Real Implementation of delay (Core Interception)
In my custom scheduler, the core connection point for delay lies in implementing the scheduleResumeAfterDelay interface:
override fun scheduleResumeAfterDelay(
timeMillis: Long,
continuation: CancellableContinuation<Unit>
) {
// [Corresponds to Dispatch & Resume] Encapsulate "resume coroutine" into a Runnable task
val block = Runnable {
with(continuation) {
// The official standard implementation would re-dispatch threads via dispatcher.dispatch
// Here, resume directly on the Looper thread
resumeUndispatched(Unit)
}
}
// [Corresponds to Schedule] Encapsulate into a Message, throw it into the delay queue
val msg = Message().apply {
callback = block
}
handler.sendMessageDelayed(msg, timeMillis)
}
🔥 4. Complete delay Execution Link Diagram
launch (Start Coroutine)
↓
delay(1000)
↓
【1. Suspend】Suspend coroutine (save Continuation state, release current thread)
↓
【2. Schedule】Call scheduleResumeAfterDelay → Encapsulate Message(callback = resume) into queue
↓
【Underlying Wait】MessageQueue / DefaultExecutor advances time without blocking business threads
↓
【3. Dispatch】Time's up! Looper retrieves message, dispatches via Dispatcher, decides which thread to resume on
↓
【4. Resume】Call continuation.resume() to resume state machine
↓
Continue executing "end"
5. Conclusion
- The essence of
delayis: State machine suspension (Suspend) $\rightarrow$ Time registration (Schedule) $\rightarrow$ Thread dispatch (Dispatch) $\rightarrow$ Callback resume (Resume). - During the entire suspension period, no thread sleeps, no thread blocks, no CPU spins; there is only the scheduling system advancing time extremely efficiently.
🚀 6. The Value of This Demo
Although this code is a Demo, it expresses the closed-loop collaboration among EventLoop, MessageQueue, Dispatcher, Delay, and Continuation with the most concise code.
👉 Welcome to my GitHub repository to read the complete source code implementation