Go协程-goroutine


Go协程-goroutine

参考
Go语言底层原理剖析 - 第14、15章 协程




1 协程基础

1.1 进程与线程

协程可以看作轻量的线程,因此为了深入理解协程,必须对进程、线程及上下文切换等概念有所了解。

在计算机科学中,线程是可以由调度程序(通常是操作系统的一部分)独立管理的最小程序指令集而进程是程序运行的实例。简单来说,线程是程序执行的基本单位,进程是资源管理的基本单位
线程是进程的组成部分,一个进程可以包含多个线程,这些线程并发执行并共享进程的内存(例如全局变量)等资源。而进程之间相对独立,不同进程具有不同的内存地址空间、代表程序运行的机器码、进程状态、操作系统资源描述符等。

线程与进程的区别

1.2 线程上下文切换

在多核CPU上,线程可以分布在多个CPU核心上,从而实现真正的并行处理。为了平衡每个线程能够被CPU处理的时间并最大化利用CPU资源,操作系统需要在适当的时间通过定时器中断(Timer Interrupt)、I/O设备中断、系统调用时执行上下文切换(Context Switch)。

当发生线程上下文切换时,需要从操作系统用户态转移到内核态,记录上一个线程的重要寄存器值(例如栈寄存器SP)、进程状态等信息,这些信息存储在操作系统线程控制块(Thread Control Block)中。当切换到下一个要执行的线程时,需要加载重要的CPU寄存器值,并从内核态转移到操作系统用户态。​


1.3 线程与协程

一般来说,协程被认为是轻量级的线程。下面从调度方式、上下文切换的速度、调度策略、栈的大小这四个方面分析线程与协程的不同。

1.3.1 调度方式

线程不同的是,协程是用户态的,操作系统内核感知不到协程的存在,协程的管理依赖Go语言运行时自身提供的调度器。Go语言中的协程是从属于某一个线程的,协程与线程的对应关系为M:N,即多对多。Go语言调度器可以将多个协程调度到一个线程中,一个协程也可能切换到多个线程中执行。

线程与协程的对应关系

1.3.2 上下文切换的速度

由于协程切换无须OS用户态和内核态的切换,只需要保留极少的状态和寄存器变量值(SP/BP/PC),而线程切换会保留额外的寄存器变量值(例如浮点寄存器),所以协程(0.2微秒)的速度要比线程快(1~2微秒)。

1.3.3 调度策略

线程的调度在大部分时间是抢占式的,操作系统调度器为了均衡每个线程的执行周期,会定时发出中断信号强制执行线程上下文切换。
而Go语言中的协程在一般情况下是协作式调度的,当一个协程处理完自己的任务后,可以主动将执行权限让渡给其他协程。这意味着协程可以更好地在规定时间内完成自己的工作,而不会轻易被抢占。当一个协程运行了过长时间时,Go语言调度器才会强制抢占其执行。

1.3.4 栈的大小

线程的栈大小一般是在创建时指定的,为了避免出现栈溢出(Stack Overflow),默认的栈会相对较大(例如2MB)而且在运行时不能更改。
Go语言中的协程栈默认为2KB,在Go运行时的帮助下会动态检测栈的大小,并动态地进行扩容。


1.4 并发与并行

并发指同时处理多个任务的能力,并不意味着同一时刻所有任务都在执行,而是在一个时间段内,所有的任务都能执行完毕。
并行指同时处理多个任务的状态,多核处理器是其必要条件。
由于Go语言中的协程依托于线程,所以即便处理器运行的是同一个线程,在线程内Go语言调度器也会切换多个协程执行,这时协程是并发的。如果多个协程被分配给了不同的线程,而这些线程同时被不同的CPU核心处理,那么这些协程就是并行处理的。

并发与并行的区别


2 协程入门

首先用一个例子来介绍如何使用协程,以及使用协程所带来的性能提升。

2.1 协程实践

2.1.1 顺序执行

package main

import (
    "fmt"
    "net/http"
    "time"
)

var links = []string{
    "http://www.baidu.com",
    "http://www.jd.com",
    "https://www.taobao.com",
    "https://www.163.com",
    "https://www.sohu.com",
}

func checkLink(link string) {
    _, err := http.Get(link)
    if err != nil {
        fmt.Println(link, "  --> might be down!")
        return
    }
    fmt.Println(link, "  --> is up!")
}

func main() {
    start := time.Now()
    for _, link := range links {
        checkLink(link) // 顺序执行
    }
    fmt.Println("cost:", time.Now().Sub(start))
}

// http://www.baidu.com   --> is up!
// http://www.jd.com   --> is up!
// https://www.taobao.com   --> is up!
// https://www.163.com   --> is up!
// https://www.sohu.com   --> is up!
// cost: 579.066375ms

对5个url进行顺序请求,必须等待前一个请求执行完毕,后一个请求才能继续执行。

2.1.1 并行执行

package main

import (
    "fmt"
    "net/http"
    "sync"
    "time"
)

var wg sync.WaitGroup
var links = []string{
    "http://www.baidu.com",
    "http://www.jd.com",
    "https://www.taobao.com",
    "https://www.163.com",
    "https://www.sohu.com",
}

