:mannotop manno – 第 2 页 – manno的博客

作者: manno

Go 调度器-协程调度器GMP与演化过程

今天的 Go 语言调度器有着优异的性能,但是如果我们回头看 Go 语言的 0.x 版本的调度器会发现最初的调度器不仅实现非常简陋,也无法支撑高并发的服务。调度器经过几个大版本的迭代才有今天的优异性能,历史上几个不同版本的调度器引入了不同的改进,也存在着不同的缺陷:

  • 单线程调度器 · 0.x
    • 只包含 40 多行代码;
    • 程序中只能存在一个活跃线程,由 G-M 模型组成;
  • 多线程调度器 · 1.0
    • 允许运行多线程的程序;
    • 全局锁导致竞争严重;
  • 任务窃取调度器 · 1.1
    • 引入了处理器 P,构成了目前的 G-M-P 模型;
    • 在处理器 P 的基础上实现了基于工作窃取的调度器;
    • 在某些情况下,Goroutine 不会让出线程,进而造成饥饿问题;
    • 时间过长的垃圾回收(Stop-the-world,STW)会导致程序长时间无法工作;
  • 抢占式调度器 · 1.2 ~ 至今
    • 基于协作的抢占式调度器 – 1.2 ~ 1.13
      • 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;
      • Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停;
    • 基于信号的抢占式调度器 – 1.14 ~ 至今
      • 实现基于信号的真抢占式调度
      • 垃圾回收在扫描栈时会触发抢占调度;
      • 抢占的时间点不够多,还不能覆盖全部的边缘情况;
  • 非均匀存储访问调度器 · 提案
    • 对运行时的各种资源进行分区;
    • 实现非常复杂,到今天还没有提上日程;

G-M模型

单线程调度器

0.x 版本调度器只包含表示 Goroutine 的 G 和表示线程的 M 两种结构,全局也只有一个线程。我们可以在 clean up scheduler 提交中找到单线程调度器的源代码,在这时 Go 语言的调度器还是由 C 语言实现的,调度函数 runtime.scheduler:9682400 也只包含 40 多行代码 :

static void scheduler(void) {
	G* gp;
	lock(&sched);

	if(gosave(&m->sched)){
		lock(&sched);
		gp = m->curg;
		switch(gp->status){
		case Grunnable:
		case Grunning:
			gp->status = Grunnable;
			gput(gp);
			break;
		...
		}
		notewakeup(&gp->stopped);
	}

	gp = nextgandunlock();
	noteclear(&gp->stopped);
	gp->status = Grunning;
	m->curg = gp;
	g = gp;
	gogo(&gp->sched);
}

该函数会遵循如下的过程调度 Goroutine:

  1. 获取调度器的全局锁;
  2. 调用 runtime.gosave:9682400 保存栈寄存器和程序计数器;
  3. 调用 runtime.nextgandunlock:9682400 获取下一个需要运行的 Goroutine 并解锁调度器;
  4. 修改全局线程 m 上要执行的 Goroutine;
  5. 调用 runtime.gogo:9682400 函数运行最新的 Goroutine;

虽然这个单线程调度器的唯一优点就是能运行,但是这次提交已经包含了 G 和 M 两个重要的数据结构,也建立了 Go 语言调度器的框架。

多线程调度器

Go 语言在 1.0 版本正式发布时就支持了多线程的调度器,与上一个版本几乎不可用的调度器相比,Go 语言团队在这一阶段实现了从不可用到可用的跨越。我们可以在 pkg/runtime/proc.c 文件中找到 1.0.1 版本的调度器,多线程版本的调度函数 runtime.schedule:go1.0.1 包含 70 多行代码,我们在这里保留了该函数的核心逻辑:

static void schedule(G *gp) {
	schedlock();
	if(gp != nil) {
		gp->m = nil;
		uint32 v = runtime·xadd(&runtime·sched.atomic, -1<<mcpuShift);
		if(atomic_mcpu(v) > maxgomaxprocs)
			runtime·throw("negative mcpu in scheduler");

		switch(gp->status){
		case Grunning:
			gp->status = Grunnable;
			gput(gp);
			break;
		case ...:
		}
	} else {
		...
	}
	gp = nextgandunlock();
	gp->status = Grunning;
	m->curg = gp;
	gp->m = m;
	runtime·gogo(&gp->sched, 0);
}

整体的逻辑与单线程调度器没有太多区别,因为我们的程序中可能同时存在多个活跃线程,所以多线程调度器引入了 GOMAXPROCS 变量帮助我们灵活控制程序中的最大处理器数,即活跃线程数。

多线程调度器的主要问题是调度时的锁竞争会严重浪费资源,Scalable Go Scheduler Design Doc 中对调度器做的性能测试发现 14% 的时间都花费在 runtime.futex:go1.0.1 上3,该调度器有以下问题需要解决:

  1. 调度器和锁是全局资源,所有的调度状态都是中心化存储的,锁竞争问题严重;
  2. 线程需要经常互相传递可运行的 Goroutine,引入了大量的延迟;
  3. 每个线程M都需要处理内存缓存,导致大量的内存占用且数据局部性差;
  4. 系统调用频繁阻塞和解除阻塞正在运行的线程,增加了额外性能开销;

逻辑处理器P和任务窃取调度器的提出

2012 年 Google 的工程师 Dmitry Vyukov 在 Scalable Go Scheduler Design Doc 中指出了现有多线程调度器的问题并在多线程调度器上提出了两个改进的手段:

  1. 在当前的 G-M 模型中引入了处理器 P,增加中间层;
  2. 在处理器 P 的基础上实现基于工作窃取的调度器;

基于任务窃取的 Go 语言调度器使用了沿用至今的 G-M-P 模型,我们能在 runtime: improved scheduler 提交中找到任务窃取调度器刚被实现时的源代码,调度器的 runtime.schedule:779c45a 在这个版本的调度器中反而更简单了:

