Goroutine

Goroutine是由Go运行时管理的轻量级线程(协程)。Goroutine和操作系统线程相比,Goroutine的资源占用和使用代价非常小,大小默认是2K字节。Goroutine调度的切换也不在操作系统的内部完成,代价很低。所以一个Go程序可以创建成千上万个并发的Goroutine,而把这些Goroutine按照一定算法放到cpu上执行的程序,就成了Goroutine调度器。

我在前面的文章xv6-实验参考书解析中讲过进程/线程的切换方式,当切换的时候要从用户态->内核态->另一个进程/线程的内核态->另一个进程的用户态,但是在Goroutine中不是这样。通过GMP,可以使得开销尽量小。

Coroutine

先来说说协程(Coroutine):

主流的操作系统都是采用一对一的线程模型,用户态和内核态线程具有一对一关系,可以认为用户态线程的执行完全受到操作系统调度器的管理。但是随着应用程序越来越复杂,与操作系统调度器相比,应用程序对线程的语义和执行状态更加了解,因此可能做出更优的调度策略。在这个背景下,操作系统开始提供更多对用户态线程,即协程的支持。

也就是是说其实一个线程可以分为内核态线程和用户态线程,当我们分出内核态线程和用户态线程后,一个就叫线程(内核级线程)、另一个叫协程(用户级线程)。

如下图所示,我们可以更好的理解协程和线程的关系,图片来源

协程的上下文切换切换的触发机制与内核态线程存在较大不同,操作系统可以通过中断抢占当前CPU并进行上下文切换,这种切换是强制性的,因此称为抢占式调度,但是协程是非抢占式的,协程哭一般会提供yield接口,该接口使当前协程暂时放弃CPU,从而使得其他协程调度。


补充一下常见的面经:

进程、线程、协程的区别:

进程是系统进行资源调度和分配的单位,线程是程序执行的单位,也就是CPU调度和分配的单位,线程作为进程下属的单位,可以享用进程被分配的资源,一个进程下的多个线程可以共享该进程的资源。而进程之间是不能共享资源的,进程间想要互相通信,必须通过进程间通信。

共享一个进程资源的多线程有自己独有的用户栈和内核栈,有共享的代码段、数据段和堆。线程有自己独有的context,这个context包括栈、栈指针、寄存器和程序计数器。

协程是一种用户态的轻量级线程,协程的调度完全由用户控制,协程拥有自己的context。一个线程可以有多个协程,一个进程也可以有多个协程。进程线程都是同步机制,而协程是异步机制。线程是抢占式,而协程是非抢占式,所以需要用户自己释放使用权来切换到其他协程。

进程、线程、协程切换开销的对比:

  • 同步与异步的区别:

同步是阻塞模式,而异步是非阻塞模式:

  • 同步就是指一个进程在执行某个请求的时候,若该请求需要一段时间才能返回信息,那么这个请求就会一直等待下去,直到收到返回信息才继续执行下去。

  • 异步是进程不需要一直等下去,而是继续执行下面的操作,不管其他进程的状态。当有消息返回时系统会通知进程处理,这样可以提高执行的效率。

    知乎上这个文章说得很好,分享给大家:

    同步异步 , 举个例子来说,一家餐厅吧来了5个客人,同步的意思就是说,来第一个点菜,点了个鱼,好, 厨师去捉鱼杀鱼,过了半小时鱼好了给第一位客人,开始下位一位客人,就这样一个一个来,按顺序来相同, 异步呢,异步的意思就是来第一位客人,点什么,点鱼,给它一个牌子,让他去一边等吧,下一位客人接着点菜,点完接着点让厨师做去吧,哪个的菜先好就先端出来。

    同步的优点是:同步是按照顺序一个一个来,不会乱掉,更不会出现上面代码没有执行完就执行下面的代码, 缺点:是解析的速度没有异步的快;

    异步的优点是:异步是接取一个任务,直接给后台,在接下一个任务,一直一直这样,谁的先读取完先执行谁的, 缺点:没有顺序 ,谁先读取完先执行谁的 ,会出现上面的代码还没出来下面的就已经出来了,会报错。


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

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

channel之于Goroutine

无缓冲channel和有缓冲channel

无缓冲channel发送动作和接收动作是同时发生的,例如 ch := make(chan int) ,如果没 goroutine 读取接收者<-ch ,那么发送者ch<- 就会一直阻塞,缓冲 channel 类似一个队列,只有队列满了才可能发生阻塞。

如何优化channel

和学长聊到这个问题,深夜无眠,写一下自己的理解。