func checkLink(link string) {
    _, err := http.Get(link)
    if err != nil {
        fmt.Println(link, "  --> might be down!")
        return
    }
    fmt.Println(link, "  --> is up!")
    wg.Done()
}

func main() {
    start := time.Now()
    for _, link := range links {
        wg.Add(1)
        go checkLink(link) // 并行执行
    }
    wg.Wait()
    fmt.Println("cost:", time.Now().Sub(start))
}

// http://www.baidu.com   --> is up!
// https://www.sohu.com   --> is up!
// https://www.163.com   --> is up!
// http://www.jd.com   --> is up!
// https://www.taobao.com   --> is up!
// cost: 241.047917ms

上面对url并行请求,使用waitgroup控制子协程的fork-in,主协程需要等待子协程都完成才能退出。如果不使用waitgroup,主协程会直接退出并不会等待子协程。对比运行时间,并行执行要比顺序执行快得多。




3 协程进阶

3.1 GMP模型

前面提到线程与协程之间是M:N的关系,GMP模型描述了协程是如何依托线程调度到CPU中执行的。

G代表的是Go语言中的协程(Goroutine),M代表的是实际的线程,而P代表的是Go逻辑处理器(Process)。Go语言为了方便协程调度与缓存,抽象出了逻辑处理器。

GMP模型

一个P可能在其本地包含多个G,同时,一个P在任一时刻只能绑定一个M。M与P、P与G之间并不是固定绑定的。


3.2 协程调度-基础篇

3.2.1 协程生命周期与状态转移

为了便于对协程进行管理,Go语言的调度器将协程分为多种状态。

协程的状态与转移

3.2.2 g0协程与协程切换

每个线程中都有一个特殊的协程g0,协程g0运行在操作系统线程栈上,其作用主要是执行协程调度的一系列运行时代码,而一般的协程无差别地用于执行用户代码。

在用户协程退出或者被抢占时,意味着需要重新执行协程调度,这时需要从用户协程g切换到协程g0,g0再调度新的协程。g→g0→g的过程叫作协程的上下文切换,切换时需要保存执行现场,即三个寄存器:rsp、rip、rbp。rsp寄存器始终指向函数调用栈栈顶,rip寄存器指向程序要执行的下一条指令的地址,rbp存储了函数栈帧的起始位置。

g.gobuf

3.2.3 线程绑定

线程本地存储的实际是结构体m中m.tls的地址,同时m.tls[0]会存储当前线程正在运行的协程g的地址,因此在任意一个线程内部,通过线程本地存储,都可以在任意时刻获取绑定到当前线程上的协程g、结构体m、逻辑处理器P、特殊协程g0等信息。

m.tls

3.3 协程调度-核心篇

3.3.1 调度循环

从协程g0调度到协程g,经历了从schedule函数到execute函数再到gogo函数的过程。

调度循环

3.3.2 运行队列-全局&局部

Go语言调度器将运行队列分为局部运行队列与全局运行队列。

局部运行队列是每个P特有的长度为256的数组runq,该数组模拟了一个循环队列,其中runqhead标识了循环队列的开头,runqtail标识了循环队列的末尾。每次将G放入本地队列时,都从循环队列的末尾插入,而获取时从循环队列的头部获取。在每个P内部还有一个特殊的runnext字段,标识下一个要执行的协程。
全局运行队列存储在schedt.runq中,被所有P共享。

改进后的GMP模型

3.3.3 调度过程

调度协程的优先级与顺序

3.3.4 调度时机

3.3.4.1 主动调度

协程可以选择主动让渡自己的执行权利,这主要是通过用户在代码中执行runtime.Gosched函数实现的。主动调度的原理比较简单,需要先从当前协程切换到协程g0,取消G与M之间的绑定关系,将G放入全局运行队列,并调用schedule函数开始新一轮的循环。

3.3.4.2 被动调度

被动调度指协程在休眠、channel通道堵塞、网络I/O堵塞、执行垃圾回收而暂停时,被动让渡自己执行权利的过程,可以保证最大化利用CPU资源。
和主动调度类似的是,被动调度需要先从当前协程切换到协程g0,更新协程的状态并解绑与M的关系,重新调度。和主动调度不同的是,被动调度不会将G放入全局运行队列,因为当前G的状态不是_Grunnable而是_Gwaiting,所以,被动调度需要一个额外的唤醒机制。

3.3.4.3 抢占调度

为了让每个协程都有执行的机会,并且最大化利用CPU资源,Go语言在初始化时会启动一个特殊的线程来执行系统监控任务。系统监控在一个独立的M上运行,不用绑定逻辑处理器P,系统监控每隔10ms会检测是否有准备就绪的网络协程,并放置到全局队列中。和抢占调度相关的是,系统监控服务会判断当前协程是否运行时间过长,或者处于系统调用阶段,如果是,则会抢占当前G的执行。其核心逻辑位于runtime.retake函数中。

To:信号强制抢占




文章作者: xuxiangfei
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 xuxiangfei !
  目录