跪拜 Guibai
← All articles
Android

Kotlin's delay() Never Blocks a Thread — Here's the Four-Stage Mechanism That Runs It

By 潜龙勿用之化骨龙 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Misunderstanding delay as a thread-blocking sleep leads developers to wrap it in unnecessary thread-switching or worry about main-thread freezes. Knowing it's a non-blocking suspension mechanism clarifies why structured concurrency on Android stays responsive and how custom dispatchers can hook into any event loop.

Summary

When a coroutine hits delay(1000), the current thread is released instantly — no Thread.sleep(), no spin-wait, no blocking. The remaining code is saved as a Continuation and handed to the environment's scheduler. On Android, that means a Handler message with a future timestamp; on the JVM, it falls back to a global DefaultExecutor daemon thread using LockSupport.parkNanos().

Once the timer fires, the task does not resume in-place. It first passes through the coroutine Dispatcher, which re-posts the continuation to the correct target thread — often the main thread on Android. Only then does the state machine advance and execute the code after the delay.

A companion demo rebuilds this entire mechanism from scratch with a custom Looper, Handler, Message queue, and CoroutineDispatcher. The implementation centers on the scheduleResumeAfterDelay function, which wraps the continuation resume inside a Runnable, packages it as a delayed Message, and lets the event loop handle the rest.

Takeaways
delay(1000) immediately suspends the coroutine and frees the calling thread; no thread sleeps or spins during the wait.
On Android, delay registers a timed message with the main thread's Handler and MessageQueue, which uses nativePollOnce() to track time.
On the JVM without Android, delay falls back to DefaultExecutor, a global daemon thread that parks itself with LockSupport.parkNanos() and manages a delayed-task queue.
After the timer fires, the coroutine Dispatcher re-routes the continuation to the correct target thread before resuming execution.
The entire mechanism is a four-stage pipeline: Suspend → Schedule → Dispatch → Resume, with the state machine advancing only at the final step.
Conclusions

Many developers treat delay as a magical black box; exposing it as a plain old scheduling problem demystifies coroutine performance and makes custom dispatcher implementations approachable.

The fact that delay's fallback on the JVM is a single global daemon thread (DefaultExecutor) means thousands of concurrent delays can be managed without a thread-per-delay explosion — a design choice that mirrors how production event loops work.

Building a minimal EventLoop to replicate delay reveals that the core scheduling primitive is just a time-ordered queue and a thread-safe resume callback, which is simpler than most developers expect.

Concepts & terms
Continuation
A state-machine object that Kotlin's compiler generates at each suspension point. It holds the coroutine's local state and the address of the next code to execute after resumption.
scheduleResumeAfterDelay
The interface method that a coroutine dispatcher must implement to support delay. It receives a time in milliseconds and a CancellableContinuation, and is responsible for resuming that continuation after the specified time has elapsed.
DefaultExecutor
A global daemon thread inside kotlinx.coroutines that manages delayed tasks when no platform-specific scheduler (like Android's Looper) is available. It uses LockSupport.parkNanos() to wait for the next task's deadline.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