BufferQueue 多缓冲与背压

一条 BufferQueue 只有一个逻辑 Producer 和一个逻辑 Consumer,并不意味着同一时刻只能有一块 Buffer。Producer 可能正在写下一帧,队列中可能已经排着一帧,Consumer 还可能持有上一帧;这些 Buffer 处在不同阶段,形成一条有界流水线。

以下以 Android 15 为参考版本,重点解释 64 个索引容量、有效参与数量、多个在途 Buffer、等待、WOULD_BLOCK、async mode 和吞吐/延迟取舍;对象字段和普通 queue/acquire/release 细节以第 02 篇为前置,不展开 HWC 或厂商驱动。

64 只是索引空间,真正决定背压的是"允许参与调度的容量预算"与当前 DEQUEUEDQUEUEDACQUIRED 占用。增加 Slot 只能让阶段有更多重叠机会,不能消除持续的生产/消费速率失配;当占用填满时,Producer 必须等待、返回 WOULD_BLOCK,或在满足条件的 async 路径丢弃旧提交。

64 个位置、几个参与者和多少块内存

BufferQueue 一启动就有 64 个 BufferSlot 元数据位置,但这不等于已经有 64 块图形内存。需要把三个数量分开:

数量 典型值 它描述什么 是否等于已分配 Buffer
NUM_BUFFER_SLOTS 64 mSlots[0..63] 的固定索引容量
有效参与数量 默认 2,按配置变化 当前允许进入调度的 Slot 索引数量
已绑定 GraphicBuffer 的 Slot 数 按需增长 真的已创建或导入图形资源的位置 这是实际资源数量的一部分

Core 刚构造时更接近下面的状态:

text 复制代码
mSlots:64 个固定 BufferSlot 实例
├── mFreeSlots:默认约 2 个可参与索引,GraphicBuffer 仍可能为空
└── mUnusedSlots:其余约 62 个索引,不参加当前调度

64 个位置只是元数据数组,默认参与数量 2 也不是固定的双缓冲配置。Consumer 或 Producer 调整持有上限后,新的索引才会从 mUnusedSlots 转入参与集合;后续 dequeue 或分配路径需要时,才会创建真实 GraphicBuffer

有效参与数量如何计算

Android 15 的 BufferQueueCore::getMaxBufferCountLocked() 可抽象为:

cpp 复制代码
int maxBufferCount = mMaxAcquiredBufferCount +
        mMaxDequeuedBufferCount +
        ((mAsyncMode || mDequeueBufferCannotBlock) ? 1 : 0);

return std::min(mMaxBufferCount, maxBufferCount);

公式中的几个量回答不同问题:

含义
mMaxAcquiredBufferCount Consumer 允许同时持有多少个未 release 的 Buffer
mMaxDequeuedBufferCount Producer 允许同时持有多少个未 queue/cancel 的 Buffer
mAsyncMode async 策略是否要求额外的周转余量
mDequeueBufferCannotBlock dequeue 不能长时间阻塞时是否需要额外 Slot
mMaxBufferCount 有效参与数量的总上限,通常由 Consumer 侧配置

在默认值 1 acquired + 1 dequeued + 0 extra 下,有效参与数量为 2。几个配置例子如下:

场景 acquired dequeued extra 有效参与数量
默认普通模式 1 1 0 2
默认 async 1 1 1 3
Producer 同时准备两块 1 2 0 3
双方各保留两块 2 2 0 4
双方各保留两块并 async 2 2 1 5

公式只决定参与调度的索引数量,不直接分配图形内存。有效参与数量从 2 调到 4 后,只有被 dequeue 到的索引才会创建对应的 GraphicBuffer

1:1 端点和多个在途 Buffer

"一个 Producer、一个 Consumer"限制的是直接连接到同一个 Core 的端点数量。它不限制端点内部的线程数、GPU 任务数,也不限制不同 Slot 同时处于不同所有权阶段。

text 复制代码
一个逻辑 Producer
    ├── Slot 0:DEQUEUED,正在准备帧 N+2
    └── Slot 1:DEQUEUED,另一个任务尚未提交

一个 BufferQueue
    └── Slot 2:QUEUED,帧 N+1 等待 Consumer

一个逻辑 Consumer
    ├── Slot 3:ACQUIRED,正在处理帧 N-1
    └── Slot 4:ACQUIRED,保留帧 N 作比较

这可以由不同的上层实现产生:

  • ImageWriter(maxImages > 1) 可以同时 dequeue 多个 Image,分别填充后再 queue;
  • EGL、Vulkan 或 HWUI 可能让 CPU/GPU/Consumer 处理不同帧,形成多阶段流水线;
  • ImageReader(maxImages > 1) 可以同时持有多个未 close 的 Image,用于前后帧比较、批处理或时域算法;
  • 自定义消费者可以一边处理当前帧,一边保留参考帧,甚至交给多个工作任务并行处理。

BufferQueue 只记录 Slot 的状态和计数,不负责创建线程。两个 ACQUIRED Buffer 可能是"一个正在处理、一个等待",也可能分别交给两个任务并行处理;这两种上层行为在 Core 中都体现为 mAcquireCount == 2