static void schedule(void) {
    G *gp;
 top:
    if(runtime·gcwaiting) {
        gcstopm();
        goto top;
    }

    gp = runqget(m->p);
    if(gp == nil)
        gp = findrunnable();

    ...

    execute(gp);
}
  1. 如果当前运行时在等待垃圾回收,调用 runtime.gcstopm:779c45a 函数;
  2. 调用 runtime.runqget:779c45a 和 runtime.findrunnable:779c45a 从本地或者全局的运行队列中获取待执行的 Goroutine;
  3. 调用 runtime.execute:779c45a 在当前线程 M 上运行 Goroutine;

当前处理器本地的运行队列中不包含 Goroutine 时,调用 runtime.findrunnable:779c45a 会触发工作窃取,从其它的处理器的队列中随机获取一些 Goroutine。

运行时 G-M-P 模型中引入的处理器 P 是线程和 Goroutine 的中间层,我们从它的结构体中就能看到处理器与 M 和 G 的关系:

struct P {
	Lock;

	uint32	status;
	P*	link;
	uint32	tick;
	M*	m;
	MCache*	mcache;

	G**	runq;
	int32	runqhead;
	int32	runqtail;
	int32	runqsize;

	G*	gfree;
	int32	gfreecnt;
};

处理器持有一个由可运行的 Goroutine 组成的环形的运行队列 runq,还反向持有一个线程m。调度器在调度时会从处理器的队列中选择队列头的 Goroutine 放到线程 M 上执行。如下所示的图片展示了 Go 语言中的线程 M、处理器 P 和 Goroutine 的关系。

golang-gmp

G-P-M模型

基于工作窃取的多线程调度器将每一个线程绑定到了独立的 CPU 上,这些线程会被不同处理器管理,不同的处理器通过工作窃取对任务进行再分配实现任务的平衡,也能提升调度器和 Go 语言程序的整体性能,今天所有的 Go 语言服务都受益于这一改动。

抢占式调度器

对 Go 语言并发模型的修改提升了调度器的性能,但是 1.1 版本中的调度器仍然不支持抢占式调度,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度。

基于协作的抢占式调度(~1.13)

Go 语言的调度器在 1.2 版本4中引入基于协作的抢占式调度解决下面的问题5

  • 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿;
  • 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间6,导致整个程序无法工作;

我们可以在 pkg/runtime/proc.c 文件中找到引入基于协作的抢占式调度后的调度器。

基于协作的抢占式调度的工作原理:

  1. 编译器会在调用函数前插入 runtime.morestack
  2. Go 语言runtime会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms 时发出抢占请求 StackPreempt
  3. 当发生函数调用时,可能会执行编译器插入的 runtime.morestack,它调用的 runtime.newstack 会检查 Goroutine 的 stackguard0 字段是否为 StackPreempt
  4. 如果 stackguard0 是 StackPreempt,就会触发抢占让出当前线程;

具体描述:

Go 语言在分段栈的机制上实现基于协作的抢占调度:编译器在分段栈上插入的函数morestack,所有 Goroutine 在函数调用时都有机会从被插入的函数中进入runtime检查是否需要执行抢占。Go 语言runtime也会在垃圾回收STW、系统监控中检查运行超过 10ms 的 Goroutine 并发出抢占请求 StackPreempt,Goroutine 收到信号后主动让出CPU(当前线程)。

这种实现方式虽然增加了运行时的复杂度,但是实现相对简单,也没有带来过多的额外开销,总体来看还是比较成功的实现,也在 Go 语言中使用了 10 几个版本。因为这里的抢占是通过编译器插入函数实现的,还是需要函数调用作为入口才能触发抢占,所以这是一种协作式的抢占式调度

1.2 版本的抢占式调度虽然能够缓解这个问题,但是它实现的抢占式调度是基于协作的,在之后很长的一段时间里 Go 语言的调度器都有一些无法被抢占的边缘情况,例如:没有任何函数调用的 for 循环或者垃圾回收长时间占用线程,这些问题中的一部分直到 1.14 才被基于信号的抢占式调度解决。

基于信号的抢占式调度(1.14~)

基于协作的抢占式调度虽然实现巧妙,但是并不完备,我们能在 runtime: non-cooperative goroutine preemption 中找到一些遗留问题:

Go 语言在 1.14 版本中实现了非协作的抢占式调度,目前的抢占式调度也只会在垃圾回收扫描任务时触发,我们可以梳理一下抢占式调度过程:

  1. 程序启动时,在 runtime.sighandler 中注册 SIGURG 信号的处理函数 runtime.doSigPreempt
  2. 在触发垃圾回收的栈扫描时会调用 runtime.suspendG 挂起 Goroutine,该函数会执行下面的逻辑:
    1. 将 _Grunning 状态的 Goroutine 标记成可以被抢占,即将 preemptStop 设置成 true
    2. 调用 runtime.preemptM 触发抢占;
  3. runtime.preemptM 会调用 runtime.signalM 向线程发送信号 SIGURG
  4. 操作系统会中断正在运行的线程并执行预先注册的信号处理函数 runtime.doSigPreempt
  5. runtime.doSigPreempt 函数会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用 runtime.sigctxt.pushCall
  6. runtime.sigctxt.pushCall 会修改寄存器并在程序回到用户态时执行 runtime.asyncPreempt
  7. 汇编指令 runtime.asyncPreempt 会调用运行时函数 runtime.asyncPreempt2
  8. runtime.asyncPreempt2 会调用 runtime.preemptPark
  9. runtime.preemptPark 会修改当前 Goroutine 的状态到 _Gpreempted 并调用 runtime.schedule 让当前函数陷入休眠并让出线程,调度器会选择其它的 Goroutine 继续执行;

