:mannotop 2023 年 6 月 – manno的博客

月度归档: 2023 年 6 月

Go 内存管理-垃圾回收器

source article

The Go garbage collector is responsible for collecting the memory that is not in use anymore. The implemented algorithm is a concurrent tri-color mark and sweep collector. In this article, we will see in detail the marking phase, along with the usage of the different colors.

Go1.3 标记清除法

分下面四步进行

  1. 进行 STW(stop the world 即暂停程序业务逻辑),然后从 main 函数开始找到不可达的内存占用和可达的内存占用
  2. 开始标记,程序找出可达内存占用并做标记
  3. 标记结束清除未标记的内存占用
  4. 结束 STW 停止暂停,让程序继续运行,循环该过程直到 main 生命周期结束

一开始的做法是将垃圾清理结束时才停止 STW,后来优化了方案将清理垃圾放到了 STW 之后,与程序运行同时进行,这样做减小了 STW 的时长。但是 STW 会暂停用户逻辑对程序的性能影响是非常大的,这种粒度的 STW 对于性能较高的程序还是无法接受,因此 Go1.5 采用了三色标记法优化了 STW。

Go1.5 三色标记法

三色标记算法将程序中的对象分成白色、黑色和灰色三类。

白色对象表示暂无对象引用的潜在垃圾,其内存可能会被垃圾收集器回收;

灰色对象表示活跃的对象,黑色到白色的中间状态,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;

黑色对象表示活跃的对象,包括不存在引用外部指针的对象以及从根对象可达的对象。

三色标记法分五步进行

  1. 将所有对象标记为白色
  2. 从根节点集合出发,将第一次遍历到的节点标记为灰色放入集合列表中
  3. 遍历灰色集合,将灰色节点遍历到的白色节点标记为灰色,并把灰色节点标记为黑色
  4. 循环这个过程
  5. 直到灰色节点集合为空,回收所有的白色节点

这种方法看似很好,但是将 GC 和程序是一起执行的,程序执行过程中可能会更改白色对象的引用关系,导致出现下面这种情况,被引用的对象 3 受到错误的垃圾回收,程序从而出现错误。

因此在此基础上拓展出了俩种方法,强三色不变式和弱三色不变式

  • 强三色不变式:不允许黑色对象引用白色对象
  • 弱三色不变式:黑色对象可以引用白色,白色对象存在其他灰色对象对他的引用,或者他的链路上存在灰色对象

为了实现这俩种不变式的设计思想,从而引出了屏障机制,即在程序的执行过程中加一个判断机制,满足判断机制则执行回调函数。

屏障机制分为插入屏障和删除屏障,插入屏障实现的是强三色不变式,删除屏障则实现了弱三色不变式。值得注意的是为了保证栈的运行效率,屏障只对堆上的内存对象启用,栈上的内存会在 GC 结束后启用 STW 重新扫描。

  • 插入屏障:对象被引用时触发的机制,当白色对象被黑色对象引用时,白色对象被标记为灰色
  • 删除屏障:对象被删除时触发的机制。如果灰色对象引用的白色对象被删除时,那么白色对象会被标记为灰色。(缺点:这种做法回收精度较低,一个对象即使被删除仍可以活过这一轮再下一轮被回收)

上面的屏障保护只发生在堆的对象上。因为性能考虑,栈上的引用改变不会引起屏障触发。

所以栈在 GC 迭代结束时(没有灰色节点),会对栈执行 STW,重新进行扫描清除白色节点。(STW 时间为 10-100ms)

Go1.8 三色标记 + 混合写屏障

基于插入写屏障和删除写屏障在结束时需要 STW 来重新扫描栈,所带来的性能瓶颈,Go 在 1.8 引入了混合写屏障的方式实现了弱三色不变式的设计方式,混合写屏障分下面四步

GC 开始时直接将栈上可达对象全部标记为黑色(不需要二次扫描,无需 STW)

GC 期间,任何栈上创建的新对象均为黑色

被删除引用的对象标记为灰色(无需判断是否被引用)

被添加引用的对象标记为灰色(无需判断是否被引用)

下面为混合写屏障过程

其它

GC的触发时机

触发 GC 有俩个条件,一是堆内存的分配达到控制器计算的触发堆大小,初始大小环境变量 GOGC,之后堆内存达到上一次垃圾收集的 2 倍时才会触发 GC。二是如果一定时间内没有触发,就会触发新的循环,该触发条件由 runtime.forcegcperiod 变量控制,默认为 2 分钟。

Go 内存管理

程序在内存上被分为堆区、栈区、全局数据区、代码段、数据区五个部分。对于 C++ 等早期编程语言栈上的内存由编译器管理回收,堆上的内存空间需要编程人员负责申请与释放。在 Go 中栈上内存仍由编译器负责管理回收,而堆上的内存由编译器和垃圾收集器负责管理回收,给编程人员带来了极大的便利性。

Forge开发环境的配置

下载工具

需要下载的工具:

  • JDK
  • IntelliJ IDEA
  • Forge Mod Development Kit (MDK),Forge官方出的开发环境工具包,选择版本(本教程中为1.18.2)下载MDK后请解压到你喜欢的文件夹,请注意这里的解压文件夹不要包括任何的中文、空格以及一些特殊符号(比如「!」)。

Minecraft Forge是一个Gradle项目,Gradle是一个项目构建工具,其主要作用是负责项目的依赖管理、构建等功能。依赖管理指的是帮你自动地下载和配置你开发中使用的库,也就是别人写好的方便你自己开发的代码。构建指的是将你写的mod打包成别人可以安装的jar文件。

