Do not communicate by sharing memory; instead, share memory by communicating.
不要通过共享内存来实现通信,而是通过通信来实现共享内存。
-- 它依赖 CSP 模型,基于 channel 实现。
channel 可以看成一个 FIFO 队列,对 FIFO 队列的读写都是原子的操作,不需要加锁。
channel可分为 无缓冲channel 和 有缓冲channel 。
无缓冲channel 无法缓冲元素,对它的操作一定顺序是“发送-> 接收 -> 发送 -> 接收 -> ……”,如果连续向一个 无缓冲channel 发送 2 个元素,并且没有接收的话,第二次一定会被阻塞;对于 有缓冲channel 的操作,则要“宽松”一些,毕竟是带了“缓冲”光环。
channel 是一个引用类型,所以在它被初始化之前,它的值是 nil。channel 使用 make 函数进行初始化。可以向它传递一个 int 值,代表 channel 缓冲区的大小(容量),构造出来的是一个缓冲型的 channel;不传或传 0 的,构造的就是一个无缓冲channel。
channel是 Go 里的第一对象。通过 channel,Go 实现了通过通信来实现内存共享。
特点
若只有读端没有写端,那么读端阻塞;反之亦然。
无缓冲 channel,发送者会阻塞直到接收者接收了发送的值,所以是“同步”的。
声明无缓冲channel的方式:不设置缓冲大小或者设置为 0。
// 举例
c := make(chan string)
// <==>
c := make(chan string, 0)
特点
发送方在缓冲区满的情况下阻塞,接收方在缓冲区空的情况下阻塞,所以是“异步”的。
只要缓冲区有未使用空间用于发送数据,或还包含可以接收的数据,那么其通信就会无阻塞地进行。
// 举例
c := make(chan string, 2)
在一个值为 nil 的 channel 上发送和接收数据将永久阻塞
func main() {
var ch chan int // 未初始化,值为 nil
for i := 0; i < 3; i++ {
go func(i int) {
ch <- i
}(i)
}
fmt.Println("Result: ", <-ch)
}
// 运行结果
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive (nil chan)]
关闭不再需要使用的 channel 并不是必须的。跟其他资源比如打开的文件、socket 连接不一样,这类资源使用完后不关闭后会造成句柄泄露,channel 使用完后不关闭也没有关系,channel 没有被任何协程用到后最终会被 GC 回收。关闭 channel 一般是用来通知其他协程某个任务已经完成了。
channel关闭注意事项:
(1)同一个channel不能close多次,否则会导致panic。
(2)可以通过 v, ok := <- ch 方式中的ok值来判断该channel是否已经关闭。
(3)不能向已经关闭的channel中继续写入数据,否则会导致panic。
(4)从一个已经关闭的channel中读取数据时,依据有缓冲/无缓冲可以分两种情况讨论:
①无缓冲的channel:不会panic,但读到的将是"零值"。如int型channel将读到0,bool型channel将读到false。
②有缓冲channel:不会panic,若关闭channel时缓冲区里还有数据,则可以继续读取出剩余的数据,直到数据读完后将读出"零值"。
由上,总结channel操作行为结果如下:
操作 | nil channel | closed channel | not-closed non-nil channel |
---|---|---|---|
close | panic | panic | 成功 close |
写 ch <- |
一直阻塞 | panic | 阻塞或成功写入数据 |
读 <- ch |
一直阻塞 | 读取对应类型零值 | 阻塞或成功读取数据 |
如何优雅地关闭channel:
原则:
永远不要在读取端关闭 channel ,因为写入端无法知道 channel 是否已经关闭,往已关闭的 channel 写数据会 panic。
做法:
关闭 channel 粗暴一点的做法是随意关闭,如果产生了 panic 就用 recover 避免进程挂掉。稍好一点的方案是使用标准库的 sync
包来做关闭 channel 时的协程同步,不过使用起来也稍微复杂些。
///
func SafeSend(ch chan T, value T) (closed bool) {
defer func() {
if recover() != nil {
closed = true
}
}()
ch <- value
return false
}
///
func SafeClose(ch chan T) (justClosed bool) {
defer func() {
if recover() != nil {
justClosed = false
}
}()
close(ch)
return true
}
/// 还可以通过sync.Once来实现安全关闭channel
type MyChannel struct {
C chan T
once sync.Once
}
func NewMyChannel() *MyChannel {
return &MyChannel{C: make(chan T)}
}
func (mc *MyChannel) SafeClose() {
mc.once.Do(func(){
close(mc.C)
})
}
/// 判断channel是否关闭
/// 不过实现一个这样的接口也没什么必要。
/// 因为就算通过 isClosed() 得到当前 channel 当前还未关闭,
/// 如果试图往 channel 里写数据,仍然可能会发生 panic ,
/// 因为在调用 isClosed() 后,其他协程可能已经把 channel 关闭了。
func isClosed(ch chan int) bool {
select {
case <-ch:
return true
default:
}
return false
}
拓展:下面介绍不同场景下关闭channel的优雅些的做法
场景一:一写多读
这种场景下这个唯一的写入端可以关闭 channel 用来通知读取端所有数据都已经写入完成了。读取端只需要用 for range
把 channel 中数据遍历完就可以了,当 channel 关闭时,for range
仍然会将 channel 缓冲中的数据全部遍历完然后再退出循环。
package main
import (
"fmt"
"sync"
)
func main() {
wg := &sync.WaitGroup{}
ch := make(chan int, 100)
send := func() {
for i := 0; i < 100; i++ {
ch <- i
}
// signal sending finish
close(ch)
}
recv := func(id int) {
defer wg.Done()
for i := range ch {
fmt.Printf("receiver #%d get %d\n", id, i)
}
fmt.Printf("receiver #%d exit\n", id)
}
wg.Add(3)
go recv(0)
go recv(1)
go recv(2)
send()
wg.Wait()
}
场景二:多写一读
这种场景下虽然可以用 sync.Once
来解决多个写入端重复关闭 channel 的问题,但更优雅的办法设置一个额外的 channel ,由读取端通过关闭来通知写入端任务完成不要再继续再写入数据了。
package main
import (
"fmt"
"sync"
)
func main() {
wg := &sync.WaitGroup{}
ch := make(chan int, 100)
done := make(chan struct{})
send := func(id int) {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-done:
// get exit signal
fmt.Printf("sender #%d exit\n", id)
return
case ch <- id*1000 + i:
}
}
}
recv := func() {
count := 0
for i := range ch {
fmt.Printf("receiver get %d\n", i)
count++
if count >= 1000 {
// signal recving finish
close(done)
return
}
}
}
wg.Add(3)
go send(0)
go send(1)
go send(2)
recv()
wg.Wait()
}
场景三:多写多读
这种场景稍微复杂,和上面的例子一样,也需要设置一个额外 channel 用来通知多个写入端和读取端。另外需要起一个额外的协程来通过关闭这个 channel 来广播通知。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
wg := &sync.WaitGroup{}
ch := make(chan int, 100)
done := make(chan struct{})
send := func(id int) {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-done:
// get exit signal
fmt.Printf("sender #%d exit\n", id)
return
case ch <- id*1000 + i:
}
}
}
recv := func(id int) {
defer wg.Done()
for {
select {
case <-done:
// get exit signal
fmt.Printf("receiver #%d exit\n", id)
return
case i := <-ch:
fmt.Printf("receiver #%d get %d\n", id, i)
time.Sleep(time.Millisecond)
}
}
}
wg.Add(6)
go send(0)
go send(1)
go send(2)
go recv(0)
go recv(1)
go recv(2)
time.Sleep(time.Second)
// signal finish
close(done)
// wait all sender and receiver exit
wg.Wait()
}
在 channel 的内部实现中(具体定义在 $GOROOT/src/runtime/chan.go
里),维护了 3 个队列:
hchan
中持有两个链表,接收者链表recvq
和发送者链表sendq
,它们的类型是waitq
。链表中的元素为sudog
结构体类型,它包含了发送者或接收者的协程相关的信息。通过这些信息,Go 可以在发送者不存在时阻塞住接收者,反之亦然。
以三种不同场景为例分析:
(1)当协程尝试从未关闭的 channel 中读取数据时,内部的操作如下:
<- ch
未阻塞;<- ch
未阻塞;<- ch
阻塞。(2)当协程尝试往未关闭的 channel 中写入数据时,内部的操作如下:
ch <-
未阻塞;ch <-
未阻塞;ch <-
阻塞。(3)当关闭 non-nil channel 时,内部的操作如下:
channel底层实现时用到了互斥锁和缓冲数据队列,在元素进行出队/入队时都通过锁机制保障了操作的原子性,避免了复杂的竞态情形。
// 创建读写channel
c := make(chan int)
// 创建只读channel
c := make(<-chan int)
// 创建只写channel
c := make(chan<- int)
channle 作为 golang 最重要的特性,用起来还是比较爽的。传统的 C 里要实现类型的功能的话,一般需要用到 socket 或者 FIFO 来实现,另外还要考虑数据包的完整性与并发冲突的问题,channel 则屏蔽了这些底层细节,使用者只需要考虑读写就可以了。
channel 是引用类型,了解一下 channel 底层的机制对更好的使用 channel 还是很用必要的。虽然操作原语简单,但涉及到阻塞的问题,使用不当可能会造成死锁或者无限制的协程创建最终导致进程挂掉。
channel 除在可以用来在协程之间通信外,其阻塞和唤醒协程的特性也可以用作协程之间的同步机制,文中也用示例简单介绍了这种场景下的用法。
关闭 channel 并不是必须的,只要再没有协程去引用该 channel ,其最终会被 GC 清理。所以使用的时候要特别注意:不要让协程阻塞在 channel 上,这种情况很难检测到,而且会造成 channel 和阻塞在 channel 的协程占有的资源无法被 GC 清理最终导致内存泄露。
channle 方便 golang 程序使用 CSP 的编程范型,但是 golang 是一种多范型的编程语言,golang 也支持传统的通过共享内存来通信的编程方式。终极的原则是根据场景选择合适的编程范型,不要因为 channel 好用而滥用 CSP 。
golang channel 管道 通道 信道 使用总结_whatday的博客-CSDN博客
https://www.jianshu.com/p/d24dfbb33781
[译] Go语言的有缓冲channel和无缓冲channel - 知乎