go 进阶 协程相关: 一. 协程基础与MPG线程模型(协程原理)

一. 协程基础

1. 进程与线程复习

  1. 在程序运行时以前有进程,线程两种概念, 进程是一个实体,一个用户程序就是一个进程,每个进程下可能包含一个或多个线程,进程和线程都可以解决多核CPU利用率的问题

一个cpu同一时间只能调度一个线程,多线程其实是cpu快速的在多个线程之间进行切换,造成多个线程同时执行的假象,提高cpu利用率

  1. 在程序运行时内存可以划分为堆,栈, 方法区等区域,当通过一个线程执行方法时,对应这个方法会创建出一个栈帧,里面装着局部变量存放在局部变量表中,当方法执行结束,栈帧自动销毁,方法的返回地址会指向方法区中的方法名,然后跳过该方法代码块继续向下运行
  1. 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  2. 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
  3. 栈是先进后出的,所以栈的顶部总是指向当前正在执行的方法对应的栈帧
  1. 对于栈来说最重要的信息就是栈顶,栈顶信息会保存在栈寄存器中(stack pointer)通过该寄存器就能跟踪函数的调用栈
  1. 为什么要保存到寄存器中: CPU访问寄存器的速度比访问内存的速度快100倍
  2. 此处简单说一下就行实际内部包含多种寄存器,比如栈寄存器, 指令地址寄存器, 状态寄存器
  1. 能拿到一个程序运行时的上下文并保存起来,就可以根据这个上下文随时暂停或恢复程序的运行,每个栈帧我们就可以先简单看成一个上下文,停止执行时保存当前的上下文,唤起时拿到这个上下文,基于这个上下文开始运行,会涉及到用户态与内核态的切换

操作系统会把内存分为内核空间和用户空间,内核空间的指令代码具备直接调度计算机底层资源的权限,而用户空间的指令代码没有访问计算机底层资源的能力,需要通过系统调用等方式切换至内核态从而进行计算机底层资源的申请和调度

内核态与用户态的区别

  1. 内核态与用户态是操作系统的两种运行级别,当程序运行在3级特权级上时,就可以称之为运行在用户态。因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;
  2. 当程序运行在0级特权级上时,就可以称之为运行在内核态。
  3. 运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态(比如操作硬件)
  4. 这两种状态的主要差别是
  1. 处于用户态执行时,进程所能访问的内存空间和对象受到限制,其所处于占有的处理器是可被抢占的
  2. 处于内核态执行时,则能访问所有的内存空间和对象,且所占有的处理器是不允许被抢占的。

什么情况下会造成用户态内核态之间的切换

  1. 系统调用: 用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作时。比如fork()实际上就是执行了一个创建新进程的系统调用,比如Linux的int 80h中断

用户程序通常调用库函数,由库函数再调用系统调用,库函数来决定用户程序是否进入内核态,只要库函数中某处调用了系统调用,则会进入

  1. 异常: 当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态
  2. 外围设备的中断: 当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,这个转换的过程也就是用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等
  3. 其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。

2. 为什么使用协程与协程的特点

  1. 线程的缺点也就是协程出现的原因:
  1. 在使用进程与线程时,实际底层一个cpu同一时间只能调度一个线程,多线程其实是cpu快速的在多个线程之间进行切换,造成多个线程同时执行的假象,主要时提高cpu利用率
  2. 线程是系统级别的重量资源, 线程的创建,销毁, 切换存在用户态到内核态的切换比较消耗资源
  3. Linux和Windows都是分时复用的多任务操作系统,上面跑着很多程序,所以操作系统需要在不同进程之间切换,这时候就产生了CPU上下文切换,但是存在的问题就是切换的时候非常消耗资源,默认情况下Linux只可以创建1024个进程.虽然可以修改,但是进程或线程数过多时,CPU的时间基本上都浪费在上下文切换上面了
  1. 什么是协程: 由用户程序在用户栈分配存储空间,同一线程中的多个协程间的切换只在用户态下,而不涉及核心态,因此这样的协程不存在用户态与核心态的转换,提高了CPU效率
  2. 协程的特点总结
  1. 有独立的栈空间
  2. 共享程序堆空间
  3. 调度由用户(程序)控制
  4. 协程是轻量级的线程