Forge官方写了一个叫做ForgeGradle(以后简称FG)的插件来负责整个mod开发环境的配置

开始配置

使用IDEA选择你MDK解压目录下的build.gradle打开。

打开之后,根据你网络和自身电脑的情况,会有或长或短的导入时间,这个过程需要下载很多的依赖包,而这些依赖包都存放在海外,介于中国大陆网络封锁,导致海外网络访问不稳定,这个时间将会持续几分钟至几天不等,而且很有可能失败,对于有代理的同学可以自行搜索「Gradle配置代理」来给Gradle加上代理。

如果失败了找到IDEA界面中的

一栏,双击Tasks/build目录下的buildDependents即可重新开始下载依赖包。

当导入结束,点击下方的build面板,左侧显示绿勾时说明导入成功。

image-20200426190531596

当导入完成后,点击运行右侧的Gradle面板,选择其中的Tasks/forgegradle runs下的genIntelliJRunsimage-20200426190744381

在这一步中,会自动下载剩余的一些依赖,以及Minecraft的资源文件。出于和上面相同的理由,这个过程耗时会很长,并且非常容易失败。

同样的当左侧显示「绿勾」时说明配置成功。

image-20200426192001511

选择Gradle侧栏Tasks/forgegradle runs中的runClient即可启动游戏。

在没有为Gradle配置代理的情况下,runClient有时候会耗费非常多的时间。

启动成功后,可以看见和平时游戏一样的minecraft客户端窗口弹出,我们的配置工作大功告成了。

常见问题QA

Q:依赖包下载总是失败

A:科学上网,或者重试多次并祈祷成功吧。

一些核心概念

注册

由上节可知,如果你想往Minecraft里添加一些内容,那么你必须做的一件事就是把这些新内容注册到Mod总线中。注册是一种机制,告诉游戏本身,有哪东西可以使用。你注册时需要的东西基本上可以分成两个部分:一个注册名和一个实例。

ResourceLocation

你可以把ResourceLocation想成一种特殊格式的字符串,它大概长成这样:minecraft:textures/block/stone.png,一个ResourceLocation指定了资源包下的一个特定的文件。举例来说,前面这个这个ResourceLocation代表了原版资源包下的石头的材质图片。ResouceLocation分成两部分,冒号前面的叫做「域(domain)」,在原版中只有一个域,即minecraft域,但是如果你开始开发mod,那么每个mod都会有一个或者多个域。冒号的后半部分是和assets文件夹内的目录结构一一对应的。从某种程度上来说,ResourceLocation就是一个特殊的URL。

模型和材质

在游戏中3d的对象基本上都有它的模型,模型和材质组合在一起规定了一个对象具体的样子。模型相当于是骨头,材质相当于是皮肤。在大部分时候,你的材质都是png图片,请注意保证你的材质背景是不透明的,除非你清楚意味着什么,否则不要在材质中使用半透明像素,会有不可预知的问题。

Minecraft mod的开发模型

在这节中,我们将会粗略的讲一讲Minecraft mod的开发模型是什么样子的,理解这个模型将有助于你理解mod开发中的很多操作是为了什么。

在我看来,Minecraft mod 开发基本上遵循了「事件驱动模式」,这里我们不会详细的讨论纠结什么是「事件驱动模式」,你只需要有一个感性的了解即可。

那么Minecraft「事件驱动模式」是怎么样子的呢?要回答这个问题,我们得先理清三个概念:「事件」「总线」和「事件处理器」。

首先什么是「事件」呢?就跟这个词表示的那样,「事件」就是「发生了某件事」。举例来说「当方块被破环」这个就是一个事件,「当玩家死亡」这个也是一个事件,当然我们前面举的都是非常具体的例子,事件也可以很抽象,比如「当渲染模型时」这个也是一个事件。

接下来什么是「事件处理器」呢?事件处理器就是用来处理「事件」的函数。我们可以创建一个事件处理器来处理「方块破坏事件」,里面的内容是「重新创建一个方块」,可以注册一个事件处理器来处理「玩家死亡事件」,里面的内容是「放置一个墓碑」。

最后是「总线」,总线是连接「事件」和「事件处理器」的工具,当「事件」发生的时候,「事件」的信息将会被发送到总线上,然后总线会选择监听了这个「事件」的「事件处理器」,执行这个事件处理器。

Untitled Diagram

注意这张图里的事件和事件处理器是没有先后顺序的。

在Minecraft中,所写的逻辑基本上都是事件处理。

在Forge开发里有两条总线,Mod总线Forge总线,所有和初始化相关的事件都是在Mod总线内,其他所有事件都在Forge总线内。

Go语言指针性能

原文链接

引言

本文主要想整理Go语言中值传递和指针传递本质上的区别,这里首席分享William Kennedy的一句话作为总结:

Value semantics keep values on the stack, which reduces pressure on the Garbage Collector (GC). However, value semantics require various copies of any given value to be stored, tracked and maintained. Pointer semantics place values on the heap, which can put pressure on the GC. However, pointer semantics are efficient because only one value needs to be stored, tracked and maintained.

大意可以理解为Golang中,值对象是存储在stack栈内存中的,指针对象是存储在heap堆内存中的,故使用值对象可以减少GC的压力。然而,指针传递的效率在于,存储、跟踪和维护过程中只需要传递一个值。

基于以上的认识,我们用以下例子来试试

结构体定义

以下面一个简单的struct作为示例

type S struct {
   a, b, c int64
   d, e, f string
   g, h, i float64
}

我们分别构建初始化值对象和指针对象的方法