上述 9 个步骤展示了基于信号的抢占式调度的执行过程,屏蔽掉具体的函数名,可以这样表述:

  1. M 启动时注册一个 SIGURG 信号的处理函数:sighandler。
  2. 系统监控sysmon 线程检测到执行时间过长的 goroutine、以及GC stw 时,会向相应的 M(或者说线程,每个线程对应一个 M)发送 SIGURG 信号
  3. 收到信号后,内核执行 sighandler 函数,通过 pushCall 向当前goroutine的执行流插入 asyncPreempt 函数调用
  4. 回到当前 goroutine 执行 asyncPreempt 函数,通过 mcall 切到 g0 栈执行 gopreempt_m
  5. gopreempt_m会将当前 goroutine 从running切换到runable状态,并将其重新放入到全局可运行队列,空出来的M 则继续寻找其他 goroutine 来运行
  6. 被抢占的 goroutine 再次调度过来执行时,会执行现场恢复工作

除了分析抢占的过程之外,我们还需要讨论一下抢占信号的选择,提案根据以下的四个原因选择 SIGURG 作为触发异步抢占的信号7

  1. 该信号需要被调试器透传;
  2. 该信号不会被内部的 libc 库使用并拦截;
  3. 该信号可以随意出现并且不触发任何后果;
  4. 我们需要处理多个平台上的不同信号;

STW 和栈扫描是一个可以抢占的安全点(Safe-points),所以 Go 语言会在这里先加入抢占功能8基于信号的抢占式调度只解决了垃圾回收和栈扫描时存在的问题(因为只在这些时候检查抢占),它到目前为止没有解决所有问题,但是这种真抢占式调度是调度器走向完备的开始,相信在未来我们会在更多的地方触发抢占。

数据结构

回顾一下运行时调度器的三个重要组成部分 — 线程 M、Goroutine G 和处理器 P:

img{512x368}
  1. G — 表示 Goroutine,它是一个待执行的任务;
  2. M — 表示操作系统的线程,它由操作系统的调度器调度和管理;
  3. P — 表示处理器,它可以被看做运行在线程上的本地调度器;

G – Goroutine

Goroutine 是 Go 语言调度器中待执行的任务,它在运行时调度器中的地位与线程在操作系统中差不多,但是它占用了更小的内存空间,也降低了上下文切换的开销。

Goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态提供的线程,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的 CPU。

Goroutine的主要成员变量

Goroutine 在 Go 语言运行时使用私有结构体 runtime.g 表示。这个私有结构体非常复杂,总共包含 40 多个用于表示各种状态的成员变量,这里也不会介绍所有的字段,仅会挑选其中的一部分。

栈相关

首先是与栈相关的两个字段:

type g struct {
	stack       stack
	stackguard0 uintptr
}
抢占相关

其中 stack 字段描述了当前 Goroutine 的栈内存范围 [stack.lo, stack.hi),另一个字段 stackguard0 可以用于调度器抢占式调度。除了 stackguard0 之外,Goroutine 中还包含另外三个与抢占密切相关的字段:

type g struct {
	preempt       bool // 抢占信号
	preemptStop   bool // 抢占时将状态修改成 `_Gpreempted`
	preemptShrink bool // 在同步安全点收缩栈
}
defer 和 panic相关

Goroutine 与我们在前面章节提到的 defer 和 panic 也有千丝万缕的联系,每一个 Goroutine 上都持有两个分别存储 defer 和 panic 对应结构体的链表

type g struct {
	_panic       *_panic // 最内侧的 panic 结构体
	_defer       *_defer // 最内侧的延迟函数结构体
}
线程记录、Goroutine状态、调度相关和其它

最后,我们再节选一些作者认为比较有趣或者重要的字段:

type g struct {
	m              *m
	sched          gobuf
	atomicstatus   uint32
	goid           int64
}
  • m — 当前 Goroutine 占用的线程,可能为空;
  • atomicstatus — Goroutine 的状态;
  • sched — 存储 Goroutine 的调度相关的数据,用于保存和恢复现场
  • goid — Goroutine 的 ID,该字段对开发者不可见,Go 团队认为引入 ID 会让部分 Goroutine 变得更特殊,从而限制语言的并发能力10

上述四个字段中,我们需要展开介绍 sched 字段的 runtime.gobuf 结构体中包含哪些内容:

type gobuf struct {
	sp   uintptr
	pc   uintptr
	g    guintptr
	ret  sys.Uintreg
	...
}
  • sp — 栈指针;
  • pc — 程序计数器;
  • g — 持有 runtime.gobuf 的 Goroutine;
  • ret — 系统调用的返回值;

这些内容会在调度器保存或者恢复上下文的时候用到,其中的栈指针和程序计数器会用来存储或者恢复寄存器中的值,改变程序即将执行的代码。

Goroutine的9种状态

结构体 runtime.g 的 atomicstatus 字段存储了当前 Goroutine 的状态。除了几个已经不被使用的以及与 GC 相关的状态之外,Goroutine 可能处于以下 9 种状态:

状态描述
_Gidle刚刚被分配并且还没有被初始化
_Grunnable没有执行代码,没有栈的所有权,存储在运行队列中
_Grunning可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P
_Gsyscall正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
_Gwaiting由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
_Gdead没有被使用,没有执行代码,可能有分配的栈
_Gcopystack栈正在被拷贝,没有执行代码,不在运行队列上
_Gpreempted由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
_GscanGC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在

虽然 Goroutine 在运行时中定义的状态非常多而且复杂,但是我们可以将这些不同的状态聚合成三种:等待中、可运行、运行中,运行期间会在这三种状态来回切换:

  • 等待中:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括 _Gwaiting_Gsyscall 和 _Gpreempted 几个状态;
  • 可运行:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即 _Grunnable
  • 运行中:Goroutine 正在某个线程上运行,即 _Grunning
golang-goroutine-state-transition

M – Machine Thread

Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行

在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数,我们也可以在程序中使用 runtime.GOMAXPROCS 来改变最大的活跃线程数。

scheduler-m-and-thread

图 6-32 CPU 和活跃线程

在默认情况下,一个四核机器会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的 runtime.m 结构体。

在大多数情况下,我们都会使用 Go 的默认设置,也就是线程数等于 CPU 数,默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少很多额外开销。

g0和curg

Go 语言会使用私有结构体 runtime.m 表示操作系统线程,这个结构体也包含了几十个字段,这里先来了解几个与 Goroutine 相关的字段:

type m struct {
	g0   *g
	curg *g
	...
}

其中 g0 是持有调度栈的 Goroutine,curg 是在当前线程上运行的用户 Goroutine,这也是操作系统线程唯一关心的两个 Goroutine。

g0-and-g

图 6-33 调度 Goroutine 和运行 Goroutine

g0 是一个运行时中比较特殊的 Goroutine,它会深度参与运行时的调度过程,包括 Goroutine 的创建、大内存分配和 CGO 函数的执行。

P – Processor

调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。

因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS这些处理器会绑定到不同的内核线程M上

runtime.p 是处理器的运行时表示,作为调度器的内部实现,它包含的字段也非常多,其中包括与性能追踪、垃圾回收和计时器相关的字段,这些字段也非常重要,但是在这里就不展示了,我们主要关注处理器中的线程和运行队列:

type p struct {
	m           muintptr

	runqhead uint32
	runqtail uint32
	runq     [256]guintptr //g队列
	runnext guintptr
	...
}

反向存储的线程维护着线程与处理器之间的关系,而 runqheadrunqtail 和 runq 三个字段表示处理器持有的运行队列,其中存储着待执行的 Goroutine 列表,runnext 中是线程下一个需要执行的 Goroutine。

p的5种状态

runtime.p 结构体中的状态 status 字段会是以下五种中的一种:

状态描述
_Pidle处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
_Prunning被线程 M 持有,并且正在执行用户代码或者调度器
_Psyscall没有执行用户代码,当前线程陷入系统调用
_Pgcstop被线程 M 持有,当前处理器由于垃圾回收被停止
_Pdead当前处理器已经不被使用

表 7-4 处理器的状态

通过分析处理器 P 的状态,我们能够对处理器的工作过程有一些简单理解,例如处理器在执行用户代码时会处于 _Prunning 状态,在当前线程执行 I/O 操作时会陷入 _Psyscall 状态。

GO 1.14基于信号的抢占式调度

基于协作的抢占式调度

在Go语言的v1.2版本就实现了基于协作的抢占式调用,这种调用的基本原理就是:

  1. sysmon监控线程发现有协程的执行时间太长了,那么会友好地为这个协程设置抢占标记;
  2. 当这个协程调用(call)一个函数时,会检查是否扩容栈,而这里就会检查抢占标记,如果被标记,则会让出CPU,从而实现调度。

但是这种调度方式是协程主动的,是基于协作的,但是他无法面对一些场景,比如在死循环中没有任何调用发生,那么这个协程将永远执行下去,永远不会发生调度,这显然是不可接受的。

基于信号的抢占式调度

于是,在v1.14版本,Go终于引入了基于信号的抢占式调度,我们先来看以下的两个demo:

demo

demo-1

//demo1
func main() {
	var x int
	threads := runtime.GOMAXPROCS(0)
	for i := 0; i < threads; i++ {
		go func() {
			//仅在协程中执行赋值语句,无函数调用,1.14以下版本的go对此无法抢占
			for {
				x++
			}
		}()
	}
	//主 goroutine sleep,在1.14以下版本会导致所有的P都被其它协程抢占,主goroutine无法再获得P
	time.Sleep(1000)
	fmt.Printf("is main running?")
}

我来简单解释一下上面这个程序。在主 goroutine 里,先用 GoMAXPROCS 函数拿到 CPU 的逻辑核心数 threads。这意味着 Go 进程会创建 threads 个数的 P。接着,启动了 threads 个数的 goroutine,每个 goroutine 都在执行一个无限循环,并且这个无限循环只是简单地执行 x++

接着,主 goroutine sleep 了 1 秒钟;最后,打印 x 的值。

你可以自己思考一下,输出会是什么?

如果你想出了答案,接着再看下面这个 demo:

//demo2
func main() {
	var x int
	threads := runtime.GOMAXPROCS(0)
	for i := 0; i < threads; i++ {
		go func() {
			//仅在协程中执行赋值语句,无函数调用,1.14以下版本的go对此无法抢占
			for {
				x++
			}
		}()
	}
	//主 goroutine sleep,在1.14以下版本会导致所有的P都被其它协程抢占,主goroutine无法再获得P
	runtime.GC()
	fmt.Printf("is main running?")
}

demo-2

我也来解释一下,在主 goroutine 里,只启动了一个 goroutine(虽然程序里用了一个 for 循环,但其实只循环了一次,完全是为了和前面的 demo 看起来更协调一些),同样执行了一个 x++ 的无限 for 循环。

和前一个 demo 的不同点在于,在主 goroutine 里,我们手动执行了一次 GC;最后,打印 x 的值。

如果你能答对第一题,大概率也能答对第二题。

下面我就来揭晓答案。

其实我留了一个坑,我没说用哪个版本的 Go 来运行代码。所以,正确的答案是:

Go 版本demo-1demo-2
1.13卡死卡死
1.1400

这个其实就是 Go 调度器的坑了。

假设在 demo-1 中,共有 4 个 P,于是创建了 4 个 goroutine。当主 goroutine 执行 sleep 的时候,刚刚创建的 4 个 goroutine 马上就把 4 个 P 霸占了,执行死循环,而且竟然没有进行函数调用,就只有一个简单的赋值语句。Go 1.13 对这种情况是无能为力的,没有任何办法让这些 goroutine 停下来,进程对外表现出“死机”。

demo-1 示意图

由于 Go 1.14 实现了基于信号的抢占式调度,这些执行无限循环的 goroutine 会被调度器“拿下”,P 就会空出来。所以当主 goroutine sleep 时间到了之后,马上就能获得 P,并得以打印出 x 的值。至于 x 为什么输出的是 0,不太好解释,因为这是一种未定义(有数据竞争,正常情况下要加锁)的行为,可能的一个原因是 CPU 的 cache 没有来得及更新,不过不太好验证。

理解了这个 demo,第二个 demo 其实是类似的道理:

demo-2 示意图

当主 goroutine 主动触发 GC 时,需要把所有当前正在运行的 goroutine 停止下来,即 stw(stop the world),但是 goroutine 正在执行无限循环,没法让它停下来。当然,Go 1.14 还是可以抢占掉这个 goroutine,从而打印出 x 的值,也是 0。


Go 1.14 之前的版本,能否抢占一个正在执行死循环的 goroutine 其实是有讲究的:

能否被抢占,不是看有没有调用函数,而是看函数的序言部分有没有插入扩栈检测指令。

如果没有调用函数,肯定不会被抢占。

有些虽然也调用了函数,但其实不会插入检测指令,这个时候也不会被抢占。

像前面的两个 demo,不可能有机会在函数扩栈检测期间主动放弃 CPU 使用权,从而完成抢占,因为没有函数调用。具体的过程后面有机会再写一篇文章详细讲,本文主要看基于信号的抢占式调度如何实现。

preemptone

一方面,Go 进程在启动的时候,会开启一个系统监控线程 sysmon,监控执行时间过长的 goroutine,进而发出抢占。另一方面,GC 执行 stw 时,会让所有的 goroutine 都停止,其实就是抢占。这两者都会调用 preemptone() 函数。

preemptone() 函数会沿着下面这条路径:

preemptone->preemptM->signalM->tgkill

向正在运行的 goroutine 所绑定的的那个 M(也可以说是线程)发出 SIGURG 信号。

注册 sighandler

每个 M 在初始化的时候都会设置信号处理函数

initsig->setsig->sighandler

信号执行过程

我们从“宏观”层面看一下信号的执行过程:

信号执行过程

主程序(线程)正在“勤勤恳恳”地执行指令:它已经执行完了指令 m,接着就要执行指令 m+1 了……不幸在这个时候发生了,线程收到了一个信号SIGURG,对应图中的

接着,内核会接管执行流,转而去执行预先设置好的信号处理器程序,对应到 Go 里,就是执行 sighandler,对应图中的

最后,执行流又交到线程手上,继续执行指令 m+1,对应图中的

这里其实涉及到了一些现场的保护和恢复,内核都帮我们搞定了,我们不用操心。

dosigPreempt

当线程收到 SIGURG 信号的时候,就会去执行 sighandler 函数,核心是 doSigPreempt 函数。

func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
    ...
    
    if sig == sigPreempt && debug.asyncpreemptoff == 0 {
  doSigPreempt(gp, c)
 }
 
 ...
}

doSigPreempt 这个函数其实很短,一会儿就执行完了。

func doSigPreempt(gp *g, ctxt *sigctxt) {
 ...
 if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
  // Adjust the PC and inject a call to asyncPreempt.
  ctxt.pushCall(funcPC(asyncPreempt), newpc)
 }
 ...
}

isAsyncSafePoint 函数会返回当前 goroutine 能否被抢占,以及从哪条指令开始抢占,返回的 newpc 表示安全的抢占地址。

接着,pushCall 调整了一下 SP,设置了几个寄存器的值就返回了。按理说,返回之后,就会接着执行指令 m+1 了,但那还怎么实现抢占呢?其实魔法都在 pushCall 这个函数里。

pushCall

在分析这个函数之前,我们需要先复习一下 Go 函数的调用规约,重点回顾一下 CALL 和 RET 指令就行了。

call 和 ret 指令

call 指令可以简单地理解为 push ip + JMP。这个 ip 其实就是返回地址,也就是调用完子函数接下来该执行啥指令的地址。所以 push ip 就是在 call 一个子函数之前,将返回地址压入栈中,然后 JMP 到子函数的地址执行。

ret 指令和 call 指令刚好相反,它将返回地址从栈上 pop 到 IP 寄存器,使得 CPU 从这个地址继续执行。

理解了 callret,我们再来分析 pushCall 函数:

func (c *sigctxt) pushCall(targetPC, resumePC uintptr) {
 // Make it look like we called target at resumePC.
 sp := uintptr(c.rsp())
 sp -= sys.PtrSize
 *(*uintptr)(unsafe.Pointer(sp)) = resumePC
 c.set_rsp(uint64(sp))
 c.set_rip(uint64(targetPC))
}

注意看这行注释:

// Make it look like we called target at resumePC.

它清晰地说明了这个函数的作用:让 CPU 误以为是 resumePC 调用了 targetPC。而这个 resumePC 就是上一步调用 isAsyncSafePoint 函数返回的 newpc,它代表我们抢占 goroutine 的指令地址。

前两行代码将 SP 下移了 8 个字节,并且把 resumePC 入栈(注意,它其实是一个返回地址),接着把 targetPC 设置到 ip 寄存器,sp 设置到 SP 寄存器。这使得从内核返回到用户态执行时,不是从指令 m+1,而是直接从 targetPC 开始执行,等到 targetPC 执行完,才会返回到 resumePC 继续执行。整个过程就像是 resumePC 调用了 targetPC 一样。而 targetPC 其实就是 funcPC(asyncPreempt),也就是抢占函数。

