项目概述
本文档描述了 Folia 所做改动的抽象概述。Folia 将所有已加载世界中的区块 分割成独立的计时区域,使这些区域能够独立且并行地进行计时。 我们将首先描述区域内的操作,然后是区域间的操作。
独立区域的规则
为了确保区域的独立性,维护区域的规则必须确保一个正在计时的区域 周围没有直接相邻的正在计时的邻居区域。 以下规则保证了这个不变性:
- 任何正在计时的区域在计时时不能扩大。
- 任何正在计时的区域必须最初拥有其周边之外的一小部分区块缓冲区。
- 如果区域有相邻的邻居区域,则不能开始计时。
- 相邻的区域最终必须合并成一个单一区域。
此外,为了确保一个区域不是由独立区域组成的 (这会阻碍并行性),当可能时,由多个独立区域组成的区域 最终必须分裂成独立的区域。
最后,为了确保计时区域可以存储和维护关于当前区域的数据 (例如计时计数、区域内的实体、区域内的区块、方块/流体计时列表等), 区域有它们自己的数据对象,这些对象只能在区域计时时被访问, 且只能被计时该区域的线程访问。同时,在区域合并或分裂时 有回调函数,以便适当地更新数据对象。
这些规则的实现在区域逻辑中描述。
应用这些规则的最终结果是,一个计时区域可以确保 只有当前线程对区域内包含的任何数据有写入权限, 并且在任何给定时间,独立区域的数量接近最大值。
区域内操作
区域内操作指的是由拥有区域的线程处理单个区域的数据的任何操作, 或者合并/分裂逻辑。
独立区域的计时
独立区域独立且并行地计时。独立计时意味着区域维护自己的 下一次计时的截止时间。例如,考虑两个区域 A 和 B,其中 A 的下一次 计时开始时间是 t=15ms,B 的下一次计时开始时间是 t=0ms。考虑以下事件序列:
- 在 t = 0ms 时,B 开始计时。
- 在 t = 15ms 时,A 开始计时。
- 在 t = 20ms 时,B 完成其计时。然后它被安排在 t = 50ms 时再次计时。
- 在 t = 50ms 时,B 开始其第二次计时。
- 在 t = 70ms 时,B 完成其第二次计时,并被安排在 t = 100ms 时再次计时。
- 在 t = 95ms 时,A 完成其第一次计时。它被安排在 t = 95ms 时再次计时。
重要的是要注意,在任何时候 B 的计划都不会受到 A 未能达到其 20TPS 目标的影响。
为了实现所描述的行为,每个区域在调度执行器上维护一个重复任务
(参见 SchedulerThreadPool),该执行器根据最早开始时间优先的调度算法
安排任务。该算法类似于 EDF(最早截止时间优先),但根据开始时间进行调度。
然而,考虑到每个计时的截止时间是 50ms + 开始时间,它的行为与 EDF 算法相同。
选择类 EDF 算法是因为只要线程池未被最大化利用,所有计时时间 <= 50ms 的 区域都将维持 20TPS。然而,调度算法既不感知 NUMA 也不感知 CPU 核心 - 当 n 个区域 > m 个线程时,它不会尝试将区域固定到特定核心。
由于区域独立计时,它们维护自己的计时计数器。这些含义将在下一节中描述。
计时计数器
在标准的 Vanilla 中,有几个重要的计时计数器:当前计时、游戏时间计时和日光时间计时。 当前计时计数器用于确定服务器启动以来的计时数。游戏时间计时是按世界维护的, 用于为红石、流体和其他物理事件调度方块计时。日光时间计时 简单地说就是从中午开始的计时数,按世界维护。
在 Folia 中,当前计时按区域维护。游戏时间计时分为两个计数器: 红石时间和全局游戏时间。红石时间按区域维护。全局游戏时间和 日光时间由"全局区域"维护。
在每个区域计时开始时,全局游戏时间计时和日光时间计时从全局区域复制, 当当前区域检索这些值时,它将从计时开始时收到的副本中检索。 这是为了确保在整个计时过程中,任何两次检索计时数的调用 都会报告相同的计时数。
维护全局游戏时间有几个原因:
- 需要有一个计数器来表示一个世界已经存在了多少计时, 因为游戏确实会跟踪世界已经持续的总天数。
- 大量新的实体 AI 代码使用游戏时间(原因我无法理解)来存储任务的 绝对截止时间。调整所有这些任务的截止时间并非不可能, 但工作量很大。
全局区域
全局区域是一个始终被安排以 20TPS 运行的单一调度任务,负责维护 不与任何特定区域绑定的数据:游戏规则、全局游戏时间、日光时间、 控制台命令处理、世界边界、天气等。与其他区域不同,全局区域 不需要执行任何特殊的合并或分裂逻辑,因为它永远不会分裂或合并 - 在任何时候都只有一个全局区域。全局区域不拥有任何区域特定的数据。
合并和分裂区域计时时间
由于红石和当前计时是按区域维护的,需要适当的逻辑来调整 方块/流体计时调度程序和任何其他通过红石/当前绝对计时时间 调度的内容使用的计时截止时间,以使相对截止时间不受影响。
当将区域 x(来源)合并到区域 y(目标)时, 我们可以调整 x 和 y 的截止时间,或者只调整 x 和 y 中的一个。 只调整一个更简单,任意选择区域 x。 然后,必须调整 x 的截止时间,以便考虑 y 的当前计时, 使相对截止时间保持不变。
考虑在区域 x 中的截止时间 d1 = 来源计时 + 相对截止时间。
我们希望调整后的截止时间 d2 为 d2 = 目标计时 + 相对截止时间
在区域 y 中,以便维持相对计时截止时间。我们可以通过对 d1
应用偏移量 o 来实现这一点,使得 d1 + o = d2,使用的偏移量是
o = 目标计时 - 来源计时。这个偏移量必须分别为红石计时和当前计时
计算,因为增加红石计时的逻辑可以通过 Level#tickTime 字段关闭。
最后,分裂情况很简单 - 当发生分裂时, 分裂产生的独立区域从父区域继承红石/当前计时。 因此,相对截止时间得以维持,因为在合并情况下通过应用偏移量, 在分裂情况下通过复制计时数,计时数没有变化。
在所有情况下,当区域分裂或合并时,由当前计时调度的红石或 任何其他事件都不受影响,因为在合并情况下通过应用偏移量, 在分裂情况下通过复制计时数来维持相对截止时间。
区域间操作
区域间操作指的是与当前计时区域以外的、处于完全未知状态的其他区域 一起工作的操作。这些区域可能是临时的,可能正在计时,或者可能根本不存在。
辅助操作的工具
为了辅助区域间操作,提供了几个工具。
在 NMS 中,这些工具是 EntityScheduler、RegionizedTaskQueue、
全局区域任务队列和区域本地数据提供者 RegionizedData。Folia API 有类似的
对应物,但没有区域本地数据提供者,因为 NMS 数据提供者持有关键
锁,并在执行任何回调逻辑时在代码的关键区域被调用,因此
高度容易受到涉及长时间 I/O 或世界状态修改的致命插件错误的影响。
EntityScheduler
EntityScheduler 允许将任务调度到拥有实体的区域上执行。
这在处理实体传送时特别有用,因为一旦实体开始异步传送,
在传送完成之前实体就不能计时,而且时机是未定义的。
RegionizedTaskQueue
RegionizedTaskQueue 允许将任务调度到拥有特定位置的区域的下一次计时执行,
或者如果该区域不存在则创建该区域。这对于可能需要编辑或检索
当前区域外的世界/方块/区块数据的任务很有用。
全局区域任务队列
全局区域任务队列仅用于对全局区域拥有的数据执行编辑, 如游戏规则、日光时间、天气,或使用控制台命令发送者执行命令。
RegionizedData
RegionizedData 类允许区域定义区域本地数据,
这允许区域存储数据而不必考虑来自其他区域的并发数据访问。
例如,维护当前每个区域的实体/区块/方块/流体计时列表,
这样区域就不需要考虑对这些数据集的并发访问。
这些工具允许以简单的方式解决各种跨区域问题, 例如通过使用任务队列从任何区域编辑方块/实体/世界状态, 或通过使用 RegionizedData 避免并发问题。更高级的操作,如传送、 玩家重生和传送门,都使用这些工具来确保操作是线程安全的。
实体区域内和跨维度传送
实体需要特殊的逻辑才能在其他区域或其他维度之间安全传送。
然而,在所有情况下,传送/放置实体的调用必须在拥有该实体的区域上调用。
EntityScheduler 可以用来轻松地调度代码在这样的上下文中执行。
简单传送
在简单传送中,实体已经存在于某个位置的世界中,
且目标位置和维度是已知的。这个操作分为两部分:变换和异步放置。
在这种情况下,变换操作从当前世界中移除实体,然后调整位置。
异步放置操作使用 RegionizedTaskQueue 调度一个任务到目标位置,
以在目标维度的目标位置添加实体。
各种实现细节,如非玩家实体在变换操作中被复制,都被省略了, 因为这些与高层概述无关。
玩家登录和玩家重生通常被视为简单传送。玩家登录情况只是因为 玩家在开始时不存在于任何世界中而有所不同,并且异步变换 必须额外找到一个地方来生成玩家。玩家重生类似于玩家登录, 因为重生的不同之处在于玩家在重生时在世界中。
传送门传送
传送门传送与简单传送不同,因为传送门传送不知道传送的确切位置。 因此,变换步骤不更新实体位置,而是在变换和异步放置之间插入 一个新操作:异步搜索/创建,负责查找和/或创建出口传送门。
此外,当前的 Vanilla 代码如果实体是非玩家且下界出口传送门 尚不存在,可以拒绝传送。但是由于传送门位置只在异步放置时 确定,此时中止已经太晚 - 所以,传送门逻辑已经重新设计, 使得玩家和实体之间没有区别。现在实体和玩家都会创建出口传送门, 无论是下界还是末地传送门。
服务器关闭过程
关闭过程发生在通过生成单独的关闭线程, 然后运行关闭逻辑:
- 关闭计时区域调度器,停止进一步计时
- 停止指标处理
- 禁用插件
- 停止接受新连接
- 向所有玩家发送断开连接(但不删除)数据包
- 停止所有世界的块系统
- 执行所有世界的关闭逻辑,完成所有待处理的传送,然后保存所有块 在世界上,最后保存世界数据(level.dat 和其他 .dat 文件)。
- 保存所有玩家
- 关闭资源管理器
- 释放级别锁
- 停止剩余执行器(Util 执行器、区域 I/O 线程等)
重要区别在于 Vanilla 是玩家踢和 世界保存逻辑被步骤 5-8 替换。
对于步骤 5,玩家不能在传送完成之前被踢,因为踢会保存玩家数据文件。 所以,保存移动到之后。
对于步骤 6,块系统停止在保存之前完成,以便所有块 生成被停止。这会减少服务器在关闭时对服务器的负载, 这在内存受限场景中可能是关键的。
对于步骤 7,传送完成方式取决于类型:简单或传送门。
简单传送完成于确保添加 传送实体到传送指定的目标块, 允许实体保存到目标位置, 如果传送完成之前关闭。
传送门传送完成于强制添加 传送实体到源块, 从哪里应该传送实体 from。 由于目标位置未知,实体只能放在原点(没有传送)。 虽然这种行为不是理想的,但关闭逻辑 must 考虑任何损坏的世界状态 - 这意味着找到或创建目标出口传送门可能不是选项。
传送完成必须在保存之前完成 世界,以便完成传送实体保存。
对于步骤 8,仅在传送完成之后保存玩家。
剩余步骤是 Vanilla。