:mannotop Golang – manno的博客

分类: Golang

Golang RNG审查通过的洗牌实现

Introduce:

SFMT.h: This is the header file, which defines the interface for the RNG.

SFMT.c: This file contains the source code that implements the random number generator algorithm.

SFMT-params.h and SFMT-params19937.h: These files provide parameters for the Mersenne Twister  algorithm.

SFMT-common.h: Provides common or supportive functions for the algorithm

Knuth.go is used to implement shuffling

UniformIntDistribution.go is used to implement scaling

Go语言涉及CGO的交叉编译(跨平台编译)解决办法

这几天发现使用了cgo的服务在本地和ci上编译通过打包的容器无法正常在Docker上运行

exec <pkg name>: no such file or directory

查阅资料发现要使用交叉编译[1]来实现跨平台部署。

在没有CGO调用的情况下,交叉编译只需带上三个参数便可以实现

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build

显然对于带CGO的交叉编译,CGO_ENABLED必须开启。这也就需要辅助编译器来帮我们实现交叉编译了。

CGO_ENABLED=1 GOOS=linux GOARCH=amd64 CC=gcc CGO_LDFLAGS="-static" go build main.go

CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags '-s -w --extldflags "-static -fpic"' main.go

CGO_ENABLED 这个参数为1开启CGO。

GOOS 和 GOARCH 用来指定要构建的平台为Linux

通过CC=gcc 来指定GCC编译器。而CGO_LDFLAGS=”-static”来指定CGO部分的编译为静态编译。

可选参数-ldflags 是编译选项:

  • -s -w 去掉调试信息,可以减小构建后文件体积,
  • --extldflags "-static -fpic" 完全静态编译[2],这样编译生成的文件就可以任意放到指定平台下运行,而不需要运行环境配置。

Go 调度器-网络轮询器

设计原理

网络轮询器不仅用于监控网络 I/O,还能用于监控文件的 I/O,它利用了操作系统提供的 I/O 多路复用模型来提升 I/O 设备的利用率以及程序的性能。本节会分别介绍常见的几种 I/O 模型以及 Go 语言运行时的网络轮询器如何使用多模块设计在不同的操作系统上支持多路复用。

I/O 模型

在神作《UNIX 网络编程》里,总结归纳了 5 种 I/O 模型,包括同步和异步 I/O:

  • 阻塞 I/O (Blocking I/O)
  • 非阻塞 I/O (Nonblocking I/O)
  • I/O 多路复用 (I/O multiplexing)
  • 信号驱动 I/O (Signal driven I/O)
  • 异步 I/O (Asynchronous I/O)

操作系统上的 I/O 是用户空间和内核空间的数据交互,因此 I/O 操作通常包含以下两个步骤:

  1. 等待网络数据到达网卡(读就绪)/等待网卡可写(写就绪) –> 读取/写入到内核缓冲区
  2. 从内核缓冲区复制数据 –> 用户空间(读)/从用户空间复制数据 -> 内核缓冲区(写)

即:读就绪->写就绪->加载数据到内核缓冲区->读->写

而判定一个 I/O 模型是同步还是异步,主要看第二步:数据在用户和内核空间之间复制的时候是不是会阻塞当前进程,如果会,则是同步 I/O,否则,就是异步 I/O。基于这个原则,这 5 种 I/O 模型中只有一种异步 I/O 模型:Asynchronous I/O,其余都是同步 I/O 模型。

这 5 种 I/O 模型的对比如下:

阻塞 I/O

阻塞 I/O 是最常见的 I/O 模型,在默认情况下,当我们通过 read 或者 write 等系统调用读写文件或者网络时,应用程序会被阻塞:

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t nbytes);

如下图所示,当我们执行 read 系统调用时,应用程序会从用户态陷入内核态,内核会检查文件描述符是否可读;当文件描述符中存在数据时,操作系统内核会将准备好的数据拷贝给应用程序并交回控制权。

blocking-io-mode

图 6-39 阻塞 I/O 模型

操作系统中多数的 I/O 操作都是如上所示的阻塞请求,一旦执行 I/O 操作,应用程序会陷入阻塞等待 I/O 操作的结束。

非阻塞 I/O

非阻塞 I/O,顾名思义就是:所有 I/O 操作都是立刻返回而不会阻塞当前用户进程。I/O 多路复用通常情况下需要和非阻塞 I/O 搭配使用,否则可能会产生意想不到的问题。比如,epoll 的 ET(边缘触发) 模式下,如果不使用非阻塞 I/O,有极大的概率会导致阻塞 event-loop 线程,从而降低吞吐量,甚至导致 bug。

Linux 下,我们可以通过 fcntl 系统调用来设置 O_NONBLOCK 标志位,从而把 socket 设置成 Non-blocking。当对一个 Non-blocking socket 执行读操作时,流程是这个样子:

当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 EAGAIN error。从用户进程角度讲 ,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,Non-blocking I/O 的特点是用户进程需要不断的主动询问 kernel 数据好了没有。 I/O 多路复用需要和 Non-blocking I/O 配合才能发挥出最大的威力

IO多路复用