于是我们可以看到,信号处理器程序 sighandler 只是将一个异步抢占函数给“安插”进来了,而真正的抢占过程则是在 asyncPreempt 函数中完成

异步抢占

当执行完 sighandler,执行流再次回到线程。由于 sighandler 插入了一个 asyncPreempt 的函数调用,所以 goroutine 原本的任务就得不到推进,转而执行 asyncPreempt 去了:

asyncPreempt 调用链路

mcall(fn) 的作用是切到 g0 栈去执行函数 fn, fn 永不返回。在 mcall(gopreempt_m) 这里,fn 就是 gopreempt_m,gopreempt_m 直接调用 goschedImpl

mcall 函数

还记得 mcall 函数的作用吗?它会切到 g0 栈执行 gopreempt_m,自然它也会保存 goroutine 的执行进度,其实就是 SP、BP、PC 寄存器的值,当 goroutine 再次被调度执行时,就会从原来的执行流断点处继续执行下去。

goschedImpl 函数

最精彩的部分就在 goschedImpl 函数。它首先将 goroutine 的状态从 running 改成 runnable;接着调 dropg 将 g 和 m 解绑;然后调用 globrunqput 将 goroutine 丢到全局可运行队列,由于是全局可运行队列,所以需要加锁。最后,调用 schedule() 函数进入调度循环。

运行 schedule 函数用的是 g0 栈,它会去寻找其他可运行的 goroutine,包括从当前 P 本地可运行队列获取、从全局可运行队列获取、从其他 P 偷等方式找到下一个可运行的 goroutine 并执行。

至此,这个线程就转而去执行其他的 goroutine,当前的 goroutine 也就被抢占了。

被抢占的 goroutine 什么时候会再次得到执行

因为它已经被丢到全局可运行队列了,所以它的优先级就会降低,得到调度的机会也就降低,但总还是有机会再次执行的,并且它会从调用 mcall 的下一条指令接着执行。

总结

本文讲述了 Go 语言基于信号的异步抢占的全过程,一起来回顾下:

  1. M 注册一个 SIGURG 信号的处理函数:sighandler。
  2. 系统监控sysmon 线程检测到执行时间过长的 goroutine、以及GC stw 时,会向相应的 M(或者说线程,每个线程对应一个 M)发送 SIGURG 信号
  3. 收到信号后,内核执行 sighandler 函数,通过 pushCall 向当前goroutine的执行流插入 asyncPreempt 函数调用
  4. 回到当前 goroutine 执行 asyncPreempt 函数,通过 mcall 切到 g0 栈执行 gopreempt_m
  5. gopreempt_m会将当前 goroutine 从running切换到runable状态,并将其重新放入到全局可运行队列,空出来的M 则继续寻找其他 goroutine 来运行
  6. 被抢占的 goroutine 再次调度过来执行时,会执行现场恢复工作

Go 内存管理-内存分配器

1. Go内存分配设计原理

Go内存分配器的设计思想来源于TCMalloc,全称是Thread-Caching Malloc,核心思想是把内存分为多级管理,利用缓存的思想提升内存使用效率,降低锁的粒度。

图片来自链接,侵删!

如上图所示,是Go的内存管理模型示意图,在堆内存管理上分为三个内存级别:

  • 线程缓存(MCache):作为线程独立的内存池,与线程的第一交互内存,访问无需加锁;
  • 中心缓存(MCentral):作为线程缓存的下一级,是多个线程共享的,所以访问时需要加锁;
  • 页堆(MHeap):中心缓存的下一级,在遇到32KB以上的对象时,会直接选择页堆分配大内存,而当页堆内存不够时,则会通过系统调用向系统申请内存。

1.1 内存管理基本单元mspan

//go:notinheap
type mspan struct {
   next *mspan     // next span in list, or nil if none
   prev *mspan     // previous span in list, or nil if none
   list *mSpanList // For debugging. TODO: Remove.

   startAddr uintptr // address of first byte of span aka s.base()
   npages    uintptr // number of pages in span
   
   
   freeindex uintptr

   allocBits  *gcBits
   gcmarkBits *gcBits
   allocCache uint64
   ...
}

runtime.mspan是Go内存管理的基本单元,其结构体中包含的nextprev指针,分别指向前后的runtime.mspan,所以其串联后的结构是一个双向链表。

startAddr表示此mspan的起始地址,npages表示管理的页数,每页大小8KB,这个页不是操作系统的内存页,一般是操作系统内存页的整数倍。

其它字段:

  • freeindex — 扫描页中空闲对象的初始索引;
  • allocBits 和 gcmarkBits — 分别用于标记内存的占用和回收情况;
  • allocCache — allocBits 的补码,可以用于快速查找内存中未被使用的内存;

注意使用//go:notinheap标记次结构体mspan为非堆上类型,保证此类型对象不会逃逸到堆上。

图示:

跨度类

mspan中有一个字段spanclass,称为跨度类,是对mspan大小级别的划分,每个mspan能够存放指定范围大小的对象,32KB以内的小对象在Go中,会对应不同大小的内存刻度Size Class,Size Class和Object Size是一一对应的,前者指序号 0、1、2、3,后者指具体对象大小 0B、8B、16B、24B

//go:notinheap
type mspan struct {
   ...
   spanclass   spanClass     // size class and noscan (uint8)
   ...
}

Go 语言的内存管理模块中一共包含 67 种跨度类,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象,所有的数据都会被预先计算好并存储在runtime.class_to_sizeruntime.class_to_allocnpages等变量中:

