不要通过共享内存通信,而是通过通信共享内存(Do not communicate by sharing memory; instead, share memory by communicating)Golang提供了channel这个内建类型,作为传统加锁方式进行内存通信的替代或者说升级。虽然大多数锁的问题可以通过 channel 或者传统的锁两种方式之一解决,但是 Go 语言核心团队更加推荐使用 CSP 的方式,即使用channel。那么channel该如何使用呢?
在具体说明之前,可以先看看下图,对channel有一个大致的了解。
flowchart LR
A[协程1] & B[协程2] --> channel --> C[协程3] & D[协程4] & E[协程5]
channel作为协程之间交互的桥梁,协程1和协程2将数据传入到channel,协程3,协程4,协程5从channel中将数据取出。这个过程中,不需要主动进行加锁,也直接操作一块内存空间。这就是一个常用的channel通信模型。
使用channel
创建
channel的创建可以分为两步,声明和初始化。
- 声明
var ch chan Type,Type可以是任意类型表示。如:var ch chan int- 此时
ch为nil
- 此时
- 初始化
ch = make(chan Type, [size])size是可选的。如:ch = make(chan int, 5)- 当不填入
size或者size为0时,channel是无缓冲的。即当向channel中写入数据时,如果此时没有接收者从channel读取,则会发生阻塞,直到有接收者向channel请求数据。 - 当
size不为0时,channel为有缓冲的,缓冲区大小为size。向channel写入时,如果缓冲区没有写满则不会发生阻塞。从channel读取时则是如果缓冲区有数据则不会阻塞,没有则会发生阻塞。 - 此处所说的读写都是阻塞方式进行的,并不包括讲到的配合
select进行的非阻塞读写,以及channel关闭的情况
- 当不填入
- 可以利用
:=一次性完成声明和初始化,如:ch := make(chan int) - 空的
channel(值为nil)不能使用
读写channel
这里有的喜欢用读写,有的喜欢用发送、接收,都是一个意思,不用纠结。
-
阻塞读
<-ch<-ch可以有一个或者两个返回值。一个返回值时,返回的是通过channel传输的数据。两个返回值时,会增加一个零值标记。当channel关闭后,并且数据已经读取完毕,再进行读取则会返回零值。- 具体由这几种常用组合
<-ch不接收返回值,同_ = <-ch;x := <-ch;x, ok := <-ch
-
阻塞写
ch <- xch <- x只有这一种方法,相对读取变化就少很多了
-
非阻塞读
-
利用
select可以实现非阻塞的读取,当遇到触发阻塞的情况时,放弃本次读取。理解成试探读取,成功则触发case中的代码块也可以。 -
select { case x := <-ch: fmt.Println(x) default: fmt.Println("放弃本次读取") }
-
-
非阻塞写
-
同非阻塞读一样,利用
select,当遇到触发阻塞的情况时,放弃本次写入。理解成试探读取,成功则触发case中的代码块也可以。 -
select { case ch <- x: fmt.Println("写入成功") default: fmt.Println("放弃本次写入") }
-
channel作为参数
channel是指针类型的,作为参数传递时,会对地址进行拷贝,而不会进行值拷贝。
channel作为参数传递由三种形式:可读可写、只读、只写。使用<-进行区分,注意<-指向和chan的位置,就可以很形象的区分了。
func ReadWriteChan(ch chan int) // 可读可写
func ReadOnly(ch <-chan int) // 只读
func WriteOnly(ch chan<- int) // 只写
如果在只读或只写channel上进行不允许的操作(写;读),编译阶段就会报错。
channel的关闭
关闭管道我们需要用到一个内建函数close。
close(ch)
这里的close和文件关闭不同的,并不会释放相关资源。channel的回收受垃圾回收机制控制。
注意
- 对一个关闭的通道再发送值就会导致
panic。 - 对一个关闭的通道进行接收会一直获取值直到通道为空。
- 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
- 关闭一个已经关闭的通道会导致
panic。
异常情况总结
| channel | 未初始化 | 未关闭且空 | 已关闭且空 |
|---|---|---|---|
| 接收 (读) | 阻塞 | 阻塞 | 返回零值 |
| channel | 未初始化 | 满 | 已关闭 |
|---|---|---|---|
| 发送 (写) | 阻塞 | 阻塞 | panic |
| channel | 未初始化 | 已关闭 |
|---|---|---|
| 关闭(close) | panic | panic |
遍历channel
除了使用<-来读取channel外,我们还可以使用for range遍历channel。
for x := range ch {
fmt.Println(x)
}
- 如果
ch没有关闭,for range会不断尝试从ch中阻塞读取数据。也就是说循环会一直持续。 - 如果
ch关闭了,for range会在完成最后一个数据读取后退出。
channel的经典用例
- 简易版本协程池
场景:有一个暗杀组织,接到一个大单,需要(按顺序)暗杀数位目标。现在招募了一批刺客完成任务。通过在任务栏发布任务,刺客从任务栏接受任务并完成的方式完成这一订单。
const (
TaskBarSize = 5 // 任务栏
AssasinNum = 10 // 刺客数量
TargetNum = 100 // 目标数量
)
type Task struct { // 任务
Target string // 目标
}
func main() {
taskBar := make(chan *Task, TaskBarSize) // 创建任务栏(channel)
// 招募10个刺客 创建工作协程
for i := 0; i < AssasinNum; i++ {
go func() {
for {
taskBook := <-taskBar // 刺客接收任务书 从channel读取数据
fmt.Println("肃清了", taskBook.Target) // 刺客完成任务 处理数据
}
}()
}
// 发布任务书 向管道中写入数据
for i := 0; i < TargetNum; i++ {
taskBook := &Task{"Target" + strconv.Itoa(i)}
time.Sleep(time.Millisecond * 300)
taskBar <- taskBook
}
// 等待任务结束
time.Sleep(time.Second * 10)
}
在实际应用中我们往往希望知道什么时候结束(包括任务成功、失败两种情况),而不是Sleep,以便进行下一步处理。
- 等待协程结束
示例代码利用sync.WaitGroup作为计数工具,好处是不用造轮子,计数、等待、协程安全都考虑好了,直接用就可以。如果要自己做可以考虑使用原子包atomic。不足是不够灵活,如果并不需要所有任务都完成就可以退出的情况就处理不了了。
taskBar := make(chan int, TaskBarSize)
wg := &sync.WaitGroup{}
for i := 0; i < WorkerNum; i++ {
go func(bar <-chan int, taskCount *sync.WaitGroup) {
for task := range bar {
fmt.Println("task", task)
time.Sleep(time.Second)
taskCount.Done() // 任务计数减
}
}(taskBar, wg)
}
for i := 0; i < TaskNum; i++ {
taskBar <- i
wg.Add(1) // 任务计数加
time.Sleep(time.Millisecond * 100)
}
close(taskBar)
wg.Wait() // 等大所有任务结束
fmt.Println("task finished")
- 为工作协程加上退出判定
利用context包和select为协程添加统一的退出控制。当一个协程完成目标后会通过goalChan告知主线程,主线程会调用cancel函数结束掉协程。这样可以规避掉一些协程泄露问题。
taskBar := make(chan int, TaskBarSize)
goalChan := make(chan int)
ctx, cancel := context.WithCancel(context.Background())
for i := 0; i < WorkerNum; i++ {
go func(ctx context.Context, bar <-chan int) {
for {
select {
case task := <-bar:
fmt.Println("task", task)
if task == 90 {
goalChan <- task
}
case <-ctx.Done(): // 退出控制
fmt.Println("goroutine cancel")
return
}
time.Sleep(time.Second)
}
}(ctx, taskBar)
}
for i := 0; i < TaskNum; i++ {
select {
case <-goalChan:
cancel()
goto fin
default:
taskBar <- i
time.Sleep(time.Millisecond * 100)
}
}
<-goalChan
fin:
fmt.Println("fin")
time.Sleep(time.Second * 10)
。。。。
channel的作为go并发重要工具人,在各种各样的场景中都可以发挥作用,这里只是列举了几个例子。更多的使用方法还需要大家自己取发掘。另外代码都是随手写的,有什么优化意见欢迎指教。
源码解读
代码取自go1.18,解读的主要内容包括:
- 对原有注释的翻译
- 对重要字段的注解
- 常用操作执行过程的梳理
- 。。。
并不会有详细的原理性的解释,请大家按需取阅。
channel 的底层源码和相关实现在 $GOROOT/src/runtime/chan.go 中。
数据结构
type hchan struct {
qcount uint // 缓存队列中的数据计数
dataqsiz uint // 环形队列大小
buf unsafe.Pointer // 指向缓存队列存储空间(一个长度为dataqsiz的数组)
elemsize uint16 // 元素大小
closed uint32 // 关闭标记
elemtype *_type // 元素类型
sendx uint // 已发送的元素在环形队列中的位置
recvx uint // 已接收的元素在环形队列中的位置
recvq waitq // 等待接收 (读)的队列
sendq waitq // 等待发送(写)的队列
// lock 锁保护 hchan 中的所有字段,以及此通道上被阻塞的 sudogs 中的多个字段
//
// 持有 lock 的时候,禁止更改另一个 G 的状态(特别是不要使 G 状态变成ready),
// 因为这会造成堆栈压缩时发生死锁。
lock mutex
}
recvq 和 sendq 是等待队列,waitq 是一个*sudog的双向链表:
type waitq struct {
first *sudog
last *sudog
}
type sudog struct {
g *g
next *sudog
prev *sudog
elem unsafe.Pointer // 指向数据元素(可能指向栈空间)
// 这三个字段永远不会被同时访问。
// waitlink 只由 g 使用。对 semaphores 来说,
// 只有在持有 semaRoot 锁的时候才能访问这三个字段。
acquiretime int64
releasetime int64
ticket uint32
// isSelect 表示 g 是否被选择,g.selectDone 必须进行 CAS 才能在被唤醒的竞争中胜出
isSelect bool
// success 表示 channel c 上的通信是否成功。读取channel时的第二个参数
// 如果 goroutine 在 channel c 上传了一个值而被唤醒,则为 true;
// 如果因为 c 关闭而被唤醒,则为 false。
success bool
parent *sudog // semaRoot 二叉树
waitlink *sudog // g.waiting 列表或者 semaRoot
waittail *sudog // semaRoot
c *hchan // channel
}
sudog 代表了一个在等待队列中的 g(协程),记录了这个goroutine 正在等待什么东西或者正在等待哪些东西。sudog 是 Go 中非常重要的数据结构,因为 g 与同步对象关系是多对多的。一个 g 可以出现在许多等待队列上,因此一个 g 可能有很多sudog。并且多个 g 可能正在等待同一个同步对象,因此一个对象可能有许多 sudog。sudog 是从特殊池中分配出来的。使用 acquireSudog 和 releaseSudog 分配和释放它们。g作为golang中一个重要的数据结构,继续深挖还有很多内容可以衍生,但是这里只需要知道它代表一个协程即可。
创建channel
创建channel最终会调用func makechan(t *chantype, size int) *hchan 。它的目的就是构建 hchan 对象并返回。由于 hchan 在程序中始终以引用的形式存在,通过赋值或者是传参,它指向的都是同一个对象,所以 hchan 在标准库中都是以指针形式呈现给外部的。对于 makechan 的逻辑,这里分 3 种情况:
- 无缓冲的
channel,那么只需分配hchan所需要的内存空间即可,不需要为缓存分配空间。 - 有缓冲但数据不包含指针类型。分配一块连续的内存空间,大小为
sizeof(hchan)+缓存空间大小。同时计算并记录缓存空间开始位置。 - 缓冲区所需大小不为 0,而且数据类型包含指针。对于这种情况,分配两块内存,其中一块表示 hchan 对象,另外一块用来表示缓冲区。
相关代码如下:
switch {
case mem == 0:
// Queue or element size is zero.
c = (*hchan)(mallocgc(hchanSize, nil, true))
// Race detector uses this location for synchronization.
c.buf = c.raceaddr()
case elem.ptrdata == 0:
// Elements do not contain pointers.
// Allocate hchan and buf in one call.
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
// Elements contain pointers.
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}
至于为什么要区分包含指针和不包含指针这两种情况,官方解释是出于对垃圾回收的考量。
Hchan does not contain pointers interesting for GC when elements stored in buf do not contain pointers.
发送数据
对应func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool 。参数 c 表示要向哪个 chan 发送数据, ep 表示要发送的数据的地址,block 表示是否需要阻塞, callerpc 表示调用地址。返回值表示数据是否成功发送。
chansend开头是在未加锁的情况下进行一些异常检测。
if c == nil { // 判断是否为nil,向nil的channel发送会发生阻塞
if !block { // 非阻塞写
return false // 返回false
}
// 阻塞写 以waitReasonChanSendNilChan触发阻塞 将goroutine挂起
gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
// 抛出unreachable fatal异常
throw("unreachable")
}
// 测试代码 不用在意
if debugChan {
print("chansend: chan=", c, "\n")
}
// raceenable为false 不会触发
if raceenabled {
racereadpc(c.raceaddr(), callerpc, abi.FuncPCABIInternal(chansend))
}
// 非阻塞 未关闭 已满 返回false
if !block && c.closed == 0 && full(c) {
return false
}
之后便是加锁操作了。之间还穿插了一段profile相关的代码,不用在意。
lock(&c.lock)
if c.closed != 0 { // 写已关闭的channel panic
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
if sg := c.recvq.dequeue(); sg != nil { // 如果有等待中的协程,直接将数据传过去。
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
if c.qcount < c.dataqsiz { // 如果缓冲区有空间,那么将数据写入缓冲区,完成写入
qp := chanbuf(c, c.sendx)
if raceenabled {
racenotify(c, c.sendx, nil)
}
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
unlock(&c.lock)
return true
}
// 写入失败,如果是非阻塞模式则返回false
if !block {
unlock(&c.lock)
return false
}
// 阻塞模式下,则挂起协程,等待被唤醒
...
读取数据
对应func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool)
chanrecv从c中接收数据,并且将接收到的数据存到ep中,block表示是否需要阻塞。- 如果没有数据可以接收,而且是非阻塞的情况,则返回
(false,flase)。如果c已经关闭了,将ep指向的值置为 零值,并且返回(true, false)。其它情况返回值为(true,true),表示成功从c中获取到了数据。
开始依旧是一段无锁条件下的异常检测。
if debugChan {
print("chanrecv: chan=", c, "\n")
}
if c == nil { // 从未初始化的channel读取数据,阻塞
if !block {
return
}
gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
throw("unreachable")
}
if !block && empty(c) { // 非阻塞 空channel
if atomic.Load(&c.closed) == 0 { // 未关闭 退出
return
}
if empty(c) { // 已关闭,空 返回零值
if raceenabled {
raceacquire(c.raceaddr())
}
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
}
加锁后继续处理。
lock(&c.lock)
if c.closed != 0 && c.qcount == 0 { // 已关闭,且空 返回零值
if raceenabled {
raceacquire(c.raceaddr())
}
unlock(&c.lock)
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
// 有阻塞中的写协程,直接获取数据,而不去读取缓冲区
if sg := c.sendq.dequeue(); sg != nil {
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
if c.qcount > 0 { // 缓冲区有数据 则读取缓冲区
// Receive directly from queue
qp := chanbuf(c, c.recvx)
if raceenabled {
racenotify(c, c.recvx, nil)
}
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
}
if !block { // 非阻塞则放回读取失败
unlock(&c.lock)
return false, false
}
// 阻塞模式则挂起斜侧
...
关闭channel
对应func closechan(c *hchan)
func closechan(c *hchan) {
if c == nil { // 关闭未初始化的管道,panic
panic(plainError("close of nil channel"))
}
lock(&c.lock)
if c.closed != 0 { // 重复关闭 panic
unlock(&c.lock)
panic(plainError("close of closed channel"))
}
if raceenabled {
callerpc := getcallerpc()
racewritepc(c.raceaddr(), callerpc, abi.FuncPCABIInternal(closechan))
racerelease(c.raceaddr())
}
c.closed = 1 // 修改关闭标志
var glist gList
// 释放所有读取者 返回零值 这一步是放到list中,最后进行释放
for {
sg := c.recvq.dequeue()
if sg == nil {
break
}
if sg.elem != nil {
typedmemclr(c.elemtype, sg.elem)
sg.elem = nil
}
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
if raceenabled {
raceacquireg(gp, c.raceaddr())
}
glist.push(gp)
}
// 释放所有写入者,写入者发生panic 这一步是放到list中,最后进行释放
for {
sg := c.sendq.dequeue()
if sg == nil {
break
}
sg.elem = nil
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
if raceenabled {
raceacquireg(gp, c.raceaddr())
}
glist.push(gp)
}
unlock(&c.lock)
for !glist.empty() { // 释放协程操作
gp := glist.pop()
gp.schedlink = 0
goready(gp, 3)
}
}