所谓IO多路复用就是使用select/poll/epoll这一系列的多路选择器,实现一个线程监控多个文件句柄,一旦某个文件句柄就绪(ready),就能够通知到对应应用程序的读写操作;没有文件句柄就绪时,就会阻塞应用程序,从而释放CPU资源。I/O 复用其实复用的不是 I/O 连接,而是复用线程,让一个 thread of control 能够处理多个连接(I/O 事件)

select&poll

select实现多路复用的方式是,将需要监听的描述符都放到一个文件描述符集合,然后调用select函数将文件描述符集合拷贝到内核里,让内核来检查是否有事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此描述符标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的描述符,然后再对其处理。

所以,对于select方式,在时间上,需要进行两遍遍历,一次在内核态,一次在用户态,所以其时间复杂度是O(N);在空间上,会发生两次文件描述符集合的拷贝,一次由用户空间拷贝到内核空间,一次由内核空间拷贝到用户空间,但是由于select使用的是固定长度的bitsmap,默认最多1024个文件描述符。

综上,select 有如下的缺点:

  • 最大并发数限制:使用 32 个整数的 32 位bitsmap,即 32*32=1024 来标识 fd,虽然可修改,但是有以下第 2, 3 点的瓶颈
  • 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大
  • 性能衰减严重:每次 kernel 都需要线性扫描整个 fd_set,所以随着监控的描述符 fd 数量增长,其 I/O 性能会线性下降

除了标准的 select 之外,操作系统中还提供了一个比较相似的 poll 函数,它使用链表存储文件描述符,摆脱了 1024 的数量上限。

pollselect没有本质区别,只是不再使用bitsmap来存储关注的描述符,而是采用链表的方式存储,突破了文件描述符的个数限制,但是同样需要线性扫描整个链表。随着文件描述符的个数增加,其O(N)的时间复杂度也会使得效率越来越低下。

io-multiplexing

图 6-41 I/O 多路复用函数监听文件描述符

epoll

int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); // 将所有需要监听的socket添加到epfd中

while(1) {
    int n = epoll_wait(...);
    for(events){
        // 处理逻辑
    }
}

以上是一个很经典的epoll使用逻辑:先用epoll_create创建一个epfd对象,然后通过epoll_ctl将需要监控的文件描述符放到epfd中,最后调用epoll_wait等待数据。

相比于selectpollepoll很好地解决了前二者在时间和空间上效率问题:

  1. epoll在内核中使用红黑树跟踪文件描述符集合,大大缩减了时间,相比于select/poll遍历集合的 O(N) 的时间复杂度,红黑树的时间复杂度是 O(logN)
  2. epoll使用事件驱动的机制(前缀e应该就是event的首字母),当某个文件描述符有消息时,当用户调用epoll_wait函数时,只会返回有事件发生的文件描述符的个数,因此 epoll 的 I/O 性能不会像 select&poll 那样随着监听的 fd 数量增加而出现线性衰减,是一个非常高效的 I/O 事件驱动技术

由于使用 epoll 的 I/O 多路复用需要用户进程自己负责 I/O 读写,从用户进程的角度看,读写过程是阻塞的,所以 select&poll&epoll 本质上都是同步 I/O 模型

多路复用 in GO

GO提供了网络轮询器在不同平台的多种实现

Go 语言在网络轮询器中使用 I/O 多路复用模型处理 I/O 操作,但是他没有选择最常见的系统调用 select2。虽然 select 也可以提供 I/O 多路复用的能力,但是使用它有比较多的限制:

  • 监听能力有限 — 最多只能监听 1024 个文件描述符;
  • 内存拷贝开销大 — 需要维护一个较大的数据结构存储文件描述符,该结构需要拷贝到内核中;
  • 时间复杂度 O(n) — 返回准备就绪的事件个数后,需要遍历所有的文件描述符;

为了提高 I/O 多路复用的性能,不同的操作系统也都实现了自己的 I/O 多路复用函数,例如:epollkqueue 和 evport 等。Go 语言为了提高在不同操作系统上的 I/O 操作性能,使用平台特定的函数实现了多个版本的网络轮询模块

这些模块在不同平台上实现了相同的功能,构成了一个常见的树形结构。编译器在编译 Go 语言程序时,会根据目标平台选择树中特定的分支进行编译:

netpoll-modules

图 6-43 多模块网络轮询器

如果目标平台是 Linux,那么就会根据文件中的 // +build linux 编译指令选择 src/runtime/netpoll_epoll.go 并使用 epoll 函数处理用户的 I/O 操作。

在Linux环境上,Go中的网络轮询器的底层是基于epoll实现的,所以其根本不支持磁盘IO的多路复用,任何磁盘IO的操作都会陷入内核调用

Go netpoller

Go netpoller 通过在底层对 epoll/kqueue/iocp 的封装,从而实现了使用同步编程模式达到异步执行的效果。

总结来说,所有的网络操作都以网络描述符 netFD 为中心实现。netFD 与底层 PollDesc 结构绑定,当在一个 netFD 上读写遇到 EAGAIN 错误时,就将当前 goroutine 存储到这个 netFD 对应的 PollDesc 中,同时调用 gopark 把当前 goroutine 给 park 住,直到这个 netFD 上再次发生读写事件,才将此 goroutine 给 ready 激活重新运行。

