【2-1 Golang】Go并发编程—GMP调度模型概述

tomato01 · · 568 次点击 · · 开始浏览    

&emsp;&emsp;Go语言天然具备并发特性,基于go关键字就能很方便的创建协程去执行一些并发任务,而且基于协程-管道的CSP并发编程模型,相比于传统的多线程同步方案,可以说简单太多了。从本篇文章开始,将为大家介绍Go语言的核心:并发编程;不仅包括协程/管道/锁等的基本使用,还会深入到协程实现原理,GMP协程调度模型等。 ## 并发编程入门 &emsp;&emsp;设想下我们有这么一个服务/接口:需要从其他三个服务获取数据,处理后返回给客户端,并且这三个服务不互相依赖。这时候一般如何处理呢?如果PHP语言开发,一般可能就是顺序调用这些服务获取数据了;如果是Java之类的支持多线程的语言,为了提高接口性能,通常可能会开启多个线程并发获取这些服务的数据。Go语言使用多协程去执行多个可并行子任务非常简单,使用方式如下所示: ``` package main import ( "fmt" "sync" ) func main() { //WaitGroup用于协程并发控制 wg := sync.WaitGroup{} //启动3个协程并发执行任务 for i := 0; i < 3; i ++ { asyncWork(i, &wg) } //主协程等待任务结束 wg.Wait() fmt.Println("main end") } func asyncWork(workId int, wg *sync.WaitGroup){ //开始异步任务 wg.Add(1) go func() { fmt.Println(fmt.Sprintf("work %d exec", workId)) //异步任务结束 wg.Done() }() } ``` &emsp;&emsp;main函数默认在主协程执行,而且一旦main函数执行结束,也意味这主协程执行协程,整个Go程序就会结束(与多线程程序比较类似)。主协程需要等待子协程任务执行结束,但是协程的调度执行确实随机的,go关键字只是创建协程,通常并不会立即调度执行该协程;所以如果没有一些同步手段,主协程可能以及执行完毕了,子协程还没有调度执行,也就是任务还没开始执行。sync.WaitGroup常用于多协程之间的并发控制,wg.Add标记一个异步任务的开始,主协程wg.Wait会一直阻塞,直到所有异步任务执行结束,所以我们再异步任务的最后调用了方法wg.Done,标记当前异步任务执行结束。这样当子协程全部执行完毕后,主协程就会解除阻塞。不过还有一个问题,主协程如何获取到子协程的返回数据呢?想想最简单的方式,函数asyncWork添加一个指针类型的输入参数,作为返回值呢?或者也能通过管道chan实现协程之间的数据传递。 &emsp;&emsp;管道chan用于协程直接的数据传递,想象一下,数据从管道一端写入,另外一端就能读取到该数据。设想我们有一个消息队列的消费脚本,获取到一条消息之后,需要执行比较复杂/耗时的处理,Go语言怎么处理比较好呢?拉取消息,处理,ack确认,如此循环吗?肯定不太合适,这样消费脚本的性能太低了。通常是启动一个协程专门用于从消息队列拉取消息,再将消息交给子协程异步处理,这样大大提升了消费脚本性能。而主协程就是通过管道chan将消息交给子协程去处理的。 ``` package main import ( "fmt" "time" ) func main() { //声明管道chan,最多可以存储10条消息;该容量通常限制了异步任务的最大并发量 queue := make(chan int, 10) //开启10个子协程异步处理 for i := 0; i < 10; i ++ { go asyncWork(queue) } for j := 0; j < 1000; j++ { //向管道写入消息 queue <- j } time.Sleep(time.Second) } func asyncWork(queue chan int){ //子协程死循环从管道chan读取消息,处理 for { data := <- queue fmt.Println(data) } } ``` &emsp;&emsp;管道chan可以声明多种类型,如chan int只能存储int类型数据;chan还有容量的改变,也就是最大能存储的数据量,如果chan容量已经满了,向chan写入数据会阻塞,所以通常可以通过容量限制异步任务的最大并发量。另外,如果chan没有数据,从chan读取数据也会阻塞。上面程序启动了10个子协程处理消息,而主协程循环向chan写入数据,模拟消息的消费过程。 &emsp;&emsp;在使用Go语言开发过程中,通过会有这样的需求:某些任务需要定时执行;Go语言标准库time提供了定时器相关功能,time.Ticker是定时向管道写入数据,我们可以通过监听/读取管道,实现定时功能: ``` package main import ( "fmt" "time" ) func main() { //定时器每秒向管道ticker.C写入数据 ticker := time.NewTicker(time.Second) for { //chan没有数据时,读取操作阻塞;所以循环内<- ticker.C一秒返回一次 <- ticker.C fmt.Println(time.Now().String()) } } ``` &emsp;&emsp;最后,我们再回顾一下讲解map的时候提到,并发写map会导致panic异常;假如确实有需求,需要多协程操作全局map呢?这时候可以用sync.map,这是并发安全的;另外也可以通过加锁方式实现map的并发访问: ``` package main import ( "fmt" "sync" "time" ) func main() { lock := sync.Mutex{} var m = make(map[string]int, 0) //创建10个协程 for i := 0; i <= 10; i ++ { go func() { //协程内,循环操作map for j := 0; j <= 100; j ++ { //操作之前加锁 lock.Lock() m[fmt.Sprintf("test_%v", j)] = j //操作之后释放锁 lock.Unlock() } }() } //主协程休眠3秒,否则主协程结束了,子协程没有机会执行 time.Sleep(time.Second * 3) fmt.Println(m) } ``` &emsp;&emsp;sync.Mutex是Go语言提供的排他锁,可以看到我们在操作map之前执行Lock方法加锁,操作map时候通过Unlock方法释放锁;这时候运行程序就不会出现panic异常了。不过要注意的是,锁可能会导致当前协程长时间阻塞,所以在C端高并发服务中,要慎用锁。 ## GMP调度模型基本概念 &emsp;&emsp;在介绍Go语言协程调度模型之前,我们先思考几个问题: - 到底什么是协程呢?多协程为什么能并发执行呢?想想我们了解的线程,每一个线程都有一个栈桢,操作系统负责调度线程,线程切换必然伴随着栈桢的切换。协程有栈桢吗?协程的调度由谁维护呢? - 都说协程是用户态线程,用户态是什么意思呢?协程与线程又有什么关系呢?协程的创建以及切换不需要陷入内核态吗? - Go语言是如何管理以及调度这些成千上万个协程呢?和操作系统一样,维护着可运行队列和阻塞队列吗,有没有所谓的按照时间片或者是优先级或者是抢占式调度呢? - 用户程序读写socket的时候,可能阻塞当前协程,难道读写socket都是阻塞式调用吗?Go语言是如何实现高性能网络IO呢?有没有用传说中的epoll呢? - Go程序如何执行系统调用呢?要知道操作系统只能感知到线程,而系统调用可能会阻塞当前线程,线程阻塞了,那么协程呢? &emsp;&emsp;这些问题你可能了解一些,可能不了解,不了解也不用担心,从本篇文章开始,将为你详细介绍Go语言的协程调度模型,看完之后,这些问题将不在话下。 &emsp;&emsp;说起GMP,可能都或多或少了解一些,G是协(goroutine)程,M是线程(machine),P是逻辑处理器(processor);那么为什么要这么设计呢? &emsp;&emsp;想想之前我们所熟知的线程,其由操作系统调度;现在我们需要创建协程,协程由谁调度呢?当然是我们的线程在执行调度逻辑了。那这么说,我只需要维护一个协程队列,再有个线程就能调度这些协程了呗,还需要P干什么?Go语言v1.0确实就是这么设计的。但是要知道Go语言服务通常会有多个线程,多个线程从全局协程队列获取可运行协程时候,是不是就需要加锁呢?加锁就意味着低效。 &emsp;&emsp;所以后面引入了P(P就只是一个由很多字段的数据结构而已,可以将P理解成为一种资源),一般P的数目会和系统CPU核数保持一致,;M想要调度协程,需要获取并绑定P,P只能被一个M占有,每个P都维护有协程队列,那这时候线程M调度协程,是不是只需要从当前绑定的P获取即可,也就不需要加锁了。后续很多设计都采取了这种思想,包括定时器,内存分配等逻辑,都是通过将共享数据关联到P上来避免加锁。另外,为了避免多个P负载分配不均衡,还有一个全局队列sched.runq(协程有些情况会添加到全局队列),如果当前P的协程队列为空,M还可以从全局队列查找可运行G,当然这时候就需要加锁了。 &emsp;&emsp;此时,GMP调度模型如下所示: ![2.1-1.png](https://static.golangjob.cn/220922/6a6b4112c02b344925711a5de1994019.png) &emsp;&emsp;对GMP概念有了简单的了解后,该深究下我们的重点G了。协程到底是什么呢?创建一个协程只是创建了一个结构体变量吗?还需要其他什么吗? &emsp;&emsp;同样的想想我们所熟知的线程,创建一个线程,操作系统会分配对应的线程栈,线程切换时候,操作系统会保存线程上下文,同时恢复另一个线程上下文。协程需要协程栈吗?要回答这个问题,先要说清楚线程栈是什么?如下图所示: ![2.1-2 (1).png](https://static.golangjob.cn/220922/e2ef33a0ebe6a6fbebae327cd85856e3.png) &emsp;&emsp;函数调用或者返回过程中,就伴随着函数栈桢的入栈以及出栈,我们函数中的局部变量,以及入参,返回值等,很多时候都是分配在栈上的。函数调用栈是有链接关系的,非常类似于单向链表结构。多个线程是需要并发执行的,那必然就存在多个函数调用栈。 &emsp;&emsp;协程需要并发执行吗?肯定需要。那协程肯定也需要协程栈,不然多个协程的函数调用栈不就混了。只是,线程创建后,操作系统自动分配线程栈,而操作系统压根不知道协程,怎么为其分配协程栈呢? ![2.1-3.png](https://static.golangjob.cn/220922/a3963882c47243e0741d6380ce7276fd.png) &emsp;&emsp;虚拟内存结构了解吗?如上图所示,虚拟内存被划分为代码段,数据段,堆,共享区,栈,内核区域。malloc分配的内存通常就在堆区,既然操作系统没办法为我们维护协程栈,那我们自己malloc一块内存,将其用作协程栈不就行了。可是,这明明是堆啊,函数栈桢的入栈时,怎么能入到这块堆内存呢?其实很简单,再回顾下上面的结构图,是不是有两个%rbp和%rsp指针?分别指向了当前函数栈桢的栈底和栈顶。%rbp和%rsp是两个寄存器,我们程序是可以改变它们的,只需要将其指向我们申请的堆内存,那么对操作系统而言,这块内存就是栈了,函数调用时新的函数栈桢就会从这块内存往下分配(寄存器%rsp向下移动),函数执行结束就会从这块内存回收函数栈桢(寄存器%rsp向上移动)。而协程间的切换,对Go语言来说,也不过是寄存器%rbp和%rsp的保存以及恢复了。 &emsp;&emsp;这下我们明白了,协程就是堆当栈用而已,每一个协程都对应一个协程栈;那么,调度程序呢?肯定也需要一个执行栈吧,创建协程M时,操作系统本身就帮我们维护了线程栈,而我们的调度程序直接使用这个线程栈就行了,Go语言将运行在这个线程栈的调度逻辑,称为g0协程。 ## GMP调度模型深入理解 &emsp;&emsp;协程创建/调度相关函数基本都定义在runtime/proc.go文件,go关键字在编译阶段会替换为函数runtime.newproc(fn * funcval),其中fn就是待创建协程的入口函数;协程调度主函数为runtime.schedule,该函数查询可运行协程并执行。另外,GMP模型中的G对应着结构 struct g,M对应着结构struct m,P对应着结构struct p。GMP定一如下 ``` type m struct { //g0就是调度"协程" g0 *g // goroutine with scheduling stack //当前正在调度执行的协程 curg *g // current running goroutine //当前绑定的P p puintptr // attached p for executing go code (nil if not executing go code) } type g struct { //协程id goid int64 //协程栈 stack stack // stack describes the actual stack memory: [stack.lo, stack.hi) //当前协程在哪个M m *m // current m; //协程上下文,保存着当前协程栈的bp(%rbp)、sp(%rsp),pc(下一条指令地址) sched gobuf } type p struct { //状态:如空闲,正在运行(已经绑定在M上)等等 status uint32 // one of pidle/prunning/... //当前绑定的m m muintptr // back-link to associated m (nil if idle) //协程队列(循环队列) runq [256]guintptr } ``` &emsp;&emsp;我们一直强调,M必须绑定P,才能调度协程。Go语言定义了多种P的状态,如被M绑定,如正在执行系统调用等等: ``` const ( // _Pidle means a P is not being used to run user code or the scheduler. _Pidle = iota // _Prunning means a P is owned by an M and is being used to run user code or the scheduler. _Prunning // _Psyscall means a P is not running user code. _Psyscall //正在执行系统调用 // _Pgcstop means a P is halted for STW and owned by the M that stopped the world. _Pgcstop //垃圾回收可能需要暂停所有用户代码,暂停所有的P // _Pdead means a P is no longer used (GOMAXPROCS shrank) _Pdead ) ``` &emsp;&emsp;结合GMP结构的定义,以及我们对协程栈的理解,可以得到下面的示意图: ![2.1-4 (1).png](https://static.golangjob.cn/220927/7c154a8372af9db265b57e06d3d58dbb.png) &emsp;&emsp;每一个M线程都有一个调度协程g0,调度协程执行schedule函数查询可运行协程并执行。每一个协程都有一个协程栈,这个栈不是操作系统维护的,而是Go语言在堆(heap)上申请的一块内存。gobuf定义了协程上下文结构,包括寄存器bp、sp(指向协程栈),以及寄存器pc(指向下一条指令地址,即代码段)。 &emsp;&emsp;现在应该理解了,为什么我们说协程是用户态线程。因为协程和线程一样可以并发执行,协程和线程一样拥有自己的栈桢;但是,操作系统只知道线程,协程是由Go语言也就是用户态代码调度执行的,并且协程的调度切换不需要陷入内核态(其实就是协程栈的切换),只需要在用户态保存以及恢复若干寄存器就行了。另外,我们常说的调度器其实可以理解为schedule函数,该函数运行在线程栈(Go语言中称之为调度栈)。 ## 总结 &emsp;&emsp;本篇文章是并发编程的入门,简单介绍了协程、管道,锁等的基本使用;针对GMP并发模型,重点介绍了其基本概念,以及GMP的结构定义。为了掌握GMP概念,一定要重点理解虚拟内存结构,线程栈桢结构。

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

568 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传