Repository Wiki
isHarryh/Ark-Pets

多进程架构与模块划分

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/UI FXML 模块族。

本页不覆盖(留给兄弟页面):

  • 单只桌宠子进程内部的渲染与行为状态机 —— 见相关行为/渲染页面;
  • WAL 遥测记录的文件格式与写入端实现细节 —— 见遥测(telemetry)页面;
  • Gradle 构建链路与打包分发 —— 见构建与发布页面。

说明:受本次源码探索预算(6 次工具调用)限制,桌面启动器的入口类源码未能直接读取。本页中所有结论均以已读取的 ProcessPool.java 全文与仓库文件清单为依据;凡未能验证的部分会明确标注"实现细节未在源码中核实"。

概述

Ark-Pets 是一款"明日方舟"主题的桌面宠物应用。之所以选择多进程而非单进程多窗口模型,核心动机来自源码中可验证的三点证据:

  1. 崩溃隔离:ProcessPool.ProcessResult 显式建模了"非零退出码"这一一等公民状态,并为它配套了 UnexpectedExitCodeException(见 ProcessPool.java#L86-L131)。单只桌宠的 OpenGL/JavaFX 崩溃不应拖垮承载托盘图标与配置界面的父进程。
  2. 可审计的死亡:UnexpectedExitCodeException 的构造函数会按 processId 打开 WAL 日志、用 WalExceptionCodec 解码出真正的异常并挂载为 cause(ProcessPool.java#L121-L130)。这要求子进程拥有独立 PID —— 只有进程模型才能提供这种"事后取证"能力。
  3. 生命周期独立:submit(List<String> command) 返回 Future<ProcessResult>(ProcessPool.java#L56-L66),父进程以异步方式观测子进程的存活与退出,无需共享任何堆内存。

架构

Loading diagram...

上图各环节的职责与连接依据:

  • 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)。这是跨进程传递"崩溃原因"的唯一通道,构成图中虚线所示的松耦合数据流。

仓库级模块划分

Loading diagram...

模块边界的依据与意图:

  • core 子工程:仓库根下可见 core/build.gradle,core/src/cn/harryh/arkpets/ 下按领域分包。concurrent 包收敛所有"进程并发"原语,telemetry.wal 包收敛"跨进程可观测性"原语 —— 两者通过 ProcessPool 对 WAL 的只读消费产生单向依赖,方向为 concurrent → telemetry.wal。
  • assets/UI FXML 模块族:界面被拆为 Root/Models/Behavior/Settings 四个功能模块加三个对话框(Announce、Download、Log)。这种划分与多进程架构互补:父进程负责全部 FXML 界面与配置管理,子进程只负责渲染桌宠本体,因此 UI 模块无需感知进程细节。(各 FXML 对应的 Controller 绑定关系未在本次已读源码范围内,不做展开。)

核心控制流

父进程从提交一个子进程到拿到其退出结论的完整时序:

Loading diagram...

逐步解读(对应 ProcessPool.java#L56-L96):

  1. 命令行拼装: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 引用即可获得可重启性。
  2. 异步化与守恒:进程启动与 waitFor() 被包进 FutureTask,由池内线程阻塞等待,因此父进程的主线程/JavaFX 应用线程永远不会被子进程的生命周期阻塞 —— 这是 UI 响应性与进程管理解耦的关键。
  3. 结论回传:waitFor() 返回后立即构造 record ProcessResult(int exitValue, long processId)。将 pid 一并返回是有意为之:它既是 WAL 日志的索引键,也是异常对象中的可观测字段。
  4. 异常溯源:ProcessResult.toException() 在退出码非零时构造 UnexpectedExitCodeException,其构造函数内部调用私有的 getProcessException():WalReader.open(processId) → 遍历 readAll() → 匹配 WalExceptionCodec.INSTANCE.type() → decode(payload) 还原异常,并通过 initCause 挂载(ProcessPool.java#L99-L131)。WAL 读取失败时静默吞掉 IOException 并返回 null,保证"取证失败不掩盖退出码事实"。

使用示例

启动一个同源 JVM 子进程

java
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)。

消费退出结论与异常溯源

java
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,调用方需自行判空。

直接提交任意命令行

java
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):

参数类型默认值说明
corePoolSizeint20常驻线程数下限
maximumPoolSizeintInteger.MAX_VALUE上限不设约束(配合 SynchronousQueue 表现为"提交即建线程")
keepAliveTimelong / TimeUnit60L / SECONDS超过 core 数量的空闲线程 60 秒后回收
workQueueBlockingQueueSynchronousQueue零容量直传队列,任务不排队、立即由(新建)线程执行
threadFactoryThreadFactory守护线程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)

成员类型说明
exitValueint子进程退出码,0 表示成功
processIdlong子进程 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) 抛 IOExceptioncatch (IOException ignored) 静默返回 null(L127-L129)取证失败不应掩盖退出码这一硬事实
WAL 中无异常记录遍历 readAll() 无匹配类型getProcessException() 返回 null,initCause 不执行区分"干净退出失败"与"崩溃"
池满/拒绝execute() 遇 RejectedExecutionExceptioncatch (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 构建与打包分发 —— 各自见对应目录页。

Sources

(1 files)