Linux角度仰视Goroutine的GMP

golang的调度在现在的计算机发展中, 有着开创的意义,依靠于linux kernel的调度基础,设计出了gmp来供语言级的协程调度,在生产与性能方面,有着卓越的优势

知识回顾

Linux 调度

调度策略

Linux 从2.6.23版本开始,将CFS(Completely Fair Scheduler)默认的调度器。在CFS中实际上维护了一个红黑树(在底层维护的一个sched_entity的结构体对象), 把CPU当做一种资源,并记录下每一个进程对该资源使用的情况,在调度时,调度器总是选择消耗资源最少的进程来运行。这就是所谓的"完全公平"。

在Linux内核中还存在其他4中调度器:Stop调度器、deadline调度器、RT调度器和IDLE-Task调度器。

实现原理

在实现层面,Linux通过引入virtual runtime(vruntime)来完成上面的设想,具体的,我们来看下从实际运行时间到vruntime的换算公式

vruntime = 实际运行时间 * 1024 / 进程权重

Linux所谓的调度就是调度(进程/线程)

进程控制块

由于Linux调度器实际是识别 task_struct 进行调度,而在Linux系统中task_struct就是进程控制块(PCB),基本数据结构如下: (由于线程又叫做轻量级进程(light-weight process LWP),也有PCB,创建线程使用的底层函数和进程底层一样,都是clone。所以线程的数据结构不再重复复述。)

arduino 复制代码
```    
struct task_struct {

	long state; /*任务的运行状态(-1 不可运行,0 可运行(就绪),>0 已停止)*/

	long counter;/*运行时间片计数器(递减)*/

	long priority;/*优先级*/

	long signal;/*信号*/

	struct sigaction sigaction[32];/*信号执行属性结构,对应信号将要执行的操作和标志信息*/

	long blocked; /* bitmap of masked signals */

	  /* various fields */

	int exit_code;/*任务执行停止的退出码*/

	unsigned long start_code, end_code, end_data, brk, start_stack;/*代码段地址 代码长度(字节数)

																     代码长度 + 数据长度(字节数)总长度 堆栈段地址*/

	long pid, father, pgrp, session, leader;/*进程标识号(进程号) 父进程号 父进程组号 会话号 会话首领*/

	unsigned short uid, euid, suid;/*用户标识号(用户id) 有效用户id 保存的用户id*/

	unsigned short gid, egid, sgid; /*组标识号(组id) 有效组id 保存的组id*/

	long alarm;/*报警定时值*/

	long utime, stime, cutime, cstime, start_time;/*用户态运行时间 内核态运行时间 子进程用户态运行时间

												    子进程内核态运行时间 进程开始运行时刻*/

	unsigned short used_math;/*标志:是否使用协处理器*/

	  /* file system info */

	int tty; /* -1 if no tty, so it must be signed */

	unsigned short umask;/*文件创建属性屏蔽位*/

	struct m_inode * pwd;/*当前工作目录i 节点结构*/

	struct m_inode * root;/*根目录i节点结构*/

	struct m_inode * executable;/*执行文件i节点结构*/

	unsigned long close_on_exec;/*执行时关闭文件句柄位图标志*/

	struct file * filp[NR_OPEN];/*进程使用的文件表结构*/

	  /* ldt for this task 0 - zero 1 - cs 2 - ds&ss */

	struct desc_struct ldt[3];/*本任务的局部描述符表。0-空,1-代码段cs,2-数据和堆栈段ds&ss*/

	  /* tss for this task */

	struct tss_struct tss; /*本进程的任务状态段信息结构*/
	
	const struct sched_class *sched_class; /*调度策略的执行逻辑*/
	
	 unsigned int policy; /*调度策略*/

};  

可以看出成员变量不少,我提炼一下关键的几个字段作用:

Api:

  • system() 启动一个新进程
  • exec() 以替换当前进程映像的方式启动一个新进程
  • fork() 以复制当前进程映像的方式启动一个新进程,除了task_struct和堆栈,子进程和父进程共享所有的资源,相当于复制了一个父进程,但是由于linux采用了写时复制技术,复制工作不是立即就执行,提高了效率。
  • wait() 父进程挂起,等待子进程结束。
  • clone() 创建一个线程与当前的进程共享
  • pthread_create() 创建一个用户线程,底层实际上调用clone(),并把开辟一个 stack 作为参数 thread 建立 , 同步销毁等由线程库负责。

这里补充:提到线程,这里顺便以Java为例子:每个Java线程一对一映射到Solaris平台上的一个本地线程上,并将线程调度交由本地线程的调度程序。由于Java线程是与本地线程是一对一地绑在一起的,而在调Java的SDKsetPriority()也不能百分百的修改线程的优先级;

runqueue 运行队列

Linux内核使用struct_rq结构来描述运行队列,结构体如下:

arduino 复制代码
struct rq {
	
	raw_spinlock_t lock; /*队列时钟 */ 
    
    /* 三个调度队列:CFS调度,RT调度,DL调度 */
	struct cfs_rq cfs;
	struct rt_rq rt;
	struct dl_rq dl;

    /* stop指向迁移内核线程, idle指向空闲内核线程 */
    struct task_struct *curr, *idle, *stop;
    
    /* ... */
} 

rq的基本职责就是:在每一个CPU中都有一个自己的运行队列,当一个CPU接受到一个task的时候,就会以sched_entity的实体丢入队列中,最后选择使用的调度器来进行调度;具体如流程如下图:

Schedule函数

在上图中存在一个schedule的主程序入口,具体的功能如下:

Java的线程调度

做过Java的开发项目的同学也许查看某个webServer或者Rpc的Server时候会发现经常pid有两个(如下图):其实一个是父进程,另一个就是子进程,更新专业的说一个是LWP也就是轻量级的线程。:

用一张图来描述cpu的线程调度和进程的关系:

在Java的线程调度中,我们可以发现其实即便Jvm开启的线程,但是本质上最终还是绑定内核态的一个线程,所以对于一个大型程序,我们可以开辟的线程数量至少等于运行机器的cpu内核数量。java程序里我们可以通过下面的一行代码得到这个数量:

scss 复制代码
Runtime.getRuntime().availableProcessors();

所以最小线程数量即时cpu内核数量。

如果所有的任务都是计算密集型的,这个最小线程数量就是我们需要的线程数。开辟更多的线程只会影响程序的性能,因为线程之间的切换工作,会消耗额外的资源。如果任务是IO密集型的任务,我们可以开辟更多的线程执行任务。

当一个任务执行IO操作的时候,线程将会被阻塞,处理器立刻会切换到另外一个合适的线程去执行。如果我们只拥有与内核数量一样多的线程,即使我们有任务要执行,他们也不能执行,因为处理器没有可以用来调度的线程。

如果线程有50%的时间被阻塞,线程的数量就应该是内核数量的2倍。如果更少的比例被阻塞,那么它们就是计算密集型的,则需要开辟较少的线程。如果有更多的时间被阻塞,那么就是IO密集型的程序,则可以开辟更多的线程。于是我们可以得到下面的线程数量计算公式:

线程数量 = 内核数量 / (1 - 阻塞率)

Golang调度模型

通过Java的线程调度,我们可以发现其实线程分为用户态和内核态的,在Java中只不过是一个用户态线程通过native的线程映射到一个LWP线程(前文提到的轻量级线程,作用域通过内核态支持用户态线程),最终绑定了一个内核态的线程。而Golang恰巧利用了这一点,就是自己组织调度用户态的线程,因为CPU最终只关注内核态的线程,于是协程就诞生了

*在转Golang之前,一直停其他开发同学说:Golang比较牛逼就是协程,一种跟轻量级的东西,比线程占用的内存更小,切换的时候,说消耗的CPU更小。 *眼见为实,耳听为虚。 眼见不一定为实,耳听不一定为虚。本质上协程是用户态的线程的一种调度管理,简单点来说就是:Golang 通过goruntime(协程)这种东西,在内部维持一个固定线程数的线程池,进行合理的调度,使得每一个线程上执行更多的工作来降低操作系统和硬件的负载。

脑洞开始了

N:1模式

既然可以自行的管理用户线程,那么我们可以开启N个goruntime然后通过调度器来最终绑定一个内核态的线程。这样协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。具体如下:

在这种情况很快发现了缺点,所以的线程都绑定一个线程上,如果某一个协程阻塞了,那么其他的整个线程就阻塞了。

N:M模式

基于上述的情况,我们可以考虑让一组线程绑定到一组线程,这样的话,就不会因为一个协程阻塞而导致其他的协程阻塞。 具体如下:

但是在协程的调取器问题依旧存在:

  • 中创建、销毁、调度goruntime都需要每个线程获取锁,这就形成了激烈的锁竞争。
  • 线程转移goruntime会造成延迟和额外的系统负载。+ 系统调用(CPU在线程之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

GMP模式

基于上述问题,Golang设计了一套新的协程调度模式:GMP模式,即在原来M(thread线程)和G(goruntime)中引入了P(processor);

Processor,它包含了运行goroutine的资源,如果线程想运行goroutine,必须先获取P,P中还包含了可运行的G队列。

GMP流程图:

流程文字描述:

  • 通过 go func()来创建一个goroutine;
  • 有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;
  • G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行;
  • 一个M调度G执行的过程是一个循环机制;
  • 当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;
  • 当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。

上代码,用实践证明

Go code:

erlang 复制代码
package main

import (
    "fmt"
    "os"
    "runtime"
    "runtime/trace"
    "time"
)

func main() {
    f, err := os.Create("trace.out")
    defer f.Close()
    if err != nil {
        fmt.Println(err)
    }
    err = trace.Start(f)
    defer trace.Stop()
    if err != nil {
        fmt.Println(err)
    }
    for i := 0; i < 5; i++ {
        time.Sleep(time.Second)
        fmt.Println("GMP")
    }
    fmt.Println("cpus:", runtime.NumCPU())
}

下面我们借Go tool 的trace功能来看看程序的结果:

我们再通过debug清晰的分析,这里不禁想起了看Java的GClog日志:

我们挑上述图中关键点来简单说明一下:

  • gomaxprocs: gomaxprocs=12默认是cpu的核数,这里测试的是CPU12核,通过GOMAXPROCS来设置;
  • idleprocs: 处于idle状态的P的数量;通过gomaxprocs和idleprocs的差值,我们就可知道执行go代码的P的数量;
  • threads: os threads/M的数量,包含scheduler使用的m数量,加上runtime自用的类似sysmon这样的thread的数量;
  • spinningthreads: 处于自旋状态的os thread数量;
  • idlethread: 处于idle状态的os thread的数量;
  • runqueue=0: 即对应了前面的Scheduler全局队列中G的数量;
  • [0 0 0 0 0 0 0 0]: 貌似是本地local queue中的G的数组长度,但是理论上都是0,这里自己也太清晰。

总结

从linux的系统调度,借鉴了Java的线程调度,最终可以看到Go里GMP的模式巧妙借助了用户态的线程,让Goroutine实现了占用更小的内存,降低了CPU的切换次(注意并没有消除了上下文切换,毕竟最终还是依赖内核线程调度)。

相关推荐
NiNg_1_2341 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
Chrikk3 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*3 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue3 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man3 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
customer084 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
Yaml45 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
小码编匠6 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#