显然,在底层通知 被挂起的goroutine 再次发生读写等事件的方式就是 epoll/kqueue/iocp 等事件驱动机制。

client 连接 server 的时候,listener 通过 accept 调用接收新 connection,每一个新 connection 都启动一个 goroutine 处理,accept 调用会把该 connection 的 fd 连带所在的 goroutine 上下文信息封装注册到 epoll 的监听列表里去,当 goroutine 调用 conn.Read 或者 conn.Write 等需要阻塞等待的函数时,会被 gopark 给封存起来并使之休眠,让 P 去执行本地调度队列里的下一个可执行的 goroutine,往后 Go scheduler 会在循环调度的 runtime.schedule() 函数以及 sysmon 监控线程中调用 runtime.netpoll 唤醒的可运行的 goroutine 列表并通过调用 injectglist 把剩下的 g 放入全局调度队列或者当前 P 本地调度队列去重新执行。

当 I/O 事件发生之后,runtime.netpoll 会唤醒那些在 I/O wait 的 goroutine 。

runtime.netpoll 的核心逻辑是:

根据调用方的入参 delay,设置对应的调用 epollwait 的 timeout 值;
调用 epollwait 等待发生了可读/可写事件的 fd;
循环 epollwait 返回的事件列表,处理对应的事件类型, 组装可运行的 goroutine 链表并返回。

Go netpoller的价值

Go netpoller I/O 多路复用搭配 Non-blocking I/O 而打造出来的这个原生网络模型,它最大的价值是把网络 I/O 的控制权牢牢掌握在 Go 自己的 runtime 里。非阻塞 I/O 避免了让操作网络 I/O 的 goroutine 陷入到系统调用从而进入内核态,因为一旦进入内核态,整个程序的控制权就会发生转移(到内核),不再属于用户进程了,那么也就无法借助于 Go 强大的 runtime scheduler 来调度业务程序的并发了。

而有了 netpoll 之后,借助于非阻塞 I/O ,G 就再也不会因为系统调用的读写而 (长时间) 陷入内核态,当 G 被阻塞在某个 network I/O 操作上时,实际上它不是因为陷入内核态被阻塞住了,而是被 Go runtime 主动调用 gopark 给 park 住了,此时 G 会被放置到某个 wait queue 中,而 M 会尝试运行下一个 _Grunnable 的 G,如果此时没有 _Grunnable 的 G 供 M 运行,那么 M 将解绑 P,并进入 sleep 状态。当 I/O available,在 epoll 的 eventpoll.rdr 中等待的 G 会被放到 eventpoll.rdllist 链表里并通过 netpoll 中的 epoll_wait 系统调用返回放置到全局调度队列或者 P 的本地调度队列,标记为 _Grunnable ,等待 P 绑定 M 恢复执行。

Go 调度器-系统监控sysmon

在支持多任务的操作系统中,守护进程是在后台运行的计算机程序,它不会由用户直接操作,它一般会在操作系统启动时自动运行。Kubernetes 的 DaemonSet 和 Go 语言的系统监控都使用类似设计提供一些通用的功能:

golang-system-monitor

main goroutine创建的过程中,runtime会新建一个不在调度中的线程,执行sysmon任务,这个任务也称为系统监控任务,接下来我们就看看这个任务究竟做了什么。

sysmon

func sysmon() {
	sched.nmsys++
	checkdead()

	lasttrace := int64(0)
	idle := 0
	delay := uint32(0)
	for {
		if idle == 0 {
			delay = 20
		} else if idle > 50 {
			delay *= 2
		}
		if delay > 10*1000 {
			delay = 10 * 1000
		}
		usleep(delay)
		...
	}
}

当runtime刚刚调用上述函数时,会先通过 runtime.checkdead 检查是否存在死锁,然后进入核心的监控循环;系统监控在每次循环开始时都会通过 usleep 挂起当前线程,该函数的参数是微秒,运行时会遵循以下的规则决定休眠时间:

  • 初始的休眠时间是 20μs;
  • 最长的休眠时间是 10ms;
  • 当系统监控在 50 个循环中都没有唤醒 Goroutine 时,休眠时间在每个循环都会倍增;

当程序趋于稳定之后,系统监控的触发时间就会稳定在 10ms。它除了会检查死锁之外,还会在循环中完成以下的工作:

  • 检查死锁 — 根据协程状态判断是否退出程序;
  • 运行计时器 — 获取下一个需要被触发的计时器;
  • 轮询网络 — 获取需要处理的到期文件描述符;
  • 抢占处理器 — 抢占运行时间较长的或者处于系统调用的 Goroutine;
  • 垃圾回收 — 在满足条件时触发垃圾收集回收内存;

我们在这一节中会依次介绍系统监控是如何完成上述几种不同工作的。

检查死锁(保证死锁时程序及时退出)

系统监控通过 runtime.checkdead 检查运行时是否发生了死锁,我们可以将检查死锁的过程分成以下三个步骤:

  1. 检查正在运行的线程数量(=0进入下一步);
  2. 检查是否存在正在运行的 Goroutine(不存在进入下一步);
  3. 检查处理器上是否存在计时器(不存在进入下一步);
  4. 报错并退出程序。

