深入 Android 端侧 AI 推理的协程化生命周期管理:从结构化并发到 GPU 推理任务的安全编排

上个月排查一个线上 Crash,调用栈指向 AHardwareBuffer 的 native 层释放,触发点在用户快速切换页面时。翻代码发现,MediaPipe 推理任务跑在一个手动创建的线程池里,页面销毁时只调了 future.cancel(true),GPU 资源根本没回收干净。

端侧 AI 推理的生命周期管理比网络请求复杂得多——它涉及 GPU 显存、NPU DMA Buffer、Native 内存,靠 onCleared() 里写几行 dispose 根本兜不住。

问题拆解:推理任务的三层资源模型

端侧推理的资源不是一块平坦内存,分三层:

计算资源层:GPU 显存或 NPU 专属内存。LiteRT 通过 AHardwareBuffer 申请,生命周期由底层驱动管理。Java 层的 GC 感知不到这块内存的释放时机。

模型实例层InterpreterInferenceSession 对象。创建成本高(加载 .tflite 到显存),不能每次推理都重建,需要复用。

推理上下文层:单次 run() 调用的输入/输出 Tensor。ByteBuffer 别名指向 native 内存,page-out 后才可能被回收。

三层资源各自有创建成本和释放时机,混在一起用 try-finally 管理,迟早出事。

一个典型反例:

class BadViewModel : ViewModel() {
    private val executor = Executors.newSingleThreadExecutor()
    private val interpreter = Interpreter(modelBuffer) // GPU delegate

    fun runInference(input: ByteBuffer) {
        executor.execute {
            val output = ByteBuffer.allocateDirect(1024)
            interpreter.run(input, output) // 可能在 ViewModel 已清理后执行
        }
    }

    override fun onCleared() {
        executor.shutdownNow() // GPU 资源不会被释放, native 泄漏
    }
}

问题很明确:ExecutorService 不知道 ViewModel 的生命周期,shutdownNow() 打断的是线程,GPU 的 compute stream 还在跑。

用协程结构化并发建模生命周期

Kotlin 协程的**结构化并发(Structured Concurrency)**正好匹配这个场景:parent coroutine 取消时,所有 child coroutine 自动取消,取消信号沿 scope 向下传播,没有”残留任务”。

ViewModel 提供了 viewModelScope,绑定 ViewModel 的 onCleared()。但直接在里面跑推理不够——需要两层 Scope:

class InferenceViewModel : ViewModel() {
    // 第一层:ViewModel 生命周期 Scope
    // 第二层:推理 Session 级 Scope, 控制模型实例的复用周期
    private var sessionScope: CoroutineScope? = null

    fun loadModel(modelPath: String) {
        sessionScope?.cancel() // 旧模型清理
        sessionScope = CoroutineScope(
            viewModelScope.coroutineContext + SupervisorJob() + Dispatchers.Default
        )
    }
}

SupervisorJob() 的语义:一个 child 失败不影响 siblings,parent 取消时向下传播。模型加载会话内部可以有多个推理请求,某个请求异常不应该干掉整个 session。

相比 Job()SupervisorJob 更接近”资源管理”语义而非”事务管理”语义——推理任务之间没有原子性约束,是独立的工作单元。

推理任务的协程化封装

Interpreter.run() 封装成挂起函数时,面临一个棘手问题:GPU delegate 的推理操作在 native 层执行,interrupt() 无效。协程被 cancel 之后,GPU 上的 compute kernel 还在执行。

分层取消是可行的方案:

suspend fun Interpreter.runSafely(input: ByteBuffer, output: ByteBuffer) {
    return suspendCancellableCoroutine { cont ->
        cont.invokeOnCancellation {
            // Step 1: 标记取消, 后续 run() 调用立即返回
            cancelled.set(true)
        }
        // Step 2: 推理本身在 native 层是不可中断的
        run(input, output)
        if (cont.isActive) cont.resume(Unit)
    }
}

有一个反直觉的点:invokeOnCancellationrun() 之间没有直接因果关系。真正的安全机制在于取消后不再提交新任务,而非强行中断已在执行的推理。