让三阶段重叠起来

用三块 Buffer 观察一个常见显示时刻:

text 复制代码
帧 N   :Consumer / HWC 正在读取或显示,Slot = ACQUIRED
帧 N+1 :Producer 已完成写入,Slot = QUEUED
帧 N+2 :Producer / GPU 正在写入,Slot = DEQUEUED
sequenceDiagram participant P as Producer participant Q as BufferQueue participant C as Consumer Note over P,C: 三个 Slot 在不同阶段同时在途 P->>Q: dequeue Slot 2 P->>P: 绘制帧 N+2 P->>Q: queue Slot 2 Q-->>C: FIFO 中已有帧 N+1 C->>Q: acquire 帧 N+1 C->>C: 处理/显示帧 N+1 C->>Q: release 帧 N Q-->>P: 释放的 Slot 可再次 dequeue

流水线的收益不在于"同一块 Buffer 同时被两方写读",而在于不同 Buffer 承担不同阶段。Producer 可以在 Consumer 处理上一帧时准备下一帧;Consumer 也可以在 GPU 写入下一帧时继续读取当前帧。Fence 负责保证每一块 Buffer 的真实硬件访问顺序。

Producer 更快时,容量如何一步步被占满

下面固定一个有效参与数量为 3 的普通路径,用三个 Slot 记录连续时刻。"快"只表示 Producer 在 Consumer release 之前又完成了一次提交,不代表设备帧率或延迟数据:

时刻 Slot A Slot B Slot C Producer 的下一步
t0:已有旧帧 ACQUIRED(Consumer 仍持有) QUEUED(等待 acquire) FREE dequeue C,开始写帧 N+1
t1:新帧提交 ACQUIRED QUEUED(帧 N) QUEUED(帧 N+1) 再次 dequeue 时已没有 FREE Slot
t2:普通路径 ACQUIRED QUEUED QUEUED 等待 Consumer acquire/release 释放任一 Slot
t3:Consumer 归还 A FREE(带 release Fence) QUEUED QUEUED dequeue A,先处理 release Fence,再写帧 N+2

此时其余索引仍在 mUnusedSlots,参与预算中的三个位置又都被在途状态占用。普通路径会等待;不能阻塞时可能返回 WOULD_BLOCK,async 队尾旧 Item 满足条件时也可能被新提交替换。共同原因是当前没有可安全复用的 FREE Slot。

增加 Slot 的收益与代价

增加参与 Slot 的核心收益有四类:

  1. 提高阶段重叠机会。 Producer、GPU、队列和 Consumer 更容易同时工作,减少因为某一阶段暂时占用一块 Buffer 而马上停住。
  2. 吸收短时抖动。 Consumer 偶尔多花一帧时间时,少量额外 Slot 可以先保存已完成帧。
  3. 支持多帧算法。 Consumer 可以同时保留当前帧、上一帧或参考帧,而不必立即 release。
  4. 稳定规格下的池化复用。 已分配的多个 Slot 可以循环使用,避免每帧重新申请图形内存。

但更多 Slot 并不自动带来更低延迟:

增加容量的效果 代价或边界
Producer 更晚遇到等待 Consumer 长期更慢时,容量最终仍会填满
可以积累更多已完成旧帧 旧帧等待时间变长,端到端延迟可能上升
可以保留更多参考帧 每个实际分配的 Buffer 都可能增加内存与带宽压力
让短时突发更平滑 如果各阶段本来串行,额外 Slot 不会创造并行能力

例如,假设 Consumer 每次处理一帧需要两个生产周期:

text 复制代码
容量 2:Producer 很快填满,第二帧之后频繁等待
容量 3:可以多保留一帧,短时抖动时更平滑
容量 5:可以继续延后等待,但旧帧和内存占用也更多

这是有限队列的因果演算:容量改变的是"何时感受到速率差",不会改变最慢阶段的处理速度,也不是设备性能测量。

多 Slot 与尺寸切换的关系

多个 mFreeBuffers 可能暂时绑定不同尺寸、格式或 usage 的 GraphicBuffer。普通 dequeueBuffer() 先从可用集合选 Slot,再按当前请求检查是否需要重新分配;它不按尺寸键检索所有缓存。

多 Slot 主要增加同时在途容量,并在规格稳定时提供循环复用;不同尺寸 Buffer 可能残留在不同 Slot,但增加 Slot 不保证下次 dequeue 能命中所需尺寸。

应用频繁切换尺寸时,应观察 reallocation 标志、Buffer 规格和释放时机,Slot 数本身不能说明命中率。

背压如何从"没有空位"形成

Producer 每次 dequeue 都需要一个符合配置和规格条件的可用 Slot。只要有 FREE Slot,队列还能继续吸收一部分速率差;当参与 Slot 全部落在 DEQUEUEDQUEUEDACQUIRED 时,生产端就没有可写位置。

text 复制代码
Producer 持续 queue
        ↓
QUEUED / ACQUIRED 占用增加
        ↓
FREE Slot 减少
        ↓
waitForFreeSlotThenRelock() 找不到可用 Slot
        ↓