该函数首先会检查 Go 语言运行时中正在运行的线程数量,我们通过调度器中的多个字段计算该值的结果:

func checkdead() {
	var run0 int32
	run := mcount() - sched.nmidle - sched.nmidlelocked - sched.nmsys
	if run > run0 {
		return
	}
	if run < 0 {
		print("runtime: checkdead: nmidle=", sched.nmidle, " nmidlelocked=", sched.nmidlelocked, " mcount=", mcount(), " nmsys=", sched.nmsys, "\n")
		throw("checkdead: inconsistent counts")
	}
	...
}
  1. runtime.mcount 根据下一个待创建的线程 id 和释放的线程数得到系统中存在的线程数;
  2. nmidle 是处于空闲状态的线程数量;
  3. nmidlelocked 是处于锁定状态的线程数量;
  4. nmsys 是处于系统调用的线程数量;

利用上述几个线程相关数据,我们可以得到正在运行的线程数,如果线程数量大于 0,说明当前程序不存在死锁;如果线程数小于 0,说明当前程序的状态不一致;如果线程数等于 0,我们需要进一步检查程序的运行状态:

func checkdead() {
	...
	grunning := 0
	for i := 0; i < len(allgs); i++ {
		gp := allgs[i]
		if isSystemGoroutine(gp, false) {
			continue
		}
		s := readgstatus(gp)
		switch s &^ _Gscan {
		case _Gwaiting, _Gpreempted:
			grunning++
		case _Grunnable, _Grunning, _Gsyscall:
			print("runtime: checkdead: find g ", gp.goid, " in status ", s, "\n")
			throw("checkdead: runnable g")
		}
	}
	unlock(&allglock)
	if grunning == 0 {
		throw("no goroutines (main called runtime.Goexit) - deadlock!")
	}
	...
}
  1. 当存在 Goroutine 处于 _Grunnable_Grunning 和 _Gsyscall 状态时,意味着程序发生了死锁;
  2. 当所有的 Goroutine 都处于 _Gidle_Gdead 和 _Gcopystack 状态时,意味着主程序调用了 runtime.goexit

当运行时存在等待的 Goroutine 并且不存在正在运行的 Goroutine 时,我们会检查处理器中存在的计时器:

func checkdead() {
	...
	for _, _p_ := range allp {
		if len(_p_.timers) > 0 {
			return
		}
	}

	throw("all goroutines are asleep - deadlock!")
}

如果处理器中存在等待的计时器,则所有的 Goroutine 陷入休眠状态是合理的,如果不存在等待的计时器,运行时会直接报错并退出程序。

运行计时器(计算下一次唤醒系统监控的时间)

在系统监控的循环中,我们通过 runtime.nanotime 和 runtime.timeSleepUntil 获取当前时间和计时器下一次需要唤醒的时间;当前调度器需要执行垃圾回收或者所有处理器都处于闲置状态时,如果没有需要触发的计时器,那么系统监控可以暂时陷入休眠。

休眠的时间会依据强制 GC 的周期 forcegcperiod 和计时器下次触发的时间确定,runtime.notesleep 会使用信号量同步系统监控即将进入休眠的状态。当系统监控被唤醒之后,我们会重新计算当前时间和下一个计时器需要触发的时间、调用 runtime.noteclear 通知系统监控被唤醒并重置休眠的间隔。

如果在这之后,我们发现下一个计时器需要触发的时间小于当前时间,这也说明所有的线程可能正在忙于运行 Goroutine,系统监控会启动新的线程来触发计时器,避免计时器的到期时间有较大的偏差。

轮询网络(及时将处于就绪状态的 Goroutine 加入全局运行队列)

系统监控会在findrunnable中进行网络轮询器中epollwait的轮询,但是如果上一次轮询网络已经过去了 10ms,那么系统监控还会在循环中轮询网络,检查是否有待执行的文件描述符:

func sysmon() {
	...
	for {
		...
		lastpoll := int64(atomic.Load64(&sched.lastpoll))
		if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
			atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
			list := netpoll(0)
			if !list.empty() {
				incidlelocked(-1)
				injectglist(&list)
				incidlelocked(1)
			}
		}
		...
	}
}

上述函数会非阻塞地调用 runtime.netpoll 检查待执行的文件描述符并通过 runtime.injectglist 将所有处于就绪状态的 Goroutine 加入全局运行队列(因为sysmon没有绑定的P)中:

func injectglist(glist *gList) {
	if glist.empty() {
		return
	}
	lock(&sched.lock)
	var n int
	for n = 0; !glist.empty(); n++ {
		gp := glist.pop()
		casgstatus(gp, _Gwaiting, _Grunnable)
		globrunqput(gp)
	}
	unlock(&sched.lock)
	for ; n != 0 && sched.npidle != 0; n-- {
		startm(nil, false)
	}
	*glist = gList{}
}

该函数会将所有 Goroutine 的状态从 _Gwaiting 切换至 _Grunnable 并加入全局运行队列等待运行,如果当前程序中存在空闲的处理器,会通过 runtime.startm 启动线程来执行这些任务。

抢占处理器(保证协程调度公平、防止饥饿)

