一条 BufferQueue 只有一个逻辑 Producer 和一个逻辑 Consumer,并不意味着同一时刻只能有一块 Buffer。Producer 可能正在写下一帧,队列中可能已经排着一帧,Consumer 还可能持有上一帧;这些 Buffer 处在不同阶段,形成一条有界流水线。
以下以 Android 15 为参考版本,重点解释 64 个索引容量、有效参与数量、多个在途 Buffer、等待、WOULD_BLOCK、async mode 和吞吐/延迟取舍;对象字段和普通 queue/acquire/release 细节以第 02 篇为前置,不展开 HWC 或厂商驱动。
64 只是索引空间,真正决定背压的是"允许参与调度的容量预算"与当前 DEQUEUED、QUEUED、ACQUIRED 占用。增加 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
流水线的收益不在于"同一块 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 的核心收益有四类:
- 提高阶段重叠机会。 Producer、GPU、队列和 Consumer 更容易同时工作,减少因为某一阶段暂时占用一块 Buffer 而马上停住。
- 吸收短时抖动。 Consumer 偶尔多花一帧时间时,少量额外 Slot 可以先保存已完成帧。
- 支持多帧算法。 Consumer 可以同时保留当前帧、上一帧或参考帧,而不必立即 release。
- 稳定规格下的池化复用。 已分配的多个 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 全部落在 DEQUEUED、QUEUED 或 ACQUIRED 时,生产端就没有可写位置。
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 和资源之后仍可复用。这个策略适合指针、动画和实时预览等"最新状态更重要"的内容。逐帧不可丢失的媒体或计算数据不能只因为队列更流畅就启用它。
端点、队列和执行并发不是一回事
三个层级需要分开:
| 层级 | 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 或实际测量验证,文章不提供设备性能数字。
可以从以下入口继续核对:
BufferQueueDefs.h:64 个 Slot 的索引空间。BufferQueueCore.cpp:有效参与数量公式与配置调整。BufferQueueProducer.cpp:选 Slot、等待、async 和可丢弃 Item。IGraphicBufferProducer.h:dequeued 上限与 async 接口契约。IGraphicBufferConsumer.h:acquired 上限配置。- Android
ImageReader与ImageWriter:应用层多帧持有的例子。