多进程架构与模块划分
Ark-Pets 采用"桌面启动器(父进程)+ 每只桌宠独立 JVM 子进程"的多进程模型,由 core 子工程中的 ProcessPool 统一负责子进程的创建、并发调度与退出码审计;同时仓库按 core Gradle 子工程、cn.harryh.arkpets 内部分包(concurrent、telemetry.wal 等)以及 assets/UI 的 JavaFX FXML 模块完成功能划分。
目的与范围
本页覆盖 Ark-Pets v3.x 的进程级架构与代码模块划分,具体包括:
- 父进程与子进程的职责边界与隔离动机;
ProcessPool(core/src/cn/harryh/arkpets/concurrent/ProcessPool.java)的完整实现分析:线程池参数、Executor适配、两种submit重载、ProcessResult与UnexpectedExitCodeException;- 子进程异常经 Write-Ahead Log(WAL,
telemetry.wal包)回捞的机制; - 仓库可见的模块边界:
core子工程、内部包结构、assets/UIFXML 模块族。
本页不覆盖(留给兄弟页面):
- 单只桌宠子进程内部的渲染与行为状态机 —— 见相关行为/渲染页面;
- WAL 遥测记录的文件格式与写入端实现细节 —— 见遥测(telemetry)页面;
- Gradle 构建链路与打包分发 —— 见构建与发布页面。
说明:受本次源码探索预算(6 次工具调用)限制,桌面启动器的入口类源码未能直接读取。本页中所有结论均以已读取的
ProcessPool.java全文与仓库文件清单为依据;凡未能验证的部分会明确标注"实现细节未在源码中核实"。
概述
Ark-Pets 是一款"明日方舟"主题的桌面宠物应用。之所以选择多进程而非单进程多窗口模型,核心动机来自源码中可验证的三点证据:
- 崩溃隔离:
ProcessPool.ProcessResult显式建模了"非零退出码"这一一等公民状态,并为它配套了UnexpectedExitCodeException(见 ProcessPool.java#L86-L131)。单只桌宠的 OpenGL/JavaFX 崩溃不应拖垮承载托盘图标与配置界面的父进程。 - 可审计的死亡:
UnexpectedExitCodeException的构造函数会按processId打开 WAL 日志、用WalExceptionCodec解码出真正的异常并挂载为cause(ProcessPool.java#L121-L130)。这要求子进程拥有独立 PID —— 只有进程模型才能提供这种"事后取证"能力。 - 生命周期独立:
submit(List<String> command)返回Future<ProcessResult>(ProcessPool.java#L56-L66),父进程以异步方式观测子进程的存活与退出,无需共享任何堆内存。
架构
上图各环节的职责与连接依据:
ProcessPool是父进程内唯一的进程创建入口,双重检查锁定的懒汉式单例(ProcessPool.java#L30-L42),并同时实现 JDK 的java.util.concurrent.Executor接口,使其可被当作通用任务执行器复用。ThreadPoolExecutor是真正承载FutureTask的执行器,参数为SynchronousQueue+ 守护线程工厂,即"提交即新建线程、空闲 60 秒回收"(详见下文配置选项)。ProcessBuilder以inheritIO()启动子进程 —— 子进程的 stdout/stderr 直接复用父进程的控制台流,便于同一份日志输出(ProcessPool.java#L59-L61)。- WAL 遥测链路:子进程死亡后,父进程侧用
WalReader.open(processId)定位该 PID 的日志、遍历WalRecord、由WalExceptionCodec还原异常对象(ProcessPool.java#L121-L130)。这是跨进程传递"崩溃原因"的唯一通道,构成图中虚线所示的松耦合数据流。
仓库级模块划分
模块边界的依据与意图:
core子工程:仓库根下可见 core/build.gradle,core/src/cn/harryh/arkpets/下按领域分包。concurrent包收敛所有"进程并发"原语,telemetry.wal包收敛"跨进程可观测性"原语 —— 两者通过ProcessPool对 WAL 的只读消费产生单向依赖,方向为concurrent → telemetry.wal。assets/UIFXML 模块族:界面被拆为 Root/Models/Behavior/Settings 四个功能模块加三个对话框(Announce、Download、Log)。这种划分与多进程架构互补:父进程负责全部 FXML 界面与配置管理,子进程只负责渲染桌宠本体,因此 UI 模块无需感知进程细节。(各 FXML 对应的 Controller 绑定关系未在本次已读源码范围内,不做展开。)
核心控制流
父进程从提交一个子进程到拿到其退出结论的完整时序:
逐步解读(对应 ProcessPool.java#L56-L96):
- 命令行拼装:
submit(Class<?> clazz, List<String> jvmArgs, List<String> args)从System.getProperty("java.home")与java.class.path还原出与父进程完全一致的 Java 运行时与类路径,再以clazz.getName()作为主类、args作为程序参数(ProcessPool.java#L68-L83)。设计意图:保证"子进程与父进程运行同一份代码、同一个 JVM 版本",避免分发环境漂移;同时调用方只需传Class引用即可获得可重启性。 - 异步化与守恒:进程启动与
waitFor()被包进FutureTask,由池内线程阻塞等待,因此父进程的主线程/JavaFX 应用线程永远不会被子进程的生命周期阻塞 —— 这是 UI 响应性与进程管理解耦的关键。 - 结论回传:
waitFor()返回后立即构造record ProcessResult(int exitValue, long processId)。将pid一并返回是有意为之:它既是 WAL 日志的索引键,也是异常对象中的可观测字段。 - 异常溯源:
ProcessResult.toException()在退出码非零时构造UnexpectedExitCodeException,其构造函数内部调用私有的getProcessException():WalReader.open(processId)→ 遍历readAll()→ 匹配WalExceptionCodec.INSTANCE.type()→decode(payload)还原异常,并通过initCause挂载(ProcessPool.java#L99-L131)。WAL 读取失败时静默吞掉IOException并返回null,保证"取证失败不掩盖退出码事实"。
使用示例
启动一个同源 JVM 子进程
1// 以与父进程相同的 java.home 与 classpath 启动子进程
2List<String> jvmArgs = List.of("-Xms64m", "-Xmx256m");
3List<String> appArgs = List.of("--model", "Amiya");
4
5Future<ProcessPool.ProcessResult> future =
6 ProcessPool.getInstance().submit(MyPetEntrance.class, jvmArgs, appArgs);
7
8ProcessPool.ProcessResult result = future.get(); // 阻塞等待子进程退出Source: ProcessPool.java
上述片段节选自真实签名 submit(Class<?>, List<String>, List<String>) 的调用形态;该重载内部完成 javaBin / -cp classpath / 主类名 / 参数的完整拼装后转调 submit(List<String> command)。
消费退出结论与异常溯源
1Future<ProcessPool.ProcessResult> future = ProcessPool.getInstance().submit(command);
2
3ProcessPool.ProcessResult result = future.get();
4if (!result.isSuccess()) {
5 // toException() 会自动从 WAL 中还原子进程崩溃前的真实异常
6 throw result.toException();
7}Source: ProcessPool.java
ProcessResult 是 Java record,字段即 exitValue 与 processId;isSuccess() 的判定条件是 exitValue == 0。注意 toException() 在成功场景下返回 null,调用方需自行判空。
直接提交任意命令行
List<String> command = new ArrayList<>();
// ProcessPool.submit(List<String>) 接受任意可执行命令
Future<ProcessPool.ProcessResult> future = ProcessPool.getInstance().submit(command);Source: ProcessPool.java
这是最底层的重载:ProcessBuilder(command).inheritIO().start() 后 waitFor(),不做任何 JVM 参数推断。上一节的重载最终都会汇聚到这里。
配置选项
ProcessPool 不读取任何外部配置文件,其全部可调参数硬编码于构造处(ProcessPool.java#L18-L28):
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| corePoolSize | int | 20 | 常驻线程数下限 |
| maximumPoolSize | int | Integer.MAX_VALUE | 上限不设约束(配合 SynchronousQueue 表现为"提交即建线程") |
| keepAliveTime | long / TimeUnit | 60L / SECONDS | 超过 core 数量的空闲线程 60 秒后回收 |
| workQueue | BlockingQueue | SynchronousQueue | 零容量直传队列,任务不排队、立即由(新建)线程执行 |
| threadFactory | ThreadFactory | 守护线程 | thread.setDaemon(true),父进程退出时不被池内线程拖住 |
| 单例化策略 | — | 双重检查锁定 | volatile instance + synchronized(ProcessPool.class) 懒加载 |
外部配置面则位于仓库的 ArkPetsConfigDefault.json,属于应用层配置而非进程池参数(其字段与 ProcessPool 无耦合,实现细节未在本次源码核实范围内)。
API 参考
public static ProcessPool getInstance(): ProcessPool
返回全局唯一实例;首次调用时以双重检查锁定完成初始化。
Returns: ProcessPool 单例。
public Future<ProcessResult> submit(List<String> command)
以原始命令行启动子进程并异步等待其退出。
Parameters:
command(List<String>): 完整可执行命令行(含程序名)。
Returns: Future<ProcessResult>,完成后携带退出码与 PID。
Throws:
- 由
FutureTask包装:IOException(ProcessBuilder.start()失败)、InterruptedException(等待中断)。
public Future<ProcessResult> submit(Class<?> clazz, List<String> jvmArgs, List<String> args)
以父进程同源的 JVM 与 classpath 启动指定主类的子进程。
Parameters:
clazz(Class<?>): 子进程主类。jvmArgs(List<String>): 附加 JVM 参数(如-Xmx)。args(List<String>): 传给主类的程序参数。
Returns: 同上。
public void execute(Runnable task)(Executor 接口实现)
将普通 Runnable 交给内部 executorService;对 RejectedExecutionException 采取静默忽略策略。
record ProcessResult(int exitValue, long processId)
| 成员 | 类型 | 说明 |
|---|---|---|
exitValue | int | 子进程退出码,0 表示成功 |
processId | long | 子进程 PID,同时是 WAL 日志的索引键 |
方法: isSuccess(): boolean(exitValue == 0);toException(): UnexpectedExitCodeException(成功时返回 null)。
class UnexpectedExitCodeException extends Exception
构造逻辑: 保存 exitCode 与 processId,随后尝试从 WAL 还原子进程真实异常并 initCause。getMessage() 固定返回 "The process exited with a non-zero exit code: " + exitCode。
失败模式、边界情况与并发
以下行为全部来自 ProcessPool.java 的实际实现:
| 圱面 | 现象 | 源码中的处置 | 设计意图 |
|---|---|---|---|
| 子进程非零退出 | exitValue != 0 | 构造 UnexpectedExitCodeException,从 WAL 按 PID 回捞真实异常挂载为 cause(L121-L130) | 退出码只回答"死了",WAL 回答"为什么死",两者互补 |
| WAL 缺失/损坏 | WalReader.open(pid) 抛 IOException | catch (IOException ignored) 静默返回 null(L127-L129) | 取证失败不应掩盖退出码这一硬事实 |
| WAL 中无异常记录 | 遍历 readAll() 无匹配类型 | getProcessException() 返回 null,initCause 不执行 | 区分"干净退出失败"与"崩溃" |
| 池满/拒绝 | execute() 遇 RejectedExecutionException | catch (RejectedExecutionException ignored) 静默丢弃(L48-L49) | UI 侧任务提交不因拒绝而打断 |
| 父进程退出 | 主线程结束 | 池线程为守护线程(setDaemon(true)) | 避免僵尸线程阻止 JVM 正常退出 |
| 子进程无输出流重定向 | — | builder.inheritIO()(L60) | 父子共享控制台,单一日志出口 |
并发要点:
- 单例可见性:
instance声明为volatile,配合synchronized(ProcessPool.class)的双重检查锁定(L30-L39),保证多线程首次并发调用getInstance()时的安全发布。 - 阻塞被限制在池内线程:
process.waitFor()只发生在FutureTask内部,业务线程通过Future.get()消费结果 —— JavaFX UI 线程若直接调用get()仍会阻塞,需上层自行异步化(该上层封装未在本次核实范围内)。 SynchronousQueue语义:execute/submit均走直传队列,即每个进程等待任务都对应一条独立线程;maximumPoolSize = Integer.MAX_VALUE意味着并发桌宠数理论上不受池限制,实际约束来自操作系统。
性能与运维要点
- 每桌宠一个 JVM 的成本模型:子进程以完整 JVM 启动(同
java.class.path),启动延迟与内存足迹显著高于线程模型。这是"崩溃隔离 + 独立取证"换来的代价,submit(Class, ...)重载允许调用方以jvmArgs(如-Xmx)对单个子进程做内存上限调优。 inheritIO()的运维收益:无需任何管道转发代码,子进程日志天然汇入父进程控制台;代价是无法在父子之间做流级别的过滤/重写。- PID 即追踪键:运维侧可凭
ProcessResult.processId或UnexpectedExitCodeException.getProcessId()直接对位 WAL 文件与系统进程表,是排障时的第一入口。 - 优雅关闭:
shutdown()仅委托executorService.shutdown(),不主动杀进程;对仍在运行的子进程的终止策略未在本次已读源码中体现。
扩展点
Executor接口:ProcessPool implements Executor,使其可透明替换任何期望Executor的组件(如异步任务总线),这是包内最直接的复用扩展面。- WAL 编解码器模式:
UnexpectedExitCodeException.getProcessException()以record.type().equals(WalExceptionCodec.INSTANCE.type())匹配记录类型(L122-L125),新增遥测类型只需提供同构的 Codec 实例,concurrent包无需改动。 - 命令行级扩展:
submit(List<String> command)接受任意可执行文件,理论上可用于启动非 JVM 的辅助进程(如原生工具),是脱离 Java 生态的逃生舱。
相关链接
- 源码:ProcessPool.java(多进程核心,全文 132 行)
- 源码:core/build.gradle(
core子工程构建定义) - 源码:ArkPetsConfigDefault.json(应用层默认配置)
- 相关目录:assets/UI(RootModule / ModelsModule / BehaviorModule / SettingsModule / AnnounceDialog / DownloadDialog / LogDialog 等 FXML 模块)
- 兄弟页面:桌宠子进程内部的行为状态机与渲染循环、
telemetry.wal的文件格式与写入端实现、Gradle 构建与打包分发 —— 各自见对应目录页。