每批开 4 个线程,为什么限制不住 TTS 的总并发?

批量生成配音时,每个批次最多同时处理 4 个片段。看上去并发已经受控,但两个批次同时进来,总活动数还会只有 4 吗?

如果每批都新建一个池,两个池各守着自己的 4 个线程,总数完全可以叠加。配置没有失效,真正缺少的是对共享范围的约定。

前两篇分别讨论任务输入和结果回写,这次看执行容量。下面以 Vimorph 的批量配音为源码案例,用独立 Java 21 工程验证工作项并发、队列和拒绝行为。示例不调用真实 TTS 或云存储,也不把局部限制当作生产账号的全局配额。

先看 4 限制的是哪一层

Vimorph 的批量配音任务有两层执行:外层执行批次,内层处理分段;同一个外层执行器还供音频导出 Worker 使用。AudioGenerationWorker.execute() 为每次批次执行新建线程池,工作线程数取分段数量与 4 的较小值。这里的 4 属于这个批次创建的池。另一个批次进入同一方法,会得到另一个池。

前提是这些批次已经同时进入执行阶段,而且每批至少有 4 个分段,可以按下面的理论上限理解:

同时执行的批次 每批工作项上限 合计工作项理论上限
1 批 4 4
2 批 4 8
4 批 4 16

这不是提交四批就立即出现 16 个请求,也不是生产实测值;外层排队及共享任务都会影响实际同时批数。分段工作项也不等于一个始终在途的 TTS 请求:它还会解析音色、上传文件、探测时长和刷新进度。

层次 当前源码中的配置或行为 限制对象
外层批次执行器 core=2、max=4、queueCapacity=200 该执行器接收的外层任务
每批分段池 每次 execute 创建,最多 4 个工作线程 本批分段工作项
供应商调用 在分段工作项内部执行 不能仅由前两行推导账号全局配额

外层的 max=4 也不是"从第一刻就同时执行 4 批"。按当前配置,新任务先由核心规模 2 接纳,之后优先排队;队列接纳不了才尝试扩到最大 4,再无法接纳就拒绝。外层还服务音频导出、导入及文案流任务,这些任务占用的槽位不能同时算给 TTS 批次。Spring 的执行器文档说明了线程数与队列的关系。这里没有把配置值当成线上实测并发。

线程固定,排队仍可能积压

固定线程数限制活动工作线程,却不自动限制等待任务。Java 21 的 Executors 文档明确说明,newFixedThreadPool() 使用无界工作队列。

当前批次代码逐条提交分段,并把 Future 留在列表中,再等待执行结果。即使某一时刻只有 4 个工作项在跑,其余已提交分段仍会占用队列和引用。单批任务数量当然是有限的,但这个内层队列没有因为外层配置了 200,就自动变成容量 200。

队列不是免费的缓冲区,也不是可靠消息存储。等待任务越多,排队时间和内存占用通常越值得关注;仅看活动线程数,无法得知积压是否在持续增长。本篇没有测量线上排队时间或内存峰值,也不把这些可能性写成已经发生的故障。

让多个批次共用执行容量

如果希望同一应用实例里的多个批次共享执行容量,可以让它们提交到同一个执行器实例。关键在共享关系:把线程池构造代码提取成方法,但每批仍调用一次创建新池,并没有改变限制范围。

教学工程选择固定工作线程、有界队列和显式拒绝。SharedTtsExecutor 只是受控 Callable 的执行入口,不调用真实 TTS,也没有生产任务表、自动重试或分布式协调。它与当前每批新建池的实现不同,是便于验证作用域的独立方案。

下面是 SharedTtsExecutor 构造器节选。executor 是类内保存的 ThreadPoolExecutor 字段;两个批次拿到同一 SharedTtsExecutor 对象,才共享这份容量。parallelism 是工作线程数,queueCapacity 是等待工作项数,都要求为正数。

java 复制代码
public SharedTtsExecutor(int parallelism, int queueCapacity) {
    if (parallelism < 1 || queueCapacity < 1) {
        throw new IllegalArgumentException("parallelism and queueCapacity must be positive");
    }
    executor = new ThreadPoolExecutor(
            parallelism, parallelism, 0L, TimeUnit.MILLISECONDS,
            new ArrayBlockingQueue<>(queueCapacity),
            new ThreadPoolExecutor.AbortPolicy());
}

