关于go:21-GolangGo并发编程GMP调度模型概述

2次阅读

共计 6099 个字符,预计需要花费 16 分钟才能阅读完成。

  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 调度模型如下所示:

  对 GMP 概念有了简略的理解后,该深究下咱们的重点 G 了。协程到底是什么呢?创立一个协程只是创立了一个构造体变量吗?还须要其余什么吗?

  同样的想想咱们所熟知的线程,创立一个线程,操作系统会调配对应的线程栈,线程切换时候,操作系统会保留线程上下文,同时复原另一个线程上下文。协程须要协程栈吗?要答复这个问题,先要说分明线程栈是什么?如下图所示:

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

  协程须要并发执行吗?必定须要。那协程必定也须要协程栈,不然多个协程的函数调用栈不就混了。只是,线程创立后,操作系统主动调配线程栈,而操作系统压根不晓得协程,怎么为其调配协程栈呢?

  虚拟内存构造理解吗?如上图所示,虚拟内存被划分为代码段,数据段,堆,共享区,栈,内核区域。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 构造的定义,以及咱们对协程栈的了解,能够失去上面的示意图:

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

  当初应该了解了,为什么咱们说协程是用户态线程。因为协程和线程一样能够并发执行,协程和线程一样领有本人的栈桢;然而,操作系统只晓得线程,协程是由 Go 语言也就是用户态代码调度执行的,并且协程的调度切换不须要陷入内核态(其实就是协程栈的切换),只须要在用户态保留以及复原若干寄存器就行了。另外,咱们常说的调度器其实能够了解为 schedule 函数,该函数运行在线程栈(Go 语言中称之为调度栈)。

总结

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

正文完
 0