先看结论与判断条件
- 并发设计先确认解释器、delegate、输入输出缓冲区和业务上下文各自由谁拥有,不能从 API 可调用推断线程安全。
- 单会话串行队列是缺少可靠并发证据时最容易审计的基线,它牺牲并行度换取稳定的状态边界。
- 会话池必须让每个会话独占运行时对象和可变缓冲区,池大小由真实设备证据决定,不从 CPU 核数直接推导。
- 取消只改变任务状态,不能在底层运行时仍使用缓冲区时提前复用或释放内存;超时也不等于执行已经停止。
- 关闭流程先拒绝新请求,再处理排队任务、等待在途执行并释放会话;任何阶段都需要可重复且可观察。
- 发布结论只覆盖列出的运行时版本、delegate、模型、设备和负载,单一设备通过不能外推到全部平台组合。
先把并发问题拆成所有权问题
端侧推理的竞态通常不是一个抽象的线程问题,而是两个请求在同一时间修改了同一份可变状态。解释器可能保存张量绑定和执行计划,delegate 可能持有设备资源,输入输出缓冲区会被写入,取消与关闭还会改变生命周期。若这些对象的所有权没有写清,偶发错结果、崩溃、卡死和关闭后访问就会混在一起,日志只能看到最后一个表象。可执行的起点是为每个共享对象指定唯一所有者、允许的访问线程、释放条件和失败动作。
工程上应先建立保守基线:一个运行时会话由一个执行线程拥有,每个任务创建或租用独立输入输出缓冲区,调用方只能提交不可变请求并接收 Future 或回调。该基线并不宣称所有运行时都不支持并发,而是拒绝在证据缺失时把线程安全当作默认能力。后续若要并行,应通过增加隔离会话扩展,而不是直接让更多线程进入同一对象。这样出现问题时,责任范围仍能落到单一任务、单一会话和单一缓冲区。
LiteRT、Core ML 与 ONNX Runtime Mobile 的平台和执行器模型并不完全相同,delegate 还可能只接管计算图的一部分。文章因此不提供跨框架通用的线程安全承诺,也不使用未经核实的吞吐数字。团队应把模型摘要、运行时版本、delegate 类型、设备条件、队列策略和测试回执绑定在同一记录中。只有目标组合在受控压力、取消和关闭路径上取得真实证据,才可以把结论从串行基线扩展到会话池。
| 对象 | 推荐所有者 | 允许共享的条件 | 典型失败 |
|---|---|---|---|
| 运行时会话 | 单个执行线程或池中单槽 | 官方约束和项目压力回归均明确 | 内部状态交叉或关闭竞态 |
| delegate | 与创建它的会话同生命周期 | 实现明确支持且资源隔离已验证 | 图分区状态或设备资源争用 |
| 输入缓冲区 | 单个任务 | 只读且形状、类型和容量固定 | 后一个请求覆盖前一个请求 |
| 输出缓冲区 | 单个任务直到结果复制完成 | 结果已经转成不可变对象 | 读取到部分写入或旧结果 |
| 取消状态 | 任务控制器 | 使用原子状态并由执行线程确认 | 调用方认为取消但底层仍在运行 |
| 关闭状态 | 队列或会话池管理器 | 严格执行先拒绝、再排空、后释放 | 新请求进入已释放资源 |
运行时和 delegate 需要分别建账
LiteRT Android Java API 描述了在 Android 端装载模型并选择执行路径的接口,实际项目仍要检查 delegate 是否可用,并保留适用的 CPU 路径。并发契约不能只写成“解释器可用”,还要记录哪个 delegate 被请求、最终是否参与、会话在哪个线程创建、初始化失败怎样回退。delegate 创建成功只说明初始化阶段没有立即报错,不能证明多请求下输出一致,也不能证明整张图都由同一执行器完成。
TfLiteDelegate API 把 Prepare 作为图分区和 kernel 替换的重要阶段,资源生命周期则由具体实现承担。这个事实对并发设计的直接影响是:delegate 不应被当成无状态配置值随意跨会话借用。若一个会话池需要多个槽,默认做法是每个槽独立创建运行时与 delegate,并在该槽没有在途任务时释放。是否允许共享设备上下文,需要目标实现的明确约束和项目回归,不能从底层结构体存在复制指针的能力推断。
LiteRT delegate architecture 还说明未支持节点可能留在主图,执行期间会出现图分区和执行器之间的数据传递。这使错误定位不能停留在“GPU 请求”或“CPU 回退”标签上。竞态可能发生在会话输入、分区边界、临时张量或结果复制阶段。公开文章只能给出审计方法:记录执行路径、错误类别和任务身份,但不记录用户输入或模型输出;真实的并发上限、耗时和内存水位必须由候选模型与目标设备测量。
- 运行时版本和模型摘要进入同一测试记录
- 每个会话的 delegate 类型与初始化结果可追踪
- 回退路径不会与主路径共享可变缓冲区
- delegate 释放只发生在在途任务归零之后
- 日志只保存阶段、任务号和错误类别
- 任何并行结论都限定到已验证的实现组合
串行队列与会话池解决不同问题
串行队列的价值不是追求更高吞吐,而是把一个会话的所有可变状态收敛到单一执行上下文。请求进入有界队列后,工作线程按顺序准备输入、调用运行时、复制结果并完成 Future。调用方不能直接取得解释器或底层缓冲区,也不能绕过队列执行。这样可以把排队时间、执行时间和结果交付分开观察,同时避免两个请求同时修改张量。对交互式功能而言,还应在入队前决定队列满时是拒绝、合并还是替换旧请求。
会话池用于证明过的并行需求。池中每个槽必须拥有独立会话、delegate 和工作缓冲区,调度器只分配空闲槽,不把一个槽同时借给两个任务。池并不自动带来收益:模型权重可能重复占用内存,多个 delegate 可能争用同一硬件队列,热管理也会改变持续负载表现。池大小应由代表性设备上的内存、错误、尾延迟和业务优先级共同决定,本文不会给出脱离候选的固定数值。
两种方案都需要背压。无界队列会把突发请求转换为内存增长和过期结果,会话池若继续接受无限任务也只是把等待移到别处。业务应为每类请求明确新鲜度:相机连续帧可以丢弃旧任务,用户明确点击的单次操作可以返回繁忙并允许重试,后台批处理则可持久化进度。调度策略必须在调用方看到确定结果,不能静默丢请求,也不能把超时后的旧结果覆盖到新的界面状态。
| 决策项 | 单会话串行队列 | 隔离会话池 | 放行证据 |
|---|---|---|---|
| 状态隔离 | 由单线程所有权提供 | 由独立槽位提供 | 压力测试无交叉结果 |
| 内存成本 | 通常只保留一份会话状态 | 可能复制会话和设备资源 | 真实设备峰值与退出回执 |
| 吞吐目标 | 适合可排队或可合并请求 | 适合确有并行价值的任务 | 同候选对照而非理论核数 |
| 关闭复杂度 | 排空一个执行线程 | 逐槽停止并回收 | 关闭期间拒绝新任务 |
| 故障隔离 | 单次故障可能阻塞后续任务 | 故障可限制在单槽 | 超时、取消和重建演练 |
| 默认选择 | 证据不足时采用 | 获得目标实现证据后采用 | 明确版本、设备和 delegate |
生命周期必须覆盖接受、执行和关闭
一个可靠的推理服务至少有 accepting、draining 和 closed 三类状态。accepting 允许新任务进入有界队列;draining 拒绝新任务,但让已接受任务按既定策略完成或取消;closed 表示工作线程已经退出,会话和 delegate 已释放。状态转换应由单一管理器完成,并且关闭动作可以重复调用而不产生第二次释放。应用若在页面销毁、账号退出和进程后台切换时使用不同关闭语义,也要把触发原因写进调用契约。
最危险的实现是在调用方超时后立即复用输入缓冲区。Future 超时只表示等待方不再等待,不能说明底层推理已经停止;即使调用方请求取消,运行时也可能要到安全点才返回。缓冲区必须由执行线程持有到实际调用结束,结果复制完成后才可释放。若底层实现没有可验证的中断语义,取消应标记为“结果不再交付”,而不是强行终止并提前释放资源。
关闭还要处理排队任务。立即关闭适合敏感上下文失效的场景,此时未开始的任务应以明确错误完成,在途任务等待安全结束;优雅关闭适合版本切换或普通页面离开,可先停止接收再排空。无论采用哪种策略,都要保证每个已接受任务最终进入成功、取消或失败之一,不能留下永不完成的 Future。运行时释放失败需要记录并隔离槽位,不能让后续任务误用状态不明的会话。
| 状态 | 是否接收新任务 | 已有任务处理 | 资源动作 |
|---|---|---|---|
| accepting | 允许但受容量限制 | 正常排队和执行 | 会话保持可用 |
| draining | 拒绝 | 完成或按策略取消 | 等待在途任务归零 |
| closing | 拒绝 | 所有 Future 获得终态 | 停止工作线程并关闭会话 |
| closed | 拒绝 | 不存在排队与在途任务 | 重复关闭不再释放 |
| failed | 拒绝 | 剩余任务返回明确错误 | 隔离会话并等待受控重建 |
缓冲区隔离要覆盖输入、输出和结果交付
每个任务的输入应在入队前固定形状、类型和长度,并复制到该任务独有的不可变载荷或私有缓冲区。直接把相机、音频或 UI 层正在复用的 ByteBuffer 传给后台推理,会让生产者在消费者读取期间覆盖内容。零拷贝并非禁止,但需要更严格的租约:生产者交出所有权后不能写入,执行线程释放租约后才能复用。若项目无法证明这个协议,复制一次通常比追查偶发错结果更可控。
输出也不能在运行时写入期间暴露给调用方。工作线程应在推理返回后检查输出结构,再复制或转换为不可变业务结果,然后才完成 Future。结果对象还应带任务标识、模型版本和调用上下文版本,界面层收到结果时可以判断它是否仍然新鲜。相机帧、搜索输入和连续编辑场景尤其需要这一步,否则较慢的旧任务可能在新任务之后返回并覆盖当前状态。
缓冲区容量错误必须在进入运行时之前失败。代码需要根据公开的输入契约检查长度、维度和数据类型,不允许截断、隐式扩容或沿用上一次任务的尾部数据。输出结构不符合约定时也应返回分类错误,不能继续按旧标签表解释。本文只讨论并发所有权,输入张量契约和后处理版本仍应由各自发布门禁管理;将多个问题拆开,才能知道失败发生在并发、模型契约还是业务解释层。
- 入队后调用方不能再修改任务输入
- 每个任务拥有独立的输出暂存区
- 长度、类型与形状在运行时调用前验证
- 结果复制完成后才释放底层缓冲区
- 结果绑定任务号、模型版本和上下文版本
- 过期结果不会覆盖当前业务状态
背压、取消和超时必须给调用方确定语义
有界队列容量不是性能宣传数字,而是产品决策。容量过小会让用户频繁看到繁忙,容量过大会保留大量过期输入并推高内存。团队应先按请求类型定义策略,再用真实交互和设备压力校准。连续帧适合只保留最新任务,文本生成或识别按钮适合拒绝重复点击,必须逐项完成的离线任务则适合持久调度。队列层只执行已声明策略,不能自行猜测哪个请求可以丢弃。
取消应区分 queued 和 running。排队任务尚未取得会话,可以从队列逻辑上标记取消并在取出时跳过;运行任务已经持有会话和缓冲区,取消通常只能阻止结果交付,除非目标运行时明确提供并经项目验证的安全中断。该区别需要反映在 API 返回值和日志中。调用方收到 cancelled 只说明任务结果不会继续参与业务,不应据此假设底层设备资源已经立即释放。
超时是调用方等待预算,不是资源回收指令。等待超时后,管理器仍要追踪底层任务直到安全终态,并在完成时丢弃过期结果或记录错误。若大量任务持续超时,正确动作是触发容量保护、停止接收和会话健康检查,而不是继续堆积重试。重试还必须创建新的任务身份,并由业务层判断是否允许重复执行,避免同一用户操作在旧任务尚未结束时产生两个可见结果。
用有界串行执行器建立可审计基线
下面的 Python 示例表达的是并发契约,不是某个移动运行时的绑定代码。Session 由工作线程独占,submit 复制输入并使用非阻塞入队,队列已满或服务关闭会直接失败。Future 取消后,工作线程不会把结果交给调用方,但仍会让已经开始的 session.run 安全返回。每个任务都创建独立 bytes 对象,示例没有共享可写输入输出缓冲区,也不包含模型、客户数据或真实服务地址。
Session.run 在示例中代表 LiteRT、Core ML 或 ONNX Runtime 的项目适配层。适配层必须负责输入契约检查、运行时调用和结果复制,且不能把内部可变张量直接返回。close 的执行顺序是先改变 accepting 状态,再放入停止标记并等待工作线程退出,最后由工作线程关闭会话。若真实运行时要求在创建线程释放,适配层也应在同一所有权线程完成,而不是由页面线程抢先销毁。
这段代码故意在容量参数、空输入、队列满和关闭后提交时产生可达失败路径,便于测试系统验证背压而不是静默吞掉请求。生产实现还需要任务优先级、埋点脱敏、会话重建和平台生命周期集成,但不应破坏核心不变量:一个会话同一时刻只服务一个任务,输入输出归单个任务所有,关闭后不再接受请求,每个已接受任务最终都有确定状态。
from concurrent.futures import Future
from dataclasses import dataclass
from queue import Full, Queue
from threading import Lock, Thread
import sys
if len(sys.argv) != 2:
raise SystemExit("usage: inference_queue.py capacity")
capacity = int(sys.argv[1])
if capacity < 1:
raise SystemExit("capacity must be positive")
@dataclass(frozen=True)
class Task:
payload: bytes
future: Future
class InferenceQueue:
def __init__(self, session):
self._session = session
self._queue = Queue(maxsize=capacity)
self._lock = Lock()
self._accepting = True
self._sentinel = object()
self._worker = Thread(target=self._run, daemon=True)
self._worker.start()
def submit(self, payload: bytes) -> Future:
if not payload:
raise ValueError("payload must not be empty")
future = Future()
with self._lock:
if not self._accepting:
raise RuntimeError("inference queue is closed")
try:
self._queue.put_nowait(Task(bytes(payload), future))
except Full as exc:
raise RuntimeError("inference queue is full") from exc
return future
def _run(self):
while True:
task = self._queue.get()
try:
if task is self._sentinel:
return
if task.future.set_running_or_notify_cancel():
result = bytes(self._session.run(task.payload))
task.future.set_result(result)
except Exception as exc:
if task is not self._sentinel and not task.future.done():
task.future.set_exception(exc)
finally:
self._queue.task_done()
def close(self):
with self._lock:
if not self._accepting:
return
self._accepting = False
self._queue.put(self._sentinel)
self._worker.join()
self._session.close()发布验收要覆盖压力、取消和关闭交叉路径
Android instrumented tests 适合验证依赖真实运行时、组件和系统 API 的行为,但单一设备通过不能代表完整平台矩阵。并发验收应至少覆盖队列满、排队取消、执行中取消、页面销毁、账号退出、模型版本切换、delegate 初始化失败和会话关闭后提交。每条用例都要记录候选身份、设备、系统版本、运行时、delegate、任务序列与明确断言,不记录原始用户输入和模型输出。
正确性断言不能只看没有崩溃。受控输入需要验证结果归属,确认任务 A 的输出不会进入任务 B;取消任务不得交付结果;关闭后新请求必须失败;队列排空后所有 Future 都有终态;会话池模式还要确认一个槽位不会同时租给两个任务。性能数据应与正确性分开归档,测量条件不一致时拒绝比较。没有真实候选和设备回执,文章不声称吞吐提高、延迟降低或内存稳定。
落地前先固定一份并发契约:运行时与 delegate 组合、队列或池策略、缓冲区所有权、取消语义、关闭顺序、测试矩阵和不可外推的边界。可同时参阅本站关于模型缓存版本绑定与输出日志隐私的技术说明,避免并发治理扩写成另一套版本或日志答案。需要评估具体 App 时,可通过御盾中央平台提交脱敏模型清单、运行时组合和并发状态机,先确认测试范围,再决定是否扩大并行度。
- 队列满时调用方获得明确失败或替换结果
- 排队取消和执行中取消具有不同回执
- 关闭后提交稳定失败且不会创建新会话
- 所有已接受任务最终完成、取消或失败
- 受控输入能够发现跨任务结果错配
- 结论绑定模型、运行时、delegate、设备和版本
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| LiteRT Android 端需要按目标设备条件选择可用执行路径,并为不适用的 delegate 保留处理方案。 | LiteRT Android Java API 描述 Android 端模型装载与 delegate 使用入口。 | 接口可用不证明目标模型在具体设备上输出一致、并发安全或获得性能收益。 |
| delegate 的 Prepare 阶段参与图分区和 delegate kernel 替换,资源生命周期由具体实现承担。 | TfLiteDelegate API 描述 delegate 结构、Prepare 回调和实现责任。 | 底层接口不承诺所有厂商实现具有相同线程模型、错误恢复或共享能力。 |
| delegate 可能只接管部分节点,未支持节点会留在主图并形成执行器边界。 | LiteRT delegate architecture 说明 delegate 分区、替换和未支持节点处理。 | 架构说明不能推出某台设备上的实际分区、数据传递成本或并发上限。 |
| Core ML 覆盖模型集成、编译、加载与预测等平台流程。 | Apple Core ML 给出 Core ML 的模型集成与运行职责。 | 平台具备预测 API 不等于同一模型实例可被任意线程并发调用,也不证明业务结果正确。 |
| ONNX Runtime Mobile 面向 Android 与 iOS 运行模型,并受模型算子和执行提供程序约束。 | ONNX Runtime Mobile 描述移动端运行时、模型格式和算子支持边界。 | 移动端支持说明不提供特定会话、执行提供程序或设备组合的并发安全结论。 |
| 依赖真实 Android 运行时和系统 API 的语义需要通过设备端测试验证。 | Android instrumented tests 描述在真实或模拟 Android 设备上运行测试的适用范围。 | 单个设备和单次通过不能覆盖全部 API、ABI、厂商、delegate 与持续负载。 |
| 证据不足时先让单一线程拥有一个会话,是比无约束共享更容易审计的并发基线。 | 工程判断:唯一所有者、独立缓冲区和有界队列能明确任务状态与资源释放责任。 | 该基线不证明吞吐最优;是否扩展会话池必须由目标候选和设备回执决定。 |
| 取消和超时不能自动证明底层推理已经停止,因此缓冲区要等到实际执行安全结束后才能复用。 | 工程判断:调用方等待状态与运行时资源生命周期属于两个控制面,应通过执行线程确认终态。 | 若具体运行时提供明确且已验证的安全中断能力,项目可以采用更细的取消策略。 |
工程常见问题
同一个推理会话能否直接由多个线程同时调用?
不能把可调用推断为线程安全。先查目标运行时和 delegate 的明确约束,再用同一模型、设备与版本做压力回归;证据不足时采用单会话串行队列。
串行队列是否一定会让端侧 AI 变慢?
不能脱离请求类型和设备下结论。串行队列主要解决状态所有权与背压,真实体验还取决于模型、delegate、请求新鲜度和排队策略,应在正确性通过后单独测量。
会话池大小是否可以直接设置为 CPU 核数?
不建议。会话会占用模型状态、缓冲区和设备资源,delegate 也可能共享硬件队列。池大小应由目标设备的内存、错误和持续负载证据决定。
Future 超时后能否立即复用输入缓冲区?
不能。超时只代表等待方停止等待,底层推理可能仍在读取输入。应由执行线程在实际调用返回后释放租约,再允许缓冲区复用。
页面销毁时应该立即关闭推理会话吗?
取决于会话归属。页面专属会话可进入拒绝新请求和排空流程;进程级共享会话不应被单个页面直接销毁。无论哪种情况,都要让已接受任务获得确定终态。
准备并发推理评估时需要提供哪些材料?
准备脱敏模型清单、运行时版本、delegate 选择、队列或池策略、输入输出契约、取消与关闭状态机、代表设备范围和受控测试用例,再固定候选身份。