在 Android 17 中,面向 SDK 37 或更高版本的应用将获得 MessageQueue 的新实现,该实现采用无锁机制。新实现提升了性能并减少了丢帧,但可能会导致使用 MessageQueue 私有字段和方法的客户端出现问题。要了解有关此行为变更以及如何减轻影响的更多信息,请参阅 MessageQueue 行为变更文档。这篇技术博客文章概述了 MessageQueue 的架构重构以及如何使用 Perfetto 分析锁争用问题。
Looper驱动着每个 Android 应用的 UI 线程。它从MessageQueue中取出任务,将其分发给Handler,然后循环执行。二十年来, MessageQueue一直使用单个监视器锁(即同步代码块)来保护其状态。
Android 17 为该组件引入了一项重大更新:名为DeliQueue的无锁实现。
本文解释了锁如何影响 UI 性能,如何使用 Perfetto 分析这些问题,以及用于改进 Android 主线程的具体算法和优化措施。
问题:锁竞争和优先级反转
旧版消息队列(MessageQueue)是一个优先级队列,由单个锁保护。如果后台线程在主线程执行队列维护时发送消息,则后台线程会阻塞主线程。
当两个或多个线程竞争使用同一个锁时,这称为锁争用。这种争用会导致 优先级反转,进而造成用户界面卡顿和其他性能问题。
当高优先级线程(例如 UI 线程)需要等待低优先级线程时,就会发生优先级反转。请考虑以下序列:
- 一个优先级较低的后台线程获取MessageQueue锁,以便发布它所完成的工作结果。
- 中等优先级线程变为可运行状态,内核调度器为其分配 CPU 时间,抢占低优先级线程。
- 高优先级UI 线程完成当前任务后尝试从队列中读取数据,但由于低优先级线程持有锁而被阻塞。
低优先级线程阻塞了 UI 线程,而中等优先级的工作又进一步延迟了 UI 线程。
分析与Perfetto的争议
您可以使用Perfetto诊断这些问题。在标准跟踪中,如果线程阻塞在监视器锁上,则会进入睡眠状态,Perfetto 会显示一个切片,指示锁的所有者。
查询跟踪数据时,查找名为“monitor contention with …”的切片,后跟拥有锁的线程名称和获取锁的代码位置。
案例研究:启动器卡顿
为了说明这一点,我们来分析一个用户在使用 Pixel 手机相机应用拍照后立即返回主屏幕时遇到卡顿的跟踪记录。下面是 Perfetto 的屏幕截图,显示了导致丢帧的事件:
- 症状: 启动器主线程错过了帧截止时间。它阻塞了 18 毫秒,超过了 60Hz 渲染所需的 16 毫秒截止时间。
- 诊断结果: Perfetto 显示主线程被MessageQueue锁阻塞。“BackgroundExecutor”线程持有该锁。
- 根本原因: BackgroundExecutor 以Process.THREAD_PRIORITY_BACKGROUND优先级运行(优先级极低)。它执行的是一项非紧急任务(检查应用程序使用限制)。与此同时,中等优先级的线程正在使用 CPU 时间处理来自摄像头的数据。操作系统调度程序抢占了 BackgroundExecutor 线程来运行摄像头线程。
此序列导致启动器的 UI 线程(高优先级)被相机工作线程(中优先级)间接阻塞,从而阻止启动器的后台线程(低优先级)释放锁。
使用 PerfettoSQL 查询跟踪信息
您可以使用PerfettoSQL查询跟踪数据中的特定模式。如果您拥有大量来自用户设备或测试的跟踪数据,并且正在查找能够反映问题的特定跟踪数据,这将非常有用。
例如,此查询发现消息队列争用与丢帧(卡顿)同时发生:
包含PERFETTO模块android.monitor_contention ;包含
PERFETTO模块android.frames.jank_type ;SELECT
process_name , --将持续时间从纳秒转换为毫秒SUM ( dur ) / 1000000 AS sum_dur_ms , COUNT (*) AS count_contention
FROM android_monitor_contention
WHERE is_blocked_thread_main
AND short_blocked_method LIKE "%MessageQueue%"
--仅查看具有卡顿 且upid在( SELECT DISTINCT ( upid ) FROM actual_frame_timeline_slice
WHERE android_is_app_jank_type ( jank_type ) = TRUE ) GROUP BY process_name
ORDER BY SUM ( dur ) DESC的应用进程;
在这个更复杂的示例中,连接跨多个表的跟踪数据,以识别 应用程序启动期间的消息队列争用:
包括 PERFETTO 模块 android 。监视器争用;
包括 PERFETTO 模块 android 。启动.初创公司; --合并初创公司的包和流程信息 DROP VIEW IF EXISTS startups ; CREATE VIEW startups AS SELECT startup_id , ts , dur , upid
FROM android_startups
JOIN android_startup_processes USING ( startup_id );
-- 将监控争用与同一进程中的启动项进行交叉比较。DROP TABLE IF EXISTS monitor_contention_during_startup ; CREATE VIRTUAL TABLE monitor_contention_during_startup
USING SPAN_JOIN ( android_monitor_contention PARTITIONED upid , startups PARTITIONED upid );
SELECT
process_name , SUM ( dur ) / 1000000 AS sum_dur_ms , COUNT (*) AS count_contention
FROM monitor_contention_during_startup
WHERE is_blocked_thread_main
AND short_blocked_method LIKE "%MessageQueue%" GROUP BY process_name
ORDER BY SUM ( dur ) DESC ;
您可以使用您喜欢的 LLM 编写 PerfettoSQL 查询来查找其他模式。
在 Google,我们使用BigTrace对数百万条跟踪数据运行 PerfettoSQL 查询。通过这种方式,我们证实了之前观察到的现象实际上是一个系统性问题。数据显示,消息队列锁争用影响着整个生态系统中的用户,这证实了进行根本性架构变革的必要性。
解决方案:无锁并发
我们通过实现无锁数据结构来解决消息队列争用问题,该数据结构使用原子内存操作而非排他锁来同步对共享状态的访问。如果至少有一个线程始终能够独立于其他线程的调度行为而继续执行,则称该数据结构或算法是无锁的。通常很难实现这一特性,而且对于大多数代码而言,通常不值得追求。
原子原语
无锁软件通常依赖于硬件提供的原子读-修改-写原语。
在老一代 ARM64 CPU 上,原子操作使用加载-链接/存储-条件 (LL/SC) 循环。CPU 加载一个值并标记其地址。如果另一个线程写入该地址,则存储操作失败,循环会重试。由于线程可以不断尝试并成功而无需等待其他线程,因此该操作是无锁的。
ARM64 LL / SC 循环示例
重试:
ldxr x0 , [ x1 ] //从地址 x1到x0加载独占数据add x0 , x0 , #1 //值加1 stxr
w2 , x0 , [ x1 ] // 存储独占数据。// w2在成功时取 0 ,失败时取1
cbnz w2 , retry //如果w2非零(失败),则跳转到retr 函数
较新的 ARM 架构(ARMv8.1)支持大型系统扩展(LSE) ,其中包括比较交换(CAS)或加载添加(如下所示)等指令。在 Android 17 中,我们为 Android 运行时(ART)编译器添加了对 LSE 的支持,以便检测何时支持 LSE 并生成优化后的指令:
/ ARMv8.1 LSE原子性示例
ldadd x0 , x1 , [ x2 ] //原子加载-添加。// 更快,无需循环。
较新的 ARM 架构(ARMv8.1)支持大型系统扩展(LSE) ,其中包括比较交换(CAS)或加载添加(如下所示)等指令。在 Android 17 中,我们为 Android 运行时(ART)编译器添加了对 LSE 的支持,以便检测何时支持 LSE 并生成优化后的指令:
/ ARMv8.1 LSE原子性示例
ldadd x0 , x1 , [ x2 ] //原子加载-添加。// 更快,无需循环。
在我们的基准测试中,使用 CAS 的高竞争代码比 LL/SC 变体实现了约 3 倍的速度提升。
Java 编程语言通过java.util.concurrent.atomic提供原子原语,这些原语依赖于这些以及其他专门的 CPU 指令。
数据结构:DeliQueue
为了消除MessageQueue中的锁争用,我们的工程师设计了一种名为DeliQueue 的新型数据结构。DeliQueue 将消息插入与消息处理分离:
- 消息列表(Treiber 栈): 一个无锁栈。任何线程都可以向此处推送新消息而不会发生争用。
- 优先级队列(最小堆): 一个用于处理消息的堆,由 Looper 线程独占(因此无需同步或锁即可访问)。
入队:将元素推入 Treiber 栈。
消息列表保存在 Treiber 栈 中,这是一个无锁栈,它使用 CAS 循环来更新头指针。
public class TreiberStack <E> {
AtomicReference<Node<E>> top =
new AtomicReference<Node<E>>();
public void push(E item) {
Node<E> newHead = new Node<E>(item);
Node<E> oldHead;
do {
oldHead = top.get();
newHead.next = oldHead;
} while (!top.compareAndSet(oldHead, newHead));
}
public E pop() {
Node<E> oldHead;
Node<E> newHead;
do {
oldHead = top.get();
if (oldHead == null) return null;
newHead = oldHead.next;
} while (!top.compareAndSet(oldHead, newHead));
return oldHead.item;
}
}
源代码基于《Java并发实践》[2], *可在线获取*并发布到公共领域。
任何生产者都可以随时将新的消息压入栈中。这就像在熟食店柜台取号一样——你的号码取决于你到达的时间,但你取餐的顺序不必与号码一致。由于这是一个链式栈,每个消息都是一个子栈——你可以通过跟踪栈头并向前迭代来查看消息队列在任何时间点的状态——即使在你遍历的过程中有新的消息被添加到栈顶,你也不会看到任何新消息被压入栈顶。
出队:将大量数据转移到最小堆。
为了找到下一个要处理的消息,循环器会从 Treiber 栈顶开始遍历栈,直到找到它之前处理过的最后一条消息,从而处理栈中的新消息。在循环器向下遍历栈的过程中,它会将消息插入到按截止时间排序的最小堆中。由于循环器独占该堆,因此它可以在不使用锁或原子操作的情况下对消息进行排序和处理。
在遍历栈的过程中, Looper还会创建从栈中消息指向其前驱消息的链接,从而形成一个双向链表。创建该链表是安全的,因为指向栈底的链接是通过带有 CAS 的 Treiber 栈算法添加的,而指向栈顶的链接仅由Looper线程读取和修改。这些反向链接随后用于在 O(1) 时间内从栈中的任意位置移除消息。
该设计 为生产者(将工作发布到队列的线程)提供O (1)插入,为消费者(循环器)提供摊销O (log N)处理。
使用最小堆对消息进行排序也解决了传统消息队列的一个根本缺陷:消息被保存在一个单链表(根节点在链表顶部)中。在传统实现中,从链表头部移除消息的时间复杂度为O (1) ,但插入消息的最坏情况时间复杂度为O(N) ——对于过载队列来说,这种扩展性非常差!相反,最小堆的插入和移除操作的时间复杂度呈对数级增长,不仅平均性能具有竞争力,而且在尾部延迟方面表现尤为出色。
| 旧版(已锁定)消息队列 | DeliQueue | |
|---|---|---|
| 插入 | O(N) | O (1)用于调用线程 Looper线程的时间复杂度为O(logN) |
| 从头部取出 | O (1) | O(logN) |
在传统的队列实现中,生产者和消费者使用锁来协调对底层单链表的独占访问。而在 DeliQueue 中,Treiber 栈处理并发访问,单个消费者负责对其工作队列进行排序。
移除:通过墓碑保持一致性
DeliQueue 是一种混合数据结构,它结合了无锁的 Treiber 栈和单线程的最小堆。如何在不使用全局锁的情况下保持这两个结构的同步,带来了一个独特的挑战:消息可能物理上存在于栈中,但逻辑上已从队列中移除。
为了解决这个问题,DeliQueue 使用了一种称为“墓碑标记”的技术。每条消息都通过前后指针、堆数组中的索引以及一个指示其是否已被移除的布尔标志来跟踪其在栈中的位置。当一条消息准备运行时, Looper线程会调用 CAS 方法来检查其移除标志,然后将其从堆和栈中移除。
当另一个线程需要删除一条消息时,它不会立即从数据结构中提取该消息。相反,它会执行以下步骤:
- 逻辑移除:线程使用 CAS 将消息的移除标志原子地从 false 设置为 true。消息仍保留在数据结构中,作为其待移除的证据,即所谓的“墓碑”。一旦消息被标记为待移除,DeliQueue 在每次找到该消息时,都会将其视为已从队列中移除。
- 延迟清理:从数据结构中实际移除消息的操作由Looper线程负责,并延迟到稍后执行。移除线程不会修改栈或堆,而是将消息添加到另一个无锁空闲列表栈中。
- 结构移除:只有Looper才能与堆交互或从栈中移除元素。当它唤醒时,会清空空闲列表并处理其中包含的消息。然后,每条消息都会从栈中解除链接并从堆中移除。
这种方法使堆的所有管理都保持在单线程运行状态。它最大限度地减少了并发操作和所需的内存屏障的数量,从而使关键路径更快、更简单。
遍历:良性的Java 内存模型数据竞争
大多数并发 API,例如Java 标准库中的Future ,或 Kotlin 的Job和Deferred ,都包含一种在工作完成之前取消该工作的机制。这些类的实例与底层工作单元一一对应,调用对象的cancel方法会取消与其关联的特定操作。
如今的 Android 设备配备了多核 CPU 和并发式、分代垃圾回收机制。但在 Android 最初开发时,为每个工作单元分配一个对象成本过高。因此,Android 的Handler支持通过removeMessages的多个重载版本来取消操作——它并非移除特定的Message ,而是移除所有符合指定条件的Message 。实际上,这需要遍历调用removeMessages之前插入的所有Message ,并移除符合条件的 Message。
在向前迭代时,线程只需要一次有序的原子操作,即读取栈顶元素。之后,使用普通的字段读取操作来查找下一个消息。如果Looper线程在移除消息的同时修改了下一个字段,那么Looper的写入操作和另一个线程的读取操作就会不同步——这就是数据竞争。通常,数据竞争是一个严重的 bug,会导致应用程序出现各种问题,例如内存泄漏、无限循环、崩溃、卡顿等等。然而,在某些特定的条件下,数据竞争在 Java 内存模型中可能是良性的。假设我们从一个栈开始:
我们对头节点进行原子读取,看到 A。A 的下一个指针指向 B。在我们处理 B 的同时,循环器可能会移除 B 和 C,方法是将 A 的指针更新为指向 C,然后再指向 D。
即使B和C在逻辑上已被移除,B仍然保留着指向C 的指针,C也仍然保留着指向D 的指针。读取线程会继续遍历这些已分离的移除节点,最终在D处重新加入活动栈。
通过设计 DeliQueue 来处理遍历和删除之间的竞争,我们实现了安全、无锁的迭代。
退出:原生引用计数
Looper使用本地内存分配,该分配必须在Looper退出后手动释放。如果其他线程在Looper退出时添加消息,则可能会在本地内存分配释放后继续使用,这违反了内存安全规则。我们使用带标签的引用计数来防止这种情况发生,其中原子操作数的一位用于指示Looper是否正在退出。
在使用本地内存分配之前,线程会原子地读取引用计数。如果退出位已设置,则返回 Looper即将退出,此时不应使用本地内存分配。否则,它会尝试使用 CAS 操作来增加使用本地内存分配的活动线程数。完成所需操作后,它会递减引用计数。如果在递增操作之后、递减操作之前设置了退出位,且当前引用计数为零,则它会唤醒Looper线程。
当Looper线程准备退出时,它会使用 CAS 机制在原子操作中设置退出位。如果引用计数为 0,它就可以释放其本地分配的内存。否则,它会暂停自身,并等待最后一个使用该本地分配的线程递减引用计数后被唤醒。这种方法确实意味着Looper线程会等待其他线程的进度,但这种情况只会在它退出时发生。这种情况只会发生一次,对性能要求不高,并且它保证了其他使用本地分配的代码完全无锁运行。
实现过程中还有很多其他技巧和复杂性。您可以查看源代码来了解更多关于 DeliQueue 的信息。
优化:无分支规划
在开发和测试 DeliQueue 的过程中,团队运行了许多基准测试,并仔细分析了新代码的性能。使用simpleperf 工具发现的一个问题是消息比较器代码导致的流水线刷新。
标准比较器使用条件跳转,下面简化了决定哪个消息先出现的条件:
static int compareMessages(@NonNull Message m1, @NonNull Message m2) {
if (m1 == m2) {
return 0;
}
// Primary queue order is by when.
// Messages with an earlier when should come first in the queue.
final long whenDiff = m1.when - m2.when;
if (whenDiff > 0) return 1;
if (whenDiff < 0) return -1;
// Secondary queue order is by insert sequence.
// If two messages were inserted with the same `when`, the one inserted
// first should come first in the queue.
final long insertSeqDiff = m1.insertSeq - m2.insertSeq;
if (insertSeqDiff > 0) return 1;
if (insertSeqDiff < 0) return -1;
return 0;
}
这段代码编译后会生成条件跳转指令( b.le和 cbnz指令)。当 CPU 遇到条件分支时,在条件计算完成之前,它无法确定分支是否执行,因此无法知道下一步要读取哪条指令,只能使用一种称为分支预测的技术进行猜测。在二分查找这样的场景中,每一步的分支方向都可能不同,因此很可能有一半的预测是错误的。分支预测在搜索和排序算法(例如最小堆中使用的算法)中通常效率低下,因为预测错误的代价大于预测正确的收益。当分支预测器预测错误时,它必须丢弃之前基于预测值所做的工作,并从实际执行的路径重新开始——这被称为流水线刷新。
为了找到这个问题,我们使用分支预测错误性能计数器对基准测试进行了分析,该计数器会记录分支预测器预测错误时的堆栈跟踪。然后,我们使用Google pprof将结果可视化,如下所示:
回想一下,最初的MessageQueue代码使用单链表作为有序队列。插入操作会按排序顺序遍历链表,进行线性搜索,在插入点之后的第一个元素处停止,并将新消息添加到该元素之前。从队头移除元素只需取消队头的链接即可。而 DeliQueue 使用的是最小堆,其中的元素变更需要对某些元素进行重新排序(向上或向下筛选),其复杂度为对数级。在平衡的数据结构中,任何比较操作都有相同的概率将遍历指向左子节点或右子节点。新算法渐近速度更快,但也暴露出一个新的瓶颈:搜索代码有一半的时间会因分支错误而停滞。
意识到分支未命中会降低堆代码的运行速度,我们使用无分支编程优化了代码:
// Branchless Logic
static int compareMessages(@NonNull Message m1, @NonNull Message m2) {
final long when1 = m1.when;
final long when2 = m2.when;
final long insertSeq1 = m1.insertSeq;
final long insertSeq2 = m2.insertSeq;
// signum returns the sign (-1, 0, 1) of the argument,
// and is implemented as pure arithmetic:
// ((num >> 63) | (-num >>> 63))
final int whenSign = Long.signum(when1 - when2);
final int insertSeqSign = Long.signum(insertSeq1 - insertSeq2);
// whenSign takes precedence over insertSeqSign,
// so the formula below is such that insertSeqSign only matters
// as a tie-breaker if whenSign is 0.
return whenSign * 2 + insertSeqSign;
要了解优化,请在编译器资源管理器中反汇编这两个示例,并使用LLVM-MCA(一个 CPU 模拟器),它可以生成CPU 周期的估计时间线。
The original code:
Index 01234567890123
[0,0] DeER . . . sub x0, x2, x3
[0,1] D=eER. . . cmp x0, #0
[0,2] D==eER . . cset w0, ne
[0,3] .D==eER . . cneg w0, w0, lt
[0,4] .D===eER . . cmp w0, #0
[0,5] .D====eER . . b.le #12
[0,6] . DeE---R . . mov w1, #1
[0,7] . DeE---R . . b #48
[0,8] . D==eE-R . . tbz w0, #31, #12
[0,9] . DeE--R . . mov w1, #-1
[0,10] . DeE--R . . b #36
[0,11] . D=eE-R . . sub x0, x4, x5
[0,12] . D=eER . . cmp x0, #0
[0,13] . D==eER. . cset w0, ne
[0,14] . D===eER . cneg w0, w0, lt
[0,15] . D===eER . cmp w0, #0
[0,16] . D====eER. csetm w1, lt
[0,17] . D===eE-R. cmp w0, #0
[0,18] . .D===eER. csinc w1, w1, wzr, le
[0,19] . .D====eER mov x0, x1
[0,20] . .DeE----R ret
注意其中一个条件分支b.le , 如果通过比较when字段已经知道结果,则避免比较insertSeq字段。
The branchless code:
Index 012345678
[0,0] DeER . . sub x0, x2, x3
[0,1] DeER . . sub x1, x4, x5
[0,2] D=eER. . cmp x0, #0
[0,3] .D=eER . cset w0, ne
[0,4] .D==eER . cneg w0, w0, lt
[0,5] .DeE--R . cmp x1, #0
[0,6] . DeE-R . cset w1, ne
[0,7] . D=eER . cneg w1, w1, lt
[0,8] . D==eeER add w0, w1, w0, lsl #1
[0,9] . DeE--R ret
在这里,无分支实现所需的周期和指令数甚至比分支代码的最短路径还要少——在所有情况下都更胜一筹。更快的实现速度加上消除了预测错误的分支,使我们在某些基准测试中实现了 5 倍的性能提升!
然而,这种方法并非总是适用。无分支方法通常需要执行一些最终会被丢弃的工作,而且如果分支在大多数情况下都是可预测的,那么这些浪费的工作会降低代码的运行速度。此外,移除分支通常会引入数据依赖。现代 CPU 每个周期可以执行多个操作,但它们必须等到前一条指令的输入准备就绪才能执行一条指令。相比之下,CPU 可以对分支中的数据进行推测,并在分支预测正确的情况下提前执行。
测试与验证
验证无锁算法的正确性是出了名的困难!
除了用于开发过程中持续验证的标准单元测试外,我们还编写了严格的压力测试,以验证队列不变性,并尝试诱发可能存在的数据竞争。在我们的测试实验室中,我们可以在模拟设备和真实硬件上运行数百万个测试实例。
借助**Java ThreadSanitizer** (JTSan) 检测,我们可以使用相同的测试来检测代码中的一些数据竞争问题。JTSan 没有在 DeliQueue 中发现任何数据竞争问题,但出乎意料的是,它实际上在 Robolectric 框架中检测到了两个并发错误,我们已迅速修复了这些问题。
为了提升调试能力,我们开发了新的分析工具。以下示例展示了 Android 平台代码中的一个问题:一个线程向另一个线程发送大量消息,导致大量积压,而我们添加的MessageQueue检测功能使 Perfetto 能够检测到这个问题。
要在system_server进程中启用MessageQueue跟踪,请在 Perfetto 配置中包含以下内容:
data_sources {
config {
name: "track_event"
target_buffer: 0 # Change this per your buffers configuration
track_event_config {
enabled_categories: "mq"
}
}
}
影响
DeliQueue 通过消除MessageQueue中的锁来提高系统和应用程序的性能。
-
综合基准测试: 由于改进的并发性(Treiber 堆栈)和更快的插入速度(最小堆),多线程向繁忙队列插入数据的速度比传统的MessageQueue快 5,000 倍。
-
从内部测试人员获得的 Perfetto 跟踪数据显示,应用程序主线程在锁竞争中花费的时间减少了 15%。
-
在相同的测试设备上,锁争用的减少显著改善了用户体验,例如:
- 应用程序丢帧率降低 4%。
- 系统 UI 和启动器交互中丢帧率为 -7.7%。
- 从应用程序启动到绘制第一帧的时间缩短了 9.1%(95% 分位点)。
下一步
DeliQueue 正在向 Android 17 中的应用程序推出。应用程序开发者应查看 Android 开发者博客上的“为新的无锁消息队列准备应用程序”一文,了解如何测试他们的应用程序。
参考
[1] Treiber, RK, 1986. 系统编程:应对并行性。国际商业机器公司,托马斯·J·沃森研究中心。
[2] Goetz, B., Peierls, T., Bloch, J., Bowbeer, J., Holmes, D., & Lea, D. (2006). Java 并发编程实践. Addison-Wesley Professional.
翻译自: