先看结论与判断条件
- 页面销毁只发起关闭,不在主线程同步等待不可控的 Native 工作。
- 停止接收、发出取消、唤醒等待、汇合线程和释放引用是有顺序的独立阶段。
- 条件变量等待必须使用状态谓词,关闭时更新状态并通知,不能只设置一个无人观察的布尔值。
- worker 的阻塞 I/O、外部库调用和不可中断计算需要各自的取消或超时策略。
- 迟到回调同时核对 worker generation、页面 generation 和回调目标状态,关闭后不得提交 UI。
- 发布回执绑定候选哈希、阶段时间线、ANR 诊断和设备矩阵,不能用一次顺利退出外推兼容性。
主线程的责任是发起关闭,不是等待所有工作结束
页面 onStop 或 onDestroy 往往运行在主线程。如果这里直接调用 pthread_join、std::thread::join 或等待一个没有上限的条件变量,UI 线程就把退出速度交给 Native worker 当前所做的事情:它可能在等待队列、执行长计算、读取文件、等待 Binder 或调用不可取消的第三方库。页面生命周期方法返回不了,最终表现可能是卡顿或 ANR。
正确边界是把关闭请求拆成快速入口和异步收敛。主线程只原子切换 state 为 stopping、递增 generation、拒绝新任务、发出 cancellation token,并通知所有可能睡眠的 worker。随后把 join 责任交给专用协调线程或受控执行器;UI 可以继续完成页面销毁,关闭结果通过内部回执观察,而不是通过阻塞页面线程取得。
不在主线程 join 不等于放任线程泄漏。协调器必须拥有 worker 集合和唯一关闭 future,确保同一实例只启动一次收敛流程。重复 destroy 可以取得已有 future 或返回 already_stopping,不能再创建一套 join 线程。若进程结束前仍未收敛,记录所处阶段和候选信息,不能把线程静默遗忘。
| 动作 | 主线程 | 协调线程 | 失败终态 |
|---|---|---|---|
| 停止新任务 | 提交状态切换 | 验证队列不再增长 | accepting_after_stop |
| 发出取消 | 设置代际与 token | 观察 worker 确认 | cancel_not_seen |
| 唤醒等待 | 发送通知 | 核对等待点退出 | worker_still_waiting |
| 等待 worker | 不执行 | 按阶段预算汇合 | join_budget_exceeded |
| 释放 JNI 引用 | 不抢先释放 | 全部回调收敛后执行 | reference_still_in_use |
关闭状态机要先阻断生产者,再让消费者退出
如果先让 worker 退出,再关闭 Java 或 Native 生产者,新的任务可能在退出窗口进入队列,留下无人处理的数据。关闭事务第一步把 accepting 从 true 切为 false,所有 submit 入口在锁内读取状态,stopping 之后返回明确的 rejected_after_stop。队列长度、活动任务数和 generation 同时写入关闭快照,便于判断后续是否又有生产者越界。
随后设置 stopRequested,并递增 workerGeneration。generation 使关闭前创建的任务、定时器和回调在未来即使被调度,也能识别自己属于旧代次。仅有布尔 stop 容易在对象重建后串线:新 worker 把 stop 重置为 false,旧任务误以为又可以提交。每个任务必须保存创建代次,任何提交都与当前代次比较。
状态推进应单向且幂等,例如 running、stopping、draining、joining、closed、failed。只有协调器能从 draining 进入 joining,只有确认全部线程退出且回调计数归零后才能进入 closed。错误也保留最终状态和阶段,不把 failed 改写为 closed;否则监控看到完成,实际仍有线程或 JNI 引用没有收敛。
| 状态 | 是否接收任务 | 允许动作 | 转换条件 |
|---|---|---|---|
| running | 是 | submit 与执行 | 收到唯一关闭请求 |
| stopping | 否 | 取消与通知 | 生产者门禁已生效 |
| draining | 否 | 处理或放弃活动任务 | 所有 worker 已观察取消 |
| joining | 否 | 协调线程等待退出 | 没有新活动任务 |
| closed | 否 | 返回完成回执 | 引用与线程均收敛 |
| failed | 否 | 记录阶段并升级处理 | 预算或不变量失败 |
条件变量必须把取消写进谓词并可靠唤醒
worker 经常在条件变量上等待 queue 非空。如果谓词只写成 !queue.empty(),关闭线程即使 notify_all,worker 醒来后发现队列仍空,又会继续睡眠。谓词必须包含 stopRequested,例如 stopRequested || !queue.empty();唤醒后先判断停止策略,再决定丢弃队列、处理已提交任务或执行受控 drain。
状态更新和通知的顺序要与锁协议一致。关闭方在持有相同互斥量时写 stopRequested、generation 和队列策略,释放锁后通知;worker 在循环中用谓词等待,并在取出任务前核对代次。虚假唤醒不是异常,循环谓词本来就应处理。仅靠 sleep 轮询会增加退出延迟,也无法证明关闭事件何时被观察。
一个 worker 可能等待多个来源,例如工作队列、文件描述符、平台回调或第三方库。条件变量通知只能唤醒自己的等待点。设计清单要列出每个可阻塞点及其解除方法:关闭描述符副本、触发 eventfd、调用库提供的 cancel、设置超时或让操作运行在可放弃的隔离任务。没有解除方法时不能承诺 join 会及时返回。
| 阻塞点 | 取消信号 | 唤醒方式 | 仍需验证 |
|---|---|---|---|
| 条件变量 | stopRequested | notify_all | 谓词包含停止状态 |
| 内部任务队列 | generation 失效 | 队列通知 | 旧任务不得提交 |
| 文件或 socket I/O | 关闭协议或取消句柄 | 按 API 语义执行 | 竞态与错误码 |
| 第三方计算库 | 库级 cancel | 库定义回调 | 不可中断区间 |
| JNI 回调等待 | 页面 generation | 停止投递 | 全局引用生命周期 |
join 要在专用协调线程按阶段预算执行
join 本身不是错误,错误是把未知耗时的 join 放在主线程,或者在持有 worker 需要的锁时 join。协调线程开始汇合前先释放队列锁、回调锁和 Java 桥接锁,记录 joining 阶段,然后逐个等待 worker。若 worker 退出需要更新状态,它必须能够取得相关互斥量;否则关闭方与 worker 形成确定性死锁。
时间预算应来自项目而不是通用文章的固定数字。可以分别为观察取消、活动任务 drain、线程 join 和引用释放设置预算,并记录实际耗时、最后活动点和未退出线程。预算到期不应在 UI 线程继续无限等待,也不能粗暴释放 worker 仍使用的内存;状态进入 failed 或 quarantined,由进程生命周期和产品策略处理。
多 worker 场景不要盲目串行等待第一个卡住的线程而失去其他状态。协调器先广播取消,再收集所有 worker 的 acknowledged、lastCheckpoint 和 exited 标志,然后执行汇合。回执显示哪个线程卡在何种阶段。若平台只提供阻塞 join,可由每个 worker 在退出时完成 future,协调器先等待这些可观测事件,最终再安全回收线程对象。
| 阶段 | 开始证据 | 完成证据 | 超预算记录 |
|---|---|---|---|
| stop_accepting | 状态版本 | 提交入口拒绝 | 越界生产者 |
| cancel_broadcast | generation 与 token | worker acknowledged | 未观察线程 |
| drain | 活动任务集合 | 任务为零或已放弃 | 最后检查点 |
| join | 线程 ID 集合 | 全部 exited | 未退出线程 |
| release_refs | 全局引用计数 | 计数归零并删除 | 仍持有者 |
JNI 引用和迟到回调必须晚于线程收敛释放
Native worker 回调 Java 时会涉及线程附着、JNIEnv 获取、局部或全局引用以及 pending exception。页面销毁后如果立即 DeleteGlobalRef,而 worker 仍可能回调,就会形成使用已释放引用的窗口;反过来永不释放引用又会泄漏页面。关闭协议要先禁止新回调,等待活动回调计数归零,再在合法 JNI 环境中释放全局引用。
每次回调捕获 workerGeneration、pageGeneration 和 callbackId。投递前检查 Native 状态仍允许,Java 接收端再检查页面 generation 和 lifecycle owner;任一步不匹配都记录 dropped_late_callback,不执行 UI 或业务提交。弱引用只能帮助观察目标是否还活着,不能替代 generation,因为新页面实例可能拥有相同业务角色却不是旧任务的目标。
线程附着与分离要按 JNI 边界单独设计。本文关注关闭顺序,不把 attach 或 detach 细节扩写成另一套答案;必要条件是每个 worker 的 JNI 资源有明确 owner,异常在回调后被检查和转换,线程退出前完成其责任。通用 JNI 指南不能证明变换后的候选已保持引用与线程语义,仍要设备端取证。
用关闭时间线校验器发现主线程 join 和迟到回调
下面的 Python 校验器读取脱敏事件 JSON,只检查阶段、线程角色、generation 和预算结果。事件必须按 sequence 单调排列;stop_accepting 必须早于 cancel_broadcast,join_start 必须发生在 coordinator 线程,release_refs 只能出现在所有 worker_exit 之后。任何违反都会以非零状态退出,适合放进设备测试产物的门禁。
校验器还检查 shutdown_generation 之后的 callback_commit。如果回调属于更旧 generation,或事件发生在 release_refs 之后,就直接拒绝回执。它不读取客户数据、内部地址或线程堆栈,只需要 workerId、event、threadRole、generation 和 elapsedBudgetState 等有限字段,因此可以公开解释而不暴露运行环境。
示例无法判断 worker 是否真的释放全部 Native 内存,也无法代替 ANR trace 分析。生产回执还应包含候选哈希、ABI、API level、设备标识类别和操作路径。若某阶段没有事件,校验器按缺失失败,不能根据 shutdown_complete 一条总结事件推断所有中间步骤发生过。
import json
import sys
from pathlib import Path
REQUIRED = [
"stop_accepting",
"cancel_broadcast",
"join_start",
"worker_exit",
"release_refs",
]
def stop(message):
print(f"shutdown trace rejected: {message}", file=sys.stderr)
raise SystemExit(2)
def load_trace(path_text):
path = Path(path_text)
if not path.is_file():
stop("trace file is missing")
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
stop(f"cannot parse trace: {exc}")
def validate(trace):
events = trace.get("events", [])
if not events:
raise SystemExit("shutdown trace rejected: events are missing")
sequence = [event.get("sequence") for event in events]
if any(not isinstance(value, int) for value in sequence):
stop("sequence must contain integers")
if sequence != sorted(sequence) or len(sequence) != len(set(sequence)):
stop("sequence is not strictly increasing")
names = [event.get("event") for event in events]
for required in REQUIRED:
if required not in names:
stop(f"required event is missing: {required}")
first = {name: names.index(name) for name in REQUIRED}
if not first["stop_accepting"] < first["cancel_broadcast"] < first["join_start"]:
stop("shutdown phases are out of order")
join = events[first["join_start"]]
if join.get("threadRole") == "main":
stop("join started on the main thread")
exit_positions = [i for i, name in enumerate(names) if name == "worker_exit"]
if not exit_positions or max(exit_positions) > first["release_refs"]:
stop("JNI references were released before workers exited")
generation = trace.get("shutdownGeneration")
for event in events:
if event.get("event") == "callback_commit" and event.get("generation") != generation:
stop("stale callback committed after shutdown")
if event.get("budgetState") == "exceeded" and not event.get("failureReceipt"):
stop("budget overrun has no failure receipt")
print("shutdown trace is internally consistent")
if __name__ == "__main__":
if len(sys.argv) != 2:
stop("usage: validate_shutdown.py trace.json")
validate(load_trace(sys.argv[1]))设备测试要控制竞态并用 ANR 证据定位等待链
测试需要可控地把 worker 停在各类等待点,然后触发页面销毁。用测试闩锁让它分别停在条件变量、任务执行、回调提交和引用释放之前,验证主线程快速返回、协调器推进正确、迟到回调被拒绝。随机快速进出页面可以补充压力,但不能替代可重现的事件序列。
ANR 诊断要从主线程堆栈开始,却不能停在表面帧。若主线程显示等待 Native 方法,要继续查锁 owner、worker 最后检查点、Binder 或 I/O 依赖和协调器状态。Android 的 ANR 指南强调按主线程阻塞、锁竞争、Binder、I/O 与组件超时分层定位;trace 中当前堆栈可能只是等待结果,不一定是最初阻塞源。
ApplicationExitInfo 可以辅助取得退出原因、时间和受版本限制的 ANR 或 Native 诊断信息。回执要把记录关联到同一候选哈希、设备、时间窗和页面路径。没有匹配关系的历史退出记录不能证明本次 join 问题,单个版本未出现 ANR 也不能证明所有厂商和负载下安全。
| 用例 | worker 停点 | 主线程预期 | 关闭回执 |
|---|---|---|---|
| 空队列等待 | 条件变量 | 只发起关闭 | 通知后退出 |
| 活动计算 | 取消检查点前 | 不 join | 观察取消或超预算 |
| 阻塞 I/O | 系统调用中 | 不持锁等待 | 记录解除方式 |
| 回调竞争 | 提交 UI 前 | 页面可销毁 | 旧 generation 丢弃 |
| 重复销毁 | 已在 joining | 复用关闭 future | 不重复 join |
| 进程退出 | 任意阶段 | 无伪完成 | 退出原因与阶段关联 |
发布门禁绑定阶段顺序、预算和候选证据
发布前列出每个 Native worker 的创建者、任务生产者、所有阻塞点、取消信号、唤醒方式、join owner、JNI 引用和回调目标。代码审查搜索主线程 join、持锁 join、只写 stop 不通知、无 generation 回调和过早 DeleteGlobalRef。清单能发现明显问题,但不替代设备上的生命周期和异常回归。
JNI 签名、线程模型、第三方库、阻塞 I/O、API level、加固边界或编译选项变化后,应生成新候选哈希并重跑关键时间线。NDK 常见问题资料帮助定位符号、运行库、异常和依赖装载,不能证明 worker 关闭契约在目标 ABI 上成立。预算数字应来自产品要求和实测,不从公共文章硬编码。
站内关于 JNI 线程附着与分离的文章可继续核对 worker 进入 JVM 的边界;准备评估商业 App 的 SO 加固和线程生命周期时,可通过页面行动按钮进入御盾中央平台提交候选包、worker 清单与关闭时间线。提交只启动评估,是否避免主线程阻塞仍以同一候选的设备回执为准。
- 页面线程只切换状态、递增 generation、取消和通知。
- 所有 submit 入口在 stopping 后明确拒绝新任务。
- 条件变量谓词包含 stopRequested,不依赖单次通知。
- 每个阻塞点都有可核验的解除或超预算策略。
- join 由专用协调线程执行,且不持有 worker 需要的锁。
- 回调提交前核对 worker、页面和请求 generation。
- 全部 worker 与活动回调收敛后才释放 JNI 全局引用。
- 候选变化后重跑竞态、ANR、异常和退出矩阵。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| JNI 线程、引用、异常和类加载边界会影响 Native worker 回调的稳定性。 | Android JNI tips 说明线程附着、引用生命周期、异常检查和类加载相关实践。 | 通用建议不证明某个候选 SO 的关闭顺序、ABI 或回调语义已经通过。 |
| Native 兼容排查要考虑 API level、符号、运行库、异常和依赖装载。 | Android NDK common problems 列出这些常见故障类别及排查方向。 | 清单不能代替目标 ABI 上的 worker 取消、唤醒、join 与引用释放测试。 |
| ANR 需要区分主线程阻塞、锁竞争、Binder、I/O 和组件超时。 | Diagnose Android ANRs 提供按阻塞类型定位 ANR 的官方诊断框架。 | trace 中看到 join 或 Native 帧不一定是根因,仍要追踪锁 owner 和上游依赖。 |
| 退出原因和受版本限制的诊断数据可辅助关联关闭失败。 | Android ApplicationExitInfo 提供进程退出原因、时间和部分 ANR 或 Native 诊断入口。 | 记录必须与同一候选和时间窗关联,不能用无关历史退出证明本次问题。 |
| 涉及真实 Android 生命周期和系统 API 的行为应使用设备端测试。 | Android instrumented tests 说明这类测试运行在 Android 设备环境并可使用框架 API。 | 单台设备和单一路径通过不能覆盖完整 API、ABI、芯片与厂商矩阵。 |
| 安全发布需要保留变更、构建和验证证据。 | NIST SP 800-218 SSDF 将安全开发、验证和供应链风险组织为实践框架。 | SSDF 不定义 Native worker 的状态机,也不形成任何具体加固产品验收结论。 |
| 主线程只发起关闭、协调线程执行 join,可隔离不可控等待对 UI 生命周期的影响。 | 工程判断:join 耗时由 worker 当前阻塞点决定,放在主线程会直接占用 UI 事件处理。 | 实际预算和效果需要同一候选的时间线与 ANR 回执,本文不提供性能数字。 |
| 御盾项目中的 worker 收敛和 ANR 结果当前不能从通用资料推出。 | 项目证据尚未接入:需要候选哈希、worker 清单、阶段事件、设备矩阵和退出记录。 | 回执齐备前不得声称已消除阻塞、通过兼容测试、阻断攻击或取得客户效果。 |
工程常见问题
页面 onDestroy 中调用 join 为什么容易卡住?
onDestroy 通常在主线程执行,join 的返回取决于 worker 是否观察取消、能否离开 I/O 或锁等待。任何不可控阶段都会直接阻塞 UI 生命周期。
把 join 放到后台线程后就一定不会泄漏吗?
不一定。仍需唯一协调器、阶段预算、worker 退出回执和引用释放顺序;后台无限等待只是把不可见泄漏移到另一条线程。
设置 stopRequested 后为什么还要 notify_all?
正在条件变量中睡眠的 worker 不会因布尔值改变自动醒来。谓词必须包含停止状态,关闭方更新状态后通知,worker 醒来再按谓词退出。
遇到不可中断的第三方 Native 调用怎么办?
先确认库是否提供取消或超时;没有时隔离任务并设置项目级预算,不能在超预算后释放仍被使用的对象,也不能承诺立即 join。
页面销毁后弱引用为空是否足以处理迟到回调?
不足。新页面可能已经创建,旧任务还可能写业务状态。回调需同时核对 worker generation、页面 generation 和请求代次。
SO 加固后需要重新测试 worker 关闭吗?
需要。候选变化可能影响 JNI、异常、线程、库装载和阻塞路径,旧时间线不能证明新候选仍按相同顺序收敛。