消息延迟边界
Handler.postDelayed(r, delayMillis) 的参数叫“延迟”,但源码真正保存的是一个绝对的 Message.when。这个时间点只决定消息何时具备出队资格;消息何时真正调用 Runnable.run(),还 取决于目标 Looper 何时回到 MessageQueue.next()、队列前面是否有更早消息,以及当前分发是否 已经结束。
本文面向已经读过 Handler消息发送、 MessageQueue消息入队、 MessageQueue取消息 和 Looper主循环 的读者。文章只回答一个问题:从调用 postDelayed 到回调开始,时间戳、队列和线程分别做了什么;不讨论 AlarmManager 的系统唤醒 协议,也不把没有固定上界的调度误写成精确计时器。
读完后,读者应能从 Runnable 反查它的 Message.when,解释设备深度睡眠为什么会改变相对墙钟 时间,判断消息是“尚未到期”“已经到期但尚未取出”还是“已经取出等待分发”,并定位 removeCallbacks 为什么可能赶不上已经取出的消息。
1. 时间语义
源码文件:frameworks/base/core/java/android/os/SystemClock.java
Android 暴露的三个时钟不能互换。currentTimeMillis() 是可以被用户或网络调整的墙钟; uptimeMillis() 从开机计时但在深度睡眠时停止;elapsedRealtime() 包含深度睡眠。Handler 的 延迟 API 选择的是 uptimeMillis(),因此它表达的是“目标线程的 uptime 时间到达”,不是“墙上 时间经过了多少毫秒”。
/*
* uptimeMillis is counted in milliseconds since the system was booted.
* This clock stops when the system enters deep sleep ...
* elapsedRealtime ... include deep sleep.
*/
public static native long uptimeMillis();
public static native long elapsedRealtime();uptimeMillis() 的单调性适合队列排序:调用方的墙钟被调整,不会让一个已经排好的消息突然 变成“早于”或“晚于”另一个消息。代价是深度睡眠期间这个时钟不前进,消息的 when 也不会因为 设备睡眠而自动消耗掉剩余时间。
| 时钟 | 深度睡眠期间 | 适合回答的问题 | Handler 是否使用 |
|---|---|---|---|
currentTimeMillis() | 继续走,可能被调整 | 现在是哪个日期和时间 | 否 |
uptimeMillis() | 停止 | CPU 活跃时间过去多久 | 是 |
elapsedRealtime() | 继续走 | 设备实际开机经过多久 | 否 |
这张表不是三个 API 的完整说明,而是解释延迟结果时必须先消除的术语歧义。若需求是“现实时间 到点”,不能从 postDelayed 的 uptimeMillis 语义推导出该保证。
2. 相对转换
源码文件:frameworks/base/core/java/android/os/Handler.java
postDelayed 不直接操作 Runnable 的时间。它先把 Runnable 放进 Message.callback,再调用 sendMessageDelayed;后者把负数归零,并在一次读取的 uptimeMillis() 上加上相对延迟。
public final boolean postDelayed(@NonNull Runnable r, long delayMillis) {
return sendMessageDelayed(getPostMessage(r), delayMillis);
}
public final boolean sendMessageDelayed(@NonNull Message msg, long delayMillis) {
if (delayMillis < 0) {
delayMillis = 0;
}
return sendMessageAtTime(msg, SystemClock.uptimeMillis() + delayMillis);
}因此 postDelayed(r, 1000) 的源码含义可以写成:
enqueue.when = uptimeMillis() 在投递瞬间的读数 + 1000它不等价于“在调用线程睡眠 1000 毫秒”,也不等价于“回调线程在 1000 毫秒后一定空闲”。 调用方读取时钟、创建 Message、获得队列锁和完成入队都会发生在这次读取之后;这些时间会被 包含在从调用返回到目标 when 的剩余预算中。
2.1 绝对入口
源码文件:frameworks/base/core/java/android/os/Handler.java
postAtTime 和 sendMessageAtTime 接受已经计算好的绝对 uptimeMillis。它们不再重新读取 当前时间,所以同一个绝对时间可以让多个消息共享一个排序基准。
public final boolean postAtTime(@NonNull Runnable r, long uptimeMillis) {
return sendMessageAtTime(getPostMessage(r), uptimeMillis);
}
public boolean sendMessageAtTime(@NonNull Message msg, long uptimeMillis) {
MessageQueue queue = mQueue;
if (queue == null) {
RuntimeException e = new RuntimeException(
this + " sendMessageAtTime() called with no mQueue");
Log.w("Looper", e.getMessage(), e);
return false;
}
return enqueueMessage(queue, msg, uptimeMillis);
}这也解释了两个失败边界:没有绑定队列的 Handler 返回 false;已经退出的队列则在更深的 enqueueMessage 中拒绝。无论哪一种,消息都不会获得一个“稍后再试”的时间。
3. 时间排序
源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java
队列把 when 写入 Message,再按升序插入单链表。when == 0 是队头插入的特殊值;普通 延迟消息则按时间寻找第一个更晚节点。相同时间的消息不会因为排序算法而获得一个新的时间戳。
boolean enqueueMessage(Message msg, long when) {
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
synchronized (this) {
if (msg.isInUse()) {
throw new IllegalStateException(msg + " This message is already in use.");
}
if (mQuitting) {
IllegalStateException e = new IllegalStateException(
msg.target + " sending message to a Handler on a dead thread");
Log.w(TAG, e.getMessage(), e);
msg.recycle();
return false;
}
msg.markInUse();
msg.when = when;
Message p = mMessages;
if (p == null || when == 0 || when < p.when) {
msg.next = p;
mMessages = msg;
} else {
Message prev;
for (;;) {
prev = p;
p = p.next;
if (p == null || when < p.when) {
break;
}
}
msg.next = p;
prev.next = msg;
}
}
return true;
}这里的锁保证的是“写入和排序的一致性”,不是“到期后立即执行”。入队返回 true 时, Message.when 已经固定,消息也已经属于队列;但目标线程可能仍在执行旧消息,或者尚未被 native poll 唤醒。
3.1 唤醒条件
源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java
把新消息插入队头时,如果 Looper 正在阻塞,队列需要唤醒它重新计算超时。插入中部或尾部的 消息通常不需要唤醒,因为队头仍然决定下一次到期时刻;同步屏障和异步消息会改变这个判断。
boolean needWake;
if (p == null || when == 0 || when < p.when) {
msg.next = p;
mMessages = msg;
needWake = mBlocked;
if (p == null) {
mLast = mMessages;
}
} else {
needWake = mBlocked && p.target == null && msg.isAsynchronous();
// 插入中部或尾部,并继续维护 mLast 与异步消息计数
}
if (needWake) {
nativeWake(mPtr);
}这意味着“投递一个更晚的延迟消息”通常不会打断当前等待;“投递一个新的更早队头消息”才需要 让阻塞中的 next() 重新计算等待时长。唤醒动作只改变检查时机,不直接取出消息。
图中 nativeWake 是“重新检查”的边,不是“执行 Runnable”的边。真正的执行仍要经过 next() 出队和后续 Handler 分发。
4. 到期判断
源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java
next() 先进入 native poll,再在 Java 锁内读取新的 uptimeMillis()。如果队头消息的 when 仍在未来,队列把差值转换成下一次 poll 的毫秒超时;只有 now >= msg.when 时,Message 才会 从链表摘下并返回给 Looper。
int nextPollTimeoutMillis = 0;
for (;;) {
nativePollOnce(ptr, nextPollTimeoutMillis);
synchronized (this) {
final long now = SystemClock.uptimeMillis();
Message msg = mMessages;
if (msg != null && msg.target == null) {
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
nextPollTimeoutMillis = (int) Math.min(
msg.when - now, Integer.MAX_VALUE);
} else {
// 摘除 msg,标记 in-use,并返回给 Looper
return msg;
}
} else {
nextPollTimeoutMillis = -1;
}
}
}这里至少有三个时间点:
| 时间点 | 源码事件 | 消息状态 |
|---|---|---|
T0 | Handler 读取 uptimeMillis() 并入队 | when = T0 + delay |
T1 | next() 读取 now 且 now >= when | 消息具备出队资格并被摘除 |
T2 | Handler.dispatchMessage() 调用 callback | Runnable.run() 开始 |
T1 不早于 when,但 T2 还要等 Looper 从 next() 返回并进入分发。文章讨论的“实际延迟” 如果只记录 T0 和 T2,就会把队列等待、线程忙碌和分发前开销混在一起。
5. 轮询超时
源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java
当 next() 发现消息尚未到期,nextPollTimeoutMillis 会成为下一轮 nativePollOnce 的输入。这个值的作用是让 native 层在预计到期时返回;它不是一个会强制 Java 线程停止当前 callback 的抢占定时器。
if (now < msg.when) {
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
mBlocked = false;
// 摘除并返回消息
}若当前线程已经在 Handler.dispatchMessage() 中运行一个耗时 callback,next() 根本不会被 调用,新的延迟消息也没有机会被取出。即使 native poll 在目标时间返回,Java 分发线程也必须 先结束当前 callback,才能重新检查队列。
源码文件:frameworks/base/core/java/android/os/Looper.java
Message msg = me.mQueue.next(); // might block
if (msg == null) {
return false;
}
final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
msg.target.dispatchMessage(msg);这段顺序说明了延迟消息不会打断当前消息:next() 一次只交出一个 Message, dispatchMessage() 返回后外层循环才会再次进入 next()。所以源码能支持的稳健表述是 “不早于到期时间才具备出队资格”;不能从 sendMessageDelayed 的返回值推出固定的最大晚点时间。
6. 深度睡眠
源码文件:frameworks/base/core/java/android/os/SystemClock.java
SystemClock 的类注释明确说明,uptimeMillis() 在深度睡眠时停止,而 elapsedRealtime() 包含深度睡眠。Handler 使用前者,所以设备睡眠期间,消息的 uptime 预算不会继续减少。
/**
* uptimeMillis is counted in milliseconds since the system was booted.
* This clock stops when the system enters deep sleep.
*/
public static native long uptimeMillis();
/**
* elapsedRealtime returns the time since boot, including deep sleep.
*/
public static native long elapsedRealtime();假设投递时 uptimeMillis() == 10_000,延迟为 5_000,那么 when == 15_000。如果设备随后 进入深度睡眠,墙钟可能前进了很久,但 uptime 仍停在接近 10_000;设备恢复后,队列仍按 uptimeMillis() 继续等待到 15_000。这不是“消息丢失”,而是 API 选择的时钟语义。
因此,以下需求不能混用:
- “线程保持活跃后再过 5 秒回调”:可以使用
Handler.postDelayed; - “无论设备睡眠与否,现实时间 5 秒后触发”:不能由本文这条 Handler 路径保证;
- “进程被杀后仍要保留调度”:也不是 MessageQueue 的生命周期能力。
7. 分发滞后
源码文件:frameworks/base/core/java/android/os/Looper.java
Looper 在取出 Message 后才记录分发开始时间,随后调用目标 Handler。若前一个 callback 占用 线程,后一个延迟消息的 when 可能早已过去,但它只能排在当前 callback 返回之后。
final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
msg.target.dispatchMessage(msg);
final long dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;可以用三个差值定位晚点来源:
投递预算:when - T0 = delayMillis
队列晚点:T1 - when (到期后仍未被 next() 取出)
分发前后:T2 - T1 (出队到 Runnable 开始的路径)
总观测: T2 - T0其中 T1 - when 最容易被前一个长 callback 放大;它不是 uptimeMillis 的精度误差。源码没有 给出一个脱离线程负载、队列状态和设备运行状态的统一误差常数,文章也不为不同设备虚构平均值。
图中 T0、T1、T2 不是框架字段,而是阅读源码时用于分解一次观测的三个标记。只有把 when、next() 和 dispatchMessage() 分开,才能判断问题发生在计时、队列还是线程负载。
8. 取消窗口
源码文件:frameworks/base/core/java/android/os/Handler.java
removeCallbacks 最终按 Handler、Runnable 和 token 在队列中查找并移除尚未出队的消息。它不 能撤回已经由 next() 摘下、但尚未开始 dispatchMessage() 的 Message。
public final void removeCallbacks(@NonNull Runnable r) {
mQueue.removeMessages(this, r, null);
}源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java
next() 摘下消息时会把它从 mMessages 链表断开;之后另一个线程调用移除接口,已经不再能从 队列链表找到它。
msg.next = null;
msg.markInUse();
return msg;因此取消存在明确的竞态窗口:
| 取消时机 | removeCallbacks 能否找到消息 | 结果 |
|---|---|---|
when 尚未到,消息仍在链表 | 通常可以 | 消息被移除并回收 |
next() 已摘除,尚未分发 | 不可以 | callback 仍可能运行 |
Runnable.run() 已开始 | 不可以 | 只能由 Runnable 自己检查取消标志 |
如果业务要求“取消后即使消息已出队也不执行”,需要在 Runnable 内再检查一个由业务拥有的 volatile/原子状态;removeCallbacks 本身只负责队列中尚未出队的部分。
9. 队列退出
源码文件:frameworks/base/core/java/android/os/Handler.java
postDelayed 返回 true 只表示消息成功放入队列;Handler 的 Javadoc 明确保留了另一条路径: 如果 Looper 在消息交付时间之前退出,消息仍会被丢弃。
/**
* @return true if successfully placed into the message queue.
* A result of true does not mean the Runnable will be processed --
* if the looper is quit before the delivery time ... the message will be dropped.
*/
public final boolean postDelayed(@NonNull Runnable r, long delayMillis) {
return sendMessageDelayed(getPostMessage(r), delayMillis);
}源码文件:frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java
普通退出会清理待处理消息;next() 观察到 mQuitting 后销毁 native 资源并返回 null,外层 Looper 随即结束。安全退出的保留规则也由队列实现决定,不能从 Handler 的 postDelayed API 本身推导出“延迟消息一定执行”。
if (mQuitting) {
dispose();
return null;
}这条路径与时间精度无关:消息可能还没有到期,也可能已经到期但尚未被取出;只要队列在交付前 结束,调用者都不应把此前的 true 当作完成确认。
10. 测试输入
源码文件:frameworks/base/core/tests/coretests/src/android/os/MessageQueueTest.java
Android 17 的 MessageQueueTest 使用 HandlerThread、TestLooperManager 和固定的未来 时间戳测试 Legacy 队列的排序与清理。测试不是对所有设备延迟误差的统计,而是对队列不变量的 反向输入。
final long future = SystemClock.uptimeMillis() + LONG_DELAY_MS;
handler.sendEmptyMessageAtTime(0, future);
handler.sendEmptyMessageAtTime(1, future + 1);
handler.sendEmptyMessageAtTime(4, future + 4);
handler.sendEmptyMessageAtTime(3, future + 3);
handler.sendEmptyMessageAtTime(2, future + 2);
Message m = queue.peekLastMessageForTest();
assertEquals(m.what, 4);输入先按乱序时间投递 0、1、4、3、2,再检查队尾;它证明入队结构维护了时间顺序,不证明 消息在 future 的墙钟时刻执行。
同一测试还覆盖延迟消息清理:
for (int i = 0; i < 100; i++) {
handler.sendEmptyMessageDelayed(0, LONG_DELAY_MS);
}
resetQueue();
assertNull(queue.peekLastMessageForTest());这里的输入是 100 条未来消息,动作是 resetForTest(),断言是队尾为空;它证明测试队列的 清理动作能移除待处理消息,不证明生产环境 quitSafely() 会以同样实现保留或丢弃所有未来消息。
测试文件的 syncWait() 还用 handler.post() 作为同步点,等待 Looper 处理一个普通消息后再 检查断言。这说明测试作者需要显式等待目标 Looper 前进,正好对应本文的核心边界:入队和实际 消费不是同一个时刻。
11. 复查路径
源码文件:
frameworks/base/core/java/android/os/Handler.javaframeworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.javaframeworks/base/core/java/android/os/Looper.javaframeworks/base/core/java/android/os/SystemClock.javaframeworks/base/core/tests/coretests/src/android/os/MessageQueueTest.java
可以沿下面的搜索顺序复查一次延迟消息:
rg -n "postDelayed|sendMessageDelayed|sendMessageAtTime|uptimeMillis" \
frameworks/base/core/java/android/os/Handler.java
rg -n "enqueueMessage|nextPollTimeoutMillis|nativePollOnce|mQuitting" \
frameworks/base/core/java/android/os/LegacyMessageQueue/MessageQueue.java
rg -n "mQueue.next|dispatchMessage|dispatchStart|dispatchEnd" \
frameworks/base/core/java/android/os/Looper.java
rg -n "uptimeMillis|elapsedRealtime|deep sleep" \
frameworks/base/core/java/android/os/SystemClock.java复述主线时,至少要回答四件事:相对延迟在哪里变成绝对 when,队列如何决定是否唤醒, next() 何时允许出队,以及当前 callback 为什么能把实际执行推迟到 when 之后。
12. 边界判断
Handler.postDelayed 适合“在一个仍然运行的 Looper 线程上,按 uptime 时间安排一条消息”。 它提供了队列顺序和到期资格,不提供抢占当前 callback、跨深度睡眠的墙钟保证、进程死亡后的 持久化调度或取消后的 exactly-once 语义。
当你看到“延迟 100 ms 却 300 ms 才执行”的现象时,按源码顺序检查:投递时钟是否使用了正确的 时间基准,消息是否真的成功入队,队列头是否有屏障或更早消息,Looper 是否在 when 到达时 仍处理旧 callback,以及取消调用是否已经晚于 next() 摘除。这样得到的是可定位的时间分解, 而不是把所有晚点都归咎于“Handler 精度不够”。
