字节二面:你怎么理解信道是协程的无锁通信方式?
信道是一个goroutine之间很关键的通信媒介,理解golang的信道很重要,这里记录平时易忘记的、易混淆的点。
func main() { chs := make(chan string, 2) chs <- "first" chs <- "second" for ch := range chs { fmt.Println(ch) } }
输出
first second fatal error: all goroutines are asleep - deadlock! // 读完信道,再次读取会阻塞,本例为主协程,所以会死锁。
1. 基本使用
刚声明的信道,零值为nil,无法直接使用,需配合make函数进行初始化
ic := make(chan int) ic <-22 // 向无缓冲信道写入数据 v := <-ic // 从无缓冲信道读取数据
- 无缓冲信道: 一手交钱,一手交货, sender、receiver必须同时做好动作,才能完成发送->接收;否则,先准备好的一方将会阻塞等待。
- 有缓冲信道 make(chan int,10):滑轨流水线,因为存在缓冲空间,故并不强制sender、receiver必须同时准备好;当通道空/满时, 接收方/发送方依旧会阻塞。
信道存在三种状态: nil, active, closed
针对这三种状态,sender、receiver有一些行为,我也不知道如何强行记忆这些行为:
我们看一个常规实践: 通知服务终止
var stopped chan struct{} = make( chan struct{}) // 通知服务终止 var done chan struct {} = make(chan struct{}) // 收到服务真的终止的信号 func( s * service) Stop(){ stopped = s.stopped if stopped { return } s.stopped = true close(s.stop) // tell flushLoop to stop <-s.done // and wait until it has } func (s *service) flushLoop() { defer close(s.done) // 通知现在已经关闭 for { select { case <-s.ticker.C: // 服务逻辑 // we just simply ignore error here // because the underlying bufio writer stores any errors // and we return any error from Sync() as part of the close _ = s.Sync() case <-s.stop: // 收到停止信号 return } } }
2. 从1个例子看chan的实质
package main import ( "fmt" ) func SendDataToChannel(ch chan int, value int) { fmt.Printf("ch's value:%v, chan's type: %T \n", ch, ch) // %v 显示struct的值;%T 显示类型 ch <- value } func main() { var v int ch := make(chan int) fmt.Printf("ch's value:%v, chan's type: %T \n", ch, ch) go SendDataToChannel(ch, 101) // 通过信道发送数据 v = <-ch // 从信道接受数据 fmt.Println(v) // 101 }
能正确打印101。
Q1: 刚学习golang的时候,一直给我们灌输golang函数是值传递,那上例在另外一个协程内部对形参的操作,为什么会影响外部的实参?
请关注格式化字符的日志输出:
ch's value:0xc000018180, chan's type: chan int ch's value:0xc000018180, chan's type: chan int 101
A: 上面的日志显示传递的ch
是一个指针值0xc000018180,类型是chan int
( 这并不是说ch是指向chan int
类型的指针)。
chan int
本质就是指向hchan结构体的指针。
内置函数make创建信道: func makechan(t *chantype, size int) *hchan
返回了指向hchan结构体
的指针:
type hchan struct { qcount uint // 队列中已有的缓存元素的长度 dataqsiz uint // 环形队列的长度 buf unsafe.Pointer // 环形队列的地址 elemsize uint16 closed uint32 elemtype *_type // 元素类型 sendx uint // 待发送的元素索引 recvx uint // 待接受元素索引 recvq waitq // 阻塞等待的goroutine sendq waitq // 阻塞等待的gotoutine // lock protects all fields in hchan, as well as several // fields in sudogs blocked on this channel. // // Do not change another G's status while holding this lock // (in particular, do not ready a G), as this can deadlock // with stack shrinking. lock mutex }
三个先进先出的队列。
- 信道值
- 因为缓冲区空而阻塞的 recv协程队列 : recvq
- 因为缓冲区满而阻塞的 send协程队列: sendq
Q2: 缓冲信道内部为什么要使用环形队列?
A:golang是使用数组来实现信道队列,在不移动元素的情况下, 队列会出现“假满”的情况,
以环形队列, 所有的入队出队操作依旧是 O(1)的时间复杂度,同时元素空间可以重复利用。
搭配sendx,recvx来标记实际的待插入/待拉取位置,显而易见会出现 sendx <=recvx 的情况。
recvq,recvq是由链表实现的队列,用于存储阻塞等待的goroutine和待发送/待接收值,
这两个结构也是阻塞goroutine被唤醒的准备条件。
func (q *waitq) enqueue(sgp *sudog) { sgp.next = nil x := q.last if x == nil { sgp.prev = nil q.first = sgp q.last = sgp return } sgp.prev = x x.next = sgp q.last = sgp }
3. 发送/接收的细节
① 不要使用共享内存来通信,而是使用通信来共享内存
元素值从外界进入信道会被复制,也就是说进入信道的是元素值的副本,并不是元素本身进入信道 (出信道类似)。
金玉良言落到实处:不同的线程不共享内存、不用锁,线程之间通讯用channel同步也用channel。
发送/接收数据的两个动作(G1,G2,G3)没有共享的内存,底层通过hchan结构体的buf,使用copy内存的方式进行通信,最后达到了共享内存的目的。
② 根据第①点,发送操作包括:复制待发送值,放置到信道内;
接收操作包括:复制元素值, 放置副本到接收方,删除原值,以上行为在全部完成之前都不会被打断。
所以第①点所说的无锁,其实指的业务代码无锁,信道底层实现还是靠锁。
以send操作为例,下面代码截取自 https://github.com/golang/go/blob/master/src/runtime/chan.go#L216
if c.qcount < c.dataqsiz { // Space is available in the channel buffer. Enqueue the element to send. qp := chanbuf(c, c.sendx) // 计算出buf中待插入位置的地址 if raceenabled { racenotify(c, c.sendx, nil) } typedmemmove(c.elemtype, qp, ep) // 将元素copy进指定的qp地址 c.sendx++ // 重新计算待插入位置的索引 if c.sendx == c.dataqsiz { c.sendx = 0 } c.qcount++ unlock(&c.lock) return true }
一个常规的send动作:
- 计算环形队列的待插入位置的地址
- 将元素copy进指定的qp地址
- 重新计算待插入位置的索引sendx
- 如果待插入位置==队列长度,说明插入位置已到尾部,需要插入首部。
- 以上动作加锁
③ 进入等待状态的goroutine会进入hchan的sendq/recvq列表
调度器将G1、G2置为waiting状态,G1、G2进入sendq列表,同时与逻辑处理器分离;
直到有G3尝试读取信道内recvx
元素,之后将唤醒队首G1进入runnable状态,加入调度器的runqueue。
这里面涉及gopark
, goready
两个函数。
如果是无缓冲信道引起的阻塞,将会直接拷贝G1的待发送值到G2的存储位置
✍️ https://github.com/golang/go/blob/master/src/runtime/chan.go#L527
package main import ( "fmt" "time" ) func SendDataToChannel(ch chan int, value int) { time.Sleep(time.Millisecond * time.Duration(value)) ch <- value } func main() { var v int var ch chan int = make(chan int) go SendDataToChannel(ch, 104) // 通过信道发送数据 go SendDataToChannel(ch, 100) // 通过信道发送数据 go SendDataToChannel(ch, 50) // 通过信道发送数据 go SendDataToChannel(ch, 120) // 通过信道发送数据 time.Sleep(time.Second) v = <-ch // 从信道接受数据 fmt.Println(v) time.Sleep(time.Second * 10) }
Q3:上述代码大概率稳定输出50
。
A:虽然4个goroutine被启动的顺序不定,但是肯定都阻塞了,阻塞的时机不一样,被唤醒的是sendq
队首的goroutine,基本可认为第三个goroutine被首先捕获进sendq
,因为是无缓冲信道,将会直接拷贝G3的50给到待接收地址。
4. 业内总结的信道的常规姿势
无缓冲、缓冲信道的特征,已经在golang领域形成了特定的套路。
-
当容量为0时,说明信道中不能存放数据,在发送数据时,必须要求立马有人接收,此时的信道称之为无缓冲信道。
-
当容量为1时,说明信道只能缓存一个数据,若信道中已有一个数据,此时再往里发送数据,会造成程序阻塞,利用这点可以利用信道来做锁。
-
当容量大于1时,信道中可以存放多个数据,可以用于多个协程之间的通信管道,共享资源。
Q4: 为什么无缓冲信道不适合做互斥锁?
A: 我们先思考一下锁的业务实质:
线程自行获取锁标识,执行临界区代码,释放锁标识。
无缓冲信道: 协程(从信道投递或者接收)都能起到获取锁的效果,但当没有其他协程配合时会阻塞自身执行;有其他协程配合时,获取的锁是瞬间的,根本锁不住临界区代码。
缓冲长度为1的信道: 协程(向信道投递/接收一个值)就能起到锁的效果,还可以继续执行临界区代码,之后可以(向信道接收/投递一个值)解锁,整个过程这个协程可以自行完成,完美契合业务互斥锁的行为。
5. 该选择信道 还是并发原语
go的信道在golang中是first class, 但是并发原语只是在sync包,另外经典名言“不要使用共享内存来通信,而是使用通信来共享内存”也强调了信道在golang并发中的作用。
有人给出了使用信道实现常规并发原语的方法.
[那什么时候该使用信道? 什么时候该使用并发原语?](https://cloud.tencent.com/developer/article/1412497)
信道的核心是数据流动,关注到并发问题中的数据流动,把流动的数据放在信道中,就能使用信道解决这个并发问题。
DataFlow -> Drawing -> Pipieline -> Exiting
mutex的能力是数据不动,某段时间只给一个协程访问数据的权限, 擅长数据位置固定的场景。
本文来自博客园,作者:{有态度的马甲},转载请注明原文链接:https://www.cnblogs.com/JulianHuang/p/16466093.html
欢迎关注我的原创技术、职场公众号, 加好友谈天说地,一起进化
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?