为何goroutine如此轻量 | deep dive

goroutine的轻量是尽人皆知的,即使是平常不适用golang的人在被问到golang吸引人的特性的时候,也会毫不犹豫答出:

1
goroutine非常轻量

这种答案;

那么goroutine到底是因为什么才变得如此轻量?这个问题基本上每个面试golang职位的同学都会被面试官问到,本文尝试给出一个完整的goroutine为何如此轻量的解释。

既然我们是寻找goroutine轻量的原因,那么就必须有一个参照,即:goroutine相对什么显得轻量?

Linux操作系统的原生线程是个比较好的比较对象;

从计算机资源的角度看,当今比较重要的计算机资源分别是

  1. CPU
  2. 内存
  3. 网络
  4. 磁盘

而在goroutine为何如此轻量的讨论中,基本不涉及网络和磁盘的问题,那么就剩下CPU和内存了,我们讨论的问题变成了,goroutine在CPU使用效率上和在内存使用效率上为什么比原生Linux线程要更轻量;

讨论开始之前,我们先回顾一下Linux程序的执行过程

传统Linux程序的执行过程

程序被load到内存中,代码区,bass区
整个虚拟内存
pc执行,虚拟内存转换成物理内存

函数调用,压栈,出栈

然而线程的执行并不是一帆风顺的,原因在于系统同时存在多个线程,而CPU资源的数量远远小于系统中线程的数量,此时就需要调度;

所谓调度,就是把执行的权利从一个线程转移到另一个线程身上,而这个过程中涉及到一个非常重要的操作:上下文切换

所以原生线程的开销主要表现在

  1. CPU在上下文切换中的开销
  2. 线程的栈空间

上线文切换有多贵

CPU 寄存器和程序计数器就是 CPU 上下文,因为它们都是 CPU 在运行任何任务前,必须的依赖环境。

上下文切换先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

这其中单次上下文切换的时间在微秒的粒度,即使仅仅10微秒,那么每一秒钟也仅仅能切换10万次,换句话说,每个核心在不干其他事情的情况下,只做上下文切换也只能切换10万次。

这个和动不动就上百万,上千万的goroutine的数量是不能比的。

调度模型

既然原生线程的调度成本如此之高,那么是不是可以自己接管调度?答案是肯定的,golang就是这么做的。

golang的运行时会对goroutine进行调度,为了达到调度的目的,提出来三个重要的概念:P,M,G;

1
2
3
4
5
6
7
8
9
10
11
// Goroutine scheduler
// The scheduler's job is to distribute ready-to-run goroutines over worker threads.
//
// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
// M must have an associated P to execute Go code, however it can be
// blocked or in a syscall w/o an associated P.
//
// Design doc at https://golang.org/s/go11sched.

上述三个概念中
G是goroutine;
M是内核线程;
P可以理解为golang运行时的线程,负责执行goroutine;

三者的关系是这样的

每个P都对应一个内核线程M,然后goroutine在P的队列中等待执行;

当一个G执行完毕之后,sched会调度另一个G到P上执行,而这个过程中完全没有进行操作系统层面的上下文切换,所有的操作都是在runtime完成的。

通过这种模型,golang避免了大量的上下文切换;

原始的linux线程,每分配一个线程,都会分配一定空间的栈空间,这个固定的栈空间大小可以通过

1
2
$ ulimit -as | grep stack
stack size (kbytes, -s) 8192

查到,默认是8MB

考虑下面的c代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# include <stdio.h>

int func(int m, int n) {
if (m == 0) {
return n + 1;
} else if (m > 0 && n == 0) {
return func(m - 1, 1);
} else {
return func(m - 1, func(m, n - 1));
}
}

int main(int argv, char *args[]){
func(4,5);
return 0;
}

func(4,5)就会消耗掉所有的栈空间;当然如果你嫌默认的栈空间小的话,可以通过

1
2
3
4
5
#include <sys/time.h>
#include <sys/resource.h>

getrlimit(RLIMIT_STACK, struct rlimit *rlim)
setrlimit(RLIMIT_STACK, const struct rlimit *rlim)

进行修改;

从单个栈空间为8MB来讲,8GB的内存只能容纳1024个线程,单个线程的消耗是相当大的。

那么golang是如何实现巨量的goroutine的?

答案是:通过初始分配小空间,按需增加的方式;

引用

  • https://new.blog.cloudflare.com/how-stacks-are-handled-in-go/