classbytes/objbytes/spanobjectstail wastemax waste
1881921024087.50%
2168192512043.75%
3248192341029.24%
4328192256046.88%
54881921703231.52%
6648192128023.44%
78081921023219.07%
6732768327681012.50%

上表展示了对象大小从 8B 到 32KB,总共 67 种跨度类的大小、存储的对象数以及浪费的内存空间,以表中的第四个跨度类为例,跨度类为 5 的runtime.mspan中对象的大小上限为 48 字节、管理 1 个页、最多可以存储 170 个对象。因为内存需要按照页进行管理,所以在尾部会浪费 32 字节的内存,当页中存储的对象都是 33 字节时,最多会浪费 31.52% 的资源:

((48−33)∗170+32)/8192=0.31518((48−33)∗170+32)/8192=0.31518((48−33)∗170+32)/8192=0.31518

除了上述 67 个跨度类之外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象。

1.2 线程缓存(mcache)

runtime.mcache是Go语言中的线程缓存,它会与线程上的处理器1:1绑定,用于缓存用户程序申请的微小对象。每一个线程缓存都持有numSpanClasses个(68∗268*268∗2)个mspan,存储在mcachealloc字段中。

其图示如下:

图片来源于链接,侵删!

1.3 中心缓存(mcentral)

每个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个runtime.spanSet,分别存储包含空闲对象和不包含空闲对象的内存管理单元,访问中心缓存中的内存管理单元需要使用互斥锁。

//go:notinheap
type mcentral struct {
   spanclass spanClass
   partial [2]spanSet // list of spans with a free object
   full    [2]spanSet // list of spans with no free objects
}

如图上所示,是 runtime.mcentral 中的 spanSet 的内存结构,index 字段是一个uint64类型数字的地址,该uint64的数字按32位分为前后两半部分head和tail,向spanSet中插入和获取mspan有其提供的push和pop函数,以push函数为例,会根据index的head,对spanSetBlock数据块包含的mspan的个数512取商,得到spanSetBlock数据块所在的地址,然后head对512取余,得到要插入的mspan在该spanSetBlock数据块的具体地址。之所以是512,因为spanSet指向的spanSetBlock数据块是一个包含512个mspan的集合。

由全部spanClass规格的runtime.mcentral共同组成的缓存结构如下:

1.4 页堆(mheap)

//go:notinheap
type mheap struct {
   ...
   arenas [1 &lt;&lt; arenaL1Bits]*[1 &lt;&lt; arenaL2Bits]*heapArena
   ...
   central [numSpanClasses]struct {
      mcentral mcentral
      pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
   }
   ...
}

runtime.mheap是内存分配的核心结构体,其最重要的两个字段如上。

在Go中其被作为全局变量mheap_存储:

var mheap_ mheap

页堆中包含一个长度为numSpanClasses个(68∗268*268∗2)个的runtime.mcentral数组,其中 68 个为跨度类需要 scan 的中心缓存,另外的 68 个是 noscan (没有指针,无需扫描)的中心缓存。

arenas是heapArena的二维数组的集合。如下:

2. 内存分配/释放流程

堆上所有的对象内存分配都会通过runtime.newobject进行分配,运行时根据对象大小将它们分为微对象、小对象和大对象:

  • 微对象(0, 16B):先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;多个小于16B的无指针微对象的内存分配请求,会合并向Tiny微对象空间申请,微对象的 16B 内存空间从 spanClass 为 4 或 5(无GC扫描)的mspan中获取。
  • 小对象[16B, 32KB]:先向mcache申请,mcache内存空间不够时,向mcentral申请,mcentral不够,则向页堆mheap申请,再不够就向操作系统申请。
  • 大对象(32KB, +∞):大对象直接向页堆mheap申请。

对于内存的释放,遵循逐级释放的策略。当ThreadCache的缓存充足或者过多时,则会将内存退还给CentralCache。当CentralCache内存过多或者充足,则将低命中内存块退还PageHeap。

Go 内存管理-栈空间管理

1. 系统栈和Go栈

1.1 系统线程栈

如果我们在Linux中执行 pthread_create 系统调用,进程会启动一个新的线程,这个栈大小一般为系统的默认栈大小。

对于栈上的内存,程序员无法直接操作,由系统统一管理,一般的函数参数、局部变量(C语言)会存储在栈上。

1.2 Go栈

Go语言在用户空间实现了一套runtime的管理系统,其中就包括了对内存的管理,Go的内存也区分堆和栈,但是需要注意的是,Go栈内存其实是从系统堆中分配的内存,因为同样运行在用户态,Go的运行时也没有权限去直接操纵系统栈。

Go语言使用用户态协程goroutine作为执行的上下文,其使用的默认栈大小比线程栈高的多,其栈空间和栈结构也在早期几个版本中发生过一些变化:

  1. v1.0 ~ v1.1 — 最小栈内存空间为 4KB;
  2. v1.2 — 将最小栈内存提升到了 8KB;
  3. v1.3 — 使用连续栈替换之前版本的分段栈;
  4. v1.4 — 将最小栈内存降低到了 2KB;

2. 栈操作

在前面的《Golang调度器》系列我们也讲过,Go语言中的执行栈由runtime.stack,该结构体中只包含两段字段,分别表示栈的顶部和底部,每个栈结构体都在[lo, hi)的范围内:

type stack struct {
	lo uintptr
	hi uintptr
}

栈的结构虽然非常简单,但是想要理解 Goroutine 栈的实现原理,还是需要我们从编译期间和运行时两个阶段入手:

  1. 为了防止栈空间不足,编译器会在编译阶段会通过cmd/internal/obj/x86.stacksplit在调用函数前插入runtime.morestack或者runtime.morestack_noctxt函数;
  2. 运行时创建新的 Goroutine 时会在runtime.malg中调用runtime.stackalloc申请新的栈内存,并在编译器插入的runtime.morestack中检查栈空间是否充足;