系统监控会在循环中调用 runtime.retake 抢占处于运行或者系统调用中的处理器,该函数会遍历运行时的全局处理器,每个处理器都存储了一个 runtime.sysmontick

type sysmontick struct {
	schedtick   uint32
	schedwhen   int64
	syscalltick uint32
	syscallwhen int64
}

该结构体中的四个字段分别存储了处理器的调度次数、处理器上次调度时间、系统调用的次数以及系统调用的时间。runtime.retake 的循环包含了两种不同的抢占逻辑:

func retake(now int64) uint32 {
	n := 0
	for i := 0; i < len(allp); i++ {
		_p_ := allp[i]
		pd := &_p_.sysmontick
		s := _p_.status
		if s == _Prunning || s == _Psyscall {
			t := int64(_p_.schedtick)
			if pd.schedwhen+forcePreemptNS <= now {
				preemptone(_p_)
			}
		}

		if s == _Psyscall {
			if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
				continue
			}
			if atomic.Cas(&_p_.status, s, _Pidle) {
				n++
				_p_.syscalltick++
				handoffp(_p_)
			}
		}
	}
	return uint32(n)
}
  1. 当处理器处于 _Prunning 或者 _Psyscall 状态时,如果上一次触发调度的时间已经过去了 10ms,我们会通过 runtime.preemptone 抢占当前处理器;
  2. 当处理器处于 _Psyscall 状态时,在满足以下两种情况下会调用 runtime.handoffp 让出处理器的使用权:
    1. 当处理器的运行队列不为空或者不存在空闲处理器时2
    2. 当系统调用时间超过了 10ms 时3

系统监控通过在循环中抢占处理器来避免同一个 Goroutine 占用线程太长时间造成饥饿问题。

垃圾回收

在最后,系统监控还会决定是否需要触发强制垃圾回收,runtime.sysmon 会构建 runtime.gcTrigger 并调用 runtime.gcTrigger.test 方法判断是否需要触发垃圾回收:

func sysmon() {
	...
	for {
		...
		if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
			lock(&forcegc.lock)
			forcegc.idle = 0
			var list gList
			list.push(forcegc.g)
			injectglist(&list)
			unlock(&forcegc.lock)
		}
		...
	}
}

如果需要触发垃圾回收,我们会将用于垃圾回收的 Goroutine 加入全局队列,让调度器选择合适的处理器去执行。

小结

运行时通过系统监控来触发线程的抢占、网络的轮询和垃圾回收,保证Go语言运行时的可用性。系统监控能够很好地解决尾延迟的问题,减少调度器调度 Goroutine 的饥饿问题并保证计时器在尽可能准确的时间触发。

几个比官方库效率更高的开源库

本文列出这几个库并不是让你去立刻替换官方库。例如net/http包,实际上它已经可以满足大多数使用场景。

在使用官方库时遇到了问题,我们很容易通过搜索引擎找到解决方案,或者直接向 Go 官方提 issue 。当切换为开源库时,如果遇到了问题,并不一定能及时得到处理。

官方库的 API 几乎可以保证能与 Go 版本的迭代一直兼容,而三方库可能存在潜在的版本兼容问题,这也是切换时需要考虑的问题。

本文列出来的几个开源库,它们的重点都是优化对应官方库的性能问题。我们可以从这些开源库中,学到很多实用的 Go 代码优化技巧。

当然,如果你的项目中因为这些官方库而导致了性能问题,不妨一试。

net/http -> fasthttp

地址:github.com/valyala/fas…

fasthttp号称比net/http快十倍,其优化的核心思路很简单:资源复用。

  • 复用 goroutine,减轻 runtime 调度压力;
  • 对象复用,大量使用 sync.Pool 减轻 GC 压力。

除了复用,还有其他的一些优化手段,例如尽量避免 string 与 []byte 的转换开销等。

这些优化技巧和最佳实践,在其 Github 主页上已经贴心给出:_github.com/valyala/fas…

因为fasthttp的实现与标准库差距较大,所以它与net/http的 API 接口是不同的,这导致从net/http重构为fasthttp需要一些学习成本。

使用fasthttp的知名项目:Fiber、Gearbox、atreugo 等。

encoding/json -> jsoniter

地址:github.com/json-iterat…

jsoniter(json-iterator)是一款快且灵活的 JSON 解析器,同时提供 Java 和 Go 两个版本。官方称 Golang 版本可以比标准库(encoding/json)快 6 倍之多。

image.png 最重要的是,它与标准库encoding/json完全兼容。

  • Marshal()
# encoding/json 
import "encoding/json"json.Marshal(&data)

# jsoniter
import jsoniter "github.com/json-iterator/go"
var json = jsoniter.ConfigCompatibleWithStandardLibrary
json.Marshal(&data)
  • Unmarshal()
# encoding/json
import "encoding/json"
json.Unmarshal(input, &amp;data)

# jsoniter
import jsoniter "github.com/json-iterator/go"
var json = jsoniter.ConfigCompatibleWithStandardLibrary
json.Unmarshal(input, &amp;data)

对其优化原理感兴趣的读者可以看这里:jsoniter.com/benchmark.h…

golang/protobuf -> gogo/protobuf

地址:github.com/gogo/protob…

