函数通信
多个线程之间交换数据无非是两种方式
共享内存加互斥锁或信号量
等同步机制防止资源竞争;先进先出(FIFO)
将资源分配给等待时间最长的线程。
Go采用channel通信的原因
- 不需要考虑竞争 :向共享内存写入必须用锁保护,否则容易出现数据竞争的问题。channel内部已经处理了同步问题(维护了一个FIFO的队列(
sendq
和recvq
)。 - 顺序一致性:共享内存中的多个线程间需要自己解决读取写入的顺序问题。channel保证了收发顺序一致。
- 数据交换方式上更明确(数据同步):channel是点对点的,指定了数据流向,从一个goroutine发送到另一个goroutine共享内存需要自己维护数据流向。send/receive两个操作就完成了数据同步,不需要额外的同步措施。向共享内存写入数据后,要通过其他方式通知等待线程。
- 不需要手动管理同步工具:共享内存需要显示创建和管理同步工具如互斥锁、信号量等。channel内部已封装了这些细节。
Go 中用于并发协程同步数据的组件
一个是 sync 和sync/atomic包里面的,如sync.Mutex、sync.RWMutex、sync.WaitGroup等,另一个是 channel。只有channel才是Go语言推荐的并发同步的方式,是一等公民,用户使用channel甚至不需要引入包名。
Channel结构
hchan结构体
channel的底层数据结构是hchan,在src/runtime/chan.go 中。
go
type hchan struct {
qcount uint // 队列中所有数据总数
dataqsiz uint // 循环队列大小
buf unsafe.Pointer // 指向循环队列的指针
elemsize uint16 // 循环队列中元素的大小
closed uint32 // chan是否关闭的标识
elemtype *_type // 循环队列中元素的类型
sendx uint // 已发送元素在循环队列中的位置
recvx uint // 已接收元素在循环队列中的位置
recvq waitq // 等待接收的goroutine的等待队列
sendq waitq // 等待发送的goroutine的等待队列
lock mutex // 控制chan并发访问的互斥锁
}
qcount代表chan中已经接收但还没被读取的元素的个数;
dataqsiz代表循环队列的大小(若是无缓冲通道,则默认为1);
buf 是指向循环队列的指针,循环队列是大小固定的用来存放chan接收的数据的队列;
elemtype 和 elemsiz 表示循环队列中元素的类型和元素的大小;
sendx:待发送的数据在循环队列buffer中的位置索引;
recvx:待接收的数据在循环队列buffer中的位置索引;
recvq 和 sendq 分别表示等待接收数据的 goroutine 与等待发送数据的 goroutine;
阻塞协程队列waitq与sudog结构体
sendq
和 recvq
存储了当前 Channel 由于缓冲区空间不足而阻塞的 Goroutine 列表,这些等待队列使用双向链表 waitq
表示,链表中所有的元素都是 sudog 结构:
go
type waitq struct { //阻塞的协程队列
first *sudog //队列头部
last *sudog //队列尾部 }
rust
type sudog struct { //sudog:包装协程的节点
g *g //goroutine,协程;
next *sudog //队列中的下一个节点;
prev *sudog //队列中的前一个节点;
elem unsafe.Pointer //读取/写入 channel 的数据的容器;
isSelect bool //标识当前协程是否处在 select 多路复用的流程中;
c *hchan //标识与当前 sudog 交互的 chan.
}
Channel类型
- 同步 (synchronous)(无缓冲)通道:没有缓冲区,发送端要等待接收端准备好才能发送数据。
- 异步 (asynchronous)(有缓冲)通道:有缓冲的 pointer 型Channel。可以设置缓冲区大小,发送端发送数据不会阻塞,只有缓冲区满了才阻塞。
- 异步通道带值元素:有缓冲的 struct 型Channel
Channel构造器函数源码分析
go
func makechan(t *chantype, size int) *hchan {
elem := t.elem //Channel中元素类型
// 每个元素的内存大小为elem.size,channel的容量为size,计算出总内存mem
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
if overflow || mem > maxAlloc-hchanSize || size < 0 {
panic(plainError("makechan: size out of range"))
}
var c *hchan
switch {
case mem == 0: //无缓冲型Channel
//hchanSize默认为96字节
c = (*hchan)(mallocgc(hchanSize, nil, true))
// 竞争检测器使用此位置进行同步。因为无缓冲,buf指向自己,用于竞争检测
c.buf = c.raceaddr()
case elem.ptrdata == 0: //有缓冲, 元素类型是struct的情况
c = (*hchan)(mallocgc(hchanSize+mem, nil, true)) // mallocgc分配hchan内存加上mem大小(mem可能是0)
c.buf = add(unsafe.Pointer(c), hchanSize) // c.buf指向hchan起始地址加上hchanSize,即指向buf字段
default: //有缓冲的 pointer 型Channel
c = new(hchan)
c.buf = mallocgc(mem, elem, true) // c.buf指向缓冲区内存起始地址
}
// 初始化hchan
c.elemsize = uint16(elem.size) //每个元素在内存中占用的字节数
c.elemtype = elem //元素类型
c.dataqsiz = uint(size) //队列中元素的数量上限
lockInit(&c.lock, lockRankHchan) //初始化读写保护锁
return c
}
elem.ptrdata
就是检测chan元素类型是否是指针类型:
-
如果elem.ptrdata == 0,表示chan元素是非指针类型,如struct(连续内存)
-
如果elem.ptrdata != 0,表示chan元素是指针类型
发送数据
-
首先判断通道是否为nil即未初始化,若为空则引发死锁
-
若通道非空,由于channel是共享资源,故需要对通道进行lock加锁
-
继续判断通道是否关闭,若关闭,则引发panic:send on closed channel
-
通道非空未关闭,则正式进入写入流程,首先判断是否有阻塞的读协程
-
若有阻塞的读协程,此时环形缓冲区内元素个数为0, 则唤醒读协程,直接将要发送的数据传递给它,并完成写入,进行解锁返回
-
没有阻塞的读协程,则判断环形缓冲区是否有空间
- 若环形缓冲区有空间,则直接将当前元素添加到环形缓冲区 sendx的位置,并更新写入位置sendx与通道元素个数qcount,解锁后返回函数。
- 若环形缓冲区无空间,将当前协程加入阻塞写协程队列中,阻塞协程,等待被读协程唤醒,并完成解锁
-
更多详细的内容查看Golang Channel 实现原理与源码分析 - 掘金 (juejin.cn)
接受数据
-
首先判断通道是否为nil即未初始化,若为空则引发死锁
-
若通道非空,由于channel是共享资源,故需要对通道进行lock加锁
-
继续判断通道是否关闭,若关闭,则判断环形缓冲区是否有元素,若无元素,则返回对应元素的零值。
-
通道非空未关闭,则正式进入写入流程,首先判断是否有阻塞的写协程
-
若有阻塞的写协程, 说明环形缓冲区为无缓冲型或已被写满,故判断channel是否为无缓冲型
- 若 channel为无缓冲型,则直接读取写协程元素,并唤醒写协程;
- 若 channel 为有缓冲型,则读取环形缓冲区头部元素,并将写协程元素写入缓冲区尾部后唤醒写协程,更新读写索引;
-
若没有阻塞的写协程,则判断环形缓冲区是否有空间
- 若环形缓冲区有空间,则直接将当前元素添加到环形缓冲区 sendx的位置,并更新写入位置sendx与通道元素个数qcount,解锁后返回函数。
- 若环形缓冲区无空间,将当前协程加入阻塞写协程队列中,阻塞协程,等待被读协程唤醒,并完成解锁
-
关闭channel流程
- 首先判断通道是否为nil即未初始化,若关闭空channel则引发panic(plainError("close of nil channel"))
-
若通道非空,由于channel是共享资源,故需要对通道进行lock加锁
-
继续判断通道是否关闭,若已经关闭,则引发panic(plainError("close of closed channel"))
-
通道非空未关闭,则正式进入关闭流程:
-
若有阻塞读协程队列,则将阻塞读协程队列中的协程节点统一添加到 glist,此时一定无阻塞写协程队列,先通知 recvq 的原因在于确保任何等待从通道接收数据的 goroutine 都能够迅速被通知通道已关闭。这样可以防止它们在等待更多数据时无限期地阻塞。而sendq遇到通道关闭时会直接引发panic,而不是等待。因此,先关闭 recvq 可以确保接收者 goroutine 不会阻塞,同时能够及时地了解通道已关闭。
-
若有阻塞写协程队列,则将阻塞写协程队列中的协程节点统一添加到 glist,此时一定无阻塞读协程队列
-
唤醒 glist 当中的所有协程.
-
scss
func chansend1(c *hchan, elem unsafe.Pointer) {
chansend(c, elem, true, getcallerpc())
}
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
if c == nil {
gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
throw("unreachable")
}
lock(&c.lock)
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
// ...
不同状态下channel的执行结果
操作 \ 状态 | 非空、未关闭的chan | 关闭状态 | nil 通道 |
---|---|---|---|
Send 结果 | 阻塞或正常读取数据。缓冲型 channel 为空或非缓冲型 channel 没有等待发送者时会阻塞 | Panic | 阻塞(deadlock) |
Recv 结果 | 阻塞或正常写入数据。非缓冲型 channel 没有等待接收者或缓冲型 channel buf 满时会被阻塞 | 接收数据或返回零值 | 阻塞(deadlock) |
close | 成功 | Panic | Panic |
for-range 读取 Channel 数据
不管是有缓冲还是无缓冲,都可以使用 for-range 从 channel 中读取数据,并且这个是一直循环读取的。
for-range 中的 range 产生的迭代值为 Channel 中发送的值,如果已经这个 channel 已经 close 了,那么首先还会继续执行,直到所有值被读取完,然后才会跳出 for 循环,因此,通过 for-range 读取 chann 数据会比较方便,因为我们只需要读取数据就行了,不需管他的退出,在 close 之后如果数据读取完了会自动帮我们退出。如果既没有 close 也没有数据可读,那么就会阻塞到 range 这里,除非有数据产生或者 chan 被关闭了。但是如果 channel 是 nil,读取会被阻塞,也就是会一直阻塞在 range 位置。
一个示例如下:
go
ch := make(chan int) // 一直循环读取 range 中的迭代值
for v := range ch {
// 得到了 v 这个 chann 中的值
fmt.Println("读取数据:",v)
}
select 读写 Channel 数据
-
select 的 case 分支里面,可以读数据,也可以写数据。最多只允许有一个 default case,它可以放在 case 列表的任何位置,并且没有任何影响。
-
select 可以同时处理多个 channel,如果有同时多个 case 分支可以去处理,比如同时有多个 channel 可以接收数据,那么 Go 会伪随机(pseudo-random)的选择一个 case 处理。如果没有 case 需要处理,则会选择 default 分支去处理。如果没有 default case,则 select 语句会阻塞,直到某个 case 分支可以处理了。
-
每次 select 语句的执行,是会扫描完所有的 case 后才确定如何执行,而不是说遇到合适的 case 就直接执行了。
-
对于 nil channel 上的操作会一直被阻塞,如果没有 default case,只有 nil channel 的 select 会一直被阻塞。
-
select 语句和 switch 语句一样,它不是循环,它只会选择一个 case 来处理,如果想一直处理channel,你可以在外面加一个无限的 for 循环
Channel 的读写超时机制【select + timeout】
我们的一般常见场景就是,当我们从 chann 中进行读取数据,或者写入数据的时候,想要快速返回得到是否成功的结果,如果被 chann 阻塞后,需要指定一定的超时时间,然后如果在超时时间内还没有返回,那么就超时退出,不能一直阻塞在读写 chann 的流程中。
Go 的 time 库里面,提供了 time.NewTimer()、time.After()、time.NewTicker() 等方法,最终都可以通过这些方法来返回或者得到一个 channel,然后向这个 channel 中发送数据,就可以实现定时器的功能。
channel 可以通过 select + timeout 来实现阻塞超时的使用姿势,超时读写的姿势如下:
go
// 通过 select 实现读超时,如果读 chann 阻塞 timeout 的时间后就会返回
func ReadWithSelect(ch chan int) (x int, err error) {
timeout := time.NewTimer(time.Microsecond * 500)
select {
case x = <-ch:
return x, nil
case <-timeout.C:
return 0, errors.New("read time out")
}
}
// 通过 select 实现写超时,如果写 chann 阻塞 timeout 的时间后就会返回
func WriteChWithSelect(ch chan int) error {
timeout := time.NewTimer(time.Microsecond * 500)
select {
case ch <- 1:
return nil
case <-timeout.C:
return errors.New("write time out")
}
}
TryEnqueue 无阻塞写 Channel 数据
有些场景,我们期望往缓冲队列中写入数据的时候,如果队列已满,那么不要进行写阻塞,而是写完发现队列已满就抛错,那么我们可以通过如下机制的封装来实现,原理是通过一个 select 和 一个 default 语句去实现,有一个 default 就不会阻塞了:
go
var jobChan = make(chan int, 3)
func TryEnqueue(job int) bool {
select {
case jobChan <- job:
fmt.Printf("true\n") // 队列未满
return true
default:
fmt.Printf("false\n") // 队列已满
return false
}
}
参考文献
作者:AllenWu
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
作者:Pistachiout
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
作者:腾讯云开发者
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。