专用 dispatcher 配置两条线程、一个队列位置。两条任务正在等待,第三条排队,再提交第四条时,实验没有收到预期中的拒绝异常:第四条在提交它的协调线程上执行,业务在途数从2增到3。

相同规模的 JDK ThreadPoolExecutor 改用 AbortPolicy,第四次提交抛出拒绝异常,应用将它映射为503。有限队列与明确拒绝,是两个需要分别验证的条件。

执行位置首先是实际线程

第12篇已经区分调用返回与工作线程。本篇进一步比较默认 dispatcher 和命名 lab-blocking-dispatcher。受控任务进入后记录线程名、增加在途计数,再等待独立协调线程释放。

默认任务实际运行在线程名包含 default-dispatcher 的位置;专用任务包含 lab-blocking-dispatcher。两个位置的 inFlightBeforeRelease 都为1。这个结果证明当前注入和配置确实选用了不同执行位置,不证明它们拥有独立 CPU、数据库连接或下游资源。

命名执行上下文沿用 Play 提供的类:

1
2
3
4
5
6
public final class LabExecutionContext extends CustomExecutionContext {
@Inject
public LabExecutionContext(ActorSystem system) {
super(system, "lab-blocking-dispatcher");
}
}

CustomExecutionContext按名称查找 dispatcher,并提供捕获当前类加载器的 current()。名称是否存在、配置是否生效,都应在启动和真实任务中验证,不能只看 Java 字段类型。

两条工作线程与一个等待位置

本篇配置限定线程池与任务队列:

1
2
3
4
5
6
7
8
9
10
lab-blocking-dispatcher {
type = Dispatcher
executor = "thread-pool-executor"
thread-pool-executor {
fixed-pool-size = 2
task-queue-size = 1
task-queue-type = "array"
}
throughput = 1
}

任务1和2进入后停在 release latch;任务3提交后尚未开始。在这段受控窗口,submitted-started=1。Pekko 的这个数是应用观测差值,并不是读取私有队列的直接指标。

Actor 邮箱、dispatcher 任务队列和业务请求数还需要分开。一次 dispatcher Runnable 可能处理邮箱中的多条消息,throughput 不等于 HTTP 并发上限。当前实验直接向执行器提交 Runnable,因此不能据此推出 Actor 邮箱恰有一个位置。

饱和时第四条去了哪里

Pekko1.0.3当前线程池的 SaneRejectedExecutionHandler,在执行器未 shutdown 时调用 Runnable.run(),shutdown 时抛出拒绝异常。原始构件字节码保存在隔离 source-evidence.txt;它与本次第四条任务的线程观测一致。

饱和窗口 Pekko 当前策略 JDK AbortPolicy 对照
两条任务正在执行 active=2 active=2
第三条等待 submitted-started=1 queue.size=1
第四条提交 调用协调线程直接执行 RejectedExecutionException
对外响应 200,报告 caller-runs 503,报告拒绝
释放后终态 完成4、在途0、ActorSystem停止 完成3、队列0、pool停止

Pekko 工作线程仍只有配置的两条,新增执行位置是调用线程;所以“线程池最多两条线程”与“这组业务最多两个在途任务”不能混为一条断言。如果调用者是其他重要执行位置,caller-runs 会把阻塞工作带回那里。

本实验第四条工作是有限的即时记录,并不故意阻塞协调线程。若让它也等待只能由协调线程随后释放的 latch,提交过程会卡住,驱动代码无法走到 release,这会把实验写成自锁。释放机制必须独立于被占满的资源。

拒绝成为公开响应

对照线程池采用两条固定线程、ArrayBlockingQueue(1) 与 AbortPolicy:

1
2
3
4
ThreadPoolExecutor pool = new ThreadPoolExecutor(2, 2, 0L,
TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(1),
Thread.ofPlatform().name("lab-abort-", 0).factory(),
new ThreadPoolExecutor.AbortPolicy());

