20、多线程与并发推理:线程安全模型执行、任务队列设计、避免ANR
各位同学,咱们今天聊点硬核的。多线程与并发推理,说白了就是让你的AI模型在App里跑得又快又稳,还不卡界面。
我刚开始做NNAPI开发时,踩过一个坑:直接在UI线程里加载模型、跑推理。结果呢?App直接黑屏,系统弹出"应用无响应"对话框。用户一看,直接卸载。嗯,从那以后,我再也不敢在主线程里碰模型了。
为什么需要多线程推理?
你想想看,NNAPI的推理任务通常需要几十到几百毫秒。这个时间虽然不长,但足以让UI线程卡顿。Android系统有个"ANR"机制——如果主线程超过5秒没响应,系统就会弹窗。
更关键的是,现代手机往往有多个NPU/DSP核心。单线程推理只能用一个核心,白白浪费了硬件性能。我曾在骁龙8 Gen2上测试过,多线程并发推理比单线程快了将近3倍。
线程安全模型执行
NNAPI的模型对象默认不是线程安全的。什么意思?就是你不能让两个线程同时调用同一个模型的run()方法。否则轻则数据错乱,重则直接崩溃。
我个人习惯的做法是:每个线程持有独立的模型副本。但模型加载很耗时,怎么办?用对象池。
// 线程安全的模型池
public class ModelPool {
private final LinkedList<NeuralNetworks.Model> available = new LinkedList<>();
private final Object lock = new Object();
public ModelPool(int poolSize, String modelPath) {
for (int i = 0; i < poolSize; i++) {
available.add(loadModel(modelPath));
}
}
public NeuralNetworks.Model acquire() {
synchronized (lock) {
while (available.isEmpty()) {
try {
lock.wait(); // 没有空闲模型就等待
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
return available.poll();
}
}
public void release(NeuralNetworks.Model model) {
synchronized (lock) {
available.add(model);
lock.notify(); // 唤醒等待的线程
}
}
}
这里有个细节:wait()和notify()必须在同步块里用。我曾经因为忘记加synchronized,结果出现了诡异的"假唤醒"问题——线程被唤醒了但模型还没释放,直接拿到null。
任务队列设计
光有模型池还不够。你还需要一个任务队列来管理推理请求。想象一下:用户连续拍了10张照片,每张都要做风格迁移。如果同时启动10个线程,手机直接变暖手宝。
我的方案是:用ExecutorService配合有界队列。
public class InferenceQueue {
private final ExecutorService executor;
private final BlockingQueue<Runnable> taskQueue;
private final ModelPool modelPool;
public InferenceQueue(int maxConcurrency, int queueSize) {
this.taskQueue = new LinkedBlockingQueue<>(queueSize);
this.executor = new ThreadPoolExecutor(
maxConcurrency, maxConcurrency,
0L, TimeUnit.MILLISECONDS,
taskQueue,
new ThreadPoolExecutor.CallerRunsPolicy() // 队列满了就由调用线程执行
);
this.modelPool = new ModelPool(maxConcurrency, "model.nna");
}
public Future<Result> submit(InferenceTask task) {
return executor.submit(() -> {
NeuralNetworks.Model model = modelPool.acquire();
try {
return task.execute(model);
} finally {
modelPool.release(model);
}
});
}
}
注意那个CallerRunsPolicy。当队列满了,新任务不会丢弃,而是由提交任务的线程(通常是主线程)直接执行。这其实是一种"反压"机制——告诉调用方:我忙不过来了,你等等。
避免ANR的实战策略
ANR是Android开发者的噩梦。对于NNAPI推理,最常见的ANR场景是:模型加载太慢,或者推理时间过长。
我总结了一套"三层防护"策略:
- 预加载:App启动时,在后台线程提前加载模型。用户真正使用时,模型已经就绪。
- 超时控制:每个推理任务设置超时时间。超时了就放弃,不要死等。
- 降级方案:如果NNAPI推理超时,回退到CPU推理(虽然慢,但至少不卡死)。
// 带超时的推理任务
public class TimeoutInferenceTask implements Callable<Result> {
private final NeuralNetworks.Model model;
private final float[] input;
private final long timeoutMs;
@Override
public Result call() throws Exception {
FutureTask<Result> future = new FutureTask<>(() -> {
// 执行NNAPI推理
return runInference(model, input);
});
Executors.newSingleThreadExecutor().execute(future);
try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true); // 取消任务
return fallbackToCPU(input); // 降级到CPU
}
}
}
这里有个坑:future.cancel(true)只是发送中断信号,NNAPI底层不一定响应。我后来改用NeuralNetworks.Computation.finish()来强制终止推理。
知识体系总览
下面这张图是我自己梳理的多线程推理架构,你看一眼就能明白整体脉络:
性能调优经验
最后分享几个实战中总结的调优参数:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 线程池核心数 | CPU核心数 - 1 | 留一个核心给UI线程和其他系统服务 |
| 任务队列容量 | 16 ~ 32 | 太小容易丢任务,太大容易撑爆内存 |
| 推理超时时间 | 500ms ~ 1000ms | 超过1秒用户体验明显变差 |
| 模型池大小 | 等于线程池核心数 | 避免模型竞争,也避免浪费内存 |
嗯,这些参数不是死的。我在不同手机上测试过,骁龙和天玑的调度策略不一样。建议你在目标设备上做A/B测试,找到最优值。