您当前的位置:首页 > 电脑百科 > 程序开发 > 语言 > Go语言

并发问题的解决思路以及Go语言调度器工作原理

时间:2020-07-02 10:35:25  来源:  作者:

今天的文章我首先说一下之前文章里的思考题的解决思路,我会给出完整可运行的代码。之后通过观察程序的运行结果里的现象简单介绍 Go 语言的调度器是如何对 goroutine 进行调度的。

回答之前的问题

先来回顾一下上周文章里思考题的题目:

假设有一个超长的切片,切片的元素类型为int,切片中的元素为乱序排列。限时5秒,使用多个goroutine查找切片中是否存在给定值,在找到目标值或者超时后立刻结束所有goroutine的执行。

比如切片为:[23, 32, 78, 43, 76, 65, 345, 762, ...... 915, 86],查找的目标值为345,如果切片中存在目标值程序输出:"Found it!"并且立即取消仍在执行查找任务的 goroutine 。如果在超时时间未找到目标值程序输出:"Timeout! Not Found",同时立即取消仍在执行查找任务的 goroutine 。

首先题目里提到了 在找到目标值或者超时后立刻结束所有goroutine的执行 ,完成这两个功能需要借助计时器、通道和 context 才行。我能想到的第一点就是要用 context.WithCancel 创建一个上下文对象传递给每个执行任务的 goroutine ,外部在满足条件后(找到目标值或者已超时)通过调用上下文的取消函数来通知所有 goroutine 停止工作。

func main() {
    timer := time.NewTimer(time.Second * 5)
    ctx, cancel := context.WithCancel(context.Background())
    resultChan := make(chan bool)
  ......
    select {
    case <-timer.C:
        fmt.Fprintln(os.Stderr, "Timeout! Not Found")
        cancel()
    case <- resultChan:
        fmt.Fprintf(os.Stdout, "Found it!n")
        cancel()
    }
}

执行任务的 goroutine 们如果找到目标值后需要通知外部等待任务执行的主 goroutine ,这个工作是典型的应用通道的场景,上面代码也已经看到了,我们创建了一个接收查找结果的通道,接下来要做的就是把它和上下文对象一起传递给执行任务的 goroutine 。

func SearchTarget(ctx context.Context, data []int, target int, resultChan chan bool) {
    for _, v := range data {
        select {
        case <- ctx.Done():
            fmt.Fprintf(os.Stdout, "Task cancelded! n")
            return
        default:
        }
        // 模拟一个耗时查找,这里只是比对值,真实开发中可以是其他操作
        fmt.Fprintf(os.Stdout, "v: %d n", v)
        time.Sleep(time.Millisecond * 1500)
        if target == v {
            resultChan <- true
            return
        }
    }

}

在执行查找任务的 goroutine 里接收上下文的取消信号,为了不阻塞查找任务,我们使用了 select 语句加 default 的组合:

select {
case <- ctx.Done():
    fmt.Fprintf(os.Stdout, "Task cancelded! n")
    return
default:
}

在 goroutine 里面如果找到了目标值,则会通过发送一个 true 值给 resultChan ,让外面等待的主 goroutine 收到一个已经找到目标值的信号。

resultChan <- true

这样通过上下文的 Done 通道和 resultChan 通道, goroutine 们就能相互通信了。

Go 语言中最常见的、也是经常被人提及的设计模式 — 不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存

完整的源代码如下:

package main

import (
    "context"
    "fmt"
    "os"
    "time"
)

func main() {
    timer := time.NewTimer(time.Second * 5)
    data := []int{1, 2, 3, 10, 999, 8, 345, 7, 98, 33, 66, 77, 88, 68, 96}
    dataLen := len(data)
    size := 3
    target := 345
    ctx, cancel := context.WithCancel(context.Background())
    resultChan := make(chan bool)
    for i := 0; i < dataLen; i += size {
        end := i + size
        if end >= dataLen {
            end = dataLen - 1
        }
        go SearchTarget(ctx, data[i:end], target, resultChan)
    }
    select {
    case <-timer.C:
        fmt.Fprintln(os.Stderr, "Timeout! Not Found")
        cancel()
    case <- resultChan:
        fmt.Fprintf(os.Stdout, "Found it!n")
        cancel()
    }

    time.Sleep(time.Second * 2)
}

func SearchTarget(ctx context.Context, data []int, target int, resultChan chan bool) {
    for _, v := range data {
        select {
        case <- ctx.Done():
            fmt.Fprintf(os.Stdout, "Task cancelded! n")
            return
        default:
        }
        // 模拟一个耗时查找,这里只是比对值,真实开发中可以是其他操作
        fmt.Fprintf(os.Stdout, "v: %d n", v)
        time.Sleep(time.Millisecond * 1500)
        if target == v {
            resultChan <- true
            return
        }
    }

}

