5

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

 1 year ago
source link: https://studygolang.com/articles/35879
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

tomato01 · 4分钟之前 · 75 次点击 · 预计阅读时间 9 分钟 · 大约8小时之前 开始浏览    

  Go语言天然具备并发特性,基于go关键字就能很方便的创建协程去执行一些并发任务,而且基于协程-管道的CSP并发编程模型,相比于传统的多线程同步方案,可以说简单太多了。从本篇文章开始,将为大家介绍Go语言的核心:并发编程;不仅包括协程/管道/锁等的基本使用,还会深入到协程实现原理,GMP协程调度模型等。

并发编程入门

  设想下我们有这么一个服务/接口:需要从其他三个服务获取数据,处理后返回给客户端,并且这三个服务不互相依赖。这时候一般如何处理呢?如果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()
    }()
}

  main函数默认在主协程执行,而且一旦main函数执行结束,也意味这主协程执行协程,整个Go程序就会结束(与多线程程序比较类似)。主协程需要等待子协程任务执行结束,但是协程的调度执行确实随机的,go关键字只是创建协程,通常并不会立即调度执行该协程;所以如果没有一些同步手段,主协程可能以及执行完毕了,子协程还没有调度执行,也就是任务还没开始执行。sync.WaitGroup常用于多协程之间的并发控制,wg.Add标记一个异步任务的开始,主协程wg.Wait会一直阻塞,直到所有异步任务执行结束,所以我们再异步任务的最后调用了方法wg.Done,标记当前异步任务执行结束。这样当子协程全部执行完毕后,主协程就会解除阻塞。不过还有一个问题,主协程如何获取到子协程的返回数据呢?想想最简单的方式,函数asyncWork添加一个指针类型的输入参数,作为返回值呢?或者也能通过管道chan实现协程之间的数据传递。

  管道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)
    }
}

  管道chan可以声明多种类型,如chan int只能存储int类型数据;chan还有容量的改变,也就是最大能存储的数据量,如果chan容量已经满了,向chan写入数据会阻塞,所以通常可以通过容量限制异步任务的最大并发量。另外,如果chan没有数据,从chan读取数据也会阻塞。上面程序启动了10个子协程处理消息,而主协程循环向chan写入数据,模拟消息的消费过程。

  在使用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())
    }
}

  最后,我们再回顾一下讲解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)
}

  sync.Mutex是Go语言提供的排他锁,可以看到我们在操作map之前执行Lock方法加锁,操作map时候通过Unlock方法释放锁;这时候运行程序就不会出现panic异常了。不过要注意的是,锁可能会导致当前协程长时间阻塞,所以在C端高并发服务中,要慎用锁。