当然,可以在函数头加上//go:nosplit跳过栈溢出检查。

2.1 栈初始化

栈空间运行时中包含两个重要的全局变量,分别是stackpoolstackLarge,这两个变量分别表示全局的栈缓存和大栈缓存,前者可以分配小于 32KB 的内存,后者用来分配大于 32KB 的栈空间

var stackpool [_NumStackOrders]struct {
   item stackpoolItem
   _    [cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize]byte
}

//go:notinheap
type stackpoolItem struct {
   mu   mutex
   span mSpanList
}

// Global pool of large stack spans.
var stackLarge struct {
   lock mutex
   free [heapAddrBits - pageShift]mSpanList // free lists by log_2(s.npages)
}

2.2 栈分配

我们在这里会按照栈的大小分两部分介绍运行时对栈空间的分配。在 Linux 上,_FixedStack = 2048_NumStackOrders = 4_StackCacheSize = 32768,也就是如果申请的栈空间小于 32KB,我们会在全局栈缓存池或者线程的栈缓存中初始化内存:

如果申请的内存空间过大,运行时会查看runtime.stackLarge中是否有剩余的空间,如果不存在剩余空间,它会从堆上申请新的内存。

2.3 栈扩容

在之前我们就提过,编译器会在cmd/internal/obj/x86.stacksplit中为函数调用插入runtime.morestack运行时检查,它会在几乎所有的函数调用之前检查当前 Goroutine 的栈内存是否充足,如果当前栈需要扩容,我们会保存一些栈的相关信息并调用runtime.newstack创建新的栈。

在此期间可能触发抢占。

接下来就是分配新的栈内存和栈拷贝,这里就不详细描述了。

2.4 栈缩容

runtime.shrinkstack栈缩容时调用的函数,该函数的实现原理非常简单,其中大部分都是检查是否满足缩容前置条件的代码,核心逻辑只有以下这几行:

func shrinkstack(gp *g) {
	...
	oldsize := gp.stack.hi - gp.stack.lo
	newsize := oldsize / 2
	if newsize &lt; _FixedStack {
		return
	}
	avail := gp.stack.hi - gp.stack.lo
	if used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= avail/4 {
		return
	}

	copystack(gp, newsize)
}

如果要触发栈的缩容,新栈的大小会是原始栈的一半,不过如果新栈的大小低于程序的最低限制2KB,那么缩容的过程就会停止。

运行时只会在栈内存使用不足1/4时进行缩容,缩容也会调用扩容时使用的runtime.copystack开辟新的栈空间

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 &amp;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("&amp;a:%v,  &amp;b:%v, &amp;c:%v, &amp;d:%v, &amp;f:%v, &amp;g:%v, &amp;h:%v, &amp;i: %v\n",
            &amp;s.a, &amp;s.b, &amp;s.c, &amp;s.d, &amp;s.f, &amp;s.g, &amp;s.h, &amp;s.i)
         return s
      }
      s := nest2()
      fmt.Println("------ nest1 ------")
      fmt.Printf("&amp;a:%v,  &amp;b:%v, &amp;c:%v, &amp;d:%v, &amp;f:%v, &amp;g:%v, &amp;h:%v, &amp;i: %v\n",
         &amp;s.a, &amp;s.b, &amp;s.c, &amp;s.d, &amp;s.f, &amp;s.g, &amp;s.h, &amp;s.i)
      return s
   }
   s := nest1()
   fmt.Println("------ main ------")
   fmt.Printf("&amp;a:%v,  &amp;b:%v, &amp;c:%v, &amp;d:%v, &amp;f:%v, &amp;g:%v, &amp;h:%v, &amp;i: %v\n",
      &amp;s.a, &amp;s.b, &amp;s.c, &amp;s.d, &amp;s.f, &amp;s.g, &amp;s.h, &amp;s.i)
}

func TestPointAddress(t *testing.T) {
   nest1 := func() *S {
      nest2 := func() *S {
         s := byPoint()
         fmt.Println("------ nest2 ------")
         fmt.Printf("&amp;a:%v,  &amp;b:%v, &amp;c:%v, &amp;d:%v, &amp;f:%v, &amp;g:%v, &amp;h:%v, &amp;i: %v\n",
            &amp;s.a, &amp;s.b, &amp;s.c, &amp;s.d, &amp;s.f, &amp;s.g, &amp;s.h, &amp;s.i)
         return s
      }
      s := nest2()
      fmt.Println("------ nest1 ------")
      fmt.Printf("&amp;a:%v,  &amp;b:%v, &amp;c:%v, &amp;d:%v, &amp;f:%v, &amp;g:%v, &amp;h:%v, &amp;i: %v\n",
         &amp;s.a, &amp;s.b, &amp;s.c, &amp;s.d, &amp;s.f, &amp;s.g, &amp;s.h, &amp;s.i)
      return s
   }
   s := nest1()
   fmt.Println("------ main ------")
   fmt.Printf("&amp;a:%v,  &amp;b:%v, &amp;c:%v, &amp;d:%v, &amp;f:%v, &amp;g:%v, &amp;h:%v, &amp;i: %v\n",
      &amp;s.a, &amp;s.b, &amp;s.c, &amp;s.d, &amp;s.f, &amp;s.g, &amp;s.h, &amp;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 &lt; 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 &lt; 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 &lt; b.N; i++ {
      for j := 0; j &lt; 1000000; j++  {
         s.value(s1)
      }
   }
}

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

   s = byPoint()
   s1 = byPoint()
   for i := 0; i &lt; b.N; i++ {
      for j := 0; j &lt; 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 &lt; b.N; i++ {
      for j := 0; j &lt; 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 &lt; b.N; i++ {
      for j := 0; j &lt; 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 &lt; 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 &lt; 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