04 区块任务与任务通道
前几章讲了区块如何从空区块一步步生成(第 02 章)、加载等级如何决定运行资格(第 02 章末尾)、以及区块存储管理器如何协调这一切(第 03 章)。但有一个关键问题尚未回答:当区块存储管理器决定 "这个区块需要加载" 时,生成和光照计算到底在什么时候、由谁来执行?
答案是:"在未来的某个任务中被执行"。本章就讲这个机制的三个核心部分——结果占位符、任务信箱和任务调度。
1 结果占位符的核心作用
区块生成、光照计算、数据加载这些操作,不直接返回结果。它们先给出一个承诺——"这个值以后会被填上"。任务完成后,承诺就标记为已完成,等待这个结果的后续工作就能继续执行。承诺有两个主要用途:
1.1 占位符
一个生成任务(比如 "把这个区块从空区块推进到群系阶段")会被封装好,提交到任务队列中,同时创建一个容器作为这个任务的结果占位符。任务在队列里等待执行时机;时机到了就执行,执行完毕后占位符标记为 "已完成"。
"计划" 和 "执行" 分开了:你可能在一个游戏刻的开始添加任务,但它要到好几个游戏刻之后才真正运行。
1.2 区块可用性标识
在区块管理记录(详见第 07 章)中,三个结果占位符分别回答:区块数据能不能读写、方块运算能不能进行、实体运算能不能进行。它们恰好对应第 02 章末尾加载等级表中的准加载、弱加载和强加载。
当区块不再满足某个等级要求,对应的信号重置为 "不可用"。依赖这个信号的操作会立即得到 "这个区块现在不能参与运算" 的结果。
2 主线程延迟队列
服务端有一条主线程负责处理游戏逻辑。修改世界状态、更新玩家看到的数据这类工作,必须留在这条线程上完成。游戏通常不立刻做完这类工作。它先记在一张待办列表上,等到合适的时机再由主线程逐项处理。
这就是延迟执行:工作只是往后推了,没有交给别的线程并行处理。下面三类时机决定了这张列表什么时候被处理。
3 这条队列什么时候被处理
主线程延迟队列不会自己跑。服务端主线程会在特定时机主动检查并处理它。这些时机分散在很多地方,主要有三类:
3.1 第一类:区块保存时,必须等
暂停、退出、自动保存或保存全部命令触发区块保存时,保存单个区块需要它的数据已经准备好。主线程会停下来处理延迟队列,直到所需工作完成。不完成就不能继续保存。
3.2 第二类:立刻需要区块时
最常用也最隐蔽的一类。游戏逻辑需要某个区块,但它还没准备好时:
- "获取此区块" 的工作放进主线程延迟队列;
- 队列按顺序处理,直到目标区块准备好;
- 目标准备好后,处理立刻停止——后面排队的工作留到下一次。
如果队列里已经排了 50 项工作,这些工作会在目标区块之前被处理。排在目标之后的工作只能等下一次。
触发 "立刻需要区块" 的场景至少有几百个:
- 查询或修改方块状态时
- 查询或修改方块实体时
- 查询流体状态时
- 玩家重生、进入维度、加入维度时
- 获取区域难度时
- 折跃门传送实体时
获取一个区域内的实体通常不会触发区块加载——实体查询和方块查询是两套独立的机制。
3.3 第三类:游戏刻末尾的清尾
每个游戏刻末尾还有一个延迟队列处理阶段:
- 不掉刻(当前游戏刻不超过 50 毫秒)时,主线程可以一直处理到所有维度的延迟队列清空;
- 掉刻时立刻终止,剩下的留到下一个游戏刻;
- 处理过程中新加入的任务,也可能在这一轮被处理到。
处理顺序固定为:
- 服务端自身的待办工作(如处理客户端操作)
- 主世界的区块延迟队列
- 其他维度的区块延迟队列(下界先于末地)
4 这三种时机想做什么
- 第一类,必须立刻完成。"不保存这个区块就不能安全关服"——安全性优先。
- 第二类,调用者必须立刻拿到结果。"方块查询拿不到真实状态,后面的逻辑全乱"——需求优先。
- 第三类,兜底。保证正常情况下本游戏刻产生的大部分延迟任务都在本游戏刻处理完。
处理点分散在数百个调用位置,同一个维度的延迟队列可以在任何位置被部分处理。这导致区块加载的具体时机高度不可预测。
5 任务在哪里等待
世界生成异步通道和光照异步通道会把上面的待办清单升级成一个更完整的 "信箱"。任务放进信箱后,空闲的工人取出一项执行,做完再接下一项。
这样设计有两个好处:任务不会因为暂时缺工人而丢失;同一条通道的任务始终一项接一项地做,两项工作不会同时修改同一份区块数据。后台线程池可以有很多工作线程,但对某条通道来说,同一时刻只会有一个人在处理它的信箱。
信箱会记下自己当前的状态:空闲、处理中、还是已关闭。只有空闲的信箱才会去要工人。哪怕很多地方同时往里放任务,也不会突然唤醒一群人争抢同一份工作。
6 任务通道模型
区块存储管理器把工作分给三条通道,目的是把不能混在一起的事情拆开:
世界生成异步通道和光照异步通道负责重计算,任务交给后台线程池,主线程就不必停下来等。主线程协调通道只做轻量的协调,但这些协调必须留在主线程——它们直接修改世界状态和玩家连接。
7 谁先执行
三条通道各自都可能积压很多任务。比如玩家突然飞到新区域,附近几十个区块都要生成——不能随机挑,也不能只看先来后到。
调度中心按加载等级排序:越靠近玩家、加载需求越强的区块越优先。通道空闲时,它取出优先级最高的任务发过去;区块的加载等级变了,它也跟着调整排队位置。
服务器关闭前,调度中心会等还在排队的工作处理完。
8 通道之间怎么沟通
三条通道互相投递消息,不直接调用对方的方法。世界生成完成后通知主线程,主线程发现需要光照后通知光照异步通道。每条通道只关心自己的事,不用管对方是不是正忙。
最简单的消息就是 "请做这件事"。更复杂的消息可以带一个回执地址:发送方把请求交出去,稍后通过结果承诺拿到回答。每条通道都有名字、都能收消息,大家用同样的方式沟通。
9 光照任务为什么攒起来做
方块更新和区块生成都会产生光照工作。每来一个任务就立刻算,光照通道会在大量小任务之间频繁切换;积压太久,玩家又迟迟看不到亮度更新。
Minecraft 的做法是批处理:主线程先把光照工作攒成一批,再交给光照异步通道。每个游戏刻尝试处理一批;积压到 1000 项时立刻处理,不等下一个游戏刻。这样既避免频繁切换,积压也不会无限制增长。
一批光照工作分三步:
- 准备:标记哪些区块列和子区块需要重算;
- 传播:真正计算光从哪里来、能传多远、每个方块最终有多亮;
- 收尾:通知区块管理记录光照已完成,把新的亮度数据发给客户端。
三步有严格的先后顺序:传播需要准备阶段的标记,收尾必须等传播完成。如果上一批还在处理,下一个游戏刻不会启动下一批——两批不能同时修改同一份光照数据。
光照传播只能在光照异步通道里执行。主线程直接去算的话,游戏会主动拒绝——防止一次大范围光照更新把主线程卡住。具体实现和代码走读见本章进阶部分。
10 代码走读
10.1 为什么需要后台任务机制
假设没有异步任务机制——每次调用 World.getBlockState() 时,如果目标区块不在内存中,就同步地等待它生成完毕。这意味着整个服务端主线程会被阻塞,20 TPS 无法维持。
Minecraft 用后台任务处理世界生成和光照等重计算;同时用结果承诺(CompletableFuture)把 "任务已经安排" 和 "结果已经准备好" 分开表达。真正入队的是生成或加载任务;结果承诺只记录它们何时完成,并让等待结果的后续步骤继续执行:
调度系统内部维护一个按等级优先级排序的队列。队列不用简单的先入先出顺序,越靠近玩家、越急需的区块越先处理。
设计决策——为什么用 completedLevel,不用 level? level 是 "目标"(我们想让这个区块达到什么级别),completedLevel 是 "现状"(实际达到了什么级别)。用 completedLevel 排序意味着:已经很大程度完成、离目标最近的任务,优先级最高。这避免了 "一个 level=22 但生成进度为 EMPTY 的区块" 抢在 "一个 level=33 但只需要读盘的区块" 之前——后者可能在 1 个 tick 内就完成了。
10.2 三种执行时机的设计逻辑
三种时机的设计意图:
第一类(保存时阻塞执行):"如果不保存这个区块就关服,数据会丢失"。这是唯一可以阻塞主线程的场景,因为用户已经发起了关服操作,安全性优先于性能。
第二类(getChunk 触发):"如果不加载这个区块,调用者会得到空气方块,后续逻辑全乱"。调用者依赖返回值为正确的 BlockState,所以这里必须半阻塞——执行队列直到目标完成,但执行完就停。停的原因是不想 "顺便" 帮其他区块完成任务,延长本次调用的延迟。
第三类(gt 末尾兜底):"这些任务本来应该在第二类中被顺便执行,但没排到。现在 gt 快结束了,还有时间就处理掉。" 这是兜底机制——保证正常不掉刻时所有任务都在当 gt 内完成。如果掉刻了(当前 gt 超过 50ms),未完成的任务留到下个 gt——硬实时约束优先于完整性。
10.3 "部分执行" 的问题
第二类执行时机的 "执行到目标完成即停" 导致了一个反直觉的现象:如果你调用 getChunk () 获取区块 A,恰好之前有人调用 getChunk () 获取了区块 B 且 B 的任务还在队列中,那 B 的任务会先被执行(因为排在 A 前面),然后才轮到 A。但 B 后面的任务(如果还有)就不会被执行——因为 A 完成后执行就停了。
这就是 lovexyn0827 原文说的 "非常奥利给":延迟任务的处理顺序依赖于调用顺序,而调用顺序又依赖于数百个 getBlockState()、setBlockState()、getBlockEntity() 的调用点——这些调用点分散在生物 AI、红石运算、方块更新、实体碰撞检测等各个角落。两个完全相同的玩家行为,可能因为服务端的微小时间偏差而导致完全不同的任务处理顺序。
10.4 TaskExecutor 状态机的位操作设计
任务信箱的状态标志是一个原子整数。它用位掩码同时表示两个布尔状态,而不是普通的计数器:
- bit0(值为 1):
closed标志。一旦设置,就不再清除; - bit1(值为 2):
running标志。unpause()设置,pause()清除。
unpause() 的判断 (i & 3) != 0 同时检查了 bit0 和 bit1:只要已经关闭,或者已经有线程在运行,就不能再次进入 running 状态。成功时,它用 compareAndSet(i, i | 2) 原子地设置 bit1。
pause() 则用 i & -3 清除 bit1。这里的 -3 等价于 ~2,也就是 "除了 bit1 之外全为 1" 的掩码。close() 使用同样的 CAS 思路设置 bit0:compareAndSet(i, i | 1)。
为什么不用两个 AtomicBoolean?因为这里需要保证的是组合状态的原子性。unpause() 必须同时检查 "未关闭" 和 "未运行" 两个条件,并在检查结果仍然成立时设置 running。两个独立的 AtomicBoolean 无法把这个复合判断和更新合成一次原子操作;单个 AtomicInteger 的 CAS 可以。
10.5 任务通道模型的完整创建链路
把 ThreadedAnvilChunkStorage 构造函数里的几行代码展开,就能看到任务通道模型的完整装配过程:
createExecutor() 之所以必须通过 controlActor.ask() 间接完成,是因为 queues 是普通的 HashMap,不是线程安全容器。为 actor 创建或取得对应队列,本质上是对这个 map 的访问;如果多个线程同时创建包装器或更新队列,就需要一个串行化入口。
controlActor 正是这个入口。它是单个 TaskExecutor,同一时间只会有一条执行流消费它的 mailbox,因此所有对 queues 的创建、查询、入队、出队动作都被压到同一个控制 actor 中顺序执行。
10.6 跨线程任务流转的全链路示例
以一次完整的区块加载请求为例,可以把任务怎样跨越 main、worldgen、light 三个 actor 串起来看。
步骤 1:主线程发起请求。 某个 getBlockState() 调用发现目标区块不在内存中,于是间接进入 ServerChunkManager.getChunk()。getChunk() 创建一个 UNKNOWN 加载票,区块系统再通过 ChunkTaskPrioritySystem.createMessage(holder, generationTask) 把生成动作包装成 Task<Runnable>,并通过 this.mainExecutor.send(task) 发送给 main actor。
步骤 2:main actor 处理。 main actor 来自 MessageListener.create("main", mainThreadExecutor::send),所以它不会新建线程,而是把任务交给主线程执行器。主线程在执行队列时取出任务,检查目标区块是否需要继续生成;如果需要,就通过 worldGenExecutor.send(generationTask) 把真正的生成任务转发给 worldgen actor。
步骤 3:世界生成通道处理。 世界生成执行器在线程池的工作线程上运行。它从自己的信箱中逐个取出生成任务,执行地形生成、地物放置、洞穴雕刻、结构处理等 CPU 密集型运算。完成后,对应的结果承诺被标记为完成,生成出的区块数据进入后续阶段可见的状态。
步骤 4:light actor 处理。 生成完成后,ServerLightingProvider.initializeLight() 等光照入口会安排光照任务。任务通过 lighting provider 内部持有的 executor 被发送到 light actor,TaskExecutor("light") 再在底层线程池上执行光照计算与传播。完成后,类似 super.setColumnEnabled() 的状态更新会标记该列光照已经就绪。
步骤 5:回到主线程通道。 当生成和光照的一串结果承诺都已完成,回调会通知主线程更新区块管理记录的状态。主线程完成状态更新后,区块才正式进入调用者需要的可访问状态。
关键洞察是:整个流程里,主线程没有阻塞在噪声采样或光照传播上。CPU 密集型工作交给世界生成和光照通道,主线程只负责任务调度、状态更新和必须在主线程完成的世界交互。这就是结果承诺、任务执行器和优先级调度中心协作的核心价值。
10.7 为什么 main actor 是 MessageListener 而不是 TaskExecutor?
ThreadedAnvilChunkStorage 中的 mainExecutor 最终类型是 MessageListener<ChunkTaskPrioritySystem.Task<Runnable>>,而 worldgen 和 light 的底层 actor 都是 TaskExecutor<Runnable>。这个差异不是偶然的。
TaskExecutor 有自己的消息队列和线程池执行循环,适合承载独立线程上的工作;MessageListener.create("main", mainThreadExecutor::send) 则只是一个轻量包装,send() 会直接调用 mainThreadExecutor.send()。它不创建新队列,也不创建新线程,只把消息路由到已经存在的主线程执行器。
设计原因很直接:服务端主线程本来就有一个 ThreadExecutor,也就是 MinecraftServer 自身的主循环。主线程任务必须回到这条既有执行流里运行,否则世界状态、玩家连接、实体列表等主线程约束都会被破坏。
所以区块存储管理器构造函数中才会出现这种不对称:worldgen 和 light 需要 TaskExecutor 来承载独立计算;main 只需要一个 MessageListener 包装已有的 mainThreadExecutor。mainExecutor 的职责是路由到主线程,不是创造另一条 "主线程"。
11 参考
- Discovering Minecraft - 主线程异步任务及其执行时机(CC0 协议)
net.minecraft.server.world.ServerChunkManager.MainThreadExecutornet.minecraft.server.world.ServerChunkManager.getChunk()net.minecraft.util.thread.TaskExecutornet.minecraft.util.thread.MessageListenernet.minecraft.server.world.ChunkTaskPrioritySystemnet.minecraft.server.world.ServerLightingProvider