工程通过 JDK 的 ArrayBlockingQueue 指定有限排队容量,工作线程的核心数与最大数相同。演示用一个 4 线程、队列容量 4 的共享实例接收两批工作项;负责提交和收集结果的调用线程在池外。

共享上限并不保证批次公平。本轮另一个可控例子让 A 先提交 8 项,4 项被门闩保持在执行中,另外 4 项排队;B 随后提交的一项就被拒绝。队列按工作项接纳,没有自动为 B 留出每批配额。需要公平时可考虑分层调度、轮转或每批接纳限制;教学工程没有实现这些机制。

提交入口同样很小,下面是类内 submit() 节选。Callable、Future 与前面的队列和执行器均来自 java.util.concurrent,任务由调用方提供;完整依赖和入口保留在独立工程。

java 复制代码
public <T> Future<T> submit(Callable<T> task) {
    return executor.submit(Objects.requireNonNull(task, "task"));
}

这里没有"先查队列还有几个空位再提交"。查询时看到的空位可能马上被别的批次占用,真正是否接纳,应以本次 submit 的结果为准。示例提供的队列数量观察接口只用于观测,不是预留容量。

满了以后,调用方必须知道

有界队列需要配套的接纳规则。示例使用 AbortPolicy:执行器已关闭,或工作线程和队列都满时,submit 会向调用方抛出 RejectedExecutionException。被拒绝的任务没有得到可供等待的 Future,调用方需要明确记录"没有接纳",再决定向上返回繁忙还是以后重新提交。

按循环提交一个批次,也不具有整批原子性。前几个分段可能已经被接纳,后一个才被拒绝;捕获异常不会自动撤销之前的任务。要保留已取得的 Future,并逐项记录接纳结果。不能丢掉这些句柄后把整批重新提交,假设之前什么都没执行。

为把这一步落实到可运行工程,本轮增加 PartialBatch 提交回执:Accepted 保存原始索引和 Future,Rejected 保存索引和拒绝异常。下面是 submit() 节选,executor 仍是前面的共享执行器,tasks 是调用方提供的 Callable 列表:

java 复制代码
public static <T> PartialBatch<T> submit(SharedTtsExecutor executor, List<Callable<T>> tasks) {
    Objects.requireNonNull(executor, "executor");
    List<Callable<T>> snapshot = List.copyOf(tasks);
    List<Accepted<T>> accepted = new ArrayList<>();
    List<Rejected> rejected = new ArrayList<>();
    for (int index = 0; index < snapshot.size(); index++) {
        try {
            accepted.add(new Accepted<>(index, executor.submit(snapshot.get(index))));
        } catch (RejectedExecutionException rejection) {
            rejected.add(new Rejected(index, rejection));
        }
    }
    return new PartialBatch<>(accepted, rejected);
}

在 2 个线程、1 个等待位的测试里,提交 5 项得到接纳索引 [0,1,2]、拒绝索引 [3,4]。放行后,已接纳的 3 项中有 2 项成功、1 项抛错;后两项没有被自动重试。回执只记录这次接纳结果,不把它包装成完整业务状态机。

ThreadPoolExecutor 文档还提供其他拒绝策略。CallerRunsPolicy 会让提交线程直接执行工作项;它可以减慢提交,却会把执行位置带到池外。若目标是严格限制同时执行的工作项,就不能只看池中的 4 个线程而忽略调用者。静默丢弃则更不适合需要确认生成结果的任务。

显式拒绝只是把过载反馈给调用方,不代表已经做完端到端背压。调用入口怎样减少接纳、怎样展示繁忙、怎样处理部分提交,仍需要业务契约。

接纳之后还要收集执行结果

任务被接纳与任务执行成功,是另一个区别。submit() 返回 Future 后,工作项抛出的异常会由 Future.get() 以 ExecutionException 暴露;如果不读取或登记这个结果,就可能只知道"交给了线程池"。

独立示例验证一个工作项失败后,其他已接纳任务仍可继续。PartialBatch.awaitResults() 逐项返回原索引、值、原始失败原因及取消标记,执行异常不会被改成成功;协调者中断则抛出 InterruptedException,已保存的 Future 仍可供调用方收尾。该方法没有统一总截止时间,实际任务还需自己的超时约定。

