跪拜 Guibai
← Back to the summary

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:

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)

🌟 Case 2: Pure JVM / Non-Android Environment

If there is no Android Looper, delay will fallback to the coroutine's global:

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()

🧩 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()

2. Handler (Task Dispatch)

fun sendMessageDelayed(msg: Message, delayMillis: Long)

3. Message (Task Carrier)

class Message {
    var whenTime: Long = 0L
    var callback: Runnable? = null
    var target: Handler? = null
}

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


🚀 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