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 或实际测量验证,文章不提供设备性能数字。

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

相关推荐
AFinalStone1 小时前
Android 7系统无障碍服务(二)AccessibilityManagerService 启动与初始化
android·无障碍服务
阿pin1 小时前
Android随笔-kotlin Flow
android·kotlin·flow
阳光予你1 小时前
App 启动流程:从 Launcher 点击到 Activity 创建
android
小孔龙2 小时前
BufferQueue 对象交接与同步
android
XiaoLeisj2 小时前
Kotlin Flow 常用操作符:数据变换 map、filter、onEach,时间控制 debounce、sample,终端聚合 reduce、fold
android·kotlin·android jetpack·协程·响应式编程·flow
EQ-雪梨蛋花汤2 小时前
Android 3D 开发教程(三):Sceneform-EQR 配置 PBR 材质、光照、相机与实时阴影
android·3d·材质
Dovis(誓平步青云)3 小时前
《 固井工程软件 Cemsol 的数据管理与国产化适配实践》
android·java·开发语言·人工智能
Kapaseker3 小时前
小白都看得懂的 Skill 教程 - 创建第一个 Skill
android·kotlin·vibecoding
zhangphil3 小时前
Android BitmapFactory实现AOSP ContentResolver.loadThumbnail快速取小缩略图,Kotlin
android·kotlin