协调者应留在分段工作池外。假如 4 个工作线程全被协调者占住,每个协调者又同步等待同池队列中的子任务,队列即便还有位置,子任务也可能没有线程执行,形成线程饥饿型死锁。因此改为共享池时,不能把协调者和分段任务直接合并进同一个小型固定池。

执行器应由共享它的上层统一管理,不能由先结束的批次顺手关闭。关闭、等待结束和中断请求也要分开理解:shutdown() 不等于正在执行的外部请求已经停止,Future 取消同样不能自动证明供应商不再处理。示例只验证受控任务的收尾,不延伸成完整供应商取消协议。

共享范围与 QPS 分开设计

同一个执行器实例,可以限制提交给它的活动工作项。若只想约束实际 TTS 调用,可以在调用边界获取共享 Semaphore 许可,在调用确实结束后的 finally 中归还;上传、探测等阶段可以放在许可区间外。这样限制的是接入同一许可协议的调用,不是所有分段的完整工作过程;异步供应商协议还要另行定义远端任务的生命周期。

其他语音合成入口、另建的执行器或其他应用副本不会自动服从这份许可。多实例共用供应商账号时,需要额外协调账号级额度。当前检查到的批量链路没有据此实现统一 Semaphore;本轮仅用 4 个工作项、2 个许可的受控调用验证机制,没有新增供应商限流系统。

并发数与 QPS 也不是同一个量。4 个工作项各占用多久,会影响单位时间内能开始多少次请求;重试和供应商内部行为还可能带来额外差异。把工作线程设为 4,不能直接宣称符合某个供应商每秒 4 次的规则。供应商配额需要以实际使用产品和账号的官方规则为准,本篇不填写未经核实的数字。

验证并发范围和饱和行为

本次运行的是纯 Java 21 + JUnit 独立工程,受控 Callable 代替外部调用。门闩让指定数量的工作项保持活动,AtomicInteger 记录进入和退出,等顺序明确后再放行;没有用随机 sleep 作为并发证据。

打包演示的实际输出如下:

text 复制代码
independent-pools peak-active: 8
shared-pool peak-active: 4
saturated-pool rejected: true

8 与 4 是这个受控实验中的活动工作项峰值,true 表示饱和拒绝被调用方捕获。它们不是 TTS 吞吐量、处理耗时或生产性能数据。

原 20 项测试源码与历史记录一致,本轮先复跑通过;新增 6 项后,本轮 26 项独立测试通过,失败 0、错误 0、跳过 0。原有独立池峰值 8、共享峰值 4、拒绝、Future 异常和关闭/中断用例保留;新增批次 A 占满容量后拒绝 B、部分提交回执与逐项结果、取消后工作仍未结束、同池协调者等待、共享许可和收集中断。源码 ZIP 解压后,同一套 26 项复跑通过,不另计独立测试数量。

受控任务响应中断的结果,不证明真实供应商请求可以被强制停止。没有调用真实 TTS、COS/OSS,也没有运行生产批次、Spring 异步代理、多实例协调、供应商限额或压力测试。业务实现来自只读源码核查;共享有界执行器属于教学对照。

结束语

批量执行之前,先明确每个上限约束的是哪个实例里的哪些工作项。随后给等待队列设置边界,让拒绝和失败能被调用方看见。每批最多 4 个、同一进程合计最多 4 个、供应商账号的全局额度,是三份不同的约定,需要分别落实。

相关推荐
Pniubi33 分钟前
Java为什么要分堆和栈
java·开发语言
杨凯凡36 分钟前
Spring Boot 缓存一致性怎么保证?@Cacheable、@CacheEvict 与事务提交时机实战
java·spring boot·spring cache·后端开发·缓存一致性
Rain的Java大神之路2 小时前
国家数据集团校招、社招都开了:总部雄安,这些技术岗值得认真看看
java·后端·面试
吴名氏.2 小时前
Java转AI Agent教程(第九周)错误矩阵
java·人工智能·矩阵·ai agent
文丑颜不良啊2 小时前
LeetCode1901~1920题目解析
java·leetcode
软件技术NINI2 小时前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设
tealcwu2 小时前
【Unity基础】为什么游戏需要物理按键映射?从 Unity Input Manager 到跨引擎输入设计
java·游戏·unity
vipxieliang2 小时前
Spring Boot 性能优化实战:从启动加速到运行时调优
java·spring boot