Go的Channel解析

设计原理

Go语言中最常用的,设计模式就是:不要通过共享内存的方式通信,而是应该通过通信的方式共享内存。

但是Go语言中也能使用共享内存加互斥锁进行通信,但是Go语言提供了一种不同的并发模型,即通信顺序进程。Goroutine和Channel分别对应CSP中的实体和传递信息的媒介,Goroutine之间会通过Channel传递数据。

先入先出

目前的Channel收发操作均遵循了先进先出的设计,具体规则如下:

1.先从Channel读取数据的Goroutine会先接收到数据;

2.先向Channel发送数据的Goroutine会得到先发送数据的权力;

无锁管道

锁是一种常见的并发控制技术,我们一般会将锁分成乐观锁和悲观锁,即乐观并发控制和悲观并发控制,无锁队列更准确的描述是使用乐观并发控制队列。乐观并发控制也叫乐观锁,很多人都会误认为乐观锁是与悲观锁差不多,然而它并不是真正的锁,只是一种并发控制的思想。

乐观并发控制本质上是基于验证的协议,我们使用原子指令CAS在多线程中同步数据,无锁队列的实现也依赖这一原子指令。Channel在运行时的内部表示是runtime.hchan,该结构体中包含了用于保护成员变量的互斥锁,从某种程度上说,Channel是一个用于同步和通信的有锁队列,使用了互斥锁解决程序中可能存在的线程竞争问题是很常见的,但是锁导致的休眠和唤醒会带来额外的上下文切换,如果临界区过大,加锁解锁导致的额外开销会成为性能瓶颈.

Go语言实现了无锁的Channel方案,该方案将Channel分成三种类型:

1.同步Channel -不需要缓冲区,发送方会直接将数据交给接收方;

2.异步Channel -基于环形缓存的传统生产者消费模型;

3.chan struct{} 类型的异步 Channel -struct{} 类型不占用内存空间,不需要实现缓冲区和直接发送的语义;

由于基于CAS实现的无锁Channel没有提供先进先出的特性,所以不能使用这种方式实现无锁Channel

数据结构

Go语言的Channel在运行时使用runtime.hchan结构体表示。我们在Go语言中创建新的Channel时,实际上创建的都是如下所示的结构:

该结构体中的五个字段qcount,dataqsiz,buf,sendx,recv构建底层的循环队列:

qcount-Channel中的元素个数;

dataqsiz -Channel中的循环队列的长度;

buf-Channel的缓冲区数据指针;

sendx-Channel的发送操作处理到的位置;

recvx-Channel的接收操作处理到的位置;

除此之外,elemtype和elemtype分别表示当前Channel能够收发的元素类型和大小;sendq和recvq存储了当前Channel由于缓冲区空间不足而阻塞的Goroutine列表,这些等待队列使用双链表表runtime.waitq表示,链表中所有元素都是runtime.sudog结构。

runtime.sudog 表示一个在等待列表中的 Goroutine,该结构中存储了两个分别指向前后 runtime.sudog 的指针以构成链表

创建管道

GO语言中所有的Channel创建都需要使用make关键字,编译器会将 make表达式转换成OMAKE类型的节点,并在类型检查阶段将OMAKE类型的节点转换成OMAKECHAN类型;

该阶段会对传入make关键字的缓冲区进行大小检查,如果我们不向make传递表示缓冲区大小的参数,那么就会设置一个默认值0,

也就是当前的Channel不存在缓冲区。

OMAKECHAN 类型的节点最终都会在 SSA 中间代码生成阶段之前被转换成调用 runtime.makechan 或者 runtime.makechan64 的函数

runtime.makechanruntime.makechan64 会根据传入的参数类型和缓冲区大小创建一个新的 Channel 结构,其中后者用于处理缓冲区大小大于 2 的 32 次方的情况,因为这在 Channel 中并不常见,所以我们重点关注 runtime.makechan

  • 如果当前 Channel 中不存在缓冲区,那么就只会为 runtime.hchan 分配一段内存空间;
  • 如果当前 Channel 中存储的类型不是指针类型,会为当前的 Channel 和底层的数组分配一块连续的内存空间;
  • 在默认情况下会单独为 runtime.hchan 和缓冲区分配内存;

在函数的最后会统一更新 runtime.hchan 的 elemsize、elemtype 和 dataqsiz 几个字段

发送数据

发送数据时,就需要使用ch < - i语句,编译器会将它解析成OSEND节点,并在gc.walkexpr方法中转换成runtime.chansed1:

runtime.chansend1 只是调用了 runtime.chansend 并传入 Channel 和需要发送的数据。runtime.chansend 是向 Channel 中发送数据时一定会调用的函数,该函数包含了发送数据的全部逻辑,如果我们在调用时将 block 参数设置成 true,那么表示当前发送操作是阻塞的:

在发送数据的逻辑执行之前会先为当前Channel加锁,防止多个线程并发修改数据,如果Channel已经关闭,那么向该Channel发送数据时会报"send on closed channel"错误并中止程序;

runtime.chansend函数的实现分成三个部分:

当存在等待的接收者时,通过rutime,send直接将数据发送给阻塞的接受者;

当缓冲区存在空余空间时,将发送的数据写入channel的缓冲区;

当不存在缓冲区或者缓冲区已满时,等待其他Goroutine从channel接收数据;

直接发送

如果目标Channel没有被关闭并且已经有处于等待的Goroutine,那么rutime.chansend会从接收队列recvq中取出最先陷入等待的Goroutine并直接向它发送数据:

数据的发送过程:

发送数据时会调用runtime.send,该函数的执行可以分成两个部分:

1.调用runtime.sendDirect将发送的数据直接拷贝到x =

2.调用runtime.goready将等待接收数据的Goroutine标记成可运行状态Grunnable并把该Goroutine放到发送发所在的处理器的runnext上等待执行,该处理器在下一次调度时会立刻唤醒数据的接收方

需要注意的是,发送数据的过程只是将接收方的Goroutine放到了处理器的runnext中,程序没有立刻执行该Goroutine

缓冲区

如果创建的Channel包含缓冲区并且Channel中的数据没有装满,会执行下面这段代码

在这里我们首先会使用runtime.chanbuf计算出下一个可以存储数据的位置,然后通过runtime.typedmenmmove将发送数据拷贝到缓冲区中并增加sendx和qcount计数器。

如果当前Channel的缓冲区未满,向Channel发送的数据会存储在Channel的sendx索引所在的位置并将sendx索引加一。因为这里的buf是一个循环数组,所以当sendx等于dataqsize时会重新回到数组开始的位置。

阻塞发送

当Channel没有接受者能够处理数据时,向Channel发送数据会被下游阻塞,当然使用select关键字可以向Channel非阻塞地发送消息。

阻塞式发送消息的步骤:

1.调用runtime.getg获取发送数据使用的Goroutine;

2.执行runtime.acquireSudog获取runtime.sudog结构并设置一次阻塞发送的相关信息

3.将刚刚创建并初始化的runtime.sudog加入发送等待队列,并设置当前Goroutine的waiting上,表示Goroutine正在等待sudog准备就绪;

4.调用runtime.goparkunlock将当前的Goroutine陷入沉睡等待唤醒;

5.被调度器唤醒后会执行一些收尾工作,将一些属性置零并且释放runtime.sudog结构体;

接收数据

Go语言中可以使用两种不同的方式去接收Channel中的数据:

两种不同的方法经过编译器的处理都会变成ORECV类型的节点,后者会在类型检查阶段被转换成OAS2RECV类型。数据的接收操作遵循一下的路线图:

虽然不同的接收方式会被转换成两种不同的函数调用,但是这两个函数最终还是会调用 runtime.chanrecv.

接收数据时不同情况的处理办法:

1.当从一个空的Channel接收数据时会直接调用runtime.gopark让出处理器的使用权

2.如果当前Channel已经被关闭并且缓冲区不存在任何数据,那么会清除ep指针中的数据并立刻返回

3.当存在等待的发送者时,通过runtime.recv从阻塞的发送者或者缓冲区中获取数据;

4.当缓冲区存在数据时,从Channel的缓冲区中接收数据;

5.当缓冲区中不存在数据时,等待其他Goroutine向Channel发送数据;

直接接收

当Channel的sendq队列中包含处于等待状态的Goroutine时,该函数会取出队列头等待的Goroutine,处理的逻辑和发送时一样 ,调用runtime.recv实现接收

该函数的处理方式:

1.如果Channel不存在缓冲区:

-调用runtime.recvDirect将Channel发送队列中Goroutine存储的elem数据拷贝到目标内存地址中;

2.如果Channel存在缓冲区:

将队列中的数据拷贝到接收方的内存地址;

将发送队列头的数据拷贝到缓冲区中,释放一个阻塞的发送方

无论发送哪种情况,运行时都会调用runtime.goready将当前处理器的runnext设置成发送数据的Goroutine,在调度器下一次调度时将阻塞的发送方唤醒。

缓冲区

当Channel的缓冲区中已经包含数据时,从Channel中接收数据会直接从 缓冲区中recvx的索引位置中取出数据进行处理:

如果接收数据的内存地址不为空,那么会使用runtime.typedmemmove将缓冲区中的数据拷贝到内存中,清除队列中的数据并完成收尾工作。

收尾工作包括递增 recvx,一旦发现索引超过了 Channel 的容量时,会将它归零重置循环队列的索引;除此之外,该函数还会减少 qcount 计数器并释放持有 Channel 的锁。

阻塞接收

当Channel的发送队列中不存在等待的Goroutine并且缓冲区中也不存在任何数据时,从管道中接收数据的操作会变成阻塞的,然而不是所有的操作都是阻塞的,与select语句结合使用时就可能会使用到非阻塞的接收操作;

在正常的接收场景中,我们会使用runtime.sudog将当前Goroutine包装成一个处于等待状态的Goroutine并将其加入到接收队列中。

完成入队之后,调用runtime.goparkunlock立刻触发Goroutine的调度,让出处理器的使用权并等待调度器的调度。

关闭管道

编译器会将用于关闭管道的close关键字转换成OCLOSE节点已经runtime.closechan函数。当Channel是一个空指针或者已经被关闭,GO语言运行时都会直接崩溃并抛出异常:

处理完了这些异常的情况后就可以开始执行关闭Channel的操作,将recvq和sendq两个队列中的数据加入到Goroutine列表glist中,同时清楚所有runtime.sudog上违背处理的数据

最后为所有被阻塞的Gorotuine调用runtime.goready触发调度。

相关推荐
爱吃涮毛肚的肥肥(暂时吃不了版)6 分钟前
计算机网络34——Windows内存管理
网络·计算机网络·udp
阿华的代码王国12 分钟前
MySQL ------- 索引(B树B+树)
数据库·mysql
Hello.Reader40 分钟前
StarRocks实时分析数据库的基础与应用
大数据·数据库
执键行天涯42 分钟前
【经验帖】JAVA中同方法,两次调用Mybatis,一次更新,一次查询,同一事务,第一次修改对第二次的可见性如何
java·数据库·mybatis
yanglamei19621 小时前
基于GIKT深度知识追踪模型的习题推荐系统源代码+数据库+使用说明,后端采用flask,前端采用vue
前端·数据库·flask
码哝小鱼1 小时前
firewalld封禁IP或IP段
linux·网络
工作中的程序员1 小时前
ES 索引或索引模板
大数据·数据库·elasticsearch
严格格1 小时前
三范式,面试重点
数据库·面试·职场和发展
sec0nd_1 小时前
1网络安全的基本概念
网络·安全·web安全
微刻时光2 小时前
Redis集群知识及实战
数据库·redis·笔记·学习·程序人生·缓存