3. goroutine与线程区别

  1. 内存消耗更小: Goroutine所需要的内存通常只有2kb,而线程则需要1Mb(500倍)
  2. 创建和销毁消耗小: Goroutine的创建和销毁都是自己管理,线程的创建和销毁是需要内核管理的,消耗更大
  3. 上下文切换:
  1. 线程是抢占式的,一段时间内线程执行完成,其他线程抢占cpu,需要保存很多线程状态,用来再次执行的时候恢复线程
  2. goroutine 的调度是协同式,不需要切入内核,只需要保存少量的寄存器信息需要保存和恢复

4. MPG线程模型(GO协程原理)

  1. MPG线程模型是golang使用的线程模型,是对两级线程模型的一种改进,使它能够更加灵活的进行线程之间的调度(也就是协程的调度原理,为什么协程会有以上特点的原因)
  2. 因为系统执行时分为内核态与用户态,根据这两种状态将线程分为: 内核线程, 用户线程
  1. 内核空间的线程称为内核线程,由操作系统管理和调度,能够直接操作计算机底层的资源。
  2. 用户空间的线程称为用户线程,由用户空间的代码创建、管理和销毁,线程的调度由用户空间的线程库完成,无需切换至内核态,资源消耗少且高效
  1. 根据内核线程用户线程的特点,将线程模型分了以下几种:
  1. 用户级线程模型(N:1)
  2. 内核及线程模型(1:1)
  3. 两级线程模型(M:N)
  4. MPG线程模型(特殊的两级线程模型)

用户级线程模型

  1. 用户级线程模型是指: 一个进程对应一个内核线程(也可以说时多个用户线程基始终在这一个内核线程上跑)
  2. 问题: 在用户级线程模型时,由于是一个进程对应一个内核线程,假设线程A执行产生阻塞,会导致整个进程阻塞,因为此时进程对应的内核线程只有一个,因为线程A的阻塞导致被剥夺CPU执行时间,导致整个进程失去了在CPU的执行代码权力,进程内的多线程都无法很好的利用CPU的多核运算优势,只能通过分时复用的方法轮换执行
    在这里插入图片描述

内核级线程模型

  1. 是指进程中的每个线程都会对应一个内核线程
    在这里插入图片描述
  2. 优点: 能够充分利用CPU的多核并行计算能力
  3. 缺点: 进程内每创建一个新的线程,都会在内核空间创建一个对应的内核线程,线程的管理和调度由操作系统负责,这将导致每次线程切换上下文时都会从用户态切换至内核态,产生不小的资源消耗

两级线程模型

  1. 结合了用户级线程模型和内核级线程模型,一个进程对应多个内核线程,由进程内的调度器来决定线程内的线程如何与内核空间的内核线程对应
    在这里插入图片描述
  2. 优点: 既能够有效降低线程创建和管理的资源消耗,也能够很好地提供线程并行计算的能力
  3. 问题: 给开发人员带来了技术挑战,因为需要在程序代码中模拟线程调度的细节,比如:线程切换时上下文信息的保存和恢复、栈空间大小的管理等

MPG线程模型(特殊的两级线程模型)

  1. 在MPG线程模型中对三个属性的解释
  1. M(Machine):操作系统的主线程(可以理解为内核线程在用户进程中的映射),真正干活的,一个M代表了一个内核线程,等同于系统线程
  2. P(Processor):协程执行的上下文环境(资源) ,可以看为一个局部调度器,一个P代表了M所需的上下文环境,P的数量可以通过GOMAXPROCS()来设置,代表了真正的并发度,既同一时间内可以运行多少个goroutine
  3. G(Goroutine):协程(轻量级用户线程)
  1. 另外在MPG线程模型中还存在一个Sched成员:代表调度器,它维护有存储M和G的队列以及调度器的一些状态信息等
  2. M,P,G三者之间的关系:
  1. 每一个运行的M都必须绑定一个P, 线程M创建之后会去检查并且执行G,M的数量和P不一定匹配,可以设置很多M,M和P绑定后才可运行,多余的M处于休眠状态
  2. P的个数取决于设置的GOMAXPROCS,go新版本默认使用最大内核数,比如你有8核处理器,那么P的数量就是8,所有的P都在程序启动时创建,并保存在数组中
  3. P中关联了一个LRQ(Local Run Queue)本地运行队列,里面保存着P需要执行的协程G,最多可存放256个Goroutine
  4. Sched调度器还拥有一个全局的G队列GRQ(Global Run Queue)存储的是所有未分配的协程G
  5. G需要绑定在M上才能运行,M需要绑定P才能运行,简单来说一个G的执行需要M和P的支持,一个M在与一个P关联之后形成了一个有效的G运行环境【内核线程 + 上下文环境】,每个P都会包含一个可运行的G的队列

在这里插入图片描述

  1. 这样设计使协程拥有了一下特点
  1. 假设有3个M,当三个M都运行在一个CPU上时称为并发,当3个M运行在不同CPU时称为并行
  2. G是在用户态的,是轻量级的,协程的创建,销毁,切换等操作性能消耗比内核态低
  3. 进而实现了线程跟协程的区别: 线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程
  1. MPG三者之间的大致关系: 先创建 M 通过 M 启动调度循环,然后调度循环过程中获取 G 来执行,执行过程中遇到图中 running G 后面几个 case 再次进入下一循环
    在这里插入图片描述
1. 协程调度流程
  1. 新建G协程时,G优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列中
  2. 当一个在M上运行的G协程,因为某种原因阻塞停止执行时,调度器会及时发现,并把这个G与那个M分离开,以释放计算资源供那些等待运行的G使用
  3. 当一个G需要恢复运行时,调度器又会尽快地为它寻找空闲的计算资源(包括M)并安排运行
  4. 当M不够用时,调度器向操作系统申请新的系统级线程,而当某个M已无用时,调度器会负责把它及时地销毁掉,所以Go程序能高效地利用操作系统和计算机资源
  5. 线程M想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去
  6. 基于上面的调度流程GO程序中的所有goroutine都会被充分地调度,即使这样的goroutine有数以十万计,也仍然可以如此。
2. 调度器的有两大思想
  1. 调度器的有两大思想是: 复用线程, 利用并行
  2. 复用线程:协程本身就是运行在一组线程之上,不需要频繁的创建、销毁线程,而是对线程的复用。
  3. 在调度器中复用线程还有2个体现:
  1. work stealing工作窃取: 当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。
  2. hand off切换: 当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。
  1. 利用并行:GOMAXPROCS设置P的数量,当GOMAXPROCS大于1时,就最多有GOMAXPROCS个线程处于运行状态,这些线程可能分布在多个CPU核上同时运行,使得并发利用并行。
  2. 另外,GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行
  3. 默认情况下一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死
3. GOMAXPROCS设置go运行的cpu数
  1. go中可以充分利用多核cpu的优势,可以设置运行的cup个数(go1.8后默认运行在多个cpu上,1.8前需要手动设置)
func main() {
	//获取当前cup数量
	cpuNum := runtime.NumCPU()
	//设置cpu个数为当前个数-1
	runtime.GOMAXPROCS(cpuNum)
	fmt.Print("cpuNum=",cpuNum-1)
}
4. 使用协程时需要考虑的资源竞争问题
  1. 示例步骤分析:

1.编写函数,计算各个数的阶乘,将阶乘结果存入map中
2.通过协程,启动多个协程统一将阶乘结果放入map中
3.由于多个协程都要方法到一个map,map需要定义为全局的

//1.定义全局map
var (
	myMap = make(map[int]int, 10)
)

//2.计算n的阶乘函数,将结果存入myMap中
func testGo(n int) {
	res := 1
	//遍历计算n的阶乘赋值给res
	for i := 1; i <= n; i++ {
		res *= i
	}
	//将拿到的阶乘结果res存入myMap中
	myMap[n] = res
}

//3.通过协程执行阶乘函数
func main() {
	//每遍历一次会启动一个协程,最终会启动200个协程
	for i := 1; i <= 200; i++ {
		go testGo(i)
	}

	for i, v := range myMap {
		fmt.Printf("map[%d]=%d\n", i, v)
	}
}
  1. 执行上述代码发现的问题:
  1. 在go的新版本中会提示 “concurrent map writes” 原因是map是线程不安全的,多个协程同时操作这个map造成的,三种方式解决这个问题: a.使用channel, b.使用sync 锁, c.使用map但必须进行加锁
  2. 假设上述代码不会报错,还会发现一个问题,协程跟随主线程,主线程执行完毕而协程还未执行,或未执行完毕,主线程退出,协程也跟着退出了
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值