func byValue() S {
   return S{
      a: math.MinInt64, b: math.MinInt64, c: math.MinInt64,
      d: "foo", e: "foo", f: "foo",
      g: math.MaxFloat64, h: math.MaxFloat64, i: math.MaxFloat64,
   }
}

func byPoint() *S {
   return &S{
      a: math.MinInt64, b: math.MinInt64, c: math.MinInt64,
      d: "foo", e: "foo", f: "foo",
      g: math.MaxFloat64, h: math.MaxFloat64, i: math.MaxFloat64,
   }
}

对象传递

内存地址

首先我们来探究在不同的function传递时,值对象和指针对象在内存地址方面有什么不同。我们创建两个function如下:

func TestValueAddress(t *testing.T) {
   nest1 := func() S {
      nest2 := func() S {
         s := byValue()
         fmt.Println("------ nest2 ------")
         fmt.Printf("&a:%v,  &b:%v, &c:%v, &d:%v, &f:%v, &g:%v, &h:%v, &i: %v\n",
            &s.a, &s.b, &s.c, &s.d, &s.f, &s.g, &s.h, &s.i)
         return s
      }
      s := nest2()
      fmt.Println("------ nest1 ------")
      fmt.Printf("&a:%v,  &b:%v, &c:%v, &d:%v, &f:%v, &g:%v, &h:%v, &i: %v\n",
         &s.a, &s.b, &s.c, &s.d, &s.f, &s.g, &s.h, &s.i)
      return s
   }
   s := nest1()
   fmt.Println("------ main ------")
   fmt.Printf("&a:%v,  &b:%v, &c:%v, &d:%v, &f:%v, &g:%v, &h:%v, &i: %v\n",
      &s.a, &s.b, &s.c, &s.d, &s.f, &s.g, &s.h, &s.i)
}

func TestPointAddress(t *testing.T) {
   nest1 := func() *S {
      nest2 := func() *S {
         s := byPoint()
         fmt.Println("------ nest2 ------")
         fmt.Printf("&a:%v,  &b:%v, &c:%v, &d:%v, &f:%v, &g:%v, &h:%v, &i: %v\n",
            &s.a, &s.b, &s.c, &s.d, &s.f, &s.g, &s.h, &s.i)
         return s
      }
      s := nest2()
      fmt.Println("------ nest1 ------")
      fmt.Printf("&a:%v,  &b:%v, &c:%v, &d:%v, &f:%v, &g:%v, &h:%v, &i: %v\n",
         &s.a, &s.b, &s.c, &s.d, &s.f, &s.g, &s.h, &s.i)
      return s
   }
   s := nest1()
   fmt.Println("------ main ------")
   fmt.Printf("&a:%v,  &b:%v, &c:%v, &d:%v, &f:%v, &g:%v, &h:%v, &i: %v\n",
      &s.a, &s.b, &s.c, &s.d, &s.f, &s.g, &s.h, &s.i)
}

两个方法对应的输入如下:

// TestValueAddress输出
------ nest2 ------
&a:0xc00007e2a0,  &b:0xc00007e2a8, &c:0xc00007e2b0, &d:0xc00007e2b8, &f:0xc00007e2d8, &g:0xc00007e2e8, &h:0xc00007e2f0, &i: 0xc00007e2f8
------ nest1 ------
&a:0xc00007e240,  &b:0xc00007e248, &c:0xc00007e250, &d:0xc00007e258, &f:0xc00007e278, &g:0xc00007e288, &h:0xc00007e290, &i: 0xc00007e298
------ main ------
&a:0xc00007e1e0,  &b:0xc00007e1e8, &c:0xc00007e1f0, &d:0xc00007e1f8, &f:0xc00007e218, &g:0xc00007e228, &h:0xc00007e230, &i: 0xc00007e238

// TestPointAddress输出
------ nest2 ------
&a:0xc00007e1e0,  &b:0xc00007e1e8, &c:0xc00007e1f0, &d:0xc00007e1f8, &f:0xc00007e218, &g:0xc00007e228, &h:0xc00007e230, &i: 0xc00007e238
------ nest1 ------
&a:0xc00007e1e0,  &b:0xc00007e1e8, &c:0xc00007e1f0, &d:0xc00007e1f8, &f:0xc00007e218, &g:0xc00007e228, &h:0xc00007e230, &i: 0xc00007e238
------ main ------
&a:0xc00007e1e0,  &b:0xc00007e1e8, &c:0xc00007e1f0, &d:0xc00007e1f8, &f:0xc00007e218, &g:0xc00007e228, &h:0xc00007e230, &i: 0xc00007e238

由此我们可知,值对象由于是分配在栈内存上的,所以他的生命周期跟随func:当function执行完毕时,对应function内部的对象也会被销毁被重新copy到新的栈内存;指针对象由于是分配在堆内存中的,即便function执行完毕,栈内存被清理也不会改变其分配的内存地址,而是由GC统一管理。

故值对象在不同的func中传递时,势必会引起栈内存中的copy

性能

然后让我们来看,仅在一个func中,值对象和指针对象的性能如何。

通过创建Benchmark以追踪他在循环中的性能:

func BenchmarkValueCopy(b *testing.B) {
   var s S
   out, _ := os.Create("value.out")
   _ = trace.Start(out)

   for i := 0; i < b.N; i++ {
      s = byValue()
   }

   trace.Stop()
   b.StopTimer()
   _ = fmt.Sprintf("%v", s.a)
}

