Last updated
Last updated
Go 语言中最常见的、也是经常被人提及的设计模式就是:不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。在很多主流的编程语言中,多个线程传递数据的方式一般都是共享内存,为了解决线程竞争,我们需要限制同一时间能够读写这些变量的线程数量,然而这与 Go 语言鼓励的设计并不相同。
Go 语言使用了一种不同的并发模型,即通信顺序进程(Communicating sequential processes,CSP)。Goroutine 和 Channel 分别对应 CSP 中的实体和传递信息的媒介,Goroutine 之间会通过 Channel 传递数据。
核心结构为:
一把锁
两个链表,存放等待读和等待写的协程
有缓冲区的话,一个环型链表作为缓冲区
在一些关键路径上通过 CAS
实现无锁快速操作,提升性能
当我们想要向 Channel 发送数据时,就需要使用 ch <- i
语句,编译器会将它解析成 OSEND
节点并在 cmd/compile/internal/gc.walkexpr
中转换成 runtime.chansend1
函数,里面实际调用 chansend
函数,其逻辑如下:
加锁
如果 recvq
中有等待读的协程,则直接将元素写入该协程的栈里,并修改协程状态,在下次调度时唤醒它
这步节省了一个锁和内存 copy
的步骤。一般共享内存是:G1加锁、G1写入堆、G1解锁;G2加锁、G2读堆拷贝到栈、G2解锁
这里直接是:G1加锁、G1拷贝数据到G2的栈、G1唤醒G2、G1解锁
如果 recvq
为空,则检查缓冲区是否可用,如果可用就复制数据到缓冲区中,并更新队列索引
如果缓冲区已经满了,则
将协程包装成 sudog
链表节点结构,数据保存到 sudog.elem
字段,然后将节点追加到写等待者队列( sendq
链表)中
gopark
该协程
写入完成释放锁
Go 语言中可以使用两种不同的方式去接收 Channel 中的数据:
这两种不同的方法经过编译器的处理都会变成 ORECV
类型的节点,后者会在类型检查阶段被转换成 OAS2RECV
类型。最终都会调用 runtime.chanrecv
。其逻辑如下:
快速返回:先判断 channel
是否关闭或者为空,是则直接返回
加锁
尝试从 sendq
等待队列中获取等待写的协程
如果有写等待者
没有缓冲区:取出 goroutine
并读取数据,然后唤醒这个 goroutine
,结束读取释放锁
有缓冲区 (有缓冲区的情况下还有等待的 goroutine
,说明缓冲区此时满了):从缓冲区队列队头取数据,作为返回的值;再把刚刚 sendq
里取出的那个 goroutine
放到缓冲队列队尾(保证先进先出)
如果没有写等待者
缓冲区有数据:直接读取缓冲区数据
没有缓冲区或者缓冲区为空,将当前协程加入到 recvq
读等待者队列,进入睡眠,等待有数据写入时被唤醒
关闭操作将所有排队者唤醒,并设置 closed
、param
字段。
对读写 channel
的 <- chan
、chan ->
这种写法,编译器会翻译成对函数的调用。
写
对于 x := <- c
这类的阻塞操作,会编译为 chansend1(c, x)
。
对于 select
,则编译为 if selectnbsend(c, x) {} else {}
这种逻辑。
两者底层都调用的 chansend
函数,但传的 block
参数不同。chansend1
传的是 true
,select
传 false
。
这样在使用 select
的写法时,管道才能不阻塞的立即返回 false
,case
才能跳过这个 false
,无阻塞的继续向下判断 case2
、case3
。
读
select case
每条 case
会被包装成一个 scase
对象,里面包含了 channel
,和数据元素。
select - case
则会被编译为对 selectgo
函数的调用。里面将所有 case
组成了一个 scase
数组,以随机顺序遍历这个数组。 如果能读写数据,就返回 否则新建一个 sudog
放到 scase.c
这个 channel
的等待队列里,等待唤醒。 如果所有 scase
里都没值,则最后执行 default
。
如果没有 default
,则将 sudog
放入所有 scase.c
的等待队列里
以下均不考虑 select: case <- chan
的情况
读空的 channel
写无缓冲 channel
/ 写有缓冲 channel
但数据超过缓冲区
读写 nil channel
(阻塞。主线程下会导致 fatal error: all goroutines are asleep
)
读空 channel
,阻塞
读已关闭的 channel
,正常执行
写无缓冲 channel
,阻塞
参考
编译器会将用于关闭管道的 close
关键字转换成 OCLOSE
节点以及 函数。
<- ch
阻塞
成功 / 阻塞
读到零值
ch <-
阻塞
成功 / 阻塞
panic
close(ch)
panic
成功
panic