channel 的构造语句 make(chan int),将会被 golang 编译器翻译为 runtime.makechan 函数, 其函数签名如下:

1
func makechan(t *chantype, size int) *hchan

其中,t *chantype 即构造 channel 时传入的元素类型。size int 即用户指定的 channel 缓冲区大小,不指定则为 0。该函数的返回值是 *hchan。hchan 则是 channel 在 golang 中的内部实现。其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type hchan struct {
qcount uint // buffer 中已放入的元素个数
dataqsiz uint // 用户构造 channel 时指定的 buf 大小
buf unsafe.Pointer // buffer
elemsize uint16 // buffer 中每个元素的大小
closed uint32 // channel 是否关闭,== 0 代表未 closed
elemtype *_type // channel 元素的类型信息
sendx uint // buffer 中已发送的索引位置 send index
recvx uint // buffer 中已接收的索引位置 receive index
recvq waitq // 等待接收的 goroutine list of recv waiters
sendq waitq // 等待发送的 goroutine list of send waiters

lock mutex
}

我们可以知道,hchan中定义了悲观锁来进行互斥,channel是一个用于同步和通信的有锁队列。

channel回比mutex低效吗?

今晚我被这个问题折磨了很久,查了很多资料才有了一些答案。

Golang中的channel,发送一个数据到channel和从channel接收一个数据都是原子性的。Go语言线程的设计模式就是不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。这种设计思想有明显的好处,那就是逻辑简单,但是实现成本较高,我觉得这里的实现成本较高是因为channel不但用到了锁,还需要执行调度,会比一般的互斥锁成本更高。两者之间的取舍就是(效率+低可维护性)和(相对低效率+高可维护性)。当然这只是我非常片面的看法,应该还要涉及到具体的使用场景。

操作 一个零值nil通道 一个非零值但已关闭的通道 一个非零值且尚未关闭的通道
关闭 产生恐慌 产生恐慌 成功关闭
发送数据 永久阻塞 产生恐慌 阻塞或者成功发送
接收数据 永久阻塞 永不阻塞 阻塞或者成功接收

更新


https://segmentfault.com/a/1190000017890174

我在看了上述博文之后对channel和mutex的选择有了一些认识,摘录如下:

面对一个并发问题的时候,应当选择合适的并发方式:channel还是mutex。选择的依据是他们的能力/特性:channel的能力是让数据流动起来,擅长的是数据流动的场景mutex的能力是数据不动,某段时间只给一个协程访问数据的权限擅长数据位置固定的场景


无锁队列ring buffer

按照上述我的想法来看加锁的损耗较高,对于某些特殊的情况,可以采用简单的无锁ring buffer来实现。

在ring buffer中,设置两个指针,head指向的是下一次读的位置,而tail指向的是下一次写的位置,由于是ring buffer,可以将buffer的最后一个单元不存储数据。所以,如果head == tail,那么说明buffer为空。如果 head == tail + 1 ,那么说明buffer满了。

在进行读操作时,我们只修改head的值,而在写操作时我们只修改tail的值,在写操作时,我们在写入内容到buffer之后才修改tail的值;而在进行读操作的时候,我们会读取tail的值并将其赋值给copyTail。

赋值操作是原子操作。所以在读到copyTail之后,从head到copyTail之间一定是有数据可以读的,不会出现数据没有写入就进行读操作的情况。同样的,读操作完成之后,才会修改head的数值;而在写操作之前会读取head的值判断是否有空间可以用来写数据。

所以,这时候tail到head - 1之间一定是有空间可以写数据的,而不会出现一个位置的数据还没有读出就被写操作覆盖的情况。这样就保证了RingBuffer的线程安全性。(参考博文无锁ring buffer

下面是我自己实现的无锁队列ring buffer,大伙看个热闹:

1

分段锁

分段锁提高channel效率思想的思想就是使用带有协调机制的独占锁,这些机制允许更高的并发性

用白话说就是给buffer中的不同数据段上不同的锁,那么当多个Goroutine访问不同数据段的数据时,就不会存在锁竞争,从而可以有效的提高并发访问效率。

但是这只是ConcurrentHashMap所使用的锁分段技术,我查了很多遍也没有在网上看到有人用这个思路优化channel,这也许只是我个人的脑洞。

官方大佬的答案

Go 语言社区也在 2014 年提出了无锁 Channel 的实现方案,该方案将 Channel 分成了以下三种类型:

  • 同步 Channel — 不需要缓冲区,发送方会直接将数据交给(Handoff)接收方;
  • 异步 Channel — 基于环形缓存的传统生产者消费者模型;
  • chan struct{} 类型的异步 Channel — struct{} 类型不占用内存空间,不需要实现缓冲区和直接发送(Handoff)的语义;

这个方案可以在一些关键路径上通过无锁提升channel的性能,但这玩意最后被搁浅了,我也不知道为啥。

channel一些问题

给未初始化的channel读写数据,会报错:

1
2
3
var ch chan int
ch<-1
//fatal error: all goroutines are asleep - deadlock!
1
2
3
var ch chan int
ch<-1
//fatal error: all goroutines are asleep - deadlock!

为什么会报死锁的错误呢?我在网上看到一个解释:

chan能阻塞的情况下,则直接阻塞 gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2), 然后调用throw(s string)抛出错误,其中waitReasonChanSendNilChan

没有初始化的channel,相当于nil,这个时候对其进行操作,会直接阻塞,抛出异常waitReasonChanSendNilChan,也就是报错"chan send (nil chan)",最后呈现给我们的error就是deadlock

————————————————
原文链接

Goroutine调度原理
GMP调度模型
  • M代表内核级线程,一个M就是一个线程,Goroutine就是运行在M之上,M是一个很大的结构,里面维护当前执行的goroutine等信息。

  • G代表一个Goroutine,它有自己的栈,instruction pointer和其他信息。

  • P代表Processor,它的主要用途就是用来执行Goroutine的,它维护一个Goroutine队列,里面存储了所有需要它来执行的Goroutine。

也就是说,一个G的执行需要M和P的支持,一个M在与一个P关联后形成一个有效的G的运行环境[内核环境+上下文环境]。每个P都会包含一个可运行的G的队列。

从上图中看,有2个物理线程M,每一个M都拥有一个处理器P,每一个也都有一个正在运行的Goroutine。默认的P的数量等于CPU的个数,P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个Goroutine可以同时运行。上图中灰色的代表Goroutine并没有运行,而是处于ready的就绪态,正在等待被调度,P维护这个队列。

在go语言中,一旦执行go function,runqueue队列就会在其末尾加入一个Goroutine。在下一个调度点,就从runqueue中取出一个goroutine执行。系统中这么多P,具体会加到哪个P中,要看具体的调度策略。(补充:其实还有一个全局队列,如果P中队列满了,就会存放在全局队列中。)

P 和 M 何时会被创建

1、P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。

2、M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。

调度过程
  1. 通过go func()创建一个goroutine;
  2. 新创建的G会保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;
  3. G只能运行在M中,一个M必须持有一个P。M会从P的本地队列中弹出一个可执行状态的G来执行,如果P的本地队列为空,就会向其他的M-P组合中偷取一个可执行的G来执行;
  4. 循环执行;
  5. 当M执行某一个G的时候如果发生了系统调用或者阻塞操作,M会阻塞,如果当前有一个G在执行,runtime会把这个线程M从P中摘除,然后再创建一个新的内核线程来服务于这个P;
  6. 当M系统调用结束后,这个G会尝试获取一个空闲的P执行,并放到这个P的本地队列,如果获取不到P,那么这个线程M就会变成休眠状态,加入到空闲线程中,然后这个G会被放到全局队列中。

GMP的数量都是有限制的,摘自博客

M的限制

在协程的执行中,真正干活的是GMP中的M,M的默认数量限制是10000,如果超出就会报错。通常只有在Goroutine出现阻塞的时候才会出现这种情况。

G的限制

Goroutine的创建数量理论上没有限制,但是我们的内存是有上限的,所以实际是有限制的。

P的限制

P的数量受环境变量GOMAXPROCS的直接影响。

GMP中为什么要有P
老调度器的缺点
  1. 因为缓存G的只有一个全局队列,而M有多个,这样我们多个M在访问同一个资源的时候就要加锁保持互斥,这样就加大了开销。

当一个Go程序启动之后会创建一个编号为0的主线程M0,M0负责初始化操作和启动第一个G,之后M0就和其他的M一样了。

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

真正决定并行度的是P的数量,我们看如下例子:

1

Go语言中实现了两种并发模型,一种是依赖于共享内存实现的线程-锁并发模型,另一种则是CSP。作为Go并发核心编程核心的CSP理论的核心概念——同步通信。

开发go程序的时候,时常需要使用goroutine并发处理任务,有时候这些goroutine是相互独立的,而有的时候,多个goroutine之间常常是需要同步与通信的,值得注意的是,有时候主goroutine需要控制它所属的子goroutine。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!