单线程背后的真相
平时都说 Minecraft 是单线程游戏,因此如何如何性能差,如何如何不能有效利用处理器资源,如何如何“一核有难,八核围观”。
可能有人会问了:干嘛不多线程呢?
真的就这么简单吗?
本文将详细解释这个问题的成因,以及为什么 Mod 开发时不能轻易使用多线程,以及对应的解决方案。不过要注意,虽然本文力求术语的解释精确,但难免会有疏忽之处,若有问题,烦请不吝赐教。
概念
说起多线程,有那么几个老生常谈的概念不得不再次在此重申一遍。这里,用群众喜闻乐见的“挖坑”这件事进行类比:
并发:有一群人在挖坑。可能是挖同一个坑,也可能是各自挖各自的坑。
单线程 vs. 多线程:一个人在挖坑 vs. 一群人在挖坑。坑的数量不明,人员的组成不明,挖坑的具体安排不明。
同步:可以是下面中的某一个:
线程同步:一群人在挖坑,同时有一群人在拉土,坑挖好了的时候拉土的人才开始工作。
数据同步:一群人在挖坑,以某种方式保证所有人都知道挖坑进度,防止挖到别人的坑里并产生事故。
异步:一个或一群人在挖坑,忽然有人指示开挖新坑,但并没有人为之所动;几小时后连新坑都挖完了,但具体中间是怎么安排的并没有人知道。
Minecraft 到底是不是单线程游戏?
技术上来说不是。它至少有这样几个线程:
服务器线程。游戏的主要逻辑都在这个线程上发生。即便是客户端,也会有这样一个线程(即所谓的“集成服务器”(IntegratedServer)线程,这里的“集成”是相对于只有游戏逻辑,没有显示,专门在纯命令行等环境下使用的“专用服务器”(DedicatedServer)来说的)。
渲染线程(Minecraft 1.8 起)。这是跟显卡打交道的线程。
网络线程(Minecraft 1.8 起?)。这个线程负责服务器线程和客户端线程的通信。可能不止一个。
世界生成线程(Minecraft 1.13 起)。世界生成器会在这个线程上跑。
这几个线程是 99% 的 Mod 或插件开发者肯定会与之打交道的线程。当然还有别的线程,但开发者通常不会跟那些线程打交道,就暂且不提。
那“单线程游戏”的说法从何而来?
注意这句:
服务器线程。游戏的主要逻辑都在这个线程上发生。
游戏的所有大大小小的逻辑,无一例外,都在服务器线程上执行。通常所说的“主线程”大多数时候都是指服务器线程。这个线程大约是在做这个事情(伪(C)代(+)码(+)):
void gameLoop() {
while (keepGameRunning) {
int tickTime = 0;
for (int i = 0; i < 20; i++) {
long lastTick = System.currentTimeMills();
this->doGameLogic();
int tickCost = System.currentTimeMills() - lastTick;
if (tickCost < 50) {
tickTime += 50;
sleep(50 - tickCost);
} else {
tickTime += tickCost;
}
}
if (tickTime > 1000) {
this->warnUserTPSLag();
}
}
}
一圈循环下来,20 个 tick 就这样过去了。一个 tick 里游戏要做的事情有很多——所有的 Entity 要刷新一遍,所有的 TileEntity 也要刷新一遍,还要随机选几十个方块刷新一遍,因此光照可能会重新计算,这些操作搞不好会触发更多的方块刷新。请不要忘记玩家可能在挖矿、跑路、种地、打怪升级、开新区块触发世界生成等等的操作。同时 Minecraft 本身还会发几个数据包,也会处理几个数据包。可谓事无巨细,统一在一个线程上处理。单线程说法因此而来。
那我让一个 tick 分散到多个线程上去不就可以了?
真的可以吗?
让我们回到挖坑这件事上来。一个人挖坑,的确不会快到哪里去。但两个人挖坑就一定比一个人快吗?也许你会发现只有一把铲子可以用。好一点的情况可以是有三把铲子可以用,但请注意这是资源浪费,因为一个人不太可能同时挥舞两把铲子。即便是有不多不少两把铲子,你还可能会遇到因不合理的挖坑分工导致的工伤事故,于是挖坑计划就会被推迟,你也许会因此付出更多的资源。
让我们再把问题扩大化:现在我们要找一万人挖一个天坑。有鉴于要挖的是一个天坑,你大概不会直接让一万人上铲子蛮干,而是让这一万人中的一部分会操纵挖掘机的人来操作挖掘机挖坑,剩下的人则是负责挖掘机干不了的细活以及后勤工作等等。但请注意,你挖的不是隧道,是坑,你不能把这帮人分成两队,一队从上面开始挖,一队从下面开始挖,这样只会让上面的人和挖掘机忽然掉下来,并把下面的人和挖掘机砸成板。分成三队自然更不靠谱,理由同分成两队。退一步讲,你该怎么让一帮人从下面往上开始挖坑?如果你要先钻到下面去,你不是已经先钻出一个坑来了?
所以最靠谱方案的还是从上面一起往下挖。那么能不能分成两队,一队挖左半边一队挖右半边呢?听上去可以,但请考虑一下,两队工作效率还有可能不对等,结果挖着挖着,忽然坑塌了,于是又是新的事故出现了。
所以结论是,挖坑不能像挖隧道那样分成两队分别行动,约定在某地点打通并胜利会师。
回到 Minecraft 上来。刚才的坑就是 Minecraft 的一个游戏刻(Gametick,部分 CBer 使用缩写 gt),挖坑的过程大抵就是方块、实体、TileEntity 刷新的过程。所有的刷新操作都会对周围的方块造成影响,反映到区块上,就是对区块数据的写操作,比如方块破坏、亮度更新、方块状态变更等等。两种实体(Entity 和 TileEntity)的刷新还通常会改变自身及周围方块及实体的状态。试想,一个 TileEntity 的刷新操作不在服务器线程上完成,然后它对周围的方块进行了修改,但一部分被修改的方块也不幸被选中进行刷新,此时就会出现数据竞争的情况——两个线程同时修改一个对象里的数据。此时有三个选择:
加锁。基本是套一个 synchronized 临界区域这样的东西,但加锁释锁也有开销,更何况你可能面对的是成百上千个 TileEntity 同时加锁,然后若干线程在等这些锁被释放。
免锁逻辑。对于 TileEntity 和普通实体来说这个可能好办一些,但对于采用了享元设计的方块来说... 免锁逻辑真的能实现吗?TileEntity 修改区块数据时打算怎么办?
放弃多线程,致力于优化游戏本身的性能瓶颈。
显然 1. 和 2. 的开销过于巨大,Mojang 是商业公司,他要养活那些为 Minecraft 付出辛劳与汗水的程序员,这样的重构会带来更多麻烦,使不得。
事实上,很多游戏都有类似的情况,其流程不能轻易被拆解成可以并发处理的小流程。
线程不安全
如前文所述,因为数据竞争,所以 Minecraft 是一个线程不安全的游戏。你在别的线程上修改数据,也许会造成毁灭性的后果。
// 这个方法会在网络通信线程上执行,而非游戏主线程。
public void processPacketData(CustomPacketFromClient packet) {
removeSomeTileEntities();
}
// 同时,有一个 TileEntity 还在更新...
pubilc void update() {
if (!world.isRemote) {
addSomeNewTileEntities();
}
}
这样也就只能等待 GG 了——ConcurrentModificationException
。你当然也可以说换成线程安全的容器,但如果你不是要把一个游戏刻分散到多个线程上去的话,线程安全的容器只是多此一举,除了徒增烦恼,并不能带来实质性的改变。
异步
正因为 Minecraft 的主要逻辑都集中在服务器线程上,所以下面的代码是错误的:
// 错误示范,请勿模仿!
@SubscribeEvent
public void onTick(WorldTickEvent.Pre event) {
Thread.sleep(10000L); // 延时 10 秒后执行逻辑
doLogic();
}
因为只有一个线程在执行游戏逻辑,Thread.sleep(10000L)
会使得整个游戏暂停十秒钟(一万毫秒),很容易令客户端与服务器失去连接,进而造成诸如“单机游戏时提示连接超时然后退回服务器选择界面”这样的诡异情况。很不幸,的确有新手会犯这样的错误。
正确的解决思路有两种:
1. 依赖游戏刻,而非现实世界中的时间。
// 本段代码以 Forge 为框架
private int counter = 0;
@SubscribeEvent
public void onTick(TickEvent.WorldTickEvent event) {
if (counter++ > 200) { // 理想状态下,TPS 为 20,
// 此时 10 秒内会执行不多不少 200 个游戏刻。
doLogic();
counter = 0; // 重置计数器。
}
}
2. 如果是 Bukkit 或者 Sponge 这样的框架,其提供的异步操作 API 是允许指定延迟时间的。
// 本段代码以 Sponge 为框架
Sponge.getScheduler().createAsyncExecutor(myPluginObject)
.schedule((Runnable) () -> doLogic(), 10, TimeUnit.SECONDS);
事情就这样结束了吗?并没有。
还有这样的情况:某种强力的斧子一砍就是一整棵树,但这棵树特别特别高,于是一整个游戏刻全都在全力追上砍树的进程了。怎么办?
多线程在这个时候似乎符合直觉了:因为 Minecraft 特殊的物理系统,我完全可以让树分好几段“掉”下来。但请注意,这里还是有数据同步的问题——不仅是木头会掉下来,树叶也会在失去与木头的连接后开始凋谢检查,所以正常的并发根本行不通。
Minecraft 自身有一个简单的异步执行操作的机制,addScheduledTask(Runnable task)
(MCP func_152344_a
),这个方法来自 IThreadListener
接口,所以你可以在客户端独有的 Minecraft
或者客户端和服务器都有的 MinecraftServer
两个类中找到这个方法。所有通过此方法规划的任务都会在未来的某一个游戏刻时执行。大约是下面这个逻辑(伪(C)代(+)码(+)):
void gameLoop() {
while (keepGameRunning) {
// Do other logic
for (int i = 0; i < 20; i++) {
// Do other logic
auto task = this->scheduledTaskQueue.front();
this->scheduledTaskQueue.pop();
task.run();
// Do other logic
}
// Do other logic
}
}
如果你需要让你的逻辑分散在多个游戏刻上,用这个 addScheduledTask
就可以。这样做大约相当于,让一个人每天挖一点坑,过个几百天就能挖出大坑。
addScheduledTask
还有一个用途是让一部分代码在主线程上工作,比如刚刚接收到的数据包,解析完数据,要执行逻辑时,需要这么做。
多线程的正确用法
但实际上,使用多线程有时候的确是合理的。比如 Mod 检查更新的时候。
public void versionCheck() {
try {
URL url = new URL("...");
InputStream remoteData = url.openStream();
List<String> lines = readAllLines(remoteData);
} catch (Exception e) {
logError(e);
}
}
请注意,这个 IO 操作会阻塞线程。换言之,如果网络不好,这个东西就会卡住无法继续执行,直到超时。此时多线程就是一个合理的解决方案。
new Thread(VersionCheck::versionCheck, "VersionCheck").start();