func BenchmarkPointCopy(b *testing.B) {
   var s *S
   out, _ := os.Create("point.out")
   _ = trace.Start(out)

   for i := 0; i < b.N; i++ {
      s = byPoint()
   }

   trace.Stop()
   b.StopTimer()
   _ = fmt.Sprintf("%v", s.a)
}

然后执行以下语句

go test -bench=BenchmarkValueCopy -benchmem -run=^$ -count=10 > value.txt
go test -bench=BenchmarkPointCopy -benchmem -run=^$ -count=10 > point.txt

benchmark stat如下:

// value.txt
BenchmarkValueCopy-8       225915150           5.287 ns/op          0 B/op         0 allocs/op
BenchmarkValueCopy-8       225969075           5.348 ns/op          0 B/op         0 allocs/op
BenchmarkValueCopy-8       224717500           5.441 ns/op          0 B/op         0 allocs/op
...

// point.txt
BenchmarkPointCopy-8       22525324           47.25 ns/op          96 B/op         1 allocs/op
BenchmarkPointCopy-8       25844391           46.27 ns/op          96 B/op         1 allocs/op
BenchmarkPointCopy-8       25628395           46.02 ns/op          96 B/op         1 allocs/op
...

由此可以看出,值对象的初始化比指针对象初始化要快

然后我们通过trace日志来看看具体的原因,使用以下命令:

go tool trace value.out
go tool trace point.out

value.png value.out

point.png point.out

经过trace日志可以看出,值对象在执行过程中没有GC且没有额外的goroutines;指针对象总共GC 967次。

方法执行

创建基于值对象和指针对象的空function如下:

func (s S) value(s1 S) {}

func (s *S) point(s1 *S) {}

对应的Benchmark如下:

func BenchmarkValueFunction(b *testing.B) {
   var s S
   var s1 S

   s = byValue()
   s1 = byValue()
   for i := 0; i < b.N; i++ {
      for j := 0; j < 1000000; j++  {
         s.value(s1)
      }
   }
}

func BenchmarkPointFunction(b *testing.B) {
   var s *S
   var s1 *S

   s = byPoint()
   s1 = byPoint()
   for i := 0; i < b.N; i++ {
      for j := 0; j < 1000000; j++  {
         s.point(s1)
      }
   }
}

Benchmark stat如下:

// value
BenchmarkValueFunction-8            160      7339292 ns/op

// point
BenchmarkPointFunction-8            480      2520106 ns/op

由此可以看到,在方法执行过程中,指针对象是优于值对象的。

数组

下面我们尝试构建一个值对象数组和指针对象数组,即[]S和[]*S

func BenchmarkValueArray(b *testing.B) {
   var s []S
   out, _ := os.Create("value_array.out")
   _ = trace.Start(out)

   for i := 0; i < b.N; i++ {
      for j := 0; j < 1000000; j++ {
         s = append(s, byValue())
      }
   }

   trace.Stop()
   b.StopTimer()
}

func BenchmarkPointArray(b *testing.B) {
   var s []*S
   out, _ := os.Create("point_array.out")
   _ = trace.Start(out)

   for i := 0; i < b.N; i++ {
      for j := 0; j < 1000000; j++ {
         s = append(s, byPoint())
      }
   }

   trace.Stop()
   b.StopTimer()
}

获取到的benchmark stat如下

// value array   []S
BenchmarkValueArray-8             2    542506184 ns/op   516467388 B/op       83 allocs/op
BenchmarkValueArray-8             2    532587916 ns/op   516469084 B/op       85 allocs/op
BenchmarkValueArray-8             3    501410289 ns/op   538334434 B/op       57 allocs/op

// point array   []*S
BenchmarkPointArray-8             8    232675024 ns/op   145332278 B/op  1000022 allocs/op
BenchmarkPointArray-8            10    181305981 ns/op   145321713 B/op  1000018 allocs/op
BenchmarkPointArray-8             8    329801938 ns/op   145331643 B/op  1000021 allocs/op

然后是用trace日志看看具体的GC和Goroutines的情况:

go tool trace value_array.out
go tool trace point_array.out

value_array.png value_array.out

point_array.png point_array.out

通过以上的日志可知,[]S相较于[]*S仍然有更少的GC和Goroutines,但是在实际的运行速度中,尤其是需要值传递的情况,[]*S还是优于[]S的。此外,在使用[]S修改其中某一项的值的时候会存在问题,如下

// bad case
func TestValueArrayChange(t *testing.T) {
   var s []S
   for i := 0; i < 10; i++ {
      s = append(s, byValue())
   }

   for _, v := range s {
      v.a = 1
   }

   // assert failed
   // Expected :int64(1)
  // Actual   :int64(-9223372036854775808)
   assert.Equal(t, int64(1), s[0].a)
}

// good case
func TestPointArrayChange(t *testing.T) {
   var s []*S
   for i := 0; i < 10; i++ {
      s = append(s, byPoint())
   }

   for _, v := range s {
      v.a = 1
   }

   // assert success
   assert.Equal(t, int64(1), s[0].a)
}

总结

如同一开始所说的,值对象是跟随func存储在栈内存中的,指针对象是存储在堆内存中的。

对于值对象来说,当func结束时,栈内的值对象也会跟着从一个栈复制到另一个栈;同时存储在栈内存中意味着更少的GC和Goroutines。

对于指针对象来说,在堆内存中存储无异会增加GC和Goroutines,但是在func中传递指针对象时,仅需要传递指针即可。

综上,对于仅在方法内使用的对象,或想跨方法传递的一些小的对象,那么可以使用值对象来提升效率和减少GC;但是如果需要传递大对象,或者跨越更多方法来传递对象,那么最好还是使用指针对象来传递。

