单线程背后的真相

平时都说 Minecraft 是单线程游戏,因此如何如何性能差,如何如何不能有效利用处理器资源,如何如何“一核有难,八核围观”。

可能有人会问了:干嘛不多线程呢?

真的就这么简单吗?

本文将详细解释这个问题的成因,以及为什么 Mod 开发时不能轻易使用多线程,以及对应的解决方案。不过要注意,虽然本文力求术语的解释精确,但难免会有疏忽之处,若有问题,烦请不吝赐教。

概念

说起多线程,有那么几个老生常谈的概念不得不再次在此重申一遍。这里,用群众喜闻乐见的“挖坑”这件事进行类比:

  • 并发:有一群人在挖坑。可能是挖同一个坑,也可能是各自挖各自的坑。

  • 单线程 vs. 多线程:一个人在挖坑 vs. 一群人在挖坑。坑的数量不明,人员的组成不明,挖坑的具体安排不明。

  • 同步:可以是下面中的某一个:

    • 线程同步:一群人在挖坑,同时有一群人在拉土,坑挖好了的时候拉土的人才开始工作。

    • 数据同步:一群人在挖坑,以某种方式保证所有人都知道挖坑进度,防止挖到别人的坑里并产生事故。

  • 异步:一个或一群人在挖坑,忽然有人指示开挖新坑,但并没有人为之所动;几小时后连新坑都挖完了,但具体中间是怎么安排的并没有人知道。

Minecraft 到底是不是单线程游戏?

技术上来说不是。它至少有这样几个线程:

  1. 服务器线程。游戏的主要逻辑都在这个线程上发生。即便是客户端,也会有这样一个线程(即所谓的“集成服务器”(IntegratedServer)线程,这里的“集成”是相对于只有游戏逻辑,没有显示,专门在纯命令行等环境下使用的“专用服务器”(DedicatedServer)来说的)。

  2. 渲染线程(Minecraft 1.8 起)。这是跟显卡打交道的线程。

  3. 网络线程(Minecraft 1.8 起?)。这个线程负责服务器线程和客户端线程的通信。可能不止一个。

  4. 世界生成线程(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 的刷新操作不在服务器线程上完成,然后它对周围的方块进行了修改,但一部分被修改的方块也不幸被选中进行刷新,此时就会出现数据竞争的情况——两个线程同时修改一个对象里的数据。此时有三个选择:

  1. 加锁。基本是套一个 synchronized 临界区域这样的东西,但加锁释锁也有开销,更何况你可能面对的是成百上千个 TileEntity 同时加锁,然后若干线程在等这些锁被释放。

  2. 免锁逻辑。对于 TileEntity 和普通实体来说这个可能好办一些,但对于采用了享元设计的方块来说... 免锁逻辑真的能实现吗?TileEntity 修改区块数据时打算怎么办?

  3. 放弃多线程,致力于优化游戏本身的性能瓶颈。

显然 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();

消息盒子

# 暂无消息 #

只显示最新10条未读和已读信息