ProtoBuf 的全称是 Protocol Buffers,它是由 Google 开发和定义的与 XML、JSON 类似的一种协议格式,用于高效存储与读取结构化数据。它基于二进制,因此使用 ProtoBuf 能将数据压缩得更小。

gogo/protobuf是基于官方库golang/protobuf的增强版实现:

  • golang/protobuf更快地序列化与反序列化;
  • 更规范的 Go 结构;
  • 兼容golang/protobuf
  • 可选地生成额外的帮助代码,减少代码输入;
  • 可以生成测试代码和 benchmark 代码;
  • 其他序列化格式;

有很多知名项目都在使用该库,例如 etcd、k8s、docker swarmkit、tidb、nakama 等。

html/template -> valyala/quicktemplate

地址:github.com/valyala/qui…

quicktemplate启发自 Python 的 Mako 项目,是一个快速、强大且易于使用的 Go 模板渲染引擎,它的主要特性如下:

  • quicktemplate会先将编写的模板代码转换为 Go 语言代码,再进行编译渲染。因此,它比标准库html/template快 20 倍以上。
  • quicktemplate的语法与 Go 语法非常类似,几乎没有学习成本。
  • 几乎所有的 bug 都能在模板编译时被捕获,因此在实际项目中,很少会有受模板相关的bug影响。
  • 模板中可以嵌入任意 Go 代码。

虽然quicktemplate的主要目的是生成 HTML,但它也可用于生成其他数据。

例如,使用quicktemplate可以轻松实现 JSON 和 XML 序列化,并且通过quicktemplate的序列化通常也会比通过标准库encoding/jsonencoding/xml更快。

Go 内存管理-内存逃逸分析

学习过C语言的都知道,在C栈区域会存放函数的参数、局部变量等,而这些局部变量的地址是不能返回的,除非是局部静态变量地址,字符串常量地址或者动态分配的地址,因为程序调用完函数后,局部变量会随着此函数的栈帧一起被释放。而对于程序员主动申请的内存则存储在堆上,需要使用malloc等函数进行申请,同时也需要使用free等函数释放,由程序员进行管理,而申请内存后如果没有释放,就有可能造成内存泄漏。

但是在Go中,程序员根本无需感知数据是在栈(Go栈)上,还是在堆上,因为编译器会帮你承担这一切,将内存分配到栈或者堆上。在编译器优化中,逃逸分析是用来决定指针动态作用域的方法。Go语言的编译器使用逃逸分析决定哪些变量应该分配在栈上,哪些变量应该分配在堆上,包括使用newmake和字面量等方式隐式分配的内存,Go语言逃逸分析遵循以下两个不变性:

  1. 指向栈对象的指针不能存在于堆中;
  2. 指向栈对象的指针不能在栈对象回收后存活;

逃逸分析是在编译阶段进行的,可以通过go build -gcflags="-m -m -l"命令查到逃逸分析的结果,最多可以提供4个-m, m 越多则表示分析的程度越详细,一般情况下我们可以采用两个-m分析。使用-l禁用掉内联优化,只关注逃逸优化即可。

1. 几种逃逸分析

1.1 函数返回局部变量的指针

package main

func Add(x, y int) *int {
   res := 0
   res = x + y
   return &res
}

func main() {
   Add(1, 2)
}

逃逸分析结果如下:

$ go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
./main.go:4:2: res escapes to heap:
./main.go:4:2:   flow: ~r2 = &res:
./main.go:4:2:     from &res (address-of) at ./main.go:6:9
./main.go:4:2:     from return &res (return) at ./main.go:6:2
./main.go:4:2: moved to heap: res
note: module requires Go 1.18

分析结果很明显,函数返回的局部变量是一个指针变量,当函数Add执行结束后,对应的栈帧就会被销毁,引用返回到函数之外,如果在外部解引用这个地址,就会导致程序访问非法内存,所以编译器会经过逃逸分析后在堆上分配内存。

1.2 interface(any)类型逃逸

package main

import (
   "fmt"
)

func main() {
   str := "hello world"
   fmt.Printf("%v\n", str)
}

逃逸分析如下:

$ go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
./main.go:9:13: str escapes to heap:
./main.go:9:13:   flow: {storage for ... argument} = &{storage for str}:
./main.go:9:13:     from str (spill) at ./main.go:9:13
./main.go:9:13:     from ... argument (slice-literal-element) at ./main.go:9:12
./main.go:9:13:   flow: {heap} = {storage for ... argument}:
./main.go:9:13:     from ... argument (spill) at ./main.go:9:12
./main.go:9:13:     from fmt.Printf("%v\n", ... argument...) (call parameter) at ./main.go:9:12
./main.go:9:12: ... argument does not escape
./main.go:9:13: str escapes to heap

通过这个分析你可能会认为str escapes to heap表示这个str逃逸到了堆,但是却没有上一节中返回值中明确写上moved to heap: res,那实际上str是否真的逃逸到了堆上呢?

escapes to heap vs moved to heap

我们可以写如下代码试试:

package main

import "fmt"

func main() {
   str := "hello world"
   str1 := "nihao!"
   fmt.Printf("%s\n", str)
   println(&str)
   println(&str1)
}

其逃逸分析和上面的没有区别:

