Goroutine的深入浅出

一、Go中的堆和栈

1.1 堆和栈是什么

​ 栈(stack)的三种含义:

​ 数据结构:是一组数据的存放方式,特点为LIFO,即后进先出(Last in, first out)。

​ 运行方式:叫做"调用栈"。程序运行的时候,总是先完成最上层的调用,然后将它的值返回到下一层调用,直至完成整个调用栈,返回最后的结果。

​ 内存区域:是存放数据的一种内存区域。程序运行的时候,需要内存空间存放数据。系统会划分出两种不同的内存空间:一种叫做stack(栈),另一种叫做heap(堆)。

​ stack(栈)是有结构的,每个区块按照一定次序存放,可以明确知道每个区块的大小;heap(堆)是没有结构的,数据可以任意存放。

​ 一般来说,每个线程分配一个stack,每个进程分配一个heap,也就是说,stack是线程独占的,heap是线程共用的。

1.2 变量分配在栈还是堆?

​ 堆区(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。

​ 栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。

​ 全局变量:一直常驻在内存中直到程序的结束,然后被系统垃圾回收。

​ 局部变量: 在函数中定于的变量,每次执行的时候就创建一个新的实体,一直生存到没有使用(例如没有外部指针指向它,函数退出的时候没有路径访问到这个变量),这个时候它占用的空间就会被回收。

​ 下面代码定义了函数的参数m0,局部变量m1,m2,m3,m4,m5,返回了局部变量m3:

package main

func foo(m0 int) *int {
	var m1 int = 11
	var m2 int = 12
	var m3 int = 13
	var m4 int = 14
	var m5 int = 15

	println(&m0, &m1, &m2, &m3, &m4, &m5)

	return &m3
}

func main() {
	m := foo(100)
	println(*m)
}

Goroutine的深入浅出

​ 查看调试信息,可以看出m3是分配在heap(堆)中,其他变量则是在stack(栈)中;虽然m3是局部变量,但是m3的指针被其他区域引用,当函数结束,此时m3并不会被释放,而是将局部变量m3申请在堆上。

二、进程、线程和协程

2.1 进程

​ 进程,直观点说,保存在硬盘上的程序运行以后,会在内存空间里形成一个独立的内存体,这个内存体有自己独立的地址空间,有自己的堆,上级挂靠单位是操作系统。操作系统会以进程为单位,分配系统资源(CPU时间片、内存等资源),进程是资源分配的最小单位

2.2 线程

​ 线程,有时被称为轻量级进程,是操作系统调度(CPU调度)执行的最小单位。和其它本进程的线程共享地址空间,拥有自己独立的栈和共享的堆,共享堆,不共享栈。

Goroutine的深入浅出

2.3 协程

​ 协程(用户线程),是一种比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

Goroutine的深入浅出

三、Go语言的协程Goroutine

3.1 Golang的GMP模型

​ M:结构是Machine,系统线程,它由操作系统管理,goroutine就是跑在M之上的;
​ P:结构是Processor,处理器(上下文环境),调度G到M上,其维护了一个goroutine队列,即runqueue;
​ G:是goroutine实现的核心结构,它包含了栈,指令指针,一个go routine单元。

Goroutine的深入浅出

3.2 调度器策略

​ 1、每一个Goroutine都是通过P分配到内核线程,然后通过OS调度器把内核线程分配到CPU的核上执行;

​ 2、一个P只能绑定一个M,P会维护一个本地队列,如果本地队列满了,就会变把当前一半队列的G移动到全局队列;

​ 3、G是按照队列的方式通过P分配到内核线程(一个内核线程只能运行一个G),内核线程分配到CPU是抢占式的;

​ 4、P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。

​ 5、在Go中,当G1在内核线程上占用CPU 10ms后,M上运行的G1会切换为G0,G0负责调度时协程的切换,从P中的本地队列取下一个G到线程上执行,G1将放回本地队列。

M0是启动程序后的编号为0的主线程,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。

G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。

3.3 调度遇到阻塞的情况

​ Goroutine阻塞一般有:系统调用(syscall),网络IO,协程挂起,执行计算四种;

系统调用(syscall):G会阻塞内核线程M,此时M运行着G先跟P分离,P寻找其他空闲的M进行绑定;等G的系统调用完成后,G将重新分配到全局队列,M也会继续寻找绑定空闲的P;

网络IO:netpoller网络轮询器(NetPoller)来处理网络请求和 IO 操作的问题,其后台通过 kqueue(MacOS),epoll(Linux)或 iocp(Windows)来实现 IO 多路复,。不会导致M被阻塞,仅阻塞G;

协程挂起:当G遇到channel阻塞,sleep等阻塞后,G将挂起,不阻塞M,挂起完成过后才会放到队列里面,等待P的分配;

执行计算:当G遇到执行程序比较长时,超过10ms后会让出CPU执行权,回到队列等待,不阻塞M。

3.4 GODEBUG调度分析

1、使用sleep阻塞协程:

package main

import (
	"fmt"
	"runtime"
	"time"
)

func init() {
	runtime.GOMAXPROCS(4)
	num := runtime.NumCPU()
	fmt.Printf("cpu num :%v\n", num)

}
func main() {
  for i := 1; i <= 10; i++ {
		go func(i int) {
			time.Sleep(10 * time.Second)
			fmt.Printf("i = %v,end\n", i)
		}(i)
	}
	for {
	}
}

执行结果:

Goroutine的深入浅出

gomaxprocs: P的数量,本例有4个P, 因为默认的P的属性是和cpu核心数量默认一致,当然也可以通过GOMAXPROCS来设置;
idleprocs:处于idle状态的P的数量,上图数值为3,所以有一个P绑定的M在执行G,因为main函数有个死循环;
threads:os threads/M的数量,包含scheduler使用的m数量,加上runtime自用的类似sysmon这样的thread的数量;
spinningthreads:处于自旋状态的os thread数量;
idlethread: 处于idle状态的os thread的数量;
runqueue=0:Scheduler全局队列中G的数量;
[0 0 0 0]:分别为4个P的local queue中的G的数量。

总结:当所有G遇到sleep挂起时,G不会放在队列上,也不会阻塞M,但是main函数有个死循环,队列也没有可用G,故执行main函数的G一直在M上执行;

1、使用紧密计算阻塞协程:

package main

import (
	"fmt"
	"runtime"
	"time"
)

func init() {
	runtime.GOMAXPROCS(4)
	num := runtime.NumCPU()
	fmt.Printf("cpu num :%v\n", num)

}
func main() {
  for i := 1; i <= 10; i++ {
		go func(i int) {
			for j := 0; j <= 2999999999; j++ {

			}
			fmt.Printf("i = %v,end\n", i)
		}(i)
	}
	for {
	}
}

执行结果:
Goroutine的深入浅出

总结:当每个G执行时间长的时候,超过10ms后会让出CPU执行权,回到队列等待,不阻塞M。

上一篇:进程、线程以及Goroutine的区别


下一篇:golang 12. goroutine goexit