GMP调度模型基本概念

  在介绍Go语言协程调度模型之前,我们先思考几个问题:

  • 到底什么是协程呢?多协程为什么能并发执行呢?想想我们了解的线程,每一个线程都有一个栈桢,操作系统负责调度线程,线程切换必然伴随着栈桢的切换。协程有栈桢吗?协程的调度由谁维护呢?
  • 都说协程是用户态线程,用户态是什么意思呢?协程与线程又有什么关系呢?协程的创建以及切换不需要陷入内核态吗?
  • Go语言是如何管理以及调度这些成千上万个协程呢?和操作系统一样,维护着可运行队列和阻塞队列吗,有没有所谓的按照时间片或者是优先级或者是抢占式调度呢?
  • 用户程序读写socket的时候,可能阻塞当前协程,难道读写socket都是阻塞式调用吗?Go语言是如何实现高性能网络IO呢?有没有用传说中的epoll呢?
  • Go程序如何执行系统调用呢?要知道操作系统只能感知到线程,而系统调用可能会阻塞当前线程,线程阻塞了,那么协程呢?

  这些问题你可能了解一些,可能不了解,不了解也不用担心,从本篇文章开始,将为你详细介绍Go语言的协程调度模型,看完之后,这些问题将不在话下。

  说起GMP,可能都或多或少了解一些,G是协(goroutine)程,M是线程(machine),P是逻辑处理器(processor);那么为什么要这么设计呢?

  想想之前我们所熟知的线程,其由操作系统调度;现在我们需要创建协程,协程由谁调度呢?当然是我们的线程在执行调度逻辑了。那这么说,我只需要维护一个协程队列,再有个线程就能调度这些协程了呗,还需要P干什么?Go语言v1.0确实就是这么设计的。但是要知道Go语言服务通常会有多个线程,多个线程从全局协程队列获取可运行协程时候,是不是就需要加锁呢?加锁就意味着低效。

  所以后面引入了P(P就只是一个由很多字段的数据结构而已,可以将P理解成为一种资源),一般P的数目会和系统CPU核数保持一致,;M想要调度协程,需要获取并绑定P,P只能被一个M占有,每个P都维护有协程队列,那这时候线程M调度协程,是不是只需要从当前绑定的P获取即可,也就不需要加锁了。后续很多设计都采取了这种思想,包括定时器,内存分配等逻辑,都是通过将共享数据关联到P上来避免加锁。另外,为了避免多个P负载分配不均衡,还有一个全局队列sched.runq(协程有些情况会添加到全局队列),如果当前P的协程队列为空,M还可以从全局队列查找可运行G,当然这时候就需要加锁了。

  此时,GMP调度模型如下所示:

2.1-1.png

  对GMP概念有了简单的了解后,该深究下我们的重点G了。协程到底是什么呢?创建一个协程只是创建了一个结构体变量吗?还需要其他什么吗?

  同样的想想我们所熟知的线程,创建一个线程,操作系统会分配对应的线程栈,线程切换时候,操作系统会保存线程上下文,同时恢复另一个线程上下文。协程需要协程栈吗?要回答这个问题,先要说清楚线程栈是什么?如下图所示:

2.1-2 (1).png

  函数调用或者返回过程中,就伴随着函数栈桢的入栈以及出栈,我们函数中的局部变量,以及入参,返回值等,很多时候都是分配在栈上的。函数调用栈是有链接关系的,非常类似于单向链表结构。多个线程是需要并发执行的,那必然就存在多个函数调用栈。

  协程需要并发执行吗?肯定需要。那协程肯定也需要协程栈,不然多个协程的函数调用栈不就混了。只是,线程创建后,操作系统自动分配线程栈,而操作系统压根不知道协程,怎么为其分配协程栈呢?

2.1-3.png

  虚拟内存结构了解吗?如上图所示,虚拟内存被划分为代码段,数据段,堆,共享区,栈,内核区域。malloc分配的内存通常就在堆区,既然操作系统没办法为我们维护协程栈,那我们自己malloc一块内存,将其用作协程栈不就行了。可是,这明明是堆啊,函数栈桢的入栈时,怎么能入到这块堆内存呢?其实很简单,再回顾下上面的结构图,是不是有两个%rbp和%rsp指针?分别指向了当前函数栈桢的栈底和栈顶。%rbp和%rsp是两个寄存器,我们程序是可以改变它们的,只需要将其指向我们申请的堆内存,那么对操作系统而言,这块内存就是栈了,函数调用时新的函数栈桢就会从这块内存往下分配(寄存器%rsp向下移动),函数执行结束就会从这块内存回收函数栈桢(寄存器%rsp向上移动)。而协程间的切换,对Go语言来说,也不过是寄存器%rbp和%rsp的保存以及恢复了。

  这下我们明白了,协程就是堆当栈用而已,每一个协程都对应一个协程栈;那么,调度程序呢?肯定也需要一个执行栈吧,创建协程M时,操作系统本身就帮我们维护了线程栈,而我们的调度程序直接使用这个线程栈就行了,Go语言将运行在这个线程栈的调度逻辑,称为g0协程。