为了打印演示结果所以加了几处 time.Sleep ,这个程序更多的是提供思路框架,所以细节的地方没有考虑。有几位读者把他们的答案发给了我,其中有一位的提供的答案在代码实现上考虑的更全面,这个我们放到文末再说。

上面程序的执行结果如下:

v: 1 
v: 88 
v: 33 
v: 10 
v: 345 
Found it!
v: 2 
v: 999 
Task cancelded! 
v: 68 
Task cancelded! 
Task cancelded!

因为是并发程序所以每次打印的结果的顺序是不一样的,这个你们可以自己试验一下。而且也并不是先开启的 goroutine 就一定会先执行,主要还是看调度器先调度哪个。

Go语言调度器

所有应用程序都是运行在操作系统上,真正用来干活(计算)的是 CPU 。所以谈到 Go 语言调度器,我们也绕不开操作系统、进程与线程这些概念。线程是操作系统调度时的最基本单元,而 linux 在调度器并不区分进程和线程的调度,它们在不同操作系统上也有不同的实现,但是在大多数的实现中线程都属于进程。

多个线程可以属于同一个进程并共享内存空间。因为多线程不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之间的通信也正是基于共享的内存进行的,与重量级的进程相比,线程显得比较轻量。

虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1 兆以上的内存空间,在对线程进行切换时不止会消耗较多的内存,恢复寄存器中的内容还需要向操作系统申请或者销毁对应的资源。

大量的线程出现了新的问题

  • 高内存占用
  • 调度的CPU高消耗

然后工程师们就发现,其实一个线程分为"内核态"线程和"用户态"线程。

一个 用户态线程 必须要绑定一个 内核态线程 ,但是CPU并不知道有 用户态线程 的存在,它只知道它运行的是一个 内核态线程 (Linux的PCB进程控制块)。这样,我们再去细化分类,内核线程依然叫线程(thread),用户线程叫协程(co-routine)。既然一个协程可以绑定一个线程,那么也可以通过实现协程调度器把多个协程与一个或者多个线程进行绑定。

Go 语言的 goroutine 来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。

Go 中,协程被称为 goroutine ,它非常轻量,一个 goroutine 只占几KB,并且这几KB就足够 goroutine 运行完,这就能在有限的内存空间内支持大量 goroutine ,支持了更多的并发。虽然一个 goroutine 的栈只占几KB,但实际是可伸缩的,如果需要更多内存, runtime 会自动为 goroutine 分配。

既然我们知道了 goroutine 和系统线程的关系,那么最关键的一点就是实现协程调度器了。

Go 目前使用的调度器是2012年重新设计的,因为之前的调度器性能存在问题,所以使用4年就被废弃了。重新设计的调度器使用 G-M-P 模型并一直沿用至今。

并发问题的解决思路以及Go语言调度器工作原理

 

调度器G-M-P模型

  • G — 表示 goroutine,它是一个待执行的任务;
  • M — 表示操作系统的线程,它由操作系统的调度器调度和管理;
  • P — 表示处理器,它可以被看做运行在线程上的本地调度器;

G

gorotuine 就是 Go 语言调度器中待执行的任务,它在运行时调度器中的地位与线程在操作系统中差不多,但是它占用了更小的内存空间,也降低了上下文切换的开销。

goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态提供的线程,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的 CPU 。

M

Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。

在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数,我们也可以使用 runtime.GOMAXPROCS 来改变程序中最大的线程数。一个四核机器上会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的 runtime.m 结构体。

在大多数情况下,我们都会使用 Go 的默认设置,也就是活跃线程数等于 CPU 个数,在这种情况下不会触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少非常多的额外开销。

操作系统线程在 Go 语言中会使用私有结构体 runtime.m 来表示

type m struct {
    g0   *g 
    curg *g
    ...
}

其中 g0 是持有调度栈的 goroutine , curg 是在当前线程上运行的用户 goroutine ,用户 goroutine 执行完后,线程切换回 g0 上, g0 会从线程 M 绑定的 P 上的等待队列中获取 goroutine 交给线程。

P

调度器中的处理器 P 是线程和 goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 goroutine ,它能在 goroutine 进行一些 I/O 操作时及时切换,提高线程的利用率。因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS ,这些处理器会绑定到不同的内核线程上并利用线程的计算资源运行 goroutine 。

此外在调度器里还有一个全局等待队列,当所有P本地的等待队列被占满后,新创建的 goroutine 会进入全局等待队列。 P 的本地队列为空后, M 也会从全局队列中拿一批待执行的 goroutine 放到 P 本地的等待队列中。

GMP模型图示

并发问题的解决思路以及Go语言调度器工作原理

 

GMP模型图示

  • 全局队列:存放等待运行的G。
  • P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G时,G优先加入到P的本地队列,如果队列已满,则会把本地队列中一半的G移动到全局队列。
  • P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。
  • M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。
  • goroutine 调度器和OS调度器是通过M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU上执行。

调度器的策略

调度器的一个策略是尽可能的复用现有的活跃线程,通过以下两个机制提高线程的复用:

  1. work stealing机制,当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。
  2. hand off机制,当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。

Go 的运行时并不具备操作系统内核级的硬件中断能力,基于工作窃取的调度器实现,本质上属于先来先服务的协作式调度,为了解决响应时间可能较高的问题,目前运行时实现了协作式调度和抢占式调度两种不同的调度策略,保证在大部分情况下,不同的 G 能够获得均匀的 CPU 时间片。

协作式调度依靠被调度方主动弃权,系统监控到一个 goroutine 运行超过10ms会通过 runtime.Gosched 调用主动让出执行机会。抢占式调度则依靠调度器强制将被调度方被动中断。



Tags:Go语言 调度器   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,如有任何标注错误或版权侵犯请与我们联系(Email:2595517585@qq.com),我们将及时更正、删除,谢谢。
▌相关推荐
今天的文章我首先说一下之前文章里的思考题的解决思路,我会给出完整可运行的代码。之后通过观察程序的运行结果里的现象简单介绍 Go 语言的调度器是如何对 goroutine 进行调...【详细内容】
2020-07-02  Tags: Go语言 调度器  点击:(29)  评论:(0)  加入收藏
▌简易百科推荐
zip 是一种常见的归档格式,本文讲解 Go 如何操作 zip。首先看看 zip 文件是如何工作的。以一个小文件为例:(类 Unix 系统下)$ cat hello.textHello!执行 zip 命令进行归档:$ zip...【详细内容】
2021-12-17  Go语言中文网    Tags:Go语言   点击:(13)  评论:(0)  加入收藏
大家好,我是 polarisxu。前段时间,Russ Cox 明确了泛型相关的事情,原计划在标准库中加入泛型相关的包,改放到 golang.org/x/exp 下。目前,Go 泛型的主要设计者 ianlancetaylor 完...【详细内容】
2021-11-30  Go语言中文网    Tags:slices 包   点击:(24)  评论:(0)  加入收藏
前言最近因为项目需要写了一段时间的 Go ,相对于 Java 来说语法简单同时又有着一些 Python 之类的语法糖,让人大呼”真香“。 但现阶段相对来说还是 Python 写的多一些,偶尔还...【详细内容】
2021-11-25  crossoverJie    Tags:Go   点击:(29)  评论:(0)  加入收藏
go-micro是基于 Go 语言用于开发的微服务的 RPC 框架,主要功能如下:服务发现,负载均衡 ,消息编码,请求/响应,Async Messaging,可插拔接口,最后这个功能牛p安装步骤安装proto...【详细内容】
2021-09-06    石老师小跟班  Tags:go-micro   点击:(197)  评论:(0)  加入收藏
GoLand 2021.2 EAP 5 现已发布。用户可以从工具箱应用程序中获得 EAP 构建,也可以从官方网站手动下载。并且从此 EAP 开始,只有拥有有效的 JetBrains 帐户才能加入该计划。手...【详细内容】
2021-06-29  IT实战联盟  今日头条  Tags:GoLand   点击:(185)  评论:(0)  加入收藏
作者:HDT3213今天给大家带来的开源项目是 Godis:一个用 Go 语言实现的 Redis 服务器。支持: 5 种数据结构(string、list、hash、set、sortedset) 自动过期(TTL) 发布订阅、地理位...【详细内容】
2021-06-18  HelloGitHub  今日头条  Tags:Go   点击:(125)  评论:(0)  加入收藏
统一规范篇合理规划目录本篇主要描述了公司内部同事都必须遵守的一些开发规矩,如统一开发空间,既使用统一的开发工具来保证代码最后的格式的统一,开发中对文件和代码长度的控制...【详细内容】
2021-05-18  1024课堂    Tags:Go语言   点击:(232)  评论:(0)  加入收藏
闭包概述 闭包不是Go语言独有的概念,在很多编程语言中都有闭包 闭包就是解决局部变量不能被外部访问的一种解决方案 是把函数当作返回值的一种应用 代码演示总体思想:在函数...【详细内容】
2021-05-14  HelloGo  今日头条  Tags:Go语言   点击:(223)  评论:(0)  加入收藏
一时想不开,想了解一下Go语言,于是安装了并体验了一下。下载1. 进入golang.google.cn 点击Download Go 2.选择对应的操作系统,点击后开始下载。 安装1. windows下执行傻瓜式安...【详细内容】
2021-05-12  程序员fearlazy  fearlazy  Tags:Go语言   点击:(236)  评论:(0)  加入收藏
1.简介channel是Go语言的一大特性,基于channel有很多值得探讨的问题,如 channel为什么是并发安全的? 同步通道和异步通道有啥区别? 通道为何会阻塞协程? 使用通道导致阻塞的协程...【详细内容】
2021-05-10  程序员麻辣烫  今日头条  Tags:Go通道   点击:(274)  评论:(0)  加入收藏
最新更新
栏目热门
栏目头条