Android's MessageQueue Is Not a Queue — It's a Time-Aware Message Selector
Handler (Part 2): Why Isn't MessageQueue a Normal Queue?
Part 1 clarified one thing:
Handler.post simply wraps a Runnable into a Message,
then puts it into the target Looper's MessageQueue.
But new questions immediately arise:
Once a Message is in the queue, why isn't it executed immediately?
Why doesn't postDelayed need to create a new timer thread?
Why doesn't the main thread spin in a busy loop burning CPU when there are no messages?
Why are some normal messages blocked by a sync barrier?
Why can async messages bypass the barrier?
Why might post return false after quit / quitSafely?
These questions reveal that MessageQueue is not a normal FIFO queue.
It doesn't just "save messages"; it is also responsible for:
Time judgment:
Which message has reached its `when`, and which hasn't.
Order selection:
How to choose among due messages, sync barriers, and async messages.
Idle judgment:
Whether to execute IdleHandlers when the queue temporarily has no executable messages.
Thread waiting:
Calling nativePollOnce when there are no immediately executable messages;
whether it truly blocks depends on the current round's timeout.
Thread waking:
When a new message changes the current waiting result,
wake the waiting Looper via nativeWake if necessary.
Quit semantics:
After quit / quitSafely, which messages can still be processed, and which posts will fail.
So this article answers only one question:
After a Message is enqueued, how does MessageQueue decide when it can be retrieved by the Looper?
The source code in this article references the android-16.0.0_r4 tag of Android 16 QPR2. The main text only discusses the stable mechanisms of Handler, without expanding on version-specific implementation differences.
Let's first present the core model:
MessageQueue
|
| Saves Messages
| Judges whether they are due based on `when`
| Selects async Messages when encountering a sync barrier
| Calculates wait time when there are no executable messages
v
nativePollOnce
|
| timeout = -1 or positive: block waiting for fd / wake / timeout
| timeout = 0: return immediately and re-check the queue
v
Re-enter MessageQueue.next()
This mechanism doesn't answer "is there something in the queue?" but rather:
Is there a Message in the queue right now that can be retrieved and executed?
These two questions are different. Having messages in the queue does not mean they can be executed right now.
1. What Information Does a Message Carry When Enqueued?
Message is not just a small object holding a what. Before a Handler puts it into MessageQueue, at least several types of information will affect subsequent selection:
| Field | Role | Impact on execution timing |
|---|---|---|
when |
Earliest executable time for this message | Cannot be retrieved before this time |
target == null |
Internal sync barrier marker | Changes the message selection rules after the barrier |
asynchronous |
Whether it's an async message | Can bypass a sync barrier |
As mentioned in Part 1, callback determines the dispatch path after retrieval, and Handler.enqueueMessage() writes the current Handler into msg.target. This part only focuses on when, barriers, and asynchronous, which affect "when it can be selected."
The minimal chain:
Handler.post(r)
-> sendMessageDelayed(msg, 0)
-> sendMessageAtTime(msg, now)
-> MessageQueue.enqueueMessage(msg, when)
For a delayed task:
handler.postDelayed(runnable, 5000);
It does not create a dedicated timer thread for this task. It calculates a future when, then puts the message into the target MessageQueue:
when = SystemClock.uptimeMillis() + 5000
Later, each time MessageQueue.next() selects a message, it checks whether the candidate message's when has arrived. "Arrived" only means "currently eligible to be retrieved," not that it will execute precisely at that millisecond; preceding callbacks, thread scheduling, etc., can still cause it to start later.
Source code entry points:
So the accurate description of postDelayed is:
postDelayed is a delayed scheduling interface, but it does not create a dedicated timer thread for the task;
it calculates a future `when` and enqueues the message; the Looper only has a chance to retrieve it after the corresponding time is reached.
2. Is MessageQueue a FIFO?
It cannot simply be called one.
The rule for a normal FIFO queue is:
First in, first out.
MessageQueue's rule is closer to:
The message with the earliest due `when` is more likely to be retrieved first;
but if a sync barrier is encountered, normal sync messages may be blocked;
if there is an async message, it may bypass the barrier and execute first.
Internally, different data structures can be used, but the same set of selection semantics must be maintained externally. Rather than treating a specific linked list implementation as the whole truth, it's better to first look at this semantic skeleton:
boolean enqueueMessage(Message msg, long when) {
if (queueIsQuitting) {
return false;
}
msg.when = when;
saveMessageByExecutionTime(msg);
if (newMessageChangesCurrentWaitResult) {
nativeWake(mPtr);
}
return true;
}
This is not compilable source code from a specific version, but rather four stable constraints:
1. A Message carries its earliest executable time, `when`;
2. `next()` must find a candidate message that satisfies the current time and barrier rules;
3. When there are no executable messages, it must calculate the wait time based on the next opportunity;
4. When a new message changes the current waiting result, it must wake the target Looper thread.
Therefore, the execution order is not a simple copy of the order in which post() was called.
For example:
handler.postDelayed(taskA, 10_000);
handler.post(taskB);
Although taskA was enqueued first, taskB's when is earlier, so taskB will be retrieved first.
Thus, don't understand MessageQueue as a normal FIFO:
It is more like a message selector behind the target Looper.
3. Why Can MessageQueue.next() Block?
Looper.loop() continuously calls MessageQueue.next().
If there is a message in the queue that has expired and is not blocked by a barrier, next() returns it.
If there are no executable messages, next() cannot spin. Otherwise, the main thread would burn CPU constantly when there are no messages.
So next() calculates how long it should wait next:
Queue has no messages:
Can wait indefinitely until woken up.
Queue has messages, but `when` hasn't arrived yet:
Wait for the remaining time until `when`.
A sync barrier is encountered, but there are no async messages:
Wait for subsequent messages or barrier removal.
The logical skeleton:
Message next() {
int timeout = 0;
for (;;) {
nativePollOnce(ptr, timeout);
long now = SystemClock.uptimeMillis();
Message msg = selectExecutableMessage(now);
if (msg != null) {
removeFromQueue(msg);
return msg;
}
timeout = computeNextPollTimeout(now);
if (runIdleHandlersIfEligible()) {
timeout = 0; // IdleHandler might have enqueued again, re-check in the next round first
}
}
}
Again, this is not compilable source code from a specific version, but represents three stable actions:
1. Enter native wait based on the timeout calculated in the previous round;
2. After waking up, select the currently executable Message based on `when`, barrier, and async rules;
3. When there is no executable Message, recalculate the timeout and enter the next round.
IdleHandler is a callback opportunity inserted when the queue meets idle conditions. As for whether message selection and state updates are protected by object locks, concurrent containers, or other synchronization methods, that belongs to internal implementation and is not a mechanism this main line depends on.
Source code entry points:
Here it must be emphasized:
Looper.loop is a loop, but it is not a busy spin.
When there are no immediately executable messages, MessageQueue calls nativePollOnce.
When timeout is positive or -1, the target thread enters a blocking wait;
when timeout is 0, poll returns immediately and re-checks the queue.
For example, when IdleHandlers need to run or the queue needs a quick re-check, the current round's timeout might be 0. So "calling nativePollOnce" cannot be directly equated to "the thread will definitely sleep this time."
This is the key to "why the main thread doesn't burn CPU when there are no messages."
4. What's the Difference Between nativePollOnce and Java sleep?
nativePollOnce is not a simple Thread.sleep().
It connects to the native Looper behind it and can wait for multiple types of events:
Wait according to the timeout calculated by the Java MessageQueue;
Wait for a wake event;
Wait for registered file descriptor events;
Wait for native layer callbacks;
Wait for a timeout return.
Source code entry points:
To put it simply:
The Java layer MessageQueue is responsible for calculating:
Is there a Java Message that can be executed now?
If not, what is the maximum time to wait?
The native Looper is responsible for waiting:
Wait for time based on the passed-in timeout;
Wait for fd events;
Wait for wake;
Wait for other native events.
The boundary here is very important: the native Looper does not read Message.when, nor does it know "which Java Message is due." The Java MessageQueue first converts the selection result into a timeout, and the native layer is only responsible for waiting according to this timeout or returning immediately, and handling wake, registered fds, and native callbacks.
So when troubleshooting, seeing a thread in nativePollOnce doesn't directly mean "it's stuck." It might just be:
There are currently no executable messages;
Or the next delayed message hasn't reached its time yet;
Or the thread is waiting for wake, registered input or other fd events, and timeout.
If an enqueue request for an expired message was accepted at the time, and it changed the current waiting result, the normal enqueue path will trigger nativeWake(). Therefore, a thread stack stopped at nativePollOnce is usually the result of "MessageQueue currently believes there are no executable messages," not an independent root cause for a message being late. The next step should be to check back: whether the message was accepted by the queue, whether when has arrived, whether there is an evidenced barrier scenario, whether the message has been removed, and whether the Looper is quitting.
5. Why nativeWake After Another Thread Enqueues?
Suppose the main thread is already blocked in nativePollOnce with a positive timeout or -1. At this time, a child thread posts a message that will change the current waiting result:
mainHandler.post(() -> updateUi());
If the message is just placed in the queue but the main thread is not woken up, the main thread might continue sleeping until the original timeout. Hence, nativeWake is needed.
But the role of nativeWake is easily misunderstood.
The accurate statement is:
nativeWake does not execute the message.
nativeWake only wakes up the waiting Looper thread.
After waking up, the Looper must re-enter MessageQueue.next()
to determine if there is now an executable Message.
Source code entry points:
The minimal chain is:
Child Thread
-> MessageQueue.enqueueMessage()
-> Determine if the new message changes the current waiting result
-> nativeWake() if necessary
Target Thread
-> nativePollOnce is woken up
-> Returns to MessageQueue.next()
-> Re-checks the queue
-> Retrieves an executable Message
So nativeWake solves "how to wake up the target thread when it's asleep," not "how the message executes."
6. Why Is a Sync Barrier a Message with target == null?
The sync barrier is the most easily misrepresented point in MessageQueue.
Normal messages all have a target, because after being retrieved they must be handed over to a specific Handler:
msg.target = handler
A sync barrier is a special message that has no target Handler:
msg.target == null
Its role is not to execute a piece of business code, but to change the queue's selection rules:
Sync messages behind the barrier cannot be retrieved temporarily;
Async messages can bypass the barrier and continue execution.
A typical call relationship in UI traversal is:
ViewRootImpl.scheduleTraversals()
-> MessageQueue.postSyncBarrier()
-> Choreographer.postCallback(CALLBACK_TRAVERSAL, ...)
-> The async frame scheduling path can bypass the barrier
-> ViewRootImpl.doTraversal() first removes the barrier, then executes traversal
So it's not "Choreographer creates the barrier," but rather ViewRootImpl inserts the barrier when scheduling a traversal, then hands it over to Choreographer to drive the corresponding callbacks.
Source code entry point:
The core of this diagram is:
A sync barrier is not a "higher priority task."
It does not dispatch itself.
It only makes MessageQueue skip normal sync messages when selecting the next message,
and look for subsequent async Messages.
Also, don't misunderstand async message as a "faster message." The accurate statement is:
An async message is a message that can bypass a sync barrier.
It does not guarantee shorter execution time, nor does it make slow callbacks faster. It only changes "whether it can be selected when a barrier exists."
Source code entry points:
This is also why you shouldn't casually change business Handlers to async. Async is not a universal accelerator; it changes the ordering semantics under a barrier. The sync barrier itself is also a hidden system mechanism; business code should not treat it as a tuning interface. When used internally by the system, the token must be removed in pairs along cancellation, exception, and normal completion paths.
7. Is IdleHandler a Low-Priority Task Queue?
Not accurate.
IdleHandler is called when the queue enters an "idle" judgment. So-called idle usually means:
There are currently no immediately executable Messages;
Or the next Message hasn't reached its `when` yet.
This is not the same as a "low-priority queue." IdleHandler has no strict scheduling guarantees and is not suitable for heavy tasks. It is suitable for lightweight, deferrable work that won't affect the timeliness of the next message.
Wrong usage:
Putting time-consuming I/O into IdleHandler;
Assuming IdleHandler will definitely execute immediately;
Assuming IdleHandler can stably replace background threads.
A more stable understanding is:
IdleHandler is a lightweight callback opportunity given when MessageQueue is temporarily idle.
It is not a task scheduling framework.
If an IdleHandler itself executes for a long time, it will also occupy the Looper thread, and subsequent messages will still be delayed.
8. Why Can post Fail After quit / quitSafely?
Many of Handler's posting methods return a boolean:
boolean ok = handler.post(runnable);
This return value is not decorative. true means the enqueue request was accepted by the queue at that time; false means it was not accepted.
When MessageQueue is quitting or has already quit, enqueuing can fail:
Looper.quit():
Quit as soon as possible; the queue no longer continues to normally receive and process subsequent messages.
Looper.quitSafely():
Process messages that were already due when the quit was requested;
future-time delayed messages will be removed and will not continue waiting for execution.
So when troubleshooting "Handler didn't execute," the first step shouldn't be looking directly at handleMessage(). First ask:
Did the post/sendMessage happen?
Was the return value true?
Has the target Looper already quit?
Has the HandlerThread already exited?
If post() already returned false, then further analysis of sync barriers, slow delivery, or slow dispatch is meaningless. This enqueue request was rejected, and the message did not enter the target queue.
Conversely, true is not a promise of "will definitely execute in the end." After enqueuing, a message can still be cleared by removeCallbacks(), removeMessages(), or removeCallbacksAndMessages(), and can also be cancelled during subsequent lifecycle exits.
9. Why Can't the Message Be Retrieved Yet?
First, unify the main chain for a normal Handler message:
Posting
-> Enqueuing
-> Waiting
-> Retrieval
-> Dispatch and Execution
"Quitting" is not a final stage that every message goes through. The following situations can prevent a message from continuing along the main chain from side paths:
Not posted at all;
Enqueue request rejected;
Removed after enqueuing;
Actively cancelled after lifecycle end;
Looper / HandlerThread exited.
After ruling out these side paths, Part 2 only cares about "why it's still waiting, why it hasn't been retrieved by next() yet":
`when` not reached:
postDelayed, sendMessageAtTime, or retry backoff placed the execution time in the future.
Preceding message is slow:
The target Looper can only dispatch one Message at a time.
Thread scheduling delay:
The target thread is already runnable, but hasn't obtained CPU for a long time.
Evidenced sync barrier scenario:
Normal sync messages are blocked by the barrier; async messages can bypass it.
Currently no executable messages:
MessageQueue calls nativePollOnce;
timeout may cause the thread to block, or it may be 0 and return immediately to re-check the queue.
As for whether the callback is stuck in Binder, locks, I/O, or native/HAL after the message has started dispatch, that belongs to the execution evidence of Part 4 and won't be expanded upon here.
10. Vague Statements vs. Accurate Statements
First group:
Vague statement:
MessageQueue is a queue.
Accurate statement:
MessageQueue is a message selector with capabilities for time, barriers, idling, quitting, and native wait/wake.
Second group:
Vague statement:
postDelayed starts a timer.
Accurate statement:
postDelayed is a delayed scheduling interface, but it does not create a dedicated timer thread for each task;
it calculates a future Message.when and enqueues it; the message only has a chance to be retrieved by the Looper after it expires;
preceding callbacks and thread scheduling can still cause it to start later.
Third group:
Vague statement:
When there are no messages, the main thread is in an infinite loop.
Accurate statement:
Looper.loop is a loop, but it is not a busy spin;
when there are no immediately executable messages, it calls nativePollOnce;
it blocks when timeout is positive or -1, and returns immediately when timeout is 0.
Fourth group:
Vague statement:
nativeWake executes the message.
Accurate statement:
nativeWake only wakes up the target Looper thread;
after waking up, it must re-enter MessageQueue.next to determine if there is an executable message.
Fifth group:
Vague statement:
Async messages are faster.
Accurate statement:
Async messages are not faster normal messages;
they are special messages that can bypass a sync barrier.
11. Conclusion of This Part
MessageQueue is not a normal FIFO queue.
What it truly answers is:
After a Message has been enqueued,
why it can be executed now,
or why it cannot be executed yet.
When a message doesn't execute, you can't just say "the queue is blocked." First confirm whether it is still on the normal main chain, then determine if it was rejected, removed, cancelled from a side path, or interrupted due to Looper exit.
If the enqueue request was accepted at the time, and there is evidence that the message hasn't been removed and is still waiting, what Part 2 really checks is:
`when` not reached;
Preceding message execution took too long;
Thread scheduling delay;
Evidenced sync barrier scenario;
Currently no executable messages, leading to a nativePollOnce call.
Up to here, two things have been resolved:
Part 1:
How a task enters the target thread from the calling thread.
Part 2:
After a task enters the queue, why it isn't executed immediately.
The next part continues deeper into the system:
How exactly to distinguish between the main thread, HandlerThread, and Binder threads?