运维八一 运维八一
首页
运维杂记
编程浅尝
周积跬步
专栏
生活
关于
收藏
  • 分类
  • 标签
  • 归档
Source (opens new window)

运维八一

运维,运维!
首页
运维杂记
编程浅尝
周积跬步
专栏
生活
关于
收藏
  • 分类
  • 标签
  • 归档
Source (opens new window)
  • Go

    • 前言

    • Go基础知识

    • Go基本语法

    • 实战项目:简单web服务

    • 基本数据类型

    • 内置运算符

    • 分支和循环

    • 函数 function

    • 结构体 struct

    • 方法 method

    • 实战项目:跟踪函数调用链

    • 接口 interface

    • 并发 concurrency

      • 什么是并发
      • goroutine
        • 2. goroutine
          • 2.1 goroutine 基本用法
          • 2.1.1 创建goroutine
          • 2.1.2 退出goroutine
          • 2.2 goroutine 间的通信
          • 2.3 Goroutine 调度器
          • 2.3.1 Goroutine 调度器模型与演化过程
          • 2.3.2 G-P-M 模型
      • select
      • channel
      • 基于共享内存的并发模型
      • 原子操作 atomic包
    • 指针

    • 实战项目:实现轻量级线程池

    • 实战项目:实现TCP服务器

    • go常用包

    • Gin框架

    • go随记

  • Python

  • Shell

  • Java

  • Vue

  • 前端

  • 编程浅尝
  • Go
  • 并发 concurrency
lyndon
2022-06-07
目录

goroutine

# 2. goroutine

goroutine:==由 Go 运行时(runtime)负责调度的、轻量的用户级线程。==

goroutine 优势:

  • 资源占用小,每个 goroutine 的初始栈大小仅为 2k;
  • 由 Go 运行时而不是操作系统调度,goroutine 上下文切换在用户层完成,开销更小;
  • 在语言层面而不是通过标准库提供。goroutine 由go关键字创建,一退出就会被回收或销毁,开发体验更佳;
  • 语言内置 channel 作为 goroutine 间通信原语,为并发设计提供了强大支撑。

# 2.1 goroutine 基本用法

并发是一种能力,它让你的程序可以由若干个代码片段组合而成,并且每个片段都是独立运行的。goroutine 恰恰就是 Go 原生支持并发的一个具体实现。无论是 Go 自身运行时代码还是用户层 Go 代码,都无一例外地运行在 goroutine 中。

# 2.1.1 创建goroutine

Go 语言通过 ==go关键字+函数/方法== 的方式创建一个 goroutine。创建后,新 goroutine 将拥有独立的代码执行流,并与创建它的 goroutine 一起被 Go 运行时调度。

go fmt.Println("I am a goroutine")

var c = make(chan int)
go func(a, b int) {
    c <- a + b
}(3,4)
 
// $GOROOT/src/net/http/server.go
c := srv.newConn(rw)
go c.serve(connCtx)
1
2
3
4
5
6
7
8
9
10

通过 go 关键字,可以基于已有的具名函数 / 方法创建 goroutine,也可以基于匿名函数 / 闭包创建 goroutine。

# 2.1.2 退出goroutine

goroutine 的使用代价很低,Go 官方也推荐多多使用 goroutine。而且,多数情况下,不需要考虑对 goroutine 的退出进行控制:goroutine 的执行函数的返回,就意味着 goroutine 退出。

如果 main goroutine 退出了,那么也意味着整个应用程序的退出。

# 2.2 goroutine 间的通信

传统语言(比如:C++、Java、Python 等)的并发模型是基于对内存的共享的。这种传统的基于共享内存的并发模型很难用,且易错。

传统的编程语言并非面向并发而生的,面对并发的逻辑多是基于操作系统的线程。并发的执行单元(线程)之间的通信,利用的也是操作系统提供的线程或进程间通信的原语,比如:共享内存、信号(signal)、管道(pipe)、消息队列、套接字(socket)等。

Go 语言从设计伊始,就将解决传统并发模型的问题作为 Go 的一个目标,并在新并发模型设计中借鉴了著名计算机科学家Tony Hoare (opens new window)提出的 **CSP(Communicationing Sequential Processes,通信顺序进程)**并发模型。

Tony Hoare 的 CSP 模型旨在简化并发程序的编写,让并发程序的编写与编写顺序程序一样简单。Tony Hoare 认为输入输出应该是基本的编程原语,数据处理逻辑(也就是 CSP 中的 P)只需调用输入原语获取数据,顺序地处理数据,并将结果数据通过输出原语输出就可以了。

CSP 通信模型:

img

在 Go 中,与 P(“Process”)对应的是 goroutine。为了实现 CSP 并发模型中的输入和输出原语,Go 还引入了 goroutine(P)之间的通信原语channel。goroutine 可以从 channel 获取输入数据,再将处理后得到的结果数据通过 channel 输出。通过 channel 将 goroutine(P)组合连接在一起,让设计和编写大型并发系统变得更加简单和清晰,再也不用为那些传统共享内存并发模型中的问题而伤脑筋了。

获取 goroutine 的退出状态,就可以使用 channel 原语实现:

func spawn(f func() error) <-chan error {
    c := make(chan error)

    go func() {
        c <- f()
    }()

    return c
}

func main() {
    c := spawn(func() error {
        time.Sleep(2 * time.Second)
        return errors.New("timeout")
    })
    fmt.Println(<-c)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

在 main goroutine 与子 goroutine 之间建立了一个元素类型为 error 的 channel,子 goroutine 退出时,会将它执行的函数的错误返回值写入这个 channel,main goroutine 可以通过读取 channel 的值来获取子 goroutine 的退出状态。

虽然 CSP 模型已经成为 Go 语言支持的主流并发模型,但 Go 也支持传统的、基于共享内存的并发模型,并提供了基本的低级别同步原语(主要是 sync 包中的互斥锁、条件变量、读写锁、原子操作等)。

并发原语的选择:(使用 channel,还是在低级同步原语保护下的共享内存?)

  • 从程序的整体结构来看,Go 始终推荐以 CSP 并发模型风格构建并发程序,尤其是在复杂的业务层面,这能提升程序的逻辑清晰度,大大降低并发设计的复杂性,并让程序更具可读性和可维护性。
  • 对于局部情况,比如涉及性能敏感的区域或需要保护的结构体数据时,可以使用更为高效的低级同步原语(如 mutex),保证 goroutine 对数据的同步访问。

# 2.3 Goroutine 调度器

一个 Go 程序中可以创建成千上万个并发的 Goroutine,而将这些 Goroutine 按照一定算法放到“CPU”上执行的程序,就被称为 Goroutine 调度器(Goroutine Scheduler)。

Goroutine 调度器的任务:将 Goroutine 按照一定算法放到不同的操作系统线程中去执行。

# 2.3.1 Goroutine 调度器模型与演化过程

Goroutine 调度器的演化过程:

  • 从最初的 G-M 模型、到 G-P-M 模型;
  • 从不支持抢占,到支持协作式抢占,再到支持基于信号的异步抢占。

G-M 模型

每个 Goroutine 对应于运行时中的一个抽象结构:G(Goroutine) ,而被视作“物理 CPU”的操作系统线程被抽象为另外一个结构:M(machine)。调度器的工作就是将 G 调度到 M 上去运行。

2012 年 3 月 28 日,Go 1.0 正式发布。在这个版本中,Go 开发团队实现了一个简单的 Goroutine 调度器。为了更好地控制程序中活跃的 M 的数量,调度器引入了 GOMAXPROCS 变量来表示 Go 调度器可见的“处理器”的最大数量。

这个模型实现起来比较简单,也能正常工作,但是却存在着诸多问题。前英特尔黑带级工程师、现谷歌工程师德米特里 - 维尤科夫(Dmitry Vyukov) (opens new window)在其《Scalable Go Scheduler Design》 (opens new window)一文中指出了 G-M 模型的一个重要不足:限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。这个问题主要体现在这几个方面:

  • 单一全局互斥锁(Sched.Lock) 和集中状态存储的存在,导致所有 Goroutine 相关操作,比如创建、重新调度等,都要上锁;
  • Goroutine 传递问题:M 经常在 M 之间传递“可运行”的 Goroutine,这导致调度延迟增大,也增加了额外的性能损耗;
  • 每个 M 都做内存缓存,导致内存占用过高,数据局部性较差;
  • 由于系统调用(syscall)而形成的频繁的工作线程阻塞和解除阻塞,导致额外的性能损耗。

G-P-M 调度模型

P 是一个“逻辑 Proccessor”,每个 G(Goroutine)要想真正运行起来,首先需要被分配一个 P,也就是进入到 P 的本地运行队列(local runq)中。对于 G 来说,P 就是运行它的“CPU”,可以说:在 G 的眼里只有 P。但从 Go 调度器的视角来看,真正的“CPU”是 M,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。

德米特里 - 维尤科夫改进了 Go 调度器,在 Go 1.1 版本中实现了 G-P-M 调度模型和 work stealing (opens new window) 算法,这个模型一直沿用至今。模型如下图所示:

img

不支持抢占式调度

一旦某个 G 中出现死循环的代码逻辑,那么 G 将永久占用分配给它的 P 和 M,而位于同一个 P 中的其他 G 将得不到调度,出现**“饿死”**的情况。当只有一个 P(GOMAXPROCS=1)时,整个 Go 程序中的其他 G 都将“饿死”。

基于协作的“抢占式”调度

Go 编译器在每个函数或方法的入口处加上了一段额外的代码 (runtime.morestack_noctxt),让运行时有机会在这段代码中检查是否需要执行抢占调度。

德米特里 - 维尤科夫提出了《Go Preemptive Scheduler Design》 (opens new window)并在 Go 1.2 中实现了基于协作的“抢占式”调度。

这种解决方案只能说局部解决了“饿死”问题,只在有函数调用的地方才能插入“抢占”代码(埋点),对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占。

比如,死循环等并没有给编译器插入抢占代码的机会,这就会导致 GC 在等待所有 Goroutine 停止时的等待时间过长,从而导致 GC 延迟 (opens new window),内存占用瞬间冲高;甚至在一些特殊情况下,导致在 STW(stop the world)时死锁。

基于信号的异步抢占

基于系统信号,也就是通过向线程发送信号的方式来抢占正在运行的 Goroutine。

Go 在 1.14 版本中接受了奥斯汀 - 克莱门茨(Austin Clements)的提案 (opens new window),增加了对非协作的抢占式调度的支持。除了这些大的迭代外,Goroutine 的调度器还有一些小的优化改动,比如通过文件 I/O poller 减少 M 的阻塞等。

Go 运行时已经实现了 netpoller,这使得即便 G 发起网络 I/O 操作,也不会导致 M 被阻塞(仅阻塞 G),也就不会导致大量线程(M)被创建出来。但是对于文件 I/O 操作来说,一旦阻塞,那么线程(M)将进入挂起状态,等待 I/O 返回后被唤醒。这种情况下 P 将与挂起的 M 分离,再选择一个处于空闲状态(idle)的 M。如果此时没有空闲的 M,就会新创建一个 M(线程),所以,这种情况下,大量 I/O 操作仍然会导致大量线程被创建。

为了解决这个问题,Go 开发团队的伊恩 - 兰斯 - 泰勒(Ian Lance Taylor)在 Go 1.9 中增加了一个针对文件 I/O 的 Poller (opens new window)的功能,这个功能可以像 netpoller 那样,在 G 操作那些支持监听(pollable)的文件描述符时,仅会阻塞 G,而不会阻塞 M。不过这个功能依然不能对常规文件有效,常规文件是不支持监听的(pollable)。但对于 Go 调度器而言,这也算是一个不小的进步了。

从 Go 1.2 以后,Go 调度器就一直稳定在 G-P-M 调度模型上,尽管有各种优化和改进,但也都是基于这个模型之上的。**那未来的 Go 调度器会往哪方面发展呢?**德米特里 - 维尤科夫在 2014 年 9 月提出了一个新的设计草案文档:《NUMA‐aware scheduler for Go》 (opens new window),作为对未来 Goroutine 调度器演进方向的一个提议,不过至今似乎这个提议也没有列入开发计划。

# 2.3.2 G-P-M 模型

G、P、M 的定义:

  • G: 代表 Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等,而且 G 对象是可以重用的;
  • P: 代表逻辑 processor,P 的数量决定了系统内最大可并行的 G 的数量,P 的最大作用还是其拥有的各种 G 对象队列、链表、一些缓存和状态;
  • M: M 代表着真正的执行计算资源。在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。
// Go 1.12.7 版本
//src/runtime/runtime2.go
type g struct {
    stack      stack   // offset known to runtime/cgo
    sched      gobuf
    goid       int64
    gopc       uintptr // pc of go statement that created this goroutine
    startpc    uintptr // pc of goroutine function
    ... ...
}

type p struct {
    lock mutex

    id          int32
    status      uint32 // one of pidle/prunning/...
  
    mcache      *mcache
    racectx     uintptr

    // Queue of runnable goroutines. Accessed without lock.
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr

    runnext guintptr

    // Available G's (status == Gdead)
    gfree    *g
    gfreecnt int32

    ... ...
}

type m struct {
    g0            *g     // goroutine with scheduling stack
    mstartfn      func()
    curg          *g     // current running goroutine
    ... ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

G 被抢占调度

如果某个 G 没有进行系统调用(syscall)、没有进行 I/O 操作、没有阻塞在一个 channel 操作上,调度器通过使 G 被抢占调度让 G 停下来并调度下一个可运行的 G 。

只要 G 调用函数,Go 运行时就有了抢占 G 的机会。Go 程序启动时,运行时会去启动一个名为 sysmon 的 M(一般称为监控线程),这个 M 的特殊之处在于它不需要绑定 P 就可以运行(以 g0 这个 G 的形式),这个 M 在整个 Go 程序的运行过程中至关重要。

sysmon 被创建的部分代码以及 sysmon 的执行逻辑:

//$GOROOT/src/runtime/proc.go

// The main goroutine.
func main() {
     ... ...
    systemstack(func() {
        newm(sysmon, nil)
    })
    .... ...
}

// Always runs without a P, so write barriers are not allowed.
//
//go:nowritebarrierrec
func sysmon() {
    // If a heap span goes unused for 5 minutes after a garbage collection,
    // we hand it back to the operating system.
    scavengelimit := int64(5 * 60 * 1e9)
    ... ...

    if  .... {
        ... ...
        // retake P's blocked in syscalls
        // and preempt long running G's
        if retake(now) != 0 {
            idle = 0
        } else {
            idle++
        }
       ... ...
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

sysmon 每 20us~10ms 启动一次,sysmon 主要完成了这些工作:

  • 释放闲置超过 5 分钟的 span 内存;
  • 如果超过 2 分钟没有垃圾回收,强制执行;
  • 将长时间未处理的 netpoll 结果添加到任务队列;
  • 向长时间运行的 G 任务发出抢占调度;
  • 收回因 syscall 长时间阻塞的 P;

sysmon 将“向长时间运行的 G 任务发出抢占调度”,这个事情由函数retake实施:

// $GOROOT/src/runtime/proc.go

// forcePreemptNS is the time slice given to a G before it is
// preempted.
const forcePreemptNS = 10 * 1000 * 1000 // 10ms

func retake(now int64) uint32 {
          ... ...
           // Preempt G if it's running for too long.
            t := int64(_p_.schedtick)
            if int64(pd.schedtick) != t {
                pd.schedtick = uint32(t)
                pd.schedwhen = now
                continue
            }
            if pd.schedwhen+forcePreemptNS > now {
                continue
            }
            preemptone(_p_)
         ... ...
}

func preemptone(_p_ *p) bool {
    mp := _p_.m.ptr()
    if mp == nil || mp == getg().m {
        return false
    }
    gp := mp.curg
    if gp == nil || gp == mp.g0 {
        return false
    }

    gp.preempt = true //设置被抢占标志

    // Every call in a go routine checks for stack overflow by
    // comparing the current stack pointer to gp->stackguard0.
    // Setting gp->stackguard0 to StackPreempt folds
    // preemption into the normal stack overflow check.
    gp.stackguard0 = stackPreempt
    return true
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

如果一个 G 任务运行 10ms,sysmon 就会认为它的运行时间太久而发出抢占式调度的请求。一旦 G 的抢占标志位被设为 true,那么等到这个 G 下一次调用函数或方法时,运行时就可以将 G 抢占并移出运行状态,放入队列中,等待下一次被调度。

特殊情况下 G 的调度方法:

  • 第一种:channel 阻塞或网络 I/O 情况下的调度;

    如果 G 被阻塞在某个 channel 操作或网络 I/O 操作上时,G 会被放置到某个等待(wait)队列中,而 M 会尝试运行 P 的下一个可运行的 G。如果这个时候 P 没有可运行的 G 供 M 运行,那么 M 将解绑 P,并进入挂起状态。

    当 I/O 操作完成或 channel 操作完成,在等待队列中的 G 会被唤醒,标记为可运行(runnable),并被放入到某 P 的队列中,绑定一个 M 后继续执行。

  • 第二种:系统调用阻塞情况下的调度。

    如果 G 被阻塞在某个系统调用(system call)上,那么不光 G 会阻塞,执行这个 G 的 M 也会解绑 P,与 G 一起进入挂起状态。如果此时有空闲的 M,那么 P 就会和它绑定,并继续执行其他 G;如果没有空闲的 M,但仍然有其他 G 要去执行,那么 Go 运行时就会创建一个新 M(线程)。

    当系统调用返回后,阻塞在这个系统调用上的 G 会尝试获取一个可用的 P,如果没有可用的 P,那么 G 会被标记为 runnable,之前的那个挂起的 M 将再次进入挂起状态。

上次更新: 2022/06/12, 15:48:09
什么是并发
select

← 什么是并发 select→

最近更新
01
ctr和crictl显示镜像不一致
03-13
02
alpine镜像集成常用数据库客户端
03-13
03
create-cluster
02-26
更多文章>
Theme by Vdoing | Copyright © 2015-2024 op81.com
苏ICP备18041258号-2
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式