天空 Blond - 康威生命游戏
type: channel
link: https://skyblond.info/tag/%E5%BA%B7%E5%A8%81%E7%94%9F%E5%91%BD%E6%B8%B8%E6%88%8F/
items: 【代码札记】康威生命游戏

【代码札记】康威生命游戏
type: item
date: 2023-02-18T20:41:00+08:00
description: 本文记述我使用Kotlin在JVM上实现Conway's Game of Life。游戏规则康威生命游戏并不是什么新鲜玩意儿了，它可以视作一个二维的棋盘，每一个格子被称为细胞（Cell），每一个细胞有两种状态：死亡或存活。每一轮迭代时，所有细胞同时更新状态，更新的规则如下：若细胞当前存活：当周围的存活细胞数量小于2时，这个细胞在迭代时死亡（因人烟稀少）当周围的存活细胞数量大于3时，这个细胞在迭代时死亡（因人口过剩）当周围有2个或3个存活的细胞时，这个细胞在迭代时继续存活若细胞当前死亡：当周围有且只有3个存活细胞时，这个细胞在迭代时复活（模拟繁殖）其他形况下，细胞继续保持死亡状态这个规则其实还挺简单的，并且它可以使用多线程进行运算。实现思路这个游戏的“世界”可以用一个二维数组表示，数组的元素就是一个布尔值，true表示存活，false表示死亡。每次更新细胞时先统计它周围的存活细胞，然后根据细胞的当前状态使用if else做判断，更新细胞状态。考虑到规则要求所有细胞同时更新，因此我们需要两个“世界”，一个表示当前的细胞状态，计算出下一状态后，将新的状态写入另一个世界，表示未来的状态。当所有细胞都产生了新状态后，交换这两个世界的指针，这样一来，未来的状态就成为了现在的状态，而原来的状态则成为记录未来状态的容器。不论使用什么语言，这种设计还都挺合理的对吧？在JVM上也一样，对吧？整体来说还挺对的，但有一些小问题。首先，JVM标准并没有规定一个布尔值应该占多少内存。可能是1比特，也可能是1字节，这完全取决于JVM的实现。如果JVM使用1比特，那么含有1024个元素的布尔数组的大小就是128字节；如果采用1字节表示一个布尔，那么同样大小的数组就要占用1KB。假如说我们要渲染一个1080P的康威游戏，1920乘以1080，一共是2073600（200万）个细胞，每个细胞占用1字节，这就是2MB的内存，而原本它可以只占用不到256KB的。当然了，好在JVM中其他类型的大小是固定的。你可以选择使用byte作为容器，这样一个byte就有8个bit，但鉴于kotlin的类型系统比较生硬，在做位运算的时候需要将byte转换成int，再将int转换为byte存回去，这样子很麻烦，所以我是用Int作为容器，每个Int可以存储32个比特，即便有浪费，也最多是最后一个Int没有完全使用，浪费的部分不超过4字节。熟悉BitSet的读者可能会说，诶呀，你说的这个不就是BitSet嘛！Java中提供了一个可变长度的BitSet，可以方便地做位运算。但这里还有一个问题：内存一致性。关于这一部分，最开始的构想是使用分段锁来保护不同位置，这样既保证了内存一致性，又保证不同位置的独写互不冲突。但是后来发现一个问题：写入一个状态需要读取九个状态，换句话说，如果读写都加锁（读需要加锁保证线程可以看到之前写的数据），那么更新一个细胞就要竞争10次锁，一旦竞争失败，synchronized关键字就会把竞争失败的线程转变为操作系统的阻塞态。这是非常耗费时间的。谈及多线程和内存一致性，包括我在内，第一反应可能都是用同步锁，无论是synchronized关键字还是同步代码块，总之，正确用锁就能保证安全。但是从性能角度来看，监视器锁可能是性能最差的（字节码中使用了monitorenter和monitorexit），因为它依赖于操作系统将竞争失败的线程阻塞，锁释放时再依赖操作系统唤醒线程。在这里，我们更新一个细胞需要读取9次，而每次都要竞争锁，如果竞争失败则需要在阻塞、唤醒上浪费时间。采用自旋锁（ReentrantLock）则能够避开线程调度。选好了锁，我们再想想，到底哪里需要锁？我们使用锁的目的就是利用它的顺序性，也就是所谓的happen before，即后进入锁的线程一定能够看到先前持有锁进程产生的修改——后来的线程在读取世界状态时，必须能够看到之前由多个线程计算出来的状态。可我们真的需要使用锁来保证这一点吗？除了锁，只读内存也是同步安全的，只读就是所谓的immutable，不可变对象。通过游戏规则可知，所有细胞必须同时更新，因此这不可能是就地更新，也就是说，我们从当前世界读取状态，然后将修改写入其他地方。如果当前世界是不可变的呢？这样一来，读取的时候我们就不需要锁了。读取的时候不需要锁并不意味着不需要锁了。上锁的时机从读取时提前到了创建对象时。计算下一步的状态仍然需要一个可修改的状态，我们仍然需要使用锁来写入，而读取的时候也需要锁来保证我们可以看到其他线程的修改。但如果我们在计算完成后抄写一份，我们只需要在这里上一次锁，然后将抄写的结果作为不可变对象使用，那么之后就不再需要上锁了。实现细节WorldMap“世界”的实现如下：package info.skyblond.pocog im…