[其二] 简单的开始
正如上文所说,Folia与传统的Spigot相比,最大的不同点是以多线程运行代替了单线程运行。那么你是否足够了解什么是线程呢?
所以在开始之前,让我们先简要了解下什么是线程。我不准备摆出一大堆专业名词,这里将以比喻的手法用一些简单的语言描述。
什么是线程?
在Minecraft的服务端中,它的线程就像是工作在服务器中的工人,例如在玩家破坏方块、放置方块以及攻击实体后,都需要这个工人先拿到这个事件,然后进行处理后再返还给客户端结果。
而单线程,即服务端主要的工作都需要单独的一个工人来处理,大部分你能想到的事情都需要它来干。正常的TPS数值为20.0,即每秒进行20次Tick,即每次Tick耗时50ms。当服务器需要处理的事务变多,而这个工人没办法在短时间处理完这么多事情,比如,它每次需要耗费100ms才能处理完全部的事务,那么服务器的TPS就为10次每秒,即TPS = 10.0。
而作为服主解决这种问题的方法有这么几种:提升这个工人能处理的上限(例如提升单核性能)、降低这个工人每次的计算量(例如限制各种机器数量)、拉另一个工人过来帮帮忙(开设群组服)。
那么有什么办法来创建新的线程呢?叫一个新的工人帮我干活!最简单的方法是:
new Thread(new Runnable() {
@Override
public void run() {
//你的代码
}
}).start();
这么你就创建了一个新的线程,并可以执行你代码中的内容。当然,你也可以继承Thread类、实现 Runnable接口等方式来创建线程。
当然,Bukkit给了我们一个调度程序的API,可以让我们更方便的实现类似功能。
调度程序
调度,常用作动词,意为调动;安排人力、车辆。用作名词时,可以指担负指挥调派人力、工作、车辆等工作的人、调度员,也可以当人讲的一类称呼。见《汉书·佞幸传·董贤》:“哀帝崩。太皇太后召大司马贤,引见东厢,问以丧事调度。”
如果你对Bukkit开发有所了解,你肯定听说过调度程序。调度程序(Scheduler),你可以理解为给工人下发一些任务(Task),收到这个任务的工人需要处理任务中对应的事务。BukkitScheduler
提供了runTask
和runTaskAsynchronously
等方法,其中runTask
会让在主线程上的工人在下一Tick开始执行对应的事务,runTaskAsynchronously
则可理解为找了另外一个工人帮忙处理事务。
同样的,调度任务不仅仅只可以在主线程中发配,你完全可以在一个调度任务中继续下发其它的调度任务。
例如,主线程上的工人需要进行一些复杂的计算,这些计算可能会比较耗时,总让主线程计算可能会导致耗时较高、处理完事务的时间变长,最终导致服务器TPS下降。 因此你可以设计让主线程上的工人叫来另一个工人"异步地"帮他计算相关结果,他先去处理其他的事情,当另一个工人算出结果后,也可以使用runTask
来让主线程的工人在下一Tick直接进行事件的处理、伤害的施加等操作。在理论上,这样的设计在性能方面是较为友好的。
为什么要用调度呢? 就比如,你希望一个方法在十秒后被调用,你总不会想着用sleep方法让主线程睡眠10秒后再执行吧? 这样服务器将不会处理其它任何的事情,这肯定不是我们希望的效果,在这种情况下我们应该使用调度程序。
Folia中的调度
如上文所说,Bukkit的调度程序API都离不开关于"主线程"的概念。
而Folia中有关线程内容的修改,影响最大的便是Bukkit中调度程序API。
还记得吗,Folia服务端是一个线程管理数个区块,另一个线程管理另外几个区块的,这种情况下Folia服务端没有常规的主线程概念,每个线程之间地位都是平等的!
那么我们仍旧可以使用BukkitAPI中提供的方法来实现调度程序吗? 显然不能!所以 Bukkit插件不能直接安装在Folia服务端中,需要修改才可以在Folia中运行。
注意线程安全!
什么是线程安全?用上面的例子来讲,在Bukkit的异步调度时,异步的工人收到了将实体A变成绵羊的任务,而主线程的工人同时收到了让实体A变成猪猪的任务,那么你能确定这个实体它最终究竟是什么吗? 这仅仅作为一个小例子,在实际的情景上也许是更复杂的数据。 在异步操作(多线程编程)中,需要格外的注意公共数据、资源的访问与操作。当然包括实体与方块的操作,这同样也属于一种公共数据!
那么我们应该如何保证在异步操作中的操作是线程安全的呢?
对于BukkitAPI, 就像前文介绍异步调度程序举的例子,你可以在异步调度中使用runTask让主线程在下一tick进行对实体、方块的操作。
对于插件中缓存的数据,一般来说,使用并发集合ConcurrentHashMap 、CopyOnWriteArrayList 储存就可以解决了,但...一定安全吗?
我在这里给出一个简单的例子
Map<String,Integer> map = new ConcurrentHashMap<>();
map.put("a",1);
map.put("b",2);
new Thread(() -> {
int index = map.values().size()+1;
String name = "c";
try {
//假设由于一些计算耗时
Thread.sleep(10);
map.put(name,index);
System.out.println(map);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
new Thread(() -> {
int index = map.values().size()+1;
String name = "d";
try {
Thread.sleep(8);
map.put(name,index);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
假如我使用一个map来存放玩家名称+进服的顺序,在这个地方我使用了并发集合ConcurrentHashMap,我们希望的map数据为{a=1, b=2, c=3, d=4}
,而实际上输出是{a=1, b=2, c=3, d=3}
。这是在代码设计上的问题,例如在本例中线程先缓存了index,忽略了在计算过程中集合可能产生的变化。
使用并发集合并不能解决所有问题,作为开发者的你仍需要考虑代码是否有隐藏的线程安全问题。