$ go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
./main.go:8:13: str escapes to heap:
./main.go:8:13:   flow: {storage for ... argument} = &{storage for str}:
./main.go:8:13:     from str (spill) at ./main.go:8:13
./main.go:8:13:     from ... argument (slice-literal-element) at ./main.go:8:12
./main.go:8:13:   flow: {heap} = {storage for ... argument}:
./main.go:8:13:     from ... argument (spill) at ./main.go:8:12
./main.go:8:13:     from fmt.Printf("%s\n", ... argument...) (call parameter) at ./main.go:8:12
./main.go:8:12: ... argument does not escape
./main.go:8:13: str escapes to heap
note: module requires Go 1.18

但是,str1str二者的地址却是明显相邻的,那是怎么回事呢?

$ go run main.go
hello world
0xc00009af50
0xc00009af40

如果我们将上述代码的第8行fmt.Printf("%s\n", str)改为fmt.Printf("%p\n", &str),则逃逸分析如下,发现多了一行moved to heap: str

$ go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
./main.go:6:2: str escapes to heap:
./main.go:6:2:   flow: {storage for ... argument} = &str:
./main.go:6:2:     from &str (address-of) at ./main.go:8:21
./main.go:6:2:     from &str (interface-converted) at ./main.go:8:21
./main.go:6:2:     from ... argument (slice-literal-element) at ./main.go:8:12
./main.go:6:2:   flow: {heap} = {storage for ... argument}:
./main.go:6:2:     from ... argument (spill) at ./main.go:8:12
./main.go:6:2:     from fmt.Printf("%p\n", ... argument...) (call parameter) at ./main.go:8:12
./main.go:6:2: moved to heap: str
./main.go:8:12: ... argument does not escape
note: module requires Go 1.18

再看运行结果,发现看起来str的地址看起来像逃逸到了堆,毕竟和str1的地址明显不同:

$ go run main.go
0xc00010a210
0xc00010a210
0xc000106f50

参考如下解释

When the escape analysis says “b escapes to heap”, it means that the values in b are written to the heap. So anything referenced by b must be in the heap also. b itself need not be.

翻译过来大意是:当逃逸分析输出“b escapes to heap”时,意思是指存储在b中的值逃逸到堆上了,即任何被b引用的对象必须分配在堆上,而b自身则不需要;如果b自身也逃逸到堆上,那么逃逸分析会输出“&b escapes to heap”。

由于字符串本身是存储在只读存储区,我们使用切片更能表现以上的特性。

切片指针和底层数组无逃逸
package main

import (
   "reflect"
   "unsafe"
)

func main() {
   var i int
   i = 10
   println("&i", &i)
   b := []int{1, 2, 3, 4, 5}
   println("&b", &b) // b这个对象的地址
   println("b", unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&b)).Data)) // b的底层数组地址
}


逃逸分析是:

$ go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
./main.go:12:12: []int{...} does not escape
note: module requires Go 1.18

打印结果:

$ go run main.go
&i 0xc00009af20
&b 0xc00009af58
b 0xc00009af28

可以看到,以上分析无逃逸,且&i b &b地址连续,可以明显看到都在栈中。

切片底层数组逃逸

我们新增一个fmt包的打印:

package main

import (
   "fmt"
   "reflect"
   "unsafe"
)

func main() {
   var i int
   i = 10
   println("&i", &i)
   b := []int{1, 2, 3, 4, 5}
   println("&b", &b) // b这个对象的地址
   println("b", unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&b)).Data)) // b的底层数组地址
   fmt.Println(b) // 多加了这行
}

逃逸分析如下:

$ go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
./main.go:16:13: b escapes to heap:
./main.go:16:13:   flow: {storage for ... argument} = &{storage for b}:
./main.go:16:13:     from b (spill) at ./main.go:16:13
./main.go:16:13:     from ... argument (slice-literal-element) at ./main.go:16:13
./main.go:16:13:   flow: {heap} = {storage for ... argument}:
./main.go:16:13:     from ... argument (spill) at ./main.go:16:13
./main.go:16:13:     from fmt.Println(... argument...) (call parameter) at ./main.go:16:13
./main.go:13:12: []int{...} escapes to heap:
./main.go:13:12:   flow: b = &{storage for []int{...}}:
./main.go:13:12:     from []int{...} (spill) at ./main.go:13:12
./main.go:13:12:     from b := []int{...} (assign) at ./main.go:13:4
./main.go:13:12:   flow: {storage for b} = b:
./main.go:13:12:     from b (interface-converted) at ./main.go:16:13
./main.go:13:12: []int{...} escapes to heap
./main.go:16:13: ... argument does not escape
./main.go:16:13: b escapes to heap
note: module requires Go 1.18

可以发现,出现了b escapes to heap,然后查看打印:

$ go run main.go
&i 0xc000106f38
&b 0xc000106f58
b 0xc000120030
[1 2 3 4 5]

可以发现,b的底层数组发生了逃逸,但是b本身还是在栈中。

切片指针和底层数组都发生逃逸
package main

import (
   "fmt"
   "reflect"
   "unsafe"
)

func main() {
   var i int
   i = 10
   println("&i", &i)
   b := []int{1, 2, 3, 4, 5}
   println("&b", &b) // b这个对象的地址
   println("b", unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&b)).Data)) // b的底层数组地址
   fmt.Println(&b) // 修改这行
}

如上,将fmt.Println(b)改为fmt.Println(&b),逃逸分析如下:

$ go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
./main.go:13:2: b escapes to heap:
./main.go:13:2:   flow: {storage for ... argument} = &b:
./main.go:13:2:     from &b (address-of) at ./main.go:16:14
./main.go:13:2:     from &b (interface-converted) at ./main.go:16:14
./main.go:13:2:     from ... argument (slice-literal-element) at ./main.go:16:13
./main.go:13:2:   flow: {heap} = {storage for ... argument}:
./main.go:13:2:     from ... argument (spill) at ./main.go:16:13
./main.go:13:2:     from fmt.Println(... argument...) (call parameter) at ./main.go:16:13
./main.go:13:12: []int{...} escapes to heap:
./main.go:13:12:   flow: b = &{storage for []int{...}}:
./main.go:13:12:     from []int{...} (spill) at ./main.go:13:12
./main.go:13:12:     from b := []int{...} (assign) at ./main.go:13:4
./main.go:13:2: moved to heap: b
./main.go:13:12: []int{...} escapes to heap
./main.go:16:13: ... argument does not escape
note: module requires Go 1.18

发现多了moved to heap: b这行,然后看地址打印:

$ go run main.go
&i 0xc00006af48
&b 0xc00000c030
b 0xc00001a150
&[1 2 3 4 5]

发现不仅底层数组发生了逃逸,连b这个对象本身也发生了逃逸。

所以可以总结下来就是:

  • escapes to heap:表示这个对象里面的指针对象逃逸到堆中;
  • moved to heap:表示对象本身逃逸到堆中,根据指向栈对象的指针不能存在于堆中这一准则,该对象里面的指针对象特必然逃逸到堆中。

1.3 申请栈空间过大

package main

import (
   "reflect"
   "unsafe"
)

func main() {
   var i int
   i = 10
   println("&i", &i)

   b := make([]int, 0)
   println("&b", &b) // b这个对象的地址
   println("b", unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&b)).Data))

   b1 := make([]byte, 65536)
   println("&b1", &b1) // b1这个对象的地址
   println("b1", unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&b1)).Data))

   var a [1024*1024*10]byte
   _ = a
}

可以发现逃逸分析显示没有发生逃逸:

$ go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
./main.go:13:11: make([]int, 0) does not escape
./main.go:17:12: make([]byte, 65536) does not escape
note: module requires Go 1.18

如果将切片和数组的长度都增加1,则会发生逃逸。
逃逸分析:
$ go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
./main.go:21:6: a escapes to heap:
./main.go:21:6:   flow: {heap} = &a:
./main.go:21:6:     from a (too large for stack) at ./main.go:21:6
./main.go:17:12: make([]byte, 65537) escapes to heap:
./main.go:17:12:   flow: {heap} = &{storage for make([]byte, 65537)}:
./main.go:17:12:     from make([]byte, 65537) (too large for stack) at ./main.go:17:12
./main.go:21:6: moved to heap: a
./main.go:13:11: make([]int, 0) does not escape
./main.go:17:12: make([]byte, 65537) escapes to heap
note: module requires Go 1.18

可以发现切片类型的逃逸阈值是65536 = 64KB,数组类型的逃逸阈值是1024*1024*10 = 10MB,超过这个数值就会发生逃逸。

1.4 闭包逃逸

package main

func intSeq() func() int {
   i := 0
   return func() int {
      i++
      return i
   }
}

func main() {
    a := intSeq()
    println(a())
    println(a())
    println(a())
    println(a())
    println(a())
    println(a())
}

逃逸分析如下,可以发现闭包中的局部变量i发生了逃逸。

$ go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
./main.go:4:2: intSeq capturing by ref: i (addr=false assign=true width=8)
./main.go:5:9: func literal escapes to heap:
./main.go:5:9:   flow: ~r0 = &{storage for func literal}:
./main.go:5:9:     from func literal (spill) at ./main.go:5:9
./main.go:5:9:     from return func literal (return) at ./main.go:5:2
./main.go:4:2: i escapes to heap:
./main.go:4:2:   flow: {storage for func literal} = &i:
./main.go:4:2:     from i (captured by a closure) at ./main.go:6:3
./main.go:4:2:     from i (reference) at ./main.go:6:3
./main.go:4:2: moved to heap: i
./main.go:5:9: func literal escapes to heap
note: module requires Go 1.18

因为函数也是一个指针类型,所以匿名函数当作返回值时也发生了逃逸,在匿名函数中使用外部变量i,这个变量i会一直存在直到a被销毁,所以i变量逃逸到了堆上。

2. 总结

逃逸到堆上的内存可能会加大GC压力,所以在一些简单的场景下,我们可以避免内存逃逸,使得变量更多地分配在栈上,可以提升程序的性能。比如:

  • 不要盲目地使用指针传参,特别是参数对象很小时,虽然可以减小复制大小,但是可能会造成内存逃逸到堆上,反而降低了代码的效率;
  • 多根据代码具体分析,根据逃逸分析结果做一些优化,提高性能。

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. 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开辟新的栈空间