JDK 的 ThreadPoolExecutor 契约区分提交、排队和拒绝策略。实验捕获提交时的 RejectedExecutionException,记录 fourthStatus=503;AsyncController 用该状态构造 Result。拒绝发生在进入第四条任务之前,并不意味着先前已接受的三条任务停止。

释放后等待已接受任务完成,再关闭池,记录完成3、在途0、queue0、terminated=true。HTTP 503 和这些终态来自不同阶段,响应错误码不能代替清理证明。

拒绝响应仍需业务语义:调用者能否重试、是否已有副作用、是否需要 Retry-After,都取决于入口契约。本文没有把所有异常、连接超时或数据库冲突统一描述成可重试503。

隔离并不扩展总资源

对阻塞等待,专用池可以限制这类工作占用的执行位置;实际有效上限还受连接池和下游限制。把更多任务排入无界队列只会改变等待和内存成本。增加专用线程也不能让固定数据库连接数凭空增加。

CPU对照另在默认与专用执行位置计算1到100,000的整数平方和,两个结果均为333,338,333,350,000,并记录各自实际线程名。测试以闭式公式独立核对结果。这段计算没有用latch等待冒充CPU工作,也没有根据一次耗时给执行器排名。

CPU计算另受可用核心与任务竞争影响,不能套用“阻塞多就多加线程”的判断。当前实验不模拟CPU饱和,也不提供吞吐、p95或p99;第31篇将用固定负载对照。

第22篇已补验真实JDBC竞争:默认dispatcher的parallelism-min/max都读回2,独立PostgreSQL查询确认两条PgSleep正在执行,随后才发送三次轻量health请求。共享重跑的默认执行位置约为1.582、0.0056、0.0066秒,专用位置约为0.0120、0.0129、0.0070秒;SQL实际线程分别属于default-dispatcher与lab-blocking-dispatcher。第一次请求的等待证明这次负载影响了共享执行位置,后两次不能说明持续饱和。另一组固定连接池2的对照中,执行器4线程时连接等待者2、队列0,执行器2线程时连接等待者0、队列2,任务完成后activeConnections=0。原始时点、线程与SQL观察见evidence/batch22-25/shared-http/summary.json及sql-observations.json,这些有限功能样本仍不构成容量排名。

实验端点创建临时 ActorSystem 或线程池,是为了每轮得到可验证的有限清理终态。生产应用应该把自建资源纳入生命周期,按应用规模配置并监控,不按每请求分配整套资源。

重跑与判定

从第00篇取得累计工程,执行固定版本的 bash sbtw test stage,再运行 lab/async_checks.py 的 DEV/PROD矩阵。共享检查分别读取 /async/placement、/async/pekko 和 /async/abort,核验状态、计数、线程与关闭标记。

1
2
python3 lab/async_checks.py --dev
python3 lab/async_checks.py

12–14隔离验收已有累计26项JUnit;新增CPU对照后,AsyncTest单独8/8通过,历史与增量日志分别保留。共享累计工程34项测试与stage通过,开发、生产模式各198次HTTP检查通过,原始响应保存于batch12-17。Helpers只证明其调用层,真实矩阵才经过服务器与网络。耗时是有限功能实验,不作容量结论。

要提出的结论 应取得的证据
当前任务进入专用位置 实际线程名与进入计数
排队位置有限 明确配置、受控提交与等待观测
饱和直接拒绝 实际策略、异常与入口状态
已接受工作停止占资源 完成计数、在途与关闭终态

反例题:队列容量为1、工作线程数为2,是否可以直接声明最多只有3个业务任务被执行或接受?当前 caller-runs 的第四条任务已经给出反例;真实业务还可能有其他执行器和提交入口。

改动练习:给受控任务增加明确的入场许可,许可不足时在提交前拒绝,并在正常、异常和取消路径归还;记录 caller-runs 情况下许可是否仍限制业务在途数。不要以“换成专用 dispatcher”代替入场控制。

上一篇:CompletionStage 与控制流。下一篇:上下文与不可变请求。累计源码:最小应用。