有栈协程就是实现了一个用户态的线程,用户可以在堆上模拟出协程的栈空间,当需要进行协程上下文切换的时候,主线程只需要交换栈空间和恢复协程的一些相关的寄存器的状态就可以实现一个用户态的线程上下文切换,没有了从用户态转换到内核态的切换成本,协程的执行也就更加高效。

golang协程实现

Goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态提供的线程,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的 CPU。go 的协程是在多线程环境中,若2个协程在不同的线程中运行,可能存在同步竞争关系。
在大多数情况下,我们都会使用 Go 的默认设置,也就是线程数等于 CPU 数,默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少很多额外开销。
Go 为了提供更容易使用的并发方法,使用了 goroutine 和 channel。goroutine 来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。

golang协程GMP模型

G(job)是job, M(thread)是执行线程, P(processor)是任务队列

golang协程系统调用

所有在 UNIX 系统上运行的程序最终都会通过 C 系统调用来和内核打交道。用其他语言编写程序进行系统调用,方法不外乎两个:一是自己封装,二是依赖 glibc、或者其他的运行库。Go 语言选择了前者,把系统调用都封装到了 syscall 包。封装时也同样得通过汇编实现。
Go 语言通过 syscall.Syscall 和 syscall.RawSyscall 等使用汇编语言编写的方法封装操作系统提供的所有系统调用,在通过汇编指令 INVOKE_SYSCALL 执行系统调用前后,上述函数会调用运行时的 runtime.entersyscall 和 runtime.exitsyscall,正是这一层包装能够让我们在陷入系统调用前触发运行时的准备和清理工作。由于直接进行系统调用会阻塞当前的线程,所以只有可以立刻返回的系统调用才可能会被设置成 RawSyscall 类型,例如:SYS_EPOLL_CREATE、SYS_EPOLL_WAIT(超时时间为 0)、SYS_TIME 等。
runtime.entersyscall 会在获取当前程序计数器和栈位置之后调用 runtime.reentersyscall,它会完成 Goroutine 进入系统调用前的准备工作

  1. 禁止线程上发生的抢占,防止出现内存不一致的问题;
  2. 保证当前函数不会触发栈分裂或者增长;
  3. 保存当前的程序计数器 PC 和栈指针 SP 中的内容;
  4. 将 Goroutine 的状态更新至 _Gsyscall;
  5. 将 Goroutine 的处理器和线程暂时分离并更新处理器的状态到 _Psyscall;
  6. 释放当前线程上的锁;
    需要注意的是 runtime.reentersyscall 会使处理器和线程的分离,当前线程会陷入系统调用等待返回,在锁被释放后,会有其他 Goroutine 抢占处理器资源。
    当系统调用结束后,会调用退出系统调用的函数 runtime.exitsyscall 为当前 Goroutine 重新分配资源。

当M一旦进入系统调用后,会脱离go runtime的控制。试想万一系统调用阻塞了呢,此时又无法进行抢占,是不是整个M也就罢工了。所以为了维持整个调度体系的高效运转,必然要在进入系统调用之前要做点什么以防患未然。

  1. 异步系统调用 G 会和MP分离(G挂到netpoller)
  2. 同步系统调用 MG 会和P分离(P另寻M),当M从系统调用返回时,不会继续执行,而是将G放到run queue。

阻塞

在 Go 里面阻塞主要分为以下 4 种场景:

  1. 由于原子、互斥量或通道操作调用导致 Goroutine 阻塞,调度器将把当前阻塞的 Goroutine 切换出去,重新调度 LRQ 上的其他 Goroutine;
  2. 由于网络请求和 IO 操作导致 Goroutine 阻塞。Go 程序提供了网络轮询器(NetPoller)来处理网络请求和 IO 操作的问题,其后台通过 kqueue(MacOS),epoll(Linux)或 iocp(Windows)来实现 IO 多路复用。通过使用 NetPoller 进行网络系统调用,调度器可以防止 Goroutine 在进行这些系统调用时阻塞 M。这可以让 M 执行 P 的 LRQ 中其他的 Goroutines,而不需要创建新的 M。执行网络系统调用不需要额外的 M,网络轮询器使用系统线程,它时刻处理一个有效的事件循环,有助于减少操作系统上的调度负载。用户层眼中看到的 Goroutine 中的“block socket”,实现了 goroutine-per-connection 简单的网络编程模式。实际上是通过 Go runtime 中的 netpoller 通过 Non-block socket + I/O 多路复用机制“模拟”出来的。
  3. 当调用一些系统方法的时候(如文件 I/O),如果系统方法调用的时候发生阻塞,这种情况下,网络轮询器(NetPoller)无法使用,而进行系统调用的 G1 将阻塞当前 M1。调度器引入 其它M 来服务 M1 的P。
  4. 如果在 Goroutine 去执行一个 sleep 操作,导致 M 被阻塞了。Go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 G 任务然后设置可以强占的标识符,别的 Goroutine 就可以抢先进来执行。

Go 的并发编程

Do not communicate by sharing memory; instead, share memory by communicating.

不要通过共享内存来通信,而要通过通信来实现内存共享。
Go 的并发编程的模型则用 goroutine 和 channel 来替代。Goroutine 和线程类似,channel 和 mutex (用于内存同步访问控制)类似。channel 的底层就是通过 mutex 来控制并发的。只是 channel 是更高一层次的并发编程原语,封装了更多的功能。channel和select配合使用,实现非阻塞调用。
Goroutine 解放了程序员,让我们更能贴近业务去思考问题。而不用考虑各种像线程库、线程开销、线程调度等等这些繁琐的底层问题,goroutine 天生替你解决好了。

调度器的设计策略

复用线程:避免频繁的创建、销毁线程,而是对线程的复用。

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

特殊的 M0 和 G0

M0

M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。

G0

G0 是每次启动一个 M 都会第一个创建的 goroutine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。

协程形象比喻:

我觉得协程和线程的关系就跟下面例子差不多:
你(玩家) ===> 线程
游戏任务 ===> 协程
游戏剧情 ===> 协程调度器 状态机 magic等一堆安排任务的东西
现在 你(线程) 根据 游戏剧情(调度器等) 接 任务(协程) 去刷副本, 最后完成任务

协程有什么好处?

  1. 写代码简单了, 不会有回调地狱, 写出来的代码跟没加异步操作一样
  2. 强大的协程调度能力, 使我们可以精细化控制什么类型的线程执行什么类型的任务了
  3. 强大的协程库, 包装了很多底层系统方法, 让协程看起来真的像是线程

协程合适IO密集型? 我表示质疑, IO密集不是要阻塞么? 除开非阻塞类型的IO操作外, 阻塞不应该是线程阻塞么? 而一条线程里有多个协程(任务), 如果线程阻塞了, 协程只能重新分配给别的线程了, 至于看到有人说, 有的协程库 hook 了系统 IO 操作函数, 让协程遇到该函数立即返回, 那也解决不了根本问题, 线程该阻塞还是得阻塞(前面是协程返回了, 但总的有线程阻塞等待吧?), 除非修改内核代码, 设计内核推模型, 让内核线程监控IO操作的情况, 主动间数据推给用户, 或者设计拉模型, 完成IO后发送系统消息给用户, 让用户主动去拉取, 但这样的话, 该IO操作不再是阻塞了, 而是非阻塞IO... 我看有些 IO 本身都没实现非阻塞操作, 又或者和 epoll一样, 设计个系统线程和表单, 然后再hook掉阻塞部分, 然后不断轮询... 不过那样的话好像又和协程没关系了(协程由系统线程在执行, 协程仅仅是任务哦)
线程的概念,大致意思就是,这个地方阻塞了,但我还有其他地方的逻辑流可以计算,这些逻辑流是共享一个地址空间的,不用特别麻烦的切换页表、刷新TLB,只要把寄存器刷新一遍就行,能比切换进程开销少点。如果连时钟阻塞、 线程切换这些功能我们都不需要了,自己在进程里面写一个逻辑流调度的东西。那么我们即可以利用到并发优势,又可以避免反复系统调用,还有进程切换造成的开销,分分钟给你上几千个逻辑流不费力。这就是用户态线程。
单线程的协程举例说明:
消费者、生产者模型
如果用多线程实现该模型时,需要2个线程,生产者线程向队列中插入数据,如果队列满了,则阻塞等待;消费者线程从队列中取数据,如果队列空了,则阻塞等待;2个线程之间在队列消费过程中存在同步竞争关系,且线程切换需要保存上下文信息。
如果用协程实现该模型,在一个线程中创建2个协程,和2个线程执行类似;但由于2个协程在同一个线程中执行(1个协程执行,同线程中的其他协程等待),获取队列时不存在同步竞争关系,且协程上下文信息相对于线程切换更少,效率更高。