Minecraft是如何运作的

这节的内容非常重要,你必须在自己的大脑中构建起Minecraft运行的模型图像,这将会帮助你理解后面涉及到的概念。

在这一节中,我将介绍一下Minecraft大体上是怎么运作的,以及一个非常重要的概念:「端」。

Minecraft大体上属于「C/S架构(客户端/服务端架构)」。那么什么是「服务端」,什么又是「客户端」呢?

从名字上其实就能看出大概的意思,「服务端」是用来提供服务的,「客户端」是用户直接使用的。那么这两个端在Minecraft中是怎么体现的呢?

在Minecraft中两个端的职责区分如下:

  • 服务端负责游戏的逻辑,数据的读写。
  • 客户端接受用户的输入输出,根据来自服务端的数据来渲染游戏画面。

值得注意的是,这里客户端和服务端的区分仅是逻辑上的区分。实际上如果你处于单人模式,那么你的电脑上会同时存在服务端和客户端,而且他们处于不同的线程1。但是当你连接某个服务器时,你的电脑上只存在客户端,服务端被转移到了远程的一台服务器上。

下面一张图大概的解释了Minecraft是怎么运作的。

image-20200426110629794

看到这张图,你可能觉得奇怪,说好的是服务端负责游戏逻辑的呢,为什么客户端也有数据模型?其实这里的「客户端数据模型」只是「服务端数据模型」一个副本,虽然它们都有独立的游戏Tick,也共享很多相同的代码,但是最终逻辑还是以服务端为准。客户端和服务端是独立运行的,但是它们不可避免地需要同步数据,而在Minecraft里,所有客户端和服务端的数据同步都是通过网络数据包实现的。

在大部分时候原版已经实现好了数据同步的方法,我们只需要调用已经实现好的方法就行,但是在某些情况下,原版没有实现对应的功能,或者不适合使用原版提供的功能,我们就得自己创建和发送网络数据包来完成数据的同步。

那么接下去的问题是,我们怎么在代码中区分我们是处于客户端还是服务端呢?

Minecraft的World中有一个isRemote字段,当处于客户端时这个变量值为true,当处于服务端时这个变量值为false

Forge是什么

本教程是一个基于Forge的Mod开发教程,那么自然而然的要回答一个问题:「Forge是什么?」

乍一看,这个好像根本就不是一个问题,「Forge?Forge不就是Forge吗?」看到这个问题的你内心中的第一个浮现出的想法估计就是这个。

但是回答这个问题还是非常有必要的,接下去我会稍微讲一讲Forge是什么,以及Forge的历史。这些看上去和我们教程无关的内容,其实是Mod开发领域的「乡谣(Lore)」,学会这些可以更好的让你和其他人交流。了解自己要学东西的历史本身也是挺有趣的一件事不是吗?

我们得从Minecraft本身说起,首先我们得明确Minecraft是一个用Java写成的商业软件。这意味着两件事:第一,Minecraft相对容易修改;第二,代码本身是不开源而且是被混淆过的。在Minecraft历史的早期,因为在Mojang一直都没有给Minecraft提供官方API1,所以「Mod Coder Pack」项目诞生了(以下简称为MCP)。

还记得我之前说过的,Minecraft的两个特性吗?MCP就利用这两个特性,实现了一套工具,可以让开发者可以直接修改Minecraft jar包里的内容。

于是srgnotchmcp诞生了。

那么这三个是什么呢?

首先是notch,他是Minecraft直接反编译、反混淆之后的名称,通常是无意义的字母数字组合。你从名称Notch就可以看出,这个名字是直接来自Minecraft(以及对Notch的怨念),举例来说 j就是一个典型的notch

接下来是srg,这个名字是和notch是一一对应的,srg在一个版本里是不会变动的,之所以叫做srg,是为了纪念MCP项目开发的领导者Searge。在srg中,Minecraft中的类名已经是可读了,变量方法等名称虽然还是不可读,但是有相对应的前缀和尾缀来区分了。以上面的j为例,它的srgfunc_70114_g

最后是mcp,这个名称也是我们mod开发中接触最多的名称,在mcp中,代码已经是可读的了。和我们正常写java程序中的名称没什么两样。但是mcp是会变动的。举例来说上面的func_70114_g它的mcpgetCollisionBoxmcp中的类名和srg中的类名是相同的。

接下来我们来讲Forge,随着时间的发展,Mod开发者们意识到,直接修改Jar文件写mod的方式太过于粗暴了,而且Mod和Mod之间的兼容性可以说基本没有,Mod开发者们急需一种工具可以方便地开发Mod,并且能保证mod和mod之间的兼容性,于是Forge就诞生了。

Forge其实就是一套通过修改Minecraft方式实现的第三方API,而且随着时间的发展,MCP现在已经死亡了,除了Forge这套API,Fabric也风头正盛,而Forge本身也在Minecraft 1.13版本到来之后经历了一次重写,引入了大量函数式编程的API。

那么Forge是怎么使用我们之前提及的三个名字的呢?

在你安装完Forge之后,游戏的运行过程中,所有的内容都会反编译成srg运行,你编译好的mod同样也会被混淆成srg,保证它可以正常运行。

从零开始开发一个Minecraft模组

近来有了开发一个属于自己的mc模组的念头,但苦于找不到合适而全面的入门教程,唯一找到的一个比较全面的教程面向的minecraft版本也面向的minecraft版本已经有些过时,一些Api的使用有许多对不上的地方,于是想在这里记录一下自己在开发1.18.x版本模组时的踩坑过程,也想将这个略显过时的教程再按自己的理解翻译一遍。

