跳转至

1 goroutine

1 Goroutine 原理

1.1 Goroutine

Goroutine 是一个与其他 goroutines 并行运行在同一地址空间的 Go 函数或者方法。一个运行的程序是由一个或者多个 goroutine 组成。它与线程、协程、进程等不同。它是一个 goroutine。 -- Rob Pike

Goroutines 在同一个用户地址空间里并行独立执行 functions,channels 则用于 goroutine 间的通信和同步访问控制。

1.1.1 Goroutine 和 Thread 的区别是什么?

内存占用

  • 创建一个 goroutine 的栈内存消耗为 2KB,运行过程中,如果栈空间不够用,会自动进行扩容;
  • 创建一个 thread 为了尽量避免极端下操作系统线程栈的溢出,默认分配栈内存为 1-8 MB (POSIX Thread),并且还需要一个 Guard Page(保护页)的区域与其他的 thread 进行隔离。而栈空间一旦创建和初始化完成之后其大小就不能变化,所以说在一些特殊场景下系统线程栈存在着溢出的风险;

创建/销毁

  • thread 的创建和销毁都会有巨大的消耗,是内核级别的交互(trap);
  • goroutine 是用户态线程,是由 go runtime 管理,创建和销毁的成本是比较低的;

调度切换

  • thread 切换需要 1000-1500ns (上下文保存成本高,较多的寄存器,公平性,复杂时间计算统计),1ns 平均可以执行 12-18 条指令;
  • 由于是 thread 内的切换,执行指令的条数会减少 1.2-1.8w ,goroutine 的切换约为 200ns (用户态、3个寄存器),相当于 2.4-3.6k 条指令;
  • 对比下来 goroutines 的切换成本比 threads 小的多。

复杂性

  • thread 创建和退出, threads 间通讯复杂(share memory);
  • 系统是不能大量创建线程的(成本高,使用网络多路复用,存在大量的 callback),对应用服务线程门槛高。

1.2 GMP 调度模型

1.2.1 M:N 模型

Go 创建 M个线程(CPU执行调度的单元,内核态的 task_struct),之后创建的 N个 goroutine 都会依附在这 M个线程上执行。

m-n模型

同一个时刻,一个 thread 只可以 run 一个 goroutine。当 goroutine 发生阻塞(chan 阻塞、mutex、syscall..)Go 会把当前的 goroutine 调度走,让其他 gouroutine 来继续执行,而不是让线程阻塞休眠,而是尽可能的让 CPU 忙。

1.2.2 GMP

  • G goroutine 的缩写,go 关键词创建一个 G,无限制;
  • 使用 struct runtime.g,包含了当前 goroutine 的状态、堆栈、上下文;
  • M 工作线程(OS Thread),也曾为 Machine;
  • 使用 struct runtime.m,所有的 M 都是有线程栈的;
  • 如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈的大小不同),当指定了线程栈,则 M.stack -> G.stack,M 的 PC 寄存器指向 G 提供的函数,然后去执行;
  • P processor 是一个抽象的概念,并不是真实的 物理 CPU,是 控制 Goroutines 上下文环境的队列;
  • 它代表了 M 所需要的上下文环境,也是处理用户及代码逻辑的处理器。负责衔接 M 和 G 的调度上下文,将等待执行的 G 与 M 对接。当 P 有任务时需要创建或者唤醒一个 M 来执行它队列中的 G 任务。所以 P/M 是绑定的,构成了一个执行单元;
  • P 决定了并行任务的数量,通过 runtime.GOMAXPROCS 设定;

GMP 调度器

引入了 local queue,因为 Procrssor 的存在,runtime 并不需要做一个集中式的 Goroutine 调度,每一个 M 都会在 P local queue、 global queue 或者 其他 P 队列中找 goroutine 执行,减少全局锁对性能的影响。

GMP 调度器

ps. GMP 调度图片来源于网络,不再重复画图。

1.3 Work-stealing

1.3.1 M0 main

程序启动后,Go 已经将主线程于 M 绑定(rt0_go)。

1.3.2 Work stealing

M 绑定的 P 没有可执行的 goroutine 时,它会去按照优先级去抢占任务,找到任何一个任务,切换调用栈执行任务,再循环不断的获取任务,直至休眠。

1.3.3 Spining thread

Spining thread (线程自旋) 是相对于 thread 阻塞而言的,表象就是循环执行一个指定的逻辑(例如调度巡逻,不停的找 G)。但是这种处理带来的问题就是耗费 CPU 资源,但好处是降低了 M 的上下文切换成本,提高了性能。

  • M 带 P 的找 G 运行;
  • M 不带 P 的找 P 挂载;
  • G 创建有没有 spining M 就唤醒一个 M;

Spining thread 不会超过 GOMAXPROCS (Busy P)。

1.3.4 Syscall

Go 封装了 syscall ,也就是进入和退出 syscall 的时候执行 entersyscall/exitsyscall ,也只有被封装了系统调用才有可能出发重新调度,它将改变 P 的状态为 syscall。

系统监视器 system moniter 简称 sysmon,会定时扫描。在执行系统调用的时候,如果某个 P 的 G 执行超过一个 sysmon tick,则脱离 M。

1.3.5 System monitor

协作式抢占,当 P 在 M 上执行时间超过 10ms,sysmon 调用 preemptone(抢先调用)将 G 标记为 stackPreempt(堆栈抢占)。因此需要在某个地方住发检测逻辑,Go 当前是在检查栈是否溢出的地方判定(morestack()),M 会保存当前 G 的上下文,重新进入调度逻辑。

1.3.6 Network poller

所有的 I/O 操作都可以理解成是阻塞的。G 发起网络 I/O 操作也不会导致 M 被阻塞(仅阻塞 G),从而不会导致大量的 M 创建出来。

将异步 I/O 转换为阻塞 I/O 的部分称为 netpoller。

打开或接受链接都可以被设置为 非阻塞模式。如果试图对其进行 I/O 操作,并且文件描述符数据还没有准备好,它将返回一个错误代码,之后调用 netpoller 。

Network poller 触发点:

  • system monitor;
  • schedule() 时间表;
  • start the world;

从 ready 的网络事件中恢复 G。

gopark, G 置为 waiting 状态,等待显示 goready 唤醒,在 poller 中用的比较多,其他场景有锁、chan...

Reference

TODO

  • chan.go 源码;
  • proc.go 源码;
  • network poller 原理;