前言

最近我把Go重新过了一遍,特别是Go的协程这一块,我感觉任何事都是从简单到复杂

包括现在,所以我重新开始学习基础,后序我会出一个系列

分别为

  1. Golang协程基础 (已经完成!)
  2. Golang协程调度
  3. Golang协程控制
  4. Golang协程通信
  5. Golang垃圾回收机制

所以我会持续更新,大家请期待吧,爱你们!

Go里的协程是什么

写Go这么长时间了,在开发项目当中,感觉Golang的好处还是很多的

Golang为什么被推崇,核心就是在并发和协程方面有很大的优势

协程这个概念其实不陌生,我在大学看Python的时候就看过这方面的资料

就是轻量级的线程

但是Go的协程其实和Python又不太一样了,这里我还是认真讲一下协程是个什么玩意儿吧

进程和线程

协程是轻量级的线程,但是线程又和进程有不能说的PY交易

所以我首先来过一遍进程和线程的基础概念

首先直接说概念,这个是核心,记住就完事

  1. 线程是进程的组成部分
  2. 一个线程只能有一个进程
  3. 但是一个进程可以有多个线程
  4. 进程死了,线程一起死
  5. 进程之间相互独立
  6. 进程开销比线程大

可以这么理解,线程是进程的崽,进程是独立个体,可以随意下崽(创建线程)

操作系统里面,调度到CPU中执行的最小单位就是线程

有多核处理器的计算机,线程可以分布在多个CPU上,实现真正的并行逻辑。

看图,这就是多核处理器和单核处理器的差别(Python就是上面那个。。。GIL让他永远用不了多核

线程的切换和调度

这里还是分析线程,是为后序分析协程逻辑打好基础,要是大家伙不想看,直接跳过即可

线程好用,但是不能一下开无数个,也不能全开,也不能不返回就持续等待

所以,为了最大化利用CPU资源,操作系统需要通过定时器,IO设备,上下文切换等动作去控制线程

一般的切换逻辑是

当A线程发生切换的时候,会从用户态转移到内核中,记录寄存器值,进程状态之类的信息到操作系统线程的控制块当中

然后进行切换,切换到下一个执行线程B,加载刚刚保存那得那些寄存器值,然后从内核转移到用户态中

如果线程A和线程B不属于同一个线程,切换的时候将会更新额外的状态信息和内存地址,然后导入页表到内存里面。

可以看见一点,就是线程切换需要记录一大堆东西

这就是线程切换的一点点知识。

线程和协程的关系

协程,轻量级线程,相当于线程PLUS

但是它和线程不同的一点,人家切换或者是做别的操作

不依赖操作系统内核,而依赖自身(Go)的调度器

其实就是内部执行的一串代码

协程其实是线程的从属

下面分析下他们的具体区别还有联系

GMP模型-线程和协程的核心关系

首先看个图

这个图就是GMP模型的核心图,这个图其实说的挺明白了

首先

  1. G就是协程

  2. P就是Go的调度器

  3. M就是线程

可以看见,Go的协程依托线程

一个P可能包含了多个协程,一个P在任何时候只能有一个M

这就说明了线程和协程的关系应该是 m:n

相关的知识我也在其他的文章里说过

golang的并发机制探究

但是我后续还是会细化一下GMP模型

协程的调度方式

首先看个图

协程和线程的关系为M:N,为多对多关系

调度器P可以将多个协程调度到一个线程中,也可以切换一个协程到多个线程中运行

协程的切换

协程为什么叫轻量级线程,因为它的切换速度比线程快,根据上面我讲的线程切换

线程切换需要操作系统用户态和内核态,并且需要存储寄存器的变量值,保留额外的一些变量值(上面有说

协程切换只需要保留极少的状态值和寄存器变量值,并且一切都有Go调度器去操作,免去用户态和操作系统态交互的麻烦逻辑

线程切换的速度大概是 1-2微秒

协程切换的速度为 0.2 微秒

协程的调度策略

这里后面我的Blog也会讲,在这我就简单描述一下吧

线程的调度大部分都是抢占式的,为了平衡资源,操作系统的调度器会定时发出中断信号,来进行线程切换

协程的调度是协作式的,当一个协程处理完任务的时候,可以将执行权限交还给其他协程,不会被轻易抢占,并且协程切换也是有一定的方法,比如抢占队列,偷窃任务等等,这个我后面会另开一篇好好讲述

协程栈的大小

线程栈大小,一般是创建时指定的,为了避免溢出,默认一个栈会比较大(2MB)

这样就大大限制了线程栈的数量,如果1000个线程就需要占用2GB虚拟内存

但是协程栈大小默认为2KB,这样就可以创建一大堆协程,并且协程可以动态扩容栈大小

而线程只能固定一个栈大小

协程栈扩容的算法我后面会说(自己老开坑。。。

并发和并行

并发,并行是老生常态的话题了

我这简单说下

并发:谁先执行任务我不管,但是,某个时间段内,所有的任务都能执行完毕

并行:一起执行任务,大家一起出发

在我开发的生涯里面,这种情况不只是单纯的并发并行,一般都是一起用的

除开Python(有GIL锁)

基本都是多核并行处理多个线程任务,但是单核里面也在负责多个线程任务

所以这样的关系类似

转回Go部分,Go的调度器,会把协程分给多个线程,这些线程又很可能被分发给了不同的进程,这样的关系就类似

这样也说明,在多核的环境下,Go的并发,并行是同时存在且不冲突的。

写一个协程实例

这个我相信大家玩Go的人都会写,其实Go的协程非常简单

1
go test()

那么现在咱们要实现一个并发协程,首先实现一个线性流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func FmtSmg(name string) {
fmt.Println(name)
}

func main() {
z := []string{"yemilice", "fuck", "day"}
for _, i := range z {
FmtSmg(i)
}
}

这边输出

1
2
3
yemilice
fuck
day

现在我们改写成协程模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import (
"fmt"
"time"
)

func FmtSmg(name string) {
fmt.Println(name)
}

func main() {
z := []string{"yemilice", "fuck", "day"}
for _, i := range z {
go FmtSmg(i)
}
time.Sleep(time.Second * 2)
}

得到输出

1
2
3
day
yemilice
fuck

这就是一个简单的协程,这部分我用了time.sleep做通信等待,这个比较low,我后续会在协程通信的blog里面更详细的描述介绍

例如channel,waitgroup等

Go的并发模型

上一节我们实现了一个Go的协程实例,但是似乎我们并不了解为什么Go要这么设计

这一节我将告诉你什么是Go的并发模型,也就是Go执行的流程,这一节也是为下次的文章做好基础,比较重要

其实Go遵循的是fork-join的一种并发模型

fork可以指程序中任何地方,这里将子协程和主协程分开执行

join指的是某个时候,子协程和主协程执行分支合并在一起

看个图

这就是fork-join的并发模型

举个简单的例子

1
2
3
4
5
6
7
8

func work() {
fmt.Println("work")
}

func main() {
go work()
}

这里我们去执行,但是什么都没有返回

这里如果用fork-join表示,画图如下

可见我们需要连接点

将代码改写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func work() {
fmt.Println("work")
}

func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
work()
}()
// 这是连接点
wg.Wait()
}

这里有了连接点,用fork-join表示一下就是

这里的代码不用深究,这里属于协程控制,这里后面我会单开博客讲

这里只是为了让大家直观看到fork-join这种并发模型,这是Go预言需要遵循的并发哲学

总结

这次主要是把协程的基础概念讲了一下

  1. 讲了协程和线程的关系
  2. 讲了协程是怎么来的,怎么实现一个协程
  3. 讲了Go的并发模型

下集预告

下一节我将深入到Go协程之间的调度当中

  1. 整明白Go协程的调度原理
  2. 整明白Go协程的调度策略
  3. 整明白Go协程的几种状态