原教程地址:Minecraft Forge Mod Dev tutorial

导论

首先欢迎你来到这个教程,既然你会打开这个教程,想必你心中有了开发一个属于自己的mod的念头吧。

正好,这个教程也是为这个目的服务的。但是开发一个属于自己的mod并不是一件容易的事情,你需要学习非常多的知识才能达成这个目标,阅读和跟随这个教程只是非常浅显的部分。

首先我想让你思考一个问题:你真的需要自己从头开发一个mod吗?

其实对于大部分人的需求,不需要从零开发一个Mod。有非常多其他的办法可以达成他们的目标:原版内置的机制,MCreator和ZenScript等。

如果你的答案是确定的,那么第二个问题:你真的需要亲自写一个mod吗?

Mod开发需要编程和相当的计算机科学基础,要学好这些并不容易,但编程可不是Mod开发的全部,如果你是一个只会编程的人,我的建议是寻找同伴。记住:美工和设计在Mod开发中也是不可或缺的一部分

对于绝大部分模组来说,代码逻辑甚至是次要的部分:

些许有趣的逻辑设计+美观且风格统一的材质贴图+优秀的模型=优秀的模组。

当然,本教程只是面向Mod开发中的编程人员的,如果你对上面两个问题的答案都是肯定的,那么我觉得你可以开始阅读这个教程了。在这个教程里,我会假设你有一定的计算机科学常识,熟悉Java编程的基础,别担心,不熟悉Java其实也能看懂。

对了,本教程是编写Forge模组的教程,当下同样热门的Fabric教程可以去看这个博主的文章here

对于Forge和Fabric之争,由于我自己是从1.2.5版本开始接触minecraft的玩家,自然对Forge这个老牌模组比较感到亲切些。它们的不同之处可能是:Forge包含了很多API,而Fabric是轻量级的,对游戏底层的修改也是Fabric更接近于原版,因此Fabric在原版玩家中很受欢迎(用来装辅助mod),Fabric更新速度比Forge快得多,听说Fabric的Mod会比较好上手些,没有Forge这么多繁琐的注册总线操作。但由于Forge是老牌模组,生态这块比Fabric全面得多。选择哪个开发模组都是可以的,有竞争是好事,希望mc模组圈子能够常青。

目录

教程施工中:2023/6/16

DDIA-Transactions

Introduce

In the harsh reality of data systems, many things can go wrong:

  • The database software or hardware may fail at any time (including in the middle of a write operation).
  • The application may crash at any time (including halfway through a series of operations).
  • Interruptions in the network can unexpectedly cut off the application from the database, or one database node from another.
  • Several clients may write to the database at the same time, overwriting each other’s changes.
  • A client may read data that doesn’t make sense because it has only partially been updated.
  • Race conditions between clients can cause surprising bugs.In order to be reliable, a system has to deal with these faults and ensure that they don’t cause catastrophic failure of the entire system. However, implementing fault- tolerance mechanisms is a lot of work. It requires a lot of careful thinking about all the things that can go wrong, and a lot of testing to ensure that the solution actually works.

For decades, transactions have been the mechanism of choice for simplifying these issues. A transaction is a way for an application to group several reads and writes together into a logical unit. Conceptually, all the reads and writes in a transaction are executed as one operation: either the entire transaction succeeds (commit) or it fails (abortrollback). If it fails, the application can safely retry. With transactions, error handling becomes much simpler for an application, because it doesn’t need to worry about partial failure—i.e., the case where some operations succeed and some fail (for whatever reason).

Transactions are not a law of nature; they were created with a purpose, namely to simplify the programming model for applications accessing a database. By using transactions, the application is free to ignore certain potential error scenarios and concurrency issues, because the database takes care of them instead (we call these safety guarantees).

Isolation levels and race conditions

Transactions are an abstraction layer that allows an application to pretend that cer‐ tain concurrency problems and certain kinds of hardware and software faults don’t exist. A large class of errors is reduced down to a simple transaction abort, and the application just needs to try again.

we went particularly deep into the topic of concurrency control. We discussed several widely used isolation levels, in particular

  • read committed
  • snapshot isolation (sometimes called repeatable read), and 
  • serializable.

We characterized those isolation levels by discussing various examples of race conditions:

Dirty reads(脏读,读未提交)

One client reads another client’s writes before they have been committed. The read committed isolation level and stronger levels prevent dirty reads.

Dirty writes(脏写)

One client overwrites data that another client has written, but not yet committed. Almost all transaction implementations prevent dirty writes.

Read skew (读偏差,又称nonrepeatable reads不可重复读)

A client sees different parts of the database at different points in time. This issue is most commonly prevented with snapshot isolation, which allows a transaction to read from a consistent snapshot at one point in time. It is usually implemented with multi-version concurrency control (MVCC).

Lost updates(丢失更新)

Two clients concurrently perform a read-modify-write cycle. One overwrites the other’s write without incorporating its changes, so data is lost. Increased isolation level to ‘Repeatable Read’ so that database can perform efficient checks in conjunction, Optimistic/Pressmistic locking is the common method used to prevent lost update problems too.

Write skew(写偏差)

A transaction reads something, makes a decision based on the value it saw, and writes the decision to the database. However, by the time the write is made, the premise of the decision is no longer true. Only serializable isolation prevents this anomaly.

Phantom reads(幻读)

A transaction reads objects that match some search condition. Another client makes a write that affects the results of that search. Snapshot isolation prevents straightforward phantom reads, but phantoms in the context of write skew require special treatment, such as index-range locks.

