批量生成配音时,每个批次最多同时处理 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 个、供应商账号的全局额度,是三份不同的约定,需要分别落实。