20、多线程与并发推理:线程安全模型执行、任务队列设计、避免ANR

各位同学,咱们今天聊点硬核的。多线程与并发推理,说白了就是让你的AI模型在App里跑得又快又稳,还不卡界面。

我刚开始做NNAPI开发时,踩过一个坑:直接在UI线程里加载模型、跑推理。结果呢?App直接黑屏,系统弹出"应用无响应"对话框。用户一看,直接卸载。嗯,从那以后,我再也不敢在主线程里碰模型了。

为什么需要多线程推理?

你想想看,NNAPI的推理任务通常需要几十到几百毫秒。这个时间虽然不长,但足以让UI线程卡顿。Android系统有个"ANR"机制——如果主线程超过5秒没响应,系统就会弹窗。

更关键的是,现代手机往往有多个NPU/DSP核心。单线程推理只能用一个核心,白白浪费了硬件性能。我曾在骁龙8 Gen2上测试过,多线程并发推理比单线程快了将近3倍。

核心原则: 永远不要在UI线程执行模型推理。这是铁律。

线程安全模型执行

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。

小技巧: 模型池的大小建议设为CPU核心数+1。太多反而会因为线程切换导致性能下降。

任务队列设计

光有模型池还不够。你还需要一个任务队列来管理推理请求。想象一下:用户连续拍了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场景是:模型加载太慢,或者推理时间过长。

我总结了一套"三层防护"策略:

  1. 预加载:App启动时,在后台线程提前加载模型。用户真正使用时,模型已经就绪。
  2. 超时控制:每个推理任务设置超时时间。超时了就放弃,不要死等。
  3. 降级方案:如果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()来强制终止推理。

知识体系总览

下面这张图是我自己梳理的多线程推理架构,你看一眼就能明白整体脉络:

多线程与并发推理架构 推理请求队列 线程池 (ThreadPoolExecutor) 模型实例1 模型实例2 模型实例3 NNAPI 硬件加速 (NPU/DSP/GPU) 结果回调 / 超时降级 模型池 执行层

性能调优经验

最后分享几个实战中总结的调优参数:

参数 推荐值 说明
线程池核心数 CPU核心数 - 1 留一个核心给UI线程和其他系统服务
任务队列容量 16 ~ 32 太小容易丢任务,太大容易撑爆内存
推理超时时间 500ms ~ 1000ms 超过1秒用户体验明显变差
模型池大小 等于线程池核心数 避免模型竞争,也避免浪费内存

嗯,这些参数不是死的。我在不同手机上测试过,骁龙和天玑的调度策略不一样。建议你在目标设备上做A/B测试,找到最优值。

最后说一句: 多线程推理不是银弹。如果你的模型本身很小(比如几毫秒就能跑完),单线程反而更高效。别为了用多线程而用多线程,先测量,再优化。

公众号:蓝海资料掘金营,微信deep3321