Weak isolation levels protect against some of those anomalies but leave you, the application developer, to handle others manually (e.g., using explicit locking). Only serializable isolation protects against all of these issues.

Three different approaches to implementing serializable transactions:

1.Literally executing transactions in a serial order(串行执行事务)

If you can make each transaction very fast to execute, and the transaction throughput is low enough to process on a single CPU core, this is a simple and effective option.

2.Two-phase locking(两阶段锁)

For decades this has been the standard way of implementing serializability, but many applications avoid using it because of its performance characteristics.

3.Serializable snapshot isolation (SSI序列化快照隔离)

A fairly new algorithm that avoids most of the downsides of the previous approaches. It uses an optimistic approach, allowing transactions to proceed without blocking. When a transaction wants to commit, it is checked, and it is aborted if the execution was not serializable.

The examples in this chapter used a relational data model. However, as discussed in “The need for multi-object transactions”, transactions are a valuable database feature, no matter which data model is used.

In this chapter, we explored ideas and algorithms mostly in the context of a database running on a single machine. Transactions in distributed databases open a new set of difficult challenges, which we’ll discuss in the next two chapters.

创建和更新重要数据时,如何保证数据准确无误?

情景导入

以电商系统中的下单流程为例:

订单系统是整个电商系统中最重要的一个子系统,订单数据也就是电商企业最重要的数据资产。今天这节课,我来和你说一下,在设计和实现一个订单系统的存储过程中,有哪些问题是要特别考虑的。

一个合格的订单系统,最基本的要求是什么?数据不能错。

一个购物流程,从下单开始、支付、发货,直到收货,这么长的一个流程中,每一个环节,都少不了更新订单数据每一次更新操作又需要同时更新好几张表。这些操作可能被随机分布到很多台服务器上执行,服务器有可能故障,网络有可能出问题。

在这么复杂的情况下,保证订单数据一笔都不能错,是不是很难?实际上,只要掌握了方法,其实并不难。

  • 首先,你的代码必须是正确没 Bug 的,如果说是因为代码 Bug 导致的数据错误,那谁也救不了你。
  • 然后,你要会正确地使用数据库的事务。比如,你在创建订单的时候,同时要在订单表和订单商品表中插入数据,那这些插入数据的 INSERT 必须在一个数据库事务中执行,数据库的事务可以确保:执行这些 INSERT 语句,要么一起都成功,要么一起都失败。

但是,还有一些情况下会引起数据错误,我们一起来看一下。不过在此之前,我们要明白,对于一个订单系统而言,它的 核心功能和数据结构是怎样的

因为,任何一个电商,它的订单系统的功能都是独一无二的,基于它的业务,有非常多的功能,并且都很复杂。我们在讨论订单系统的存储问题时,必须得化繁为简,只聚焦那些最核心的、共通的业务和功能上,并且以这个为基础来讨论存储技术问题

订单系统的核心功能和数据

简单梳理一下一个订单系统必备的功能,它包含但远远不限于:

  1. 创建订单;
  2. 随着购物流程更新订单状态;
  3. 查询订单,包括用订单数据生成各种报表。

为了支撑这些必备功能,在数据库中,至少需要有这样几张表:

  1. 订单主表:也叫订单表,保存订单的基本信息。
  2. 订单商品表:保存订单中的商品信息。
  3. 订单支付表:保存订单的支付和退款信息。
  4. 订单优惠表:保存订单使用的所有优惠信息。

这几个表之间的关系是这样的:订单主表和后面的几个子表都是一对多的关系,关联的外键就是订单主表的主键,也就是订单号。

绝大部分订单系统它的核心功能和数据结构都是这样的。

如何避免重复下单?

来看一个场景:一个订单系统,提供创建订单的 HTTP 接口,用户在浏览器页面上点击 提交订单 按钮的时候,浏览器就会给订单系统发一个创建订单的请求,订单系统的后端服务,在收到请求之后,往数据库的订单表插入一条订单数据,创建订单成功。

假如说,用户点击 创建订单 的按钮时手一抖,点了两下,浏览器发了两个 HTTP 请求,结果是什么?创建了两条一模一样的订单。这样肯定不行,需要做防重。

有的同学会说,前端页面上应该防止用户重复提交表单,你说的没错。但是,网络错误会导致重传,很多 RPC 框架、网关都会有自动重试机制,所以对于订单服务来说,重复请求这个事儿,你是没办法完全避免的。

解决办法是,让你的订单服务具备幂等性。 一个幂等操作的特点是,其任意多次执行所产生的影响均与一次执行的影响相同。也就是说,一个幂等的方法,使用同样的参数,对它进行调用多次和调用一次,对系统产生的影响是一样的。所以,对于幂等的方法,不用担心重复执行会对系统造成任何改变。一个幂等的创建订单服务,无论创建订单的请求发送多少次,正确的结果是,数据库只有一条新创建的订单记录。

这里面有一个不太好解决的问题:对于订单服务来说,它怎么知道发过来的创建订单请求是不是重复请求呢?

在插入订单数据之前,先查询一下订单表里面有没有重复的订单,行不行?不太行,因为你很难用 SQL 的条件来定义 「重复的订单」,订单用户一样、商品一样、价格一样,就认为是重复订单么?不一定,万一用户就是连续下了两个一模一样的订单呢?所以这个方法说起来容易,实际上很难实现。

很多电商解决这个问题的思路是这样的。在数据库的最佳实践中有一条就是,数据库的每个表都要有主键,绝大部分数据表都遵循这个最佳实践。一般来说,我们在往数据库插入一条记录的时候,都不提供主键,由数据库在插入的同时自动生成一个主键。这样重复的请求就会导致插入重复数据。