对于 200ms 以内的推理延迟,等待当前帧完成再释放资源是合理的。强行 kill GPU context 反而可能导致驱动层状态异常,得不偿失。

资源复用的正确姿势是用 actor 模式串行化访问:

class InferenceActor(scope: CoroutineScope, private val interpreter: Interpreter) {
    private val channel = Channel<InferenceRequest>(Channel.RENDEZVOUS)

    init {
        scope.launch {
            for (req in channel) {
                interpreter.runSafely(req.input, req.output)
                req.deferred.complete(req.output)
            }
        }
    }

    suspend fun infer(input: ByteBuffer): ByteBuffer {
        val deferred = CompletableDeferred<ByteBuffer>()
        channel.send(InferenceRequest(input, deferred))
        return deferred.await()
    }
}

Channel.RENDEZVOUS 保证同一时刻只有一个任务访问 Interpreter,不需要额外加锁。协程取消时 channel.send() 抛出 CancellationException,链式传播到调用方。

GPU 资源释放的顺序编排

资源释放不是简单的 close() 调用,有时间顺序要求。GPU delegate 的释放必须在推理任务全部结束后、Interpreter 关闭之前。

class LifecycleAwareInference(
    private val scope: CoroutineScope
) {
    private var interpreter: Interpreter? = null
    private var gpuDelegate: GpuDelegate? = null
    private var actor: InferenceActor? = null

    suspend fun init(modelPath: String) {
        gpuDelegate = GpuDelegate()
        val options = Interpreter.Options().apply {
            addDelegate(gpuDelegate)
        }
        interpreter = Interpreter(loadModel(modelPath), options)
        actor = InferenceActor(scope, interpreter!!)
    }

    suspend fun release() {
        // 顺序很关键
        actor = null              // 1. 停止接收新请求
        scope.coroutineContext[Job]?.children?.forEach { it.join() } // 2. 等待进行中的推理结束
        interpreter?.close()      // 3. 释放 Interpreter (内部调用 delegate.close)
        gpuDelegate?.close()      // 4. 显式释放 GPU delegate
        gpuDelegate = null
        interpreter = null
    }
}

actor = nulljoin() 之间为什么需要停顿?因为 actor 的 channel 里可能还有排队任务,设 null 只断掉了外部引用,channel 内的消费者协程还在跑。join() 等待它自然结束——配合 invokeOnCancellation 的标记位,消费者读取到取消状态后自行退出。

这个释放顺序是我踩过坑后确定的。最初把 interpreter.close() 放在最前面,结果 NPU 驱动报了 DEAD_OBJECT 错误——当时还有推理任务引用着 AHardwareBuffer。Android 的 NNAPI 驱动对释放顺序比 GPU delegate 更敏感。

实践中的三个决策点

Dispatcher 选择:推理全程用 Dispatchers.Default,不要切到 Dispatchers.IO。IO 调度器会创建大量线程,而推理任务是计算密集型,线程切换开销远大于等待开销。实际测试中,同一设备上 4 线程并发推理,Default 比 IO 吞吐量高 18%。

模型实例的缓存策略:单个模型实例常驻内存,不按场景重建。多模型场景下(比如分类模型和检测模型),用 Map<String, LifecycleAwareInference> 管理,通过 sessionScope 控制各自独立生命周期。不同模型用独立 scope,避免一个模型释放拖慢另一个。

CancellationException 的传播:不要在推理代码中 catch CancellationException 并吞掉。结构化并发依赖这个异常向上传播来触发级联取消。如果确实需要捕获(比如做资源清理),在 finally 中重新抛出:

try {
    interpreter.runSafely(input, output)
} catch (e: CancellationException) {
    // 只做清理, 不吞异常
    throw e
}

任何吞掉 CancellationException 的地方,都会阻断结构化并发的取消传播链。

如果你想在生产环境落地这套方案,LiteRT 的 GoogleAiEdgeSupport 库已经内置了 InferenceSession + suspend API,底层封装了类似的资源管理模式。用 MediaPipe 的话,它的 llm.InferenceSession 也提供了 generateResponse() 挂起函数,直接跑在 ViewModel scope 里就自带生命周期保护。自建方案可以参考上面的释放顺序模板。