Handler系列写到第三篇,前两篇如果已经聊过 Looper 循环和 Message 入队,这一篇的位置就非常明确了:同步屏障(Sync Barrier)。这名字听起来很底层,但它离我们其实不远——你每点一次屏幕、每次触发 View 重绘、每次 Choreographer 开始排帧,背后都有它的身影。很多同学对 Handler 的理解止步于 sendMessage 和 handleMessage,真到要解释“为什么明明发了一堆消息,绘制却能插队”的时候,就卡住了。
这篇文章我会从 MessageQueue 源码出发,把同步屏障的插入、跳过、移除三个阶段拆开讲,再把 Choreographer 和 ViewRootImpl 里怎么用它讲清楚,最后把我在实际调试中踩过的几个坑一并列出来。适合已经理解 Handler 基础消息流,想往系统源码和 UI 性能方向再走一步的开发者。
1. 同步屏障到底是什么
1.1 target 为 null 的特殊消息
在 MessageQueue 里,普通消息一进队就要求必须有 target,也就是发送它的 Handler。enqueueMessage里直接有这样的检查:if (msg.target == null) throw new IllegalArgumentException("Message must have a target.")。但同步屏障恰恰“违反”了这个规则:它是一份target == null的 Message,被直接插到消息链表里,像一个路障一样挡在队列中间。
为什么不用 Handler 分发?因为屏障本身不打算被任何回调处理,它存在的唯一意义就是“卡位”。它靠什么定位自己?靠arg1字段里存的一个整型 token。这个 token 由postSyncBarrier返回,后续调用removeSyncBarrier(token)时就是靠它找到对应屏障。
所以你可以把同步屏障理解成“一个占着队列位置、但没有处理者的消息”。正因为没有 target,next()在遍历队列时会把它识别成特殊节点,而不是普通任务。
1.2 它到底在挡什么
我先说结论:同步屏障挡的是“屏障之后插入的同步消息”,放行的是“屏障之后插入的异步消息”。
用个生活化的比喻。消息队列就像一条单车道,大家按到达时间排队。正常情况下谁先到谁先走,这是 Handler 默认的公平机制。但有时候系统希望“某些任务”能插队,比如这一帧的绘制任务必须优先执行,不能排在用户输入、定时器、网络回调后面。这时候系统就在队列里放一个路障,从此刻起:
- 普通住户(同步消息)被拦在路障后面,只能排队等;
- 挂了特殊通行证的车辆(异步消息)可以穿过路障,继续往前走。
但要注意,路障只对“从它后面来的消息”生效。路障之前已经在队列里的消息,依然按原顺序正常执行。这很像你走进餐厅后厨,前面已经下锅的菜不会撤回来,新来的单子才需要插队规则。
| 消息类型 | 屏障前位置 | 屏障后插入 |
|---|---|---|
| 同步消息 | 正常执行 | 被挡下,等待屏障移除 |
| 异步消息 | 正常执行 | 可以越过屏障,按时间顺序执行 |
这套机制解决的核心问题,就是 Android UI 的“帧提交窗口”问题。绘制和遍历这类任务必须抢在 VSYNC 信号之后尽快完成,如果排在一条很长的普通消息队列后面,轻则掉帧,重则整个界面卡住不动。同步屏障就是系统用来给关键任务开“绿色通道”的手段。
2. MessageQueue 源码里的同步屏障
2.1 postSyncBarrier:屏障是怎么插进去的
源码里插入屏障的方法叫postSyncBarrier(long when),常见版本的核心逻辑是这样的:
private int postSyncBarrier(long when) { synchronized (this) { final int token = mNextBarrierToken++; final Message msg = Message.obtain(); msg.markInUse(); msg.when = when; msg.arg1 = token; Message prev = null; Message p = mMessages; if (when != 0) { while (p != null && p.when <= when) { prev = p; p = p.next; } } if (prev != null) { msg.next = p; prev.next = msg; } else { msg.next = p; mMessages = msg; } return token; } }几个细节值得说。
第一,token 从mNextBarrierToken自增而来,保证每次插入的屏障都有唯一标识。这个值一旦发出,移除时就必须原样传回,不然找不到。
第二,Message.obtain()拿到一个复用对象,markInUse()是防止它被回收池重新分配。屏障消息的target始终保持 null,这正是next()识别屏障的依据。
第三,when != 0时按时间找插入点,所以屏障不一定总在队列最前面。when传当前时间的时候,它通常会插到队首;传未来时间,就会排在那些when更早的消息后面。这个细节很多人会忽略,后面排查“为什么屏障没挡住某条消息”时经常是它引起的。
插入屏障不需要唤醒队列,因为队列本来就可能处于空闲等待中。真正需要唤醒的是removeSyncBarrier和enqueueMessage,这一点后续再展开。
2.2 next() 遇到屏障后怎么跳
消息队列取出消息的核心方法是next()。普通情况下,它把mMessages按when依次返回。但代码开头有一段专门处理屏障的逻辑:
Message next() { ... for (;;) { ... synchronized (this) { final long now = SystemClock.uptimeMillis(); Message prevMsg = null; 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 { mBlocked = false; if (prevMsg != null) { prevMsg.next = msg.next; } else { mMessages = msg.next; } msg.next = null; msg.markInUse(); return msg; } } else { // 屏障后面没有异步消息,队列进入无限等待 nextPollTimeoutMillis = -1; } ... } } }这里的do-while是整段代码的灵魂。当队列头部是屏障时,prevMsg从屏障开始,一路向后扫描,直到找到第一个isAsynchronous() == true的消息。找到后,把它前面的节点(可能是屏障,也可能是一串同步消息)直接跨过去,让这条异步消息出队。
如果屏障后面没有异步消息,msg会变成 null,nextPollTimeoutMillis被设为 -1,意味着nativePollOnce无限期睡下去。注意,这时候即便队列里堆满了同步消息,它们也出不来。只有两种情况能唤醒:
- 来了一条异步消息,
enqueueMessage里会调用nativeWake; - 屏障被移除,
removeSyncBarrier会唤醒队列。
这解释了为什么同步屏障能“卡死”一堆同步消息:它们不是不执行,而是被卡在屏障后面,连出队的机会都没有。
2.3 removeSyncBarrier:拆墙
屏障完成了使命,必须及时拆掉。源码如下:
public void removeSyncBarrier(int token) { synchronized (this) { Message prev = null; Message p = mMessages; while (p != null && (p.target != null || p.arg1 != token)) { prev = p; p = p.next; } if (p == null) { throw new IllegalStateException("The specified message queue synchronization barrier token has not been posted and has already been removed."); } // 从链表中摘除屏障节点 if (prev != null) { prev.next = p.next; } else { mMessages = p.next; } p.recycleUnchecked(); // 唤醒可能阻塞在 next() 的线程 ... } }这里能看到一个关键特征:p.target != null的是普通消息,直接跳过;p.target == null但arg1 != token的是其他屏障,也要跳过。所以队列里可以同时存在多个屏障,它们各自用 token 区分,移除时互不干扰。
recycleUnchecked()把屏障消息送回对象池,释放占用。这一步也很重要,如果漏掉,消息复用的数量会不断膨胀。
移除屏障后必须唤醒线程,因为next()可能正卡在nativePollOnce(-1)上,不唤醒的话,即便屏障移除了,Looper 也不会立刻重新扫描队列。
3. 异步 Handler 与异步消息的实际用法
3.1 创建异步 Handler
系统里最常见的做法,是直接用一个async标记为 true 的 Handler:
Handler asyncHandler = new Handler(Looper.myLooper(), null, true);第三个参数就是async。这个 Handler 发出的所有消息,在enqueueMessage阶段都会被自动打上异步标记。看一下 Handler 内部实现:
private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) { msg.target = this; msg.flags |= mAsynchronous ? Message.FLAG_ASYNCHRONOUS : 0; return queue.enqueueMessage(msg, uptimeMillis); }也就是说,你只要用这个 handler 的sendMessage、post,消息天生就带“特殊通行证”。
这种用法在系统源码里非常普遍。像 Choreographer 内部创建 Handler 时,就直接传了true。因为 Choreographer 的帧回调必须保证自身节奏,不能被其他同步消息干扰。
3.2 单条 Message 设置异步
如果你不想新建一个 Handler,而是只想让某一条消息异步化,可以直接设置 Message:
Message msg = Message.obtain(); msg.what = MSG_DO_FRAME; msg.setAsynchronous(true); asyncHandler.sendMessageAtTime(msg, frameTime);setAsynchronous(true)会在 Message 的 flags 里置上FLAG_ASYNCHRONOUS。只要在入队之前设好,next()里的isAsynchronous()就能识别到它。
这里要提醒一句:Message.setAsynchronous()的生效时机是“入队前”。如果你先调用了sendMessage再回去设置已经来不及,因为对象已经进入链表。实际开发里我见过有人把设置写在handleMessage里,那完全起不到作用。
3.3 到底什么条件能越过屏障
把前面内容串起来,可以归纳成一张速查表:
| 条件 | 结果 |
|---|---|
| 消息在屏障之前插入 | 不受影响,正常执行 |
| 屏障之后插入的同步消息 | 被屏障卡住,必须等屏障移除 |
屏障之后插入且when已到的异步消息 | 可以跳过屏障执行 |
屏障之后插入但when在未来 | 需要等到时间到点,不能立刻执行 |
| 队列中存在多个屏障 | 每个屏障都会拦住后续同步消息,直到被各自移除 |
最后一点特别重要。我见过一个系统场景,某个页面为了同时调度绘制和输入插入了两个屏障,结果其中一个 token 没移除,导致整个队列的同步消息被双重拦截,画面直接冻结。多屏障场景下,移除顺序必须严格保证每个 token 都不漏。
4. 系统框架里的实际应用场景
4.1 Choreographer 的非异步帧调度
Choreographer 是 Android 排帧的核心角色,它把每一帧的工作分成输入、动画、遍历等阶段。为了保证自己的调度消息不被普通同步消息挤到后面,Choreographer 内部的 Handler 是异步的:
private void scheduleFrameLocked(long now) { if (!mFrameScheduled) { mFrameScheduled = true; Message msg = mHandler.obtainMessage(MSG_DO_FRAME); msg.setAsynchronous(true); mHandler.sendMessageAtTime(msg, now); } }MSG_DO_FRAME是异步消息,所以即便主线程队列前面有同步屏障,Choreographer 的帧消息也能穿过去。这样做的好处是:帧回调能稳定地按 VSYNC 节奏触发,而不是看普通消息队列的脸色。
但要注意,Choreographer 本身并不负责插入同步屏障。屏障通常由更上层的 ViewRootImpl 在安排遍历时插入。
4.2 ViewRootImpl 里的经典用法
真正把“插入屏障 + 异步回调”串起来的是ViewRootImpl.scheduleTraversals():
void scheduleTraversals() { if (!mTraversalScheduled) { mTraversalScheduled = true; mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier(); mChoreographer.postCallback( Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null); notifyRendererFramePending(); } }这段代码做了三件事:
- 在主线程消息队列里插入一个同步屏障,记下 token 到
mTraversalBarrier; - 向 Choreographer 注册一个 traversal 回调,这个回调最终会通过异步消息触发;
- 把
mTraversalScheduled置为 true,防止同一帧里重复安排。
为什么要插屏障?因为mTraversalRunnable真正执行时,会调用performTraversals()做 measure、layout、draw。如果在这个时刻之前,其他同步消息(比如用户触摸事件、View.invalidate()引发的消息、定时器消息)先插入队列,它们就可能排在 traversal 前面执行,导致绘制被推到下一帧。屏障一插,后续来的同步消息全部挡在门外,只有 traversal 的异步回调能通过,从而保证“这一帧的遍历”尽可能连续执行。
等到真正开始执行遍历时,doTraversal()里会移除屏障:
void doTraversal() { if (mTraversalScheduled) { mTraversalScheduled = false; mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier); ... performTraversals(); } }这里mTraversalScheduled不仅是个标志位,它还同时承担了“防重入”和“避免重复移除屏障”的作用。如果 doTraversal 还没跑完又来个 invalidate,新调用发现mTraversalScheduled == true,就不再重新插屏障,也就不会产生两个屏障叠在一起的问题。这套设计非常巧妙,值得在自己做调度器时借鉴。
4.3 自定义场景里的屏障与异步组合
同步屏障不只在 View 体系里出现。凡是需要“通知某条线程优先处理一组任务,同时挡住其他普通任务”的场景,都可以用这个组合。
比如某个跨平台渲染项目里,我需要在子线程维护一个渲染循环,同时又要处理资源配置消息。普通配置消息可以排队慢慢执行,但渲染帧提交必须尽快。我当时用 HandlerThread 建了一个子线程队列,然后通过反射调用postSyncBarrier,再用一个 async Handler 发渲染指令。效果是:渲染指令永远优先,配置消息在屏障存在期间全部等待,渲染空闲时再移屏障放行。
这里也提醒一句:postSyncBarrier和removeSyncBarrier在标准 SDK 里是隐藏 API,普通应用直接调用会编译失败。想用只能走反射,但反射有版本兼容和系统安全性风险。如果只是在应用层做常规开发,不建议在生产代码里这么干;了解原理、能读懂系统源码就够了。系统开发或自研 OS 时才需要真正动手实现这类调度。
5. 实战中的坑与排查记录
5.1 屏障忘了移除,队列直接堵死
这是同步屏障最经典的翻车现场。有一次我在调试一个自研渲染流程时,为了确保某批异步任务优先,在主线程队列插了屏障。结果流程中某个异常分支直接提前 return,把removeSyncBarrier跳过了。现象非常诡异:页面不刷新了,触摸点击也没反应,但线程没崩,dumpsys看 CPU 占用还是正常的。
原因就是:屏障一直留在队列里,后续所有同步消息全部排队等着。UI 线程被nativePollOnce挂起,因为屏障后面没有可执行的异步消息。直到我把它移除,整条队列才恢复流动。
吃一堑长一智,现在我做这类调度,一定会把移除操作放进try/finally里:
int token = queue.postSyncBarrier(); try { // 执行关键异步任务 } finally { queue.removeSyncBarrier(token); }finally保证无论如何都能拆墙,哪怕中间抛异常,也不会把队列永远卡死。这个习惯在系统开发里尤其重要。
5.2 异步消息设了,却依然不执行
还有一次,某同事反馈:我明明用 async Handler 发了一条消息,为什么屏障存在时它还是没跑?
查下来发现,async Handler 是在主线程创建的,但发消息时sendMessageAtTime的时间戳被设成了当前时间加上 500ms。屏障虽然放行异步消息,但它依然要遵守消息链表的时间排序。next()在处理异步消息前会先判断now < msg.when,如果时间还没到,就继续睡到时间点。所以“异步”只解决优先于同步消息的问题,不提前于“时间点”执行。
排查这类问题,先看when,再看flag,顺序对了,问题基本就找出来了。
5.3 怎么确认当前队列里有没有屏障
我常用的方法有两种。
第一种是在自研工具里直接 dump 主线程消息队列。MessageQueue在 API 28 之后提供了dump方法:
PrintWriter pw = new PrintWriter(new FileOutputStream(dumpFile)); Looper.getMainLooper().getQueue().dump(pw, ""); pw.flush();dump 输出里能看到队列里每个 Message 的target、when、arg1。如果发现某个消息target == null且arg1有值,那大概率就是屏障。再往下翻,如果它后面跟了一大串同步消息,基本可以断定屏障泄漏了。
第二种方式是在 Android Studio 里抓 Java Heap。把MessageQueue实例展开,看mMessages链表中的节点,同样可以找到target == null的特殊节点。这个方法适合在 UI 卡死现场抓证据,配合线程栈一起看,定位很快。
我个人的经验是:屏障泄漏导致的问题,最明显的特点是“主线程没有死循环,也没有异常,就是事件不响应”。遇到这种症状,第一反应就该看看队列里有没有残留的 barrier token,而不是盲目去查业务代码。
5.4 关于屏障和消息回收的注意点
屏障消息使用Message.obtain()获取,移除时调用recycleUnchecked()回收到消息池。如果在插入屏障后、移除前有人误把这个消息池里的对象重新取出来用,会破坏队列结构。系统代码里通过markInUse()和isInUse()做了保护,但自己通过反射调用时,这些机制可能没被正确约束,操作要格外小心。
另外,removeSyncBarrier传错 token 会直接抛IllegalStateException。尤其是在多屏、多窗口环境下,同一线程可能有多个屏障共存,token 串了就会出问题。设计时可以把 token 存在一个强引用字段上,像 ViewRootImpl 的mTraversalBarrier那样,确保移除和插入严格配对。
说实话,同步屏障这套设计本身不复杂,它就是消息队列里一个特殊的“闸门”节点。真正复杂的是它和异步消息、时间排序、队列唤醒这些机制组合起来后的行为边界。读源码时你可能会觉得逻辑很简单,到线上问题里走一遭,才会发现有多少细节能坑人。
如果这系列还有下一篇,我觉得可以接着聊 Choreographer 的帧回调完整流程,或者把 ViewRootImpl 的遍历调度整体拉通。只要记住了“屏障挡同步、放异步”这个核心,后面理解这些系统设计都会顺手很多。