我们知道,表的主键自带唯一约束,如果我们在一条 INSERT 语句中提供了主键,并且这个主键的值在表中已经存在,那这条 INSERT 会执行失败,数据也不会被写入表中。我们可以利用数据库的这种「主键唯一约束」特性,在插入数据的时候带上主键,来解决创建订单服务的幂等性问题。

具体的做法是这样的,我们给订单系统增加一个 「生成订单号」的服务,这个服务没有参数,返回值就是一个新的、全局唯一的订单号。在用户进入创建订单的页面时,前端页面先调用这个生成订单号服务得到一个订单号,在用户提交订单的时候,在创建订单的请求中带着这个订单号。

这个订单号也是我们订单表的主键,这样,无论是用户手抖,还是各种情况导致的重试,这些重复请求中带的都是同一个订单号。订单服务在订单表中插入数据的时候,执行的这些重复 INSERT 语句中的主键,也都是同一个订单号。数据库的唯一约束就可以保证,只有一次 INSERT 语句是执行成功的,这样就实现了创建订单服务幂等性。

把上面这个幂等创建订单的流程,绘制成了时序图供你参考:

image-20201120095842638

还有一点需要注意的是,如果是因为重复订单导致插入订单表失败,订单服务不要把这个错误返回给前端页面。否则,就有可能出现这样的情况:用户点击创建订单按钮后,页面提示创建订单失败,而实际上订单却创建成功了。正确的做法是,遇到这种情况,订单服务直接返回订单创建成功就可以了。

如何解决 ABA 问题?

同样,订单系统各种更新订单的服务一样也要具备幂等性。

这些更新订单服务,比如说支付、发货等等这些步骤中的更新订单操作,最终落到订单库上,都是对订单主表的 UPDATE 操作。数据库的更新操作,本身就具备天然的幂等性,比如说,你把订单状态,从未支付更新成已支付,执行一次和重复执行多次,订单状态都是已支付,不用我们做任何额外的逻辑,这就是 天然幂等

那在实现这些更新订单服务时,还有什么问题需要特别注意的吗?还真有,在并发环境下,你需要注意 ABA 问题。

什么是 ABA 问题呢?我举个例子你就明白了。比如说,订单支付之后,小二要发货,发货完成后要填个快递单号。假设说,小二填了一个单号 666,刚填完,发现填错了,赶紧再修改成 888。对订单服务来说,这就是 2 个更新订单的请求

正常情况下,订单中的快递单号会先更新成 666,再更新成 888,这是没问题的。那不正常情况呢?666 请求到了,单号更新成 666,然后 888 请求到了,单号又更新成 888,但是 666 更新成功的响应丢了,调用方没收到成功响应,自动重试,再次发起 666 请求,单号又被更新成 666 了,这数据显然就错了。这就是非常有名的 ABA 问题

具体的时序你可以参考下面这张时序图:

image-20201120100117690

ABA 问题怎么解决?这里给你提供一个比较通用的解决方法。给你的订单主表增加一列,列名可以叫 version,也即是 「版本号」的意思。每次查询订单的时候,版本号需要随着订单数据返回给页面。页面在更新数据的请求中,需要把这个版本号作为更新请求的参数,再带回给订单更新服务。

订单服务在更新数据的时候,需要比较订单当前数据的版本号,是否和消息中的版本号一致,如果不一致就拒绝更新数据。如果版本号一致,还需要再更新数据的同时,把版本号 +1。「比较版本号、更新数据和版本号 +1」,这个过程必须在同一个事务里面执行

具体的 SQL 可以这样来写:

UPDATE orders set tracking_number = 666, version = version + 1
WHERE version = 8;

在这条 SQL 的 WHERE 条件中,version 的值需要页面在更新的时候通过请求传进来。

通过这个版本号,就可以保证,从我打开这条订单记录开始,一直到我更新这条订单记录成功,这个期间没有其他人修改过这条订单数据。因为,如果有其他人修改过,数据库中的版本号就会改变,那我的更新操作就不会执行成功。我只能重新查询新版本的订单数据,然后再尝试更新。

有了这个版本号,再回头看一下我们上面那个 ABA 问题的例子,会出现什么结果?可能出现两种情况:

  1. 第一种情况把运单号更新为 666 的操作成功了,更新为 888 的请求带着旧版本号,那就会更新失败,页面提示用户更新 888 失败。
  2. 第二种情况666 更新成功后,888 带着新的版本号,888 更新成功。这时候即使重试的 666 请求再来,因为它和上一条 666 请求带着相同的版本号,上一条请求更新成功后,这个版本号已经变了,所以重试请求的更新必然失败。

无论哪种情况,数据库中的数据与页面上给用户的反馈都是一致的。这样就可以实现幂等更新并且避免了 ABA 问题。下图展示的是第一种情况,第二种情况也是差不多的:

image-20201120100435422

总结:

  • 防止重复提交:采用幂等操作,利用预生成唯一批次号,后续操作需要带有批次号进行
  • 防止 ABA 问题:使用自增版本号记录更新操作,每次更新前检验版本号

实际上就讲了一个事儿,也就是,实现更新操作的幂等的方法

通过这样两种幂等的实现方法,就可以保证,无论请求是不是重复,表中的数据都是正确的。当然,上面讲到的实现订单幂等的方法,你完全可以套用在其他需要实现幂等的服务中,只需要这个服务操作的数据保存在数据库中,并且有一张带有主键的数据表就可以了。