普通路径等待 mDequeueCondition
        ↓
Consumer acquire/release 推进
        ↓
Slot 回到可复用集合并唤醒 Producer

waitForFreeSlotThenRelock() 等待时会暂时释放 Core 锁,让 Consumer 仍能 acquire 或 release;如果它一直持有锁,Consumer 就无法推进,等待会变成互相阻塞。释放操作改变的是资源可用性,条件变量只是把等待线程重新唤醒。

背压描述的是下游变慢后压力回传到上游的过程。排查 dequeueBuffer() 变慢时,至少要同时看:

  • 当前有效参与数量;
  • DEQUEUED、QUEUED、ACQUIRED 各有多少;
  • Consumer 是否延迟 acquire/release;
  • Fence 是否仍未 signal;
  • 是否进入 async/non-blocking 或超时路径。

增加 Slot 只能延后这个过程:

text 复制代码
短时速率差:额外 Slot 先吸收
持续速率差:有限容量最终耗尽
容量耗尽后:等待、超时或 WOULD_BLOCK

Async mode 是队列策略

"异步绘制"在 Android 语境中可能指 UI 延后调度、CPU 提交后 GPU 后台执行、多 Buffer 流水线或 BufferQueue 的 mAsyncMode。下文只把最后一种称为 async mode;它不会替 GPU 创建后台绘制线程。

mAsyncMode 主要改变三件事:

额外 Slot

有效参与数量公式中的 extra 项从 0 变为 1:

text 复制代码
普通:1 acquired + 1 dequeued + 0 = 2
async:1 acquired + 1 dequeued + 1 = 3

它给 Producer、队列和 Consumer 多留一个周转位置,但不会立即分配一块新的 GraphicBuffer。

无位时的返回策略

普通可阻塞路径可以等待 mDequeueCondition。async 或不能阻塞 dequeue 的路径,在没有符合条件的 Slot 时可能返回 WOULD_BLOCK,由上层决定跳过、重试或降低生产频率。

async 路径仍可能在 Buffer 分配、Fence、驱动提交和其他接口处等待。它只是不为"等一个 free Slot"长期阻塞。

可丢弃队尾 Item

async queue 可以把新的 BufferItem 标记为可丢弃。当队尾旧 Item 尚未被 Consumer acquire,且满足可丢弃条件时,新提交可以替换旧提交:

text 复制代码
旧 Item A:已完成生产,仍在队尾等待
        ↓ 新提交到达
新 Item B:代表更新的状态
        ↓
A 被丢弃,A 对应 Slot 回到可用集合

丢弃的是一次排队提交,不是删除底层 GraphicBuffer;Slot 和资源之后仍可复用。这个策略适合指针、动画和实时预览等"最新状态更重要"的内容。逐帧不可丢失的媒体或计算数据不能只因为队列更流畅就启用它。

flowchart LR D[DEQUEUED<br/>Producer 正在写] --> Q[QUEUED<br/>等待 Consumer] Q --> A[ACQUIRED<br/>Consumer 使用] A --> F[FREE<br/>等待复用] F --> D Q -. async:队尾可丢弃时替换 .-> DROP[旧 Item 被丢弃<br/>Slot 重新可用] D -. 无可用 Slot .-> WAIT[等待 / 超时 / WOULD_BLOCK]

端点、队列和执行并发不是一回事

三个层级需要分开:

层级 BufferQueue 能确认什么 它不能确认什么
连接 一个直接 Producer、一个直接 Consumer 端点内部有多少线程和硬件队列
在途状态 多个 Slot 可同时 DEQUEUED、QUEUED、ACQUIRED 这些 Buffer 是否真的并行计算
执行方式 Fence 约束先后访问 上层是否串行、并行或批处理

对应关系如下:

  • 一个 Producer 端点可以在配置允许时同时持有多块 DEQUEUED Buffer;
  • Core 的 FIFO 可以同时保存多条 QUEUED Item;
  • 一个 Consumer 端点可以同时持有多块 ACQUIRED Buffer,随后串行、共同或并行处理;
  • BufferQueue 不主动创建这些任务,只维护所有权和同步契约。

读代码时的三张数量表

遇到"64 个 Slot""三缓冲""Consumer 还没 release"时,分别列出三张表:

text 复制代码
表一:索引容量
mSlots 的合法范围:0..63

表二:参与容量
maxAcquired + maxDequeued + extra 的结果是多少?

表三:实时占用
当前有多少 DEQUEUED、QUEUED、ACQUIRED、FREE?

第三张表用于定位"此刻为什么拿不到空位";第一张表是实现上限,第二张表是配置计算结果。三者不能合并成一个数字:64 个元数据位置不等于 64 块图形内存,默认 2 个参与也不等于永远双缓冲。

版本边界与验证入口

版本差异、Producer/Consumer 配置和设备路径可能改变默认值、额外 Slot 或丢帧条件。设备级帧率和延迟需要通过 Trace、FrameTimeline、Perfetto 或实际测量验证,文章不提供设备性能数字。

可以从以下入口继续核对:

相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼2 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen2 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone2 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui