好 我们开始runtime的最后一部分
channel的原理
我们就看一下channel在Go里面
它到底是怎么实现的
这个PPT其实也是核心就是讲它内部的一个工作原理
然后图画也会比较生动一些
首先我们先回顾一下两个并发的一个特性
第一个叫goroutines
大家都知道所谓的可以
独立去执行一些任务的
这么一个function或者一个单元
然后它是注意它是潜在的
可以是并发去运行的
第二个就是叫channels
它是干嘛的呢
这个其实是Go语言层面原生支持的
用于两个goroutine之间
或者说goroutine与goroutine之间
去进行通讯和进行数据同步的
这个是非常方便的一个特性
我们看一下一个简单的demo
这个函数一个叫main函数里面
我们看一下它获取task
叫getTasks获取
就像我们做一个任务调度一样
叫获取一批任务
然后串行地的我们去针对每个任务for循环
然后去执行它
这是最简单的一个代码逻辑
我们再看一下
我们如果是使用channel
如何让它变成一个并行的任务分发的系统
我们先是make了一个带buffer的channel
它的它的size是3
它是有三个容量
然后我们再启动了3个goroutine
比方这个地方可能是一个叫numWorkers
就是启动多少个group
假设我们启动了3个或者5个都可以
我们启动了一排goroutine
这些goroutine
我们之前了解过GMP
这些goroutine
可以工作在任意一个M上
它可能会飘在任意一个P上
所以最终我们可以理解为它是
利用多核并行地去运行这些任务
好 我们再看一下这个代码
然后再把 还是一样的
跟这个main函数刚刚是串行执行
我们现在还是调getTasks
然后再把这些task
一个个分发到一个叫taskCh
这么一个channel里面去
现在我们把它全部发过去了
发过去以后
在我们这边就会起消费者
实际上消费者因为我开了很多个worker
可同时去执行这些任务
所以它这个代码里面
for循环 然后接收任务
然后process一个task就是执行这个任务
对大概就是这么一个流程
然后它内部其实是
还是有几个特性很有意思
第一个它是goroutine-safe
channel变量可以被n个goroutine
可以同时去访问
它是个goroutine-safe的 它是一个安全
可以在goroutine之间
安全地存储和传递一些数据
传递一些value
同时它提供了一个叫FIFO
first in first out的一个语义
然后可能会导致goroutine会阻塞
或者说阻塞之后如果又有数据
或者说你之前发的channel满了
可能会唤醒
所以它是会导致goroutine
block或者unblock
好 我们再看一下
从原理上我们分几块看
第一个就是making channels
我们创建一个channel
创建channel其实分两种
我们知道它分为buffered channel
和unbuffered channel
就一个带size 一个没有size
对于这一个语句来说make chan
这个语法来说它其实提供了几个意思
对于ch变量
第一它是个goroutine-safe
它是个goroutine-safe的变量
第二个就是通过channel
我们可以去存储一些元素放进去
然后提供FIFO的一些语义
它具体是怎么做的
在它channel内部 注意在channel内部
它其实make的chan Task
它实际上是
在内部make了一个hchan结构体
它包含几个对象
比方说包含一个buf
这个buf放什么
就是我们刚刚这个长度3
你是不是可以把这个Task放在buf里面
size为3
然后有发送的index和receive index
还有一把锁
注意这个地方其实就有锁了
我们之前经常讲通讯要使用channel
避免我去加锁编程
实际上Go它内部仍然是使用lock
互斥锁来解决数据共享的这么一个问题
但是它在语义层面屏蔽掉了
你用的时候其实它内部是基于锁的
但是你用的时候你感知不到
你从语义层面屏蔽掉的
你用起来其实就不太要去关心
只需要关心两个数据之间的传递是怎么做的
我们看一下
一个标准我要写入一个数据
从empty
一开始是empty
这两个index都等于0
一个叫发送者
一个叫接收者的两个索引
我们先看一下如果发送一个数据
很显然
这个数组因为它是个环形buffer
所以它会先填充第0位 下标0
然后我们注意这个地方send index=1
说明我发送了一条数据
下一条数据可能就是写这个位置
所以我们再发一条数据的话
这个又等于2了
如果再发直到发满
那么send index又会等于0
因为它是个环形的
就0 1 2 0 1 2 0
它会一直这样的
所以这个时候整个channel就满了
我们再看一下 如果有人来接收
接收就是从这个channel
里面取一个任务 取走一个任务
这个时候receive index就等于1了
你看注意我们recvx=1的话
我下一个获取的任务下标等于
就从这开始取的
所以一个叫入队 一个叫出队
我们再深度看一下make chan
它到底是一个什么意思
我们都知道
一个程序的内存结构从高地址到低地址
上面是放的是堆栈
下面是放的是什么
是放的是上面是栈
下面是堆
对于一个make(chan Task. 3)语法来说
实际上就是因为你是make的
make我们知道
这个hchan结构体最终会分配在哪
分配在heap上
分配在堆上
我们之前讲内存逃逸的时候
其实包括讲我们内存原理的时候其实也介绍过
因为你这个对象是分配在堆上
所以它可以返回的是
你make实际上是底层就是一个new
最终返回的是一个指针
指向这个堆区
用ch变量指向的内存地址就是堆区的
所以说为什么我们可以说
在不同的goroutine之间
可以安全的去传递变量呢
因为它两个人都是指向同一个堆区
都是指向同一个内存地址
所以它可以安全地在
function之间在函数之间去传递
所以它实际上是已经是传递的一个指针了
你不需要再对channel再去取地址
不需要再去取它的地址
因为它已经是堆区的一个内存的地址了
好 我们再看一下它
接收和发送
两个关键的语法
一个这个是什么
这个是发送
这个是接收
我们先看一下
假设现在有个goroutine G1
我们发送了一个task0到channel里面去
这个时候它结构体应该会变成什么样
第一步可能是加锁
我们之前讲的它底层是基于锁实现的
第二步就是task0会copy
注意 task0会copy到这个数组里面去
所以说 task0如果是个指针
它的copy成本就比较低
它就是一个很简单的指针的copy
但如果它是个结构体的话
可能copy成本会相对大一些
这个要注意一下 第二
copy完了之后肯定就释放锁
整个整体就结束了
对于接收者来说其实一样的
就是先拿锁
然后再出队
把task0 copy到哪
copy到t这个变量
就相当于我拷到了G2 goroutine的
这个t变量指向了它
相当于是通过锁
来保证happens before的这么一个语义
然后解锁
所以我们看一下
为什么经常叫no shared memory
我们之前的访问这种
同一个堆区的一个内存变量
我们要去操作它肯定是要加锁
为什么现在就是说
communication by shared memory
现在为什么就解决这个问题
我们看一下之前记得Rob Pike说的这句话
Do not communicate by sharing memory
instead. share memory by communicating
好 之前讲的通讯的去不要考虑
Share memory就共享一个堆区
然后大家加锁去访问这个东西
现在来说叫一种传递的机制
No shared memory是什么意思
我们全程task0
我们上面的task0变量啊
task0是没有人去加锁访问它的
你从代码层面你是看不到的
所以说你实际上是通过channel
底层的锁来保证的机制
这个communicating实际上就是
从G1发送到G2
其实就是通过channel作为一个桥梁
把task0 copy到了G2
所以这段话其实还是蛮经典的一句话
我们再看一个case
G1现在发送了一个任务
现在发送了一个任务
goroutine G1
然后第二再发送个任务
这个地方又满了一个绿色的
我再发一个 发送一个task3
请问这个时候会如果再发会发生什么
我们看一下 如果发送了一个task4
这个时候这个channel是不是满了
因为你之前声明的时候size等于3
所以G1 goroutine它的执行
会被暂停
然后直到有一个G2的receiver
从里面把这个东西取走了
然后它又能重新被写进去
它才会被唤醒
底层它是怎么一个玩法
它是怎么一个原理
好 我们先看一下
goroutine我们简单回溯一下
我们之前介绍过
它是被Go runtime创建和维护的
不是OS 不是操作系统
它是比OS操作系统肯定是更轻量的
并且我们知道它是M:N的一个这种
schedule的一个方式
M就是操作系统的线程
我们之前也学过
G就是goroutine
我们现在还是简单回溯一下
P就是我们之前说的用于
做调度的一个队列
一个P一个local队列
它因为每个P上面可以放很多执行的任务
这个时候
如果我们需要发送task4
这个时候 channel因为已经满掉了
会发生什么事情我们看一下
如果发送task4
这个时候我们会gopark
也就是说会让它停车就让它刹车
这个时候就就会调陷入到我们的调度器
陷入到go的调度器
也就是说陷入到Go runtime了
Go runtime代码里面会执行
因为G1当前是执行的
因为你突然再发个task4
它无法被执行了
这个时候会干嘛
需要把G1把它的状态从运行改成什么
改成一个waiting状态
因为你写满了
然后我就要解绑
之前这个M可能运行goroutine
我现在就需要解绑
因为它们之间没有关系
G1它是被暂停了
所以它们产生一个解绑
好 但是注意这个时候我们知道
M会切到什么
切到G0
G0是干嘛的
goroutine G0是干嘛的
它是负责调度的
调度之后它又会从local P
local P的队列里面捞一个任务出来继续执行
这样的话它又可以继续执行了
所以我们是把这种机制叫做
G1虽然它会被按需阻塞
因为你写满了你会产生了一个无法执行了
它肯定会暂停
所以说为什么叫协作式调度
是自己主动退休了
然后线程 对于M来说
它实际上还可以执行别的任务
所以说我们最终暂停的是G1 goroutine
对于线程来说它们并没有sleeping
就并没有休眠
它可以持续地执行任务
好 问题来了
如何把block的 阻塞的goroutine唤醒回来
我们再看一下
hchan就是底层channel
它的结构体内部存了
其实就是把sender
和receiver这两个都存起来
我们把它叫成什么
叫做一个叫sendq的一个变量
就是最终存储的就是
waiting的senders 和waiting的receivers
然后这个sendq你可以理解为
它内部的结构体有点像一个
放一个goroutine
然后你的元素
发送或者是接收的一个元素
我们看看具体它是怎么玩的
好还是一样的 发task4到这个channel
首先第一步
创建一个sudog对象
放什么把G1自己放进去
把这个元素也指向它
就是说G1要发送task4
现在因为满了发不进去
所以我把你暂存到waiting senders
好
这个时候在进入调度前
就触发了
我在进入到被schedule到
让M和G1解绑前
我这个操作就做掉了
所以我们再来看一下
如果这个时候G2另外一个goroutine
它来获取一个任务
会产生一个什么样的行为
首先这个时候从里面它捞了一个task1
最早写的是task1
第二步我们会pops off sudog 什么意思
就是会把sendq里面之前的
排队的这些等待者把它们唤醒
我们看一下它怎么做
先把task4 pop出来
填充到环形数组里面
因为已经有人取走一个
所以我自然而然要把它
把待发送的把它给填进去
然后再把G1的状态设置成runnable
因为我可以执行了
所以说是由G2的获取任务
task1动作
把它唤醒的
是G2把G1唤醒的
所以为什么叫协作式
是不是有点概念
你自己退出的时候自己退休了
然后其实那个时候要靠别人把你
喊你回来干活
有点像
这个时候是G2把G1给唤醒了
注意G1之前是waiting状态
要把它设置成runnable
它的颜色变了
我们是不是要把它挂载到
这个P的local queue
记得我们之前讲过亲缘性调度
就是schedule的亲缘性调度
这种做法会导致G1有可能会被其它的
其它的M
或者就是其它的P
它会产生一个队列的窃取
work stealing 它会被窃取走
所以它内存亲缘性会变得比较差
所以我们记得吧
有一个runnext的指针
所以说G1的优先级会比较高
它下一个执行完G2之后就立马会执行G1
这个就是做了个小优化
所以这个图是老的一个实现了
好 我们再换一种情况看
如果是接收者先操作的
然后发送者后到怎么办
比方说我现在有个empty的一个buffer
没有任何东西
然后我尝试去获取里面的任务内容
肯定获取不到
是不是 G2的goroutine就会被暂停
直到有人发了数据把我给唤醒起来
所以说它要做什么操作呢
还是一样的
要创建一个sudog的结构体
放到什么 这个时候要放到recvq
因为我是接收者
然后同时把自己给退休了
gopark把自己G2给退休了
这个时候我们看一下
创建几个结构体以后把G2挂载到这里
elem其实是G2的栈上的t的变量
注意这个是栈上的变量地址
注意t是栈上的变量地址
这个时候我们G1发送了一个任务
然后这个时候会干嘛
肯定是先要先把它加锁
然后把任务放进去
把任务tast任务放进去
然后再唤醒 G2
然后再 G2自己可能还一通操作加锁
然后把队列取出来
然后再赋值到它自己的t变量里面去
这个肯定就很麻烦 很烦
有没有更高级一点的做法
或者说我们思考一下
是不是有更好的一个做法 怎么做
因为这个t
实际上就是接收者的一个内存地址
可能是栈上的一个变量
变量的一个地址
这个时候是不是我们可以让G1
G1的goroutine直接去操作G2啊
有没有可能
还真可以这么干
我们注意
G1的stack和G2的stack
注意它们是在栈上的
G2的stack上
t的变量是在这个位置
G1实际上可以直接去操作G2的stack
这个是不是很高级
因为Go runtime的
整个stack是自己维护的
当然我可以去这么干的
我直接去操作G2的stack
当然了这个操作只能在runtime去实现
如果你是写Go的应用代码
你肯定是触发不了这个行为的
因为两个goroutine的stack
是有所谓的叫function frame的
你看不到function frame stack
就是你是看不到对方的栈的
所以这样的一个做法
相当于是不需要重复地入队
然后唤醒起来 然后这个人又重新地去
再去buffer里面把它抠出来再去赋值
这样操作是非常直接的
我就直接去操作它的栈上变量
所以说在恢复的过程中
G2不需要再去获取channel的lock
也不需要再去操作buffer
同时因为你少操作了buffer
你相当于我之前task4
根本就不经过环形buffer
直接无脑的透传扔给了你
绕开了buffer
直接扔给了你
所以说它也有更少的memory copy
我觉得这个思路其实是
我觉得还是很不错的一个做法
简单来说
goroutine的一些原理
我们简单回忆一下 回溯一下
goroutine-safe首先
为什么是goroutine-safe
因为hchan的内部它是使用互斥锁的
然后再看一下可以存储值和FIFO
因为它是用了一个hchan的环形buffer
另外就是说它会导goroutine暂停和恢复
我们知道它是使用sudog
waiting receivers和waiting senders
就是等待的接收者（和发送者）
最终是通过
runtime scheduler gopark goready
让goroutine暂停的
它们是协作式调度
来搞定的工作
所以我觉得是非常有意思
同样的像unbuffered channel
select我就不展开讲了
整体来说是差不多
大概的一个原理都是差不多的
PPT后面的一些部分大家
可以自己去简单的去看一看
包括甚至你可以去翻它的源码怎么去实现的
对于channel的整个原理
我们就今天就到这里就结束了
这个也是我们Go runtime的最后一个部分
Reference其实我应该是有
大几十篇reference的参考
大家也可以去翻一翻
因为整个runtime其实变化
还是非常快 非常大的
我觉得也没必要就是每一个feature都去跟进
但是对出现一些问题诊断的时候
我们可以先尝试
你之前掌握的一些runtime的知识去解释
如果不行
再看看新版本有什么新特性
如果还不行
可以去GitHub上跟Go官方的去发送issue
或者是reproduce就是重现这个问题
可能都是很方便你们去解决这些问题的
最后祝大家
runtime学好之后
其实就
恭喜发财了
谢谢 再见