GMP调度模型深入理解

  协程创建/调度相关函数基本都定义在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
}

  我们一直强调,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
)

  结合GMP结构的定义,以及我们对协程栈的理解,可以得到下面的示意图:

2.1-4.png

  每一个M线程都有一个调度协程g0,调度协程执行schedule函数查询可运行协程并执行。每一个协程都有一个协程栈,这个栈不是操作系统维护的,而是Go语言在堆(heap)上申请的一块内存。gobuf定义了协程上下文结构,包括寄存器bp、sp(指向协程栈),以及寄存器pc(指向下一条指令地址,即代码段)。

  现在应该理解了,为什么我们说协程是用户态线程。因为协程和线程一样可以并发执行,协程和线程一样拥有自己的栈桢;但是,操作系统只知道线程,协程是由Go语言也就是用户态代码调度执行的,并且协程的调度切换不需要陷入内核态(其实就是协程栈的切换),只需要在用户态保存以及恢复若干寄存器就行了。另外,我们常说的调度器其实可以理解为schedule函数,该函数运行在线程栈(Go语言中称之为调度栈)。

  本篇文章是并发编程的入门,简单介绍了协程、管道,锁等的基本使用;针对GMP并发模型,重点介绍了其基本概念,以及GMP的结构定义。为了掌握GMP概念,一定要重点理解虚拟内存结构,线程栈桢结构。


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

280

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


Recommend

  • 17
    • segmentfault.com 4 years ago
    • Cache

    【golang】GMP调度分析

    Golang最大的特色可以说是协程(goroutine)了, 协程让本来很复杂的异步编程变得简单, 让程序员不再需要面对回调地狱, 虽然现在引入了协程的语言越来越多, 但go中的协程仍然是实现的是最彻底的. 这篇文章将通过分析golang...

  • 17
    • studygolang.com 3 years ago
    • Cache

    Golang并发模型之GMP

    从进程谈起 进程与线程的区别是什么?这是一个老生长谈的一道面试题。处于不同层面对该问题的理解也大不相同。对于用户层面来说,进程就是一块运行起来的程序,线程就是程序里的一些并发的功能。对于操作系统层面来说,标准回...

  • 8
    • studygolang.com 3 years ago
    • Cache

    golang的GMP调度

    Golang 调度器四个重要结构 :M P G Sched GMP的结构源码在文件中\src\runtime\runtime2.go 简介 G :goroutine,go程序建立的用户线程。主要保存 goroutine 的运行时栈信息(stack结构体...

  • 2
    • helloteemo.github.io 2 years ago
    • Cache

    GMP调度模型

    GMP调度模型 发表于 2021-07-09 更新于 2021-09-20 分类于 Golang , 基础 阅读次数: 28...

  • 5

    深入理解 Go | 调度:GMP 模型(第一部分) 发表于 2020-04-13...

  • 3

    深入理解 Go | 调度:GMP 模型(第三部分) 发表于 2020-04-14...

  • 4

    深入理解 Go | 调度:GMP 模型(第二部分) 发表于 2020-04-14...

  • 5
    • mingzhi198.github.io 2 years ago
    • Cache

    Golang: GMP调度模型

    Statement This article is my study notes about distributed systems. Please refer to the original work for more details and indicate the source for reprinting. 1. Goroutine Goroutine = Golang...

  • 2
    • fenghaojiang.github.io 2 years ago
    • Cache

    Golang GMP调度模型

    GMP调度 runtime调度器的三个重要组成部分:线程M、Goroutine G和处理器P: G-Goroutine协程,在运行时调度器中跟线程在操作系统差不多,但是用了更小的内存空间。 M-操作系统的线程,由操作系统调度器调度管理。 P-表...

  • 7
    • mikeygithub.github.io 1 year ago
    • Cache

    Golang篇-深入理解GMP调度模型

    Golang篇-深入理解GMP调度模型 - 麦奇 麦奇

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK