浅谈golang

导语:golang能在各种语言中崛起,受各大厂青睐,就是快而轻量,那go为何如此快、如此轻量,背后的设计原理是什么,本文将做一下浅析

怎么让系统更快

想象一下我们自己写的代码,从一开始主线程处理业务到后面的单核的并发和多核的并行,都是在不同的场景选择不同的并发模型,扬长避短,发挥服务器的最大性能,以达到更快速计算业务的效果。

并发 ≠ 并行
在单个 CPU 核上,线程通过时间片或者让出控制权来实现任务切换,达到 "同时" 运行多个任务的目的,这就是所谓的并发。但实际上任何时刻都只有一个任务被执行,其他任务通过某种算法来排队。

多核 CPU 可以让同一进程内的 "多个线程" 做到真正意义上的同时运行,这才是并行。

而多线程,因为其轻量和易用,成为并发编程中使用频率最高的并发模型,包括后衍生的协程等其他子产品,也都基于它。

进程、线程、协程

谈到主线程、多线程等,那就需要解释一下进程、线程、协程的概念

  • 进程:操作系统分配资源的基本单位,有自己的独立内存空间
  • 线程:线程是CPU调度的基本单位,是基于进程clone出来的,与父进程共享父进程资源
  • 协程:协程是一种用户态的轻量级线程,协程的调度完全由用户控制,协程间切换只需要保存任务的上下文,没有内核的开销。

在多线程场景下,用户态切换需要CPU从一个线程切换到另一个线程,切换过程中需要保存当前线程的状态并且回复另一个线程的状态,这种上下文的切换,代价是很高的;如果是跨核的上下文切换Cross-Core Context Switch),可能会导致CPU缓存失效,再通过主存去访问数据,成本就更大了

golang应运而生

面对并发上下文切换带来的昂贵的成本问题,golang就应运而生了,golang来解决此问题的方法就是用goroutine

主要体现在以下两个方面:

上下文切换代价小: Goroutine 上下文切换只涉及到三个寄存器(PC / SP / DX)的值修改;而对比线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)、以及 16 个寄存器、PC、SP…等寄存器的刷新;

内存占用少:线程栈空间通常是 2M,Goroutine 栈空间最小 2K;

Golang 程序中可以轻松支持10w 级别的 Goroutine 运行,而线程数量达到 1k 时,内存占用就已经达到 2G。

Goroutine是怎么设计的呢

go的协程就是从go func开始

func main() {
	for i := 0; i < 10; i++ {
		go func() {
			fmt.Println(i)
		}()
	}
	time.Sleep(1 * time.Second)
}

以上代码块,开启了10个协程,每个协程打印输出i。由于这10个协程的调度时机并不固定,所以等到协程被调度执行的时候才会去取循环中变量i的值。

在代码,每个我们开启的协程都是一个计算任务,这些任务会被提交给go的runtime。如果计算任务非常多,有成千上万个,那么这些任务是不可能同时被立刻执行的,所以这个计算任务一定会被先暂存起来,一般的做法是放到内存的队列中等待被执行。

而消费端则是一个go runtime维护的一个调度循环。调度循环简单来说,就是不断从队列中消费计算任务并执行。这里本质上就是一个生产者-消费者模型,实现了用户任务与调度器的解耦。

go语言如何利用多核 golang多核并发性能_上下文切换

Goroutine是怎么调度的呢

goroutine调度是基于GPM模型:
G:goroutine,代表一个计算任务,由代码和上下文(如当前代码执行的位置、栈信息、状态等)组成
M:machine,系统线程,想要在CPU上执行代码必须有线程,通过系统调用clone创建
P:processor,虚拟处理器。M必须获得P才能执行P队列中的G代码,否则会陷入休眠

按照上面的例子,生产了10个计算任务,一定是要在内存中先把它存起来等待调度器去消费的。那么很显然,最合适的数据结构就是队列,先来先服务。但是这样做是有问题的。现在我们都是多核多线程模型,消费者肯定不止有一个,所以如果多个消费者去消费同一个队列,会出现线程安全的问题,必须加锁。所有计算任务G都必须在M上来执行

go语言如何利用多核 golang多核并发性能_golang_02

在Go中,为了解决加锁的问题,将全局队列拆成了多个本地队列,而这个本地队列由一个叫做P的结构来管理。

go语言如何利用多核 golang多核并发性能_golang_03

这样一来,每个M只需要去先找到一个P结构,和P结构绑定,然后执行P本地队列里的G即可,完美的解决了加锁的问题。

但是每个P的本地队列长度不可能无限长(目前为256),想象一下有成千上万个go routine的场景,这样很可能导致本地队列无法容纳这么多的Goroutine,所以Go保留了全局队列,用以处理上述情况。

那么为什么本地队列是数组,而全局队列是链表呢?由于全局队列是本地队列的兜底策略,所以全局队列大小必须是无限的,所以必须是一个链表。
那么本地队列为什么做成数组而不是链表呢?因为操作系统内存管理会将连续的存储空间提前读入缓存(局部性原理),所以数组往往会被都读入到缓存中,对缓存友好,效率较高;而链表由于在内存中分布是分散的,往往不会都读入到缓存中,效率较低。所以本地队列综合考虑性能与扩展性,还是选择了数组作为最终实现。

而Go又为了实现局部性原理,在P中又加了一个runnext的结构,这个结构大小为1,在runnext中的G永远会被最先调度执行

完整的send流程

  1. 执行go func的时候,主线程m0会调用newproc()生成一个G结构体,这里会先选定当前m0上的P结构
  2. 每个协程G都会被尝试先放到P中的runnext,若runnext为空则放到runnext中,生产结束
  3. 若runnext满,则将原来runnext中的G踢到本地队列中,将当前G放到runnext中。生产结束
  4. 若本地队列也满了,则将本地队列中的G拿出一半,加上当前协程G,这个拼成的结构在源码中叫batch,会将batch一起放到全局队列中,生产结束。这样一来本地队列的空间就不会满了,接下来的生产流程不会被本地队列满而阻塞

所以我们看到,最终runnext中的G一定是最后生产出来的G,也会被优先被调度去执行。这里是考虑到局部性原理,最近创建出来的协程一定会被最先执行,优先级是最高的。

revice是如何去从queue里边拿goroutine的呢

recive端就是一个调度循环,不断的从本地队列和全局队列消费G、给G绑定一个M、执行G,然后再次消费G、给G绑定一个M、执行G…那么执行这个调度循环的人是谁呢?答案是g0,每个M上,都有一个g0,控制自己线程上面的调度循环

即每次调度循环,都会完成g0 -> G -> g0的上下文切换。

schedule是调度循环的核心。由于P中的G分布在runnext、本地队列和全局队列中,则需要挨个判断是否有可执行的G,大体逻辑如下:

先到P上的runnext看一下是否有G,若有则直接返回
runnext为空,则去本地队列中查找,找到了则直接返回
本地队列为空,则去阻塞的去全局队列、网路轮询器、以及其他P中查找,一直阻塞直到获取到一个可用的G为止

每执行61次的调度循环,就需要去全局队列尝试获取一次。为什么要这样做呢?假设有十万个G源源不断的加入到P的本地队列中,那么全局队列中的G可能永远得不到执行被饿死,所以必须要在从本地队列获取之前有一个判断逻辑,定期从全局队列获取G以保证公平。

与此同时,调度器会将全局队列中的一半G都拿过来,放到当前P的本地队列中。这样做的目的是,如果下次调度循环到来的时候,就不必去加锁到全局队列中在获取一次G了,性能得到了很好的保障。

这里去其他P中查找可用G的逻辑也叫work stealing,即工作窃取。这里也是会使用随机算法,随机选择一个P,偷取该P中一半的G放入当前P的本地队列,然后取本地队列尾部的一个G拿来执行。

goroutine同步

goroutine同步可以有channel来控制

go语言如何利用多核 golang多核并发性能_缓存_04


channel执行流程:

尝试从recvq中获取消费者
若recvq不空,发送数据;若为空,则需要阻塞
获取一个sudog结构,给g字段赋值为当前G
把sudog挂到sendq上等待唤醒
调用gopark将M与G解绑,重新触发一次调度,M去执行其他的G

无缓冲channel必须等待recive不为空才不阻塞,有缓冲channel是可以往channel写入缓冲大小的消息不阻塞