21 PendingChunk 现象
1 PendingChunk 现象
1.1 ServerEntityManager 的三状态机
实体数据独立保存到 entities 区域文件中,由 ServerEntityManager 管理。它用 managedStatuses 记录每个 chunk 的实体数据加载状态(ServerEntityManager.java:54、62、512-516):
三个状态分别表示:
正常转换路径是 FRESH -> scheduleRead() -> PENDING -> loadChunks() -> LOADED。这个状态机的设计重点是避免覆盖旧实体数据:FRESH 必须先读,再合并或确认,再保存。问题也出在这里:PENDING 是等待态,如果异步读取永远不完成,它没有自动超时和回滚。
1.2 scheduleRead():从 FRESH 到 PENDING
当保存或卸载路径遇到 FRESH 状态时,会调用 scheduleRead()(ServerEntityManager.java:261-268):
关键点是第一行:状态会立刻从 FRESH 变成 PENDING(ServerEntityManager.java:262),不等 readChunkData() 完成,也不等数据进入 loadingQueue。
读取成功时,thenAccept(this.loadingQueue::add) 会把结果加入队列;读取以异常形式完成时,exceptionally 只记录日志(ServerEntityManager.java:265-267),不会把状态改回 FRESH,也不会标成失败态。
一旦发生这种情况,loadingQueue 收不到数据包,loadChunks() 没机会改成 LOADED,trySave() 和 isLoaded() 也会一直看到 PENDING。
1.3 loadChunks():从 PENDING 到 LOADED
ServerEntityManager.tick() 每 gt 会调用 loadChunks(),把已经读完的实体数据挂进管理器(ServerEntityManager.java:289-295):
只要 loadingQueue.poll() 取到了数据包,它就把包里的实体逐个 addEntity(),然后把对应 chunk 标为 LOADED(ServerEntityManager.java:293)。如果 poll() 返回 null,循环直接结束;它不会扫描所有 PENDING 状态,也不会主动检查某个 Future 是否超时。
1.4 计划刻的完整检查链
计划刻失效看起来像方块运行级别问题,但在这条异常路径中,真正卡住的是实体加载状态。WorldTickScheduler.collectTickableChunkTickSchedulers() 在收集到期计划刻时,会先检查 tickingFutureReadyPredicate(WorldTickScheduler.java:102-126):
如果 this.tickingFutureReadyPredicate.test(l) 返回 false,该区块的 ChunkTickScheduler 不会进入 tickableChunkTickSchedulers。ServerWorld 提供的 predicate 是 isTickingFutureReady()(ServerWorld.java:1766-1768):
第一半是 isChunkLoaded()(ServerWorld.java:1762-1764):
最后落到 ServerEntityManager.isLoaded()(ServerEntityManager.java:362-364):
完整链条是 WorldTickScheduler.collectTickableChunkTickSchedulers() -> ServerWorld.isTickingFutureReady() -> ServerWorld.isChunkLoaded() -> ServerEntityManager.isLoaded() -> managedStatuses == LOADED。如果该 chunk 卡在 PENDING,最底层返回 false,区块层计划器不会被加入本刻可执行队列。
1.5 实体卸载的重试路径与崩服链路
正常 tick 中的重试路径
实体数据的卸载走 ServerEntityManager 的独立路径, 与 TACS 的区块卸载是两条独立的线。
updateTrackingStatus 将 HIDDEN 状态 chunk 加入待卸载队列 (ServerEntityManager.java:184-192):
每 gt 的 unloadChunks() 尝试处理 pendingUnloads(ServerEntityManager.java:285-286):
unload() 内部调用 trySave(),如果 PENDING 则返回 false (ServerEntityManager.java:270-278):
两条线的对比:
正常 tick 中 PENDING 的表现:
- 区块方块数据和流体照常保存,
ChunkHolder正常从currentChunkHolders移除 - 每 gt 实体管理器尝试保存, 但因为
trySave()返回 false, 一直失败 - 这是一个无上限的重试循环:chunk 永远留在
pendingUnloads中, 每 gt 浪费一次trySave()调用
崩服链路: save-all / 关服时的 flush ()
问题爆发在需要强制完成所有实体 IO 的时候。ServerEntityManager.flush() 在 save-all 和关服时被调用 (lines 325-338):
flush() 的 while (!longSet.isEmpty()) 循环处理所有 LOADED 状态的 chunk。但 getLoadedChunks() 只收集 managedStatuses == LOADED 的条目, 不收集 PENDING。所以这个循环不会直接卡在 PENDING chunk 上。
真正的死锁发生在 dataAccess.awaitAll(false):
EntityChunkDataAccess.readChunkData(chunkPos)返回的CompletableFuture由StorageIoWorker管理- 如果 OOM 导致 IO worker 线程崩溃或任务被拒绝, 该 Future 永不 complete
flush()中 line 329:this.dataAccess.awaitAll(false)会阻塞等待所有 pending IO- 卡住的 PENDING IO 导致
awaitAll()永远不返回 - 调用
flush()的线程被阻塞 → 关服流程卡住 - Watchdog 检测到卡死 →
halt(1)
所以 "区块卸载触发崩服" 的完整链路是:
OOM 导致 entity readChunkData Future 永不 complete
↓
managedStatuses.put(pos, PENDING) // 已在 scheduleRead 中设置
↓
正常 tick:ChunkHolder 降温 → HIDDEN → pendingUnloads → 重试 unload(失败但不崩溃)
↓
关服/save-all:ServerEntityManager.flush() 被调用
↓
while (!longSet.isEmpty()) { ... } // 正常处理 LOADED chunks
↓
this.dataAccess.awaitAll(false)
↓
卡在 PENDING chunk 的未完成 IO Future 上
↓
主线程阻塞,Watchdog 检测
↓
halt(1)
1.6 PENDING 卡住的现象
当一个区块的实体状态永久停在 PENDING,表面现象会非常混合:
玩家最容易观察到的是红石和流体:中继器到点不翻转、比较器输出不更新、活塞不按预约时间推出或收回,水和岩浆也可能停止继续流动。这里的区块可能看起来仍然加载着,真正失败的是计划刻执行前的实体加载就绪检查。
1.7 为什么只影响计划刻链
几类 tick 的检查条件不同:
计划刻的特殊之处在于,ServerWorld.isTickingFutureReady() 在检查 chunk manager 的 ticking future 之前,先检查 this.isChunkLoaded(chunkPos)。这个名字容易误导:它不是问 “方块区块对象是否存在”,而是问实体管理器是否认为该 chunk 的实体数据已经 LOADED。一旦实体加载状态没有从 PENDING 走到 LOADED,计划刻会被永久挡在门外。
1.8 为什么 trySave() 无法保存 PENDING 区块
保存路径进一步放大了问题。ServerEntityManager.trySave() 的开头会先检查状态(ServerEntityManager.java:235-259):
PENDING 的处理是最硬的:直接 return false(ServerEntityManager.java:237)。它不会保存,也不会卸载,也不会把状态改成失败。
这是合理的保守策略:PENDING 表示磁盘实体数据还没读完,此时写盘可能覆盖尚未读出的实体数据。但在 OOM 卡死路径中,它会变成死锁式等待。
1.9 关服时的主线程死锁与 halt(1)
PENDING 状态卡住的最严重后果不是计划刻失效,而是关服时导致主线程死锁,最终触发 Watchdog 强制 Runtime.getRuntime().halt(1)。
当服务器执行关服流程时,MinecraftServer 会进入一个等待循环(MinecraftServer.java:634-643):
只要 shouldDelayShutdown() 返回 true,服务器就会继续 tick 区块管理器,试图清空所有待卸载任务。这个循环没有超时机制—— 它会一直运行,直到所有维度的 ThreadedAnvilChunkStorage 报告 "可以关服"。
shouldDelayShutdown() 的检查项(ThreadedAnvilChunkStorage.java:489-498):
line 492 是死锁的根源:!this.currentChunkHolders.isEmpty()。一个 ChunkHolder 要从 currentChunkHolders 移除,必须先被成功卸载。但实体数据的卸载与区块数据的卸载走两条独立的路径。
区块数据的卸载(由 TACS 的 unloadChunks() 处理)不受 PENDING 状态影响:
tryUnloadChunk()→save(chunk)保存区块 NBT →chunksToUnload.remove(pos, holder)ServerWorld.unloadEntities(chunk)不调用ServerEntityManager.unload()—— 它只执行chunk.clear()和chunk.removeChunkTickSchedulers()- 区块数据路径可以正常完成
实体数据的卸载(由 ServerEntityManager 独立处理)受 PENDING 状态阻塞:
ChunkHolder.tick()降温 →updateTrackingStatus(pos, HIDDEN)→ 加入pendingUnloads- 每 gt
unloadChunks()尝试unload(pos)→ 内部调用trySave()→ PENDING 返回 false - chunk 留在
pendingUnloads中,下个 tick 重试
此时 Watchdog 线程会检测到主线程卡死超过 60 秒(默认),触发强制退出(DedicatedServerWatchdog.java:90-103):
halt(1) 是立即终止 JVM,不执行 shutdown hooks,不 flush 缓冲区,不等待线程结束。相比正常关服(flush 所有数据、等待异步任务、生成完整日志),halt(1) 相当于进程被 kill -9。
[!DANGER]
PENDING状态卡住 + 关服 = 数据丢失高风险当 Watchdog 触发
halt(1)时:
- 所有
PENDING区块的实体数据未保存(trySave返回 false)- 所有
chunksToUnload中的区块可能未写盘- 所有
unloadTaskQueue中的异步保存任务被中断- POI 数据、光照数据、区块 NBT 可能部分写入(缓冲区未 flush)
重启后,这些区块会回滚到上次成功保存的快照,或者触发新的加载失败。如果 OOM 问题未解决,可能进入加载 →
PENDING卡住 → 关服死锁 →halt(1)→ 重启 → 再次卡住的死循环。
诊断标志:
服务器日志中出现以下特征时,可能是 PENDING 死锁:
完整的死锁链路:
关服命令执行
↓
MinecraftServer 进入关服等待循环 (line 634)
↓
while (shouldDelayShutdown() == true) {
tick 区块管理器...
} ← 无超时机制
↓
shouldDelayShutdown() 检查 currentChunkHolders
↓
!currentChunkHolders.isEmpty() == true (line 492)
↓
为什么不为空?
↓
unloadChunks() → tryUnloadChunk() → save(chunk)
↓
ServerEntityManager.trySave(chunkPos)
↓
if (status == PENDING) { return false; } (line 237)
↓
保存失败 → 卸载失败 → ChunkHolder 无法移除
↓
shouldDelayShutdown() 永远返回 true
↓
主线程陷入无限循环(持续 tick 但永远清不空)
↓
Watchdog 检测到主线程卡死超过 60 秒
↓
DedicatedServerWatchdog.shutdown() 触发
↓
Runtime.getRuntime().halt(1) (line 96)
↓
JVM 立即终止,不执行 shutdown hooks,不 flush 缓冲区
↓
数据丢失:PENDING 区块实体未保存、chunksToUnload 未写盘、
异步任务被中断、POI/光照/NBT 可能部分写入
1.10 scheduleRead() 的 Future 链
ServerEntityManager.scheduleRead() 的危险点是状态更新早于 I/O 完成:
exceptionally 不把状态改回 FRESH,是因为失败后立即重试也不一定安全:下一次保存会再次发起读取,可能形成频繁重试,甚至在半损坏数据上引发覆盖风险。源码选择记录异常并保持当前状态,但在 OOM 或 Future 未完成时,这会留下永久 PENDING 的窗口。
1.11 loadChunks() 的批处理循环
loadChunks() 没有 Future 列表,它只消费队列:
状态推进的唯一证据是 ChunkDataList 已经进入 loadingQueue。如果 poll() 为 null,函数不会扫描 managedStatuses 找出 PENDING 项;PENDING 本身不会主动过期,只有队列里的数据包能把它改成 LOADED。
1.12 tickingFutureReadyPredicate 的完整调用栈
计划刻从世界层进入执行队列前,会经过以下源码路径:
这个 predicate 来自 ServerWorld:
前半段继续进入:
最后是实体管理器状态:
所以,计划刻是否能执行,不只取决于 ChunkHolder.getTickingFuture() 是否已经得到 WorldChunk,还取决于实体管理器是否认为该 chunk 已 LOADED;PENDING 在这里和 FRESH 一样都会返回 false。
1.13 为什么 trySave() 无法保存 PENDING 区块
trySave() 的第一道门就是:
这行提前返回阻止了后面所有保存逻辑,也会让 unload(chunkPos) 失败,因为 unload() 只有在 trySave() 返回 true 后才会移除管理状态。从数据安全角度看,这是正确的;从运行稳定性角度看,一旦读取 Future 不再推进,保存、卸载、计划刻就会一起被这个状态卡住。
2 参考
net.minecraft.server.world.ServerEntityManagernet.minecraft.server.world.ServerWorldnet.minecraft.world.tick.WorldTickScheduler
