在Go语言中,Channel是一种用于在goroutine之间进行通信和同步的重要机制。它提供了一种安全、类型安全的方式来传递数据,使得并发编程变得更加直观和简单。本文将详细介绍Golang中Channel的基本概念、创建与关闭、发送与接收操作,以及相关的使用场景和注意事项。另外,Channel本身也是Golang一个很核心的设计理念的良好体现,即:
Do not communicate by sharing memory; instead, share memory by communicating.
( 来源:Share Memory By Communicating - The Go Programming Language)
Golang的Channel基本介绍
Channel的基本概念
Channel是Go语言中的一种特殊类型,它像一个队列一样,遵循先进先出(FIFO)的原则,确保数据的顺序性。每个Channel都有一个指定的类型,只能传递相同类型的数据。Channel是并发安全的,允许多个goroutine同时读写,而不会引发数据竞争。
Channel的主要作用是实现goroutine之间的通信和同步。通过Channel,一个goroutine可以发送数据到另一个goroutine,从而实现数据的交换和共享。
Channel的创建与关闭
在Go中,可以使用内置的make函数来创建一个Channel。创建Channel时需要指定其传递的数据类型,并可以选择性地指定缓冲区大小。
// 创建一个无缓冲的Channel
ch1 := make(chan int)
// 创建一个容量为10的缓冲Channel
ch2 := make(chan int, 10)
当不再需要向Channel发送数据,并且已经接收完所有数据时,应该关闭Channel。关闭Channel使用close函数。关闭后的Channel不能再发送数据,但可以继续接收已发送的数据,直到Channel为空。
close(ch1)
需要注意的是,关闭一个已经关闭的Channel会引发panic。
Channel的发送与接收操作
Channel的发送和接收操作使用操作符。发送数据到Channel使用ch 语法,从Channel接收数据使用value := 语法。
默认情况下,Channel的发送和接收操作都是阻塞的。即,在发送数据到Channel时,如果接收方没有准备好接收,发送方会被阻塞;同样,在从Channel接收数据时,如果发送方没有发送数据,接收方也会被阻塞。这种阻塞行为可以用于实现同步和协调。
如果需要实现非阻塞的发送和接收操作,可以使用select语句结合default子句。
Channel的使用场景
Channel在并发编程中有广泛的应用场景,包括但不限于:
- 消息传递
- Channel可以用于在不同的goroutine之间传递数据,实现基本的数据传输。
- 任务分发
- 可以将任务分发到多个goroutine中并行处理,每个goroutine处理完成后将结果发送回主goroutine或另一个处理结果的goroutine。
- 事件通知
- Channel可以用于实现事件的发布和订阅模式,当一个事件发生时,通过Channel将事件通知给所有订阅者。
- 同步信号
- 可以使用Channel作为信号量,当条件满足时,通过Channel发送一个信号,接收方收到信号后继续执行。
- 控制并发任务的启动和结束
- 通过Channel可以协调多个goroutine的启动和结束,确保它们按照预定的顺序执行。
- 限制并发数
- 可以使用带有缓冲的Channel来限制同时运行的goroutine数量。
- 同步操作
- 可以使用Channel来同步多个操作,确保它们按照预定的顺序进行。
- 异步处理
- Channel支持异步处理模式,即发送方发送数据后不需要等待接收方处理完成即可继续执行其他任务。
Channel实现原理
chan 使用 hchan 表示,它的传参与赋值始终都是指针形式,每个 hchan 对象代表着一个 chan。
- hchan 中包含一个缓冲区 buf,它表示已经发送但是还未被接收的数据缓存。buf 的大小由创建 chan 时的参数来决定。qcount 表示当前缓冲区中有效数据的总量,dataqsiz 表示缓冲区的大小,对于无缓冲区通道而言 dataqsiz 的值为 0。如果 qcount 和 dataqsiz 的值相同,则表示缓冲区用完了。
- 缓冲区表示的是一个环形队列 (如果你不熟悉环形队列,可以看一下 https://www.geeksforgeeks.org/circular-queue-set-1-introduction-array-implementation/)。其中 sendx 表示下一个发送的地址,recvx 表示下一个接收的地址。
- recvq 表示等待接收的 sudog 列表,一个接收语句执行时,如果缓冲区没有数据而且当前没有别的发送者在等待,那么执行者 goroutine 会被挂起,并且将对应的 sudog 对象放到 recvq 中。
- sendq 类似于 recvq,一个发送语句执行时,如果缓冲区已经满了,而且没有接收者在等待,那么执行者 goroutine 会被挂起,并且将对应的 sudog 放到 sendq 中。
- closed 表示通道是否已经被关闭,0 代表没有被关闭,非 0 值代表已经被关闭。
- lock 用于对 hchan 加锁
hchan 则是 channel 在 golang 中的内部实现。其定义如下:
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 中的所有属性大致可以分为三类:
- buffer 相关的属性。例如 buf、dataqsiz、qcount 等。 当 channel 的缓冲区大小不为 0 时,buffer 中存放了待接收的数据。使用 ring buffer 实现。
- waitq 相关的属性,可以理解为是一个 FIFO 的标准队列。其中 recvq 中是正在等待接收数据的 goroutine,sendq 中是等待发送数据的 goroutine。waitq 使用双向链表实现。
- 其他属性,例如 lock、elemtype、closed 等。
channel 的 ring buffer 实现
channel 中使用了 ring buffer(环形缓冲区) 来缓存写入的数据。ring buffer 有很多好处,而且非常适合用来实现 FIFO 式的固定长度队列。
在 channel 中,ring buffer 的实现如下:
hchan 中有两个与 buffer 相关的变量: recvx 和 sendx。其中 sendx 表示 buffer 中可写的 index, recvx 表示 buffer 中可读的 index。 从 recvx 到 sendx 之间的元素,表示已正常存放入 buffer 中的数据。
我们可以直接使用 buf[recvx] 来读取到队列的第一个元素,使用 buf[sendx] = x 来将元素放到队尾。
数据发送:
发送数据分三种情况:
- 有 goroutine 阻塞在 channel 上,此时 hchan.buf 为空:直接将数据发送给该 goroutine。
- 当前 hchan.buf 还有可用空间:将数据放到 buffer 里面。
- 当前 hchan.buf 已满:阻塞当前 goroutine。
第一种情况如下。从当前 channel 的等待队列中取出等待的 goroutine,然后调用 send。goready 负责唤醒 goroutine。
第二种情况比较简单。通过比较 qcount 和 dataqsiz 来判断 hchan.buf 是否还有可用空间。除此之后还需要调整一下 sendx 和 qcount。
数据读取:
从nil channel读取会抛异常。
从 closed channel 接收数据,如果 channel 中还有数据,接着走下面的流程。如果已经没有数据了,则返回默认值。使用 ok-idiom 方式读取的时候,第二个参数返回 false。
当前有发送 goroutine 阻塞在 channel 上,buf 已满:
如果buf size是0,则直接从sender读,否则读取队列的头
lock(&c.lock)
if sg := c.sendq.dequeue(); sg != nil {
// Found a waiting sender. If buffer is size 0, receive value
// directly from sender. Otherwise, receive from head of queue
// and add sender's value to the tail of the queue (both map to
// the same buffer slot because the queue is full).
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
buf 中有可用数据:
if c.qcount > 0 {
// Receive directly from queue
qp := chanbuf(c, c.recvx)
if raceenabled {
raceacquire(qp)
racerelease(qp)
}
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
typedmemclr(c.elemtype, qp)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.qcount--
unlock(&c.lock)
return true, true
}
buf为空,阻塞
无缓冲的通道只有当发送方和接收方都准备好时才会传送数据,否则准备好的一方将会被阻塞。
有缓存的channel区别在于只有当缓冲区被填满时,才会阻塞发送者,只有当缓冲区为空时才会阻塞接受者。
关闭channel的操作原则上应该由发送者完成,因为如果仍然向一个已关闭的channel发送数据,会导致程序抛出panic。而如果由接受者关闭channel,则会遇到这个风险。
从一个已关闭的channel中读取数据不会报错。只不过需要注意的是,接受者就不会被一个已关闭的channel的阻塞。而且接受者从关闭的channel中仍然可以读取出数据,只不过是这个channel的数据类型的默认值。我们可以通过指定接受状态位来观察接受的数据是否是从一个已关闭的channel所发送出来的数据。
有缓冲channel和无缓冲channel的应用场景
- 无缓冲channel:同步消息
- 有缓冲channel:异步消息
为什么Channel会被设计成向已经关闭的channel发送数据会引发panic
Channel 的基本特性和关闭机制
首先,我们需要了解 Channel 的基本特性和关闭机制。Channel 在 Go 中是一个类型安全的队列,它支持两个基本操作:发送(send)和接收(receive)。发送操作将数据放入 Channel,而接收操作从 Channel 中取出数据。通过关闭 Channel(使用 close 函数),发送者可以通知接收者没有更多的数据将被发送。
关闭 Channel 是一个单向操作,意味着一旦 Channel 被关闭,就不能再向其中发送数据。这是 Channel 设计中的一个关键原则,它确保了数据的发送和接收之间的同步和一致性。
为什么向已关闭的 Channel 写数据会引发 Panic?
向一个已经关闭的 Channel 发送数据会引发 panic,这主要是出于以下几个原因:
- 保持数据一致性:一旦 Channel 被关闭,接收者应该能够安全地假设不会有更多的数据被发送。如果允许向已关闭的 Channel 发送数据,这将会破坏这种一致性,导致接收者无法准确地判断 Channel 的状态,从而可能引发数据竞争或其他并发问题。
- 避免资源泄漏:Channel 的关闭通常意味着与其相关的资源(如内存和 goroutine)可以被释放。如果允许向已关闭的 Channel 发送数据,这些资源可能无法被及时释放,从而导致资源泄漏。
- 简化错误处理:通过引发 panic,Go 语言强制开发者在编写代码时处理向已关闭的 Channel 发送数据的情况。这有助于开发者在开发阶段就发现并修复潜在的错误,从而提高程序的健壮性和稳定性。
- 符合直观预期:从直觉上讲,向一个已经关闭的通信管道发送数据是不合理的。Go 语言的设计哲学倾向于直观和简洁,因此将这种操作定义为 panic 符合大多数开发者的直观预期。