前言
CSP
(communicating sequential processes) 指互相独立的并发实体之间通过共享的通讯管道(如channel)进行通信的并发模型,不同语言有不同的并发模型。
Java、C++ 的并发模型都是通过共享内存
实现的,非常典型的方式就是,在访问共享数据(如数组、Map等)的时候,对共享内存加锁
,因此,衍生出了许多线程安全的数据结构
。
Golang 借鉴CSP模型的一些概念作为并发模型的理论支持。大家最常听见的那句话
Do not communicate by sharing memory; instead, share memory by communicating.
不要通过共享内存来通信,而要通过通信来实现内存共享。 即是由此而来
在Golang中,goroutine
作为独立的并发实体,channel
作为不同实体间数据通信的管道。
本文主要介绍 Golang 中的channel
类型
channel 主要分为 有缓冲
和无缓冲
的两种 channel
两种channel最大的区别是 ,有缓冲的channel是非阻塞
模型,无缓冲的channel是阻塞
模型
有缓冲的channel
ch := make(chan int , 1)
无缓冲的channel
ch := make(chan int )
make指定len为0时,也是一个无缓冲的channel
ch := make(chan int , 0)
只读channel和只写channel
表示channel只能被读或只能被写,通常是对channel的使用作限制
func onlyReadAndWrite(){
ch := make(chan int ,1)
onlyRead(ch)
onlyWrite(ch)
}
// 参数为只读channel
func onlyRead(ch <-chan int ){
<- ch
}
// 参数为只写channel
func onlyWrite(ch chan<- int ){
ch <- 1
}
零值为nil的channel
- channel的零值可以为nil 。对这样的channel发送或接收会永远阻塞。
- 在select语句中操作nil的channel永远都不会被select到,我们可以用这个特性来激活或者禁用case
var verbose = flag.Bool("v", false, "show verbose progress messages")
func main() {
// ...start background goroutine...
// Print the results periodically.
var tick <-chan time.Time
if *verbose {
tick = time.Tick(500 * time.Millisecond)
}
var nfiles, nbytes int64
loop:
for {
select {
case size, ok := <-fileSizes:
if !ok {
break loop // fileSizes was closed
}
nfiles++
nbytes += size
case <-tick:
printDiskUsage(nfiles, nbytes)
}
}
printDiskUsage(nfiles, nbytes) // final totals
}
如果程序启动时,-v
没有传入,则tick 这个channel会保持为nil,select中的case永远不会被执行
数据结构
channel运行时数据结构存放在runtime.hchan
下,
type hchan struct {
qcount uint // channel中元素个数
dataqsiz uint // channel循环队列的长度 ,make channel中的len属性 ,即缓冲区大小
buf unsafe.Pointer // channel缓冲区数据指针;
elemsize uint16 // channel元素的大小,是 elem元数据类型的大小
closed uint32
elemtype *_type //
sendx uint // channel的send操作处理到的位置;
recvx uint // channel的recv操作处理到的位
recvq waitq // recv 等待队列(即 <- channel )
sendq waitq // send 等待队列(即 channel <- )
lock mutex
}
可以看到,channel底层依然是使用了mutex互斥锁来做并发控制。
有关Mutex可以看这篇文章
看看waitq
的结构
type waitq struct {
first *sudog
last *sudog
}
是一个sudog
结构的双向链表 ,再看看sudog
结构
type sudog struct {
g *g // 指向goroutine结构题
next *sudog // 前sudog
prev *sudog // 后sudog
***
}
可以看到,sudog
结构其实就是一个goroutine ,同时持有前后sudog
的地址,是一个双向链表
源码解读
gopark和goready
源码当中有两个系统函数出现的次数较频繁,这里简单介绍一下他们的作用
gopark
的作用
- 解除当前goroutine的m的绑定关系,将当前goroutine状态机切换为waiting 状态
- 调用一次schedule()函数,在局部调度器P发起一轮新的调度。
- 这个时候的 G 没有进入调度队列
goready
的作用
- 当前goroutine的状态机切换到 runnable 状态
- M 重新进入调度循环
- goroutine进入local queue ,等待 P 调度
创建channel
使用make方法创建channel,最终会调用runtime.makechan
,方法很简单,只做了两件事
- 参数校验
- 为分配hchan和buf内存
func makechan(t *chantype, size int) *hchan {
elem := t.elem
// 参数校验
if elem.size >= 1<<16 {
throw("makechan: invalid channel element type")
}
// 内存对齐相关
if hchanSize%maxAlign != 0 || elem.align > maxAlign {
throw("makechan: bad alignment")
}
// 计算需要分配的内存大小
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
// 检查内存大小是否超过系统限制 && 是否堆内存溢出
if overflow || mem > maxAlloc-hchanSize || size < 0 {
panic(plainError("makechan: size out of range"))
}
// 当buf不包含指针类型时,那么会为channel和底层数组分配一段连续的内存空间
// sodog会从其拥有的线程中引用该对象,因此该对象无法被gc收集(不会被gc回收)
var c *hchan
switch {
case mem == 0:
// 无缓冲区channel
c = (*hchan)(mallocgc(hchanSize, nil, true))
// Race detector uses this location for synchronization.
c.buf = c.raceaddr()
case elem.ptrdata == 0:
// buf中元素不包含指针
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
// buf中元素包含指针,为hchan和buf分配内存
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}
c.elemsize = uint16(elem.size)
c.elemtype = elem
c.dataqsiz = uint(size)
lockInit(&c.lock, lockRankHchan)
return c
}
可以提炼出几个关键点
- 如果channel元素类型不包含指针类型,会在堆上分配一段连续的内存,同时sudog会引用该hchan,该hchan不会被gc回收
- 如果channel元素类型不包含指针类型,会为buf和hchan分配内存
- channel的内存分配涉及到内存对齐的计算
发送数据
流程
使用ch <- data
往channel里推数据,最终会调用runtinme.chansend
方法
源码很长,主要做了三个事情:
- 当recvq存在等待者时,直接进入
send
方法。 - 当缓冲区存在空余空间时,将发送的数据写入channel的缓冲区。
- 当不存在缓冲区或者缓冲区已满时,等待其他goroutine从channel 接收数据。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
***
lock()
// 1. 如果recvq队列中有等待者,直接进入send方法
if sg := c.recvq.dequeue(); sg != nil {
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
// 2. 缓冲区存在空余空间,将数据写入缓冲区
if c.qcount < c.dataqsiz {
// 计算下一个可以存储数据的位置
qp := chanbuf(c, c.sendx)
// 参数 ep 放入上一步计算的 qp 对应的位置上
typedmemmove(c.elemtype, qp, ep)
// 更新send index && qcount
c.sendx++
// 环形队列
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
unlock(&c.lock)
return true
}
if !block {
unlock(&c.lock)
return false
}
// 3. 阻塞channel, 直到新的接收者从channel中读数据
// 获得当前运行的goroutine指针
gp := getg()
// 分配sudog
mysg := acquireSudog()
mysg.releasetime = 0
if t0 != 0 {
mysg.releasetime = -1
}
// dosomething
// 当前sudog入发送队列
c.sendq.enqueue(mysg)
atomic.Store8(&gp.parkingOnChan, 1)
// gopark ,goroutine变为 gwaiting 状态
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
// 为确保往channel里发送的数据不被gc回收,sodog一直引用该对象
KeepAlive(ep)
// dosomething
// 释放sudog
releaseSudog(mysg)
if closed {
if c.closed == 0 {
throw("chansend: spurious wakeup")
}
panic(plainError("send on closed channel"))
}
return true
}
流程图
看看send
方法
- 调用
runtime.sendDirect
将发送的数据直接拷贝到x = <-c
表达式中变量x
所在的内存地址上; - 调用
runtime.goready
将等待接收数据的goroutine标记成可运行状态grunnable
并把该 goroutine放到发送方所在的处理器的runnext
上等待执行,该处理器在下一次调度时会立刻唤醒数据的接收方;
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
if sg.elem != nil {
sendDirect(c.elemtype, sg, ep)
sg.elem = nil
}
gp := sg.g
unlockf()
gp.param = unsafe.Pointer(sg)
sg.success = true
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
goready(gp, skip+1)
}
接收数据
流程
使用data <- channel
从channel里接收数据,会被转换成runtime.chanrecv1
和runtime.chanrecv2
。 他们最终会调用runtinme.chanrecv
方法,主要流程如下:
- 如果channel为空,那么会直接调用
gopark
挂起当前goroutine - 如果channel已经关闭并且缓冲区没有任何数据,直接返回
- 如果channel的
sendq
队列中存在挂起的 goroutine,会将recvx
索引所在的数据拷贝到接收变量所在的内存空间上并将sendq
队列中 goroutine 的数据拷贝到缓冲区 - 如果channel的缓冲区中包含数据,那么直接读取
recvx
索引对应的数据 - 挂起当前的goroutine,同时将
sudog
结构推入recvq
队列并进入休眠状态,等待发送者向channel发送数据,从而唤醒当前goroutine。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
// 1. 当我们从空channel读数据,会调用gopark让出当前处理器占用
if c == nil {
if !block {
return
}
gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
throw("unreachable")
}
if !block && empty(c) {
if atomic.Load(&c.closed) == 0 {
return
}
if empty(c) {
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
}
lock(&c.lock)
// 2. 当前channel已经被关闭并且缓冲区中不存在任何数据,那么会清除 ep 指针中的数据并立刻返回。
if c.closed != 0 && c.qcount == 0 {
unlock(&c.lock)
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
// 3. 如果发送队列中有goroutine被阻塞,
if sg := c.sendq.dequeue(); sg != nil {
// 调用recv方法
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
// 4. 如果channel缓冲区中有数据,直接从缓冲区中读数据
if c.qcount > 0 {
qp := chanbuf(c, c.recvx)
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
typedmemclr(c.elemtype, qp)
// 更新 recv索引 和 环形队列长度
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
}
// 5. 没有阻塞的发送者 && channel缓冲区为空 ,阻塞当前goroutine
gp := getg()
mysg := acquireSudog()
// dosomething
// 将当前sudog压入channel的接收队列
c.recvq.enqueue(mysg)
atomic.Store8(&gp.parkingOnChan, 1)
// gopark 让出处理器使用权
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
if mysg != gp.waiting {
throw("G waiting list is corrupted")
}
gp.waiting = nil
gp.activeStackChans = false
if mysg.releasetime > 0 {
blockevent(mysg.releasetime-t0, 2)
}
success := mysg.success
gp.param = nil
mysg.c = nil
releaseSudog(mysg)
return true, success
}
流程图
流程图中省略了 lock
和unlock
部分
recv
方法中
- 当缓冲区存在数据时,从 channel 的缓冲区中接收数据;
- 当缓冲区中不存在数据时,等待其他 goroutine 向 channel 发送数据
- 最后使用
goready
,在调度器下一次调度时将阻塞的发送方唤醒
//
func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
if c.dataqsiz == 0 {
// 无缓冲的channel
if ep != nil {
// 将channel发送队列中goroutine存储的数据拷贝到目标内存地址中
recvDirect(c.elemtype, sg, ep)
}
} else {
// 有缓冲的channel
// ;将发送队列头的数据拷贝到缓冲区中,释放一个阻塞的发送方
qp := chanbuf(c, c.recvx)
if ep != nil {
// 将队列中的数据拷贝到接收方的内存地址
typedmemmove(c.elemtype, ep, qp)
}
// 将发送队列头的数据拷贝到缓冲区中,释放一个阻塞的发送方
typedmemmove(c.elemtype, qp, sg.elem)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.sendx = c.recvx
}
sg.elem = nil
gp := sg.g
unlockf()
gp.param = unsafe.Pointer(sg)
sg.success = true
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
// 当前处理器的 runnext 设置成发送数据的goroutine,在调度器下一次调度时将阻塞的发送方唤醒
goready(gp, skip+1)
}
思考
CSP模型的优点
- CSP 模型中,基于管道 通信,相对于 对共享内存加锁 属于一种程序设计上的抽象与封装
- 基于管道 通信,类比于生产者-消费者模型,属于一种逻辑上的解耦,相似的还有Java中线程池结构
为什么要用Channel去共享内存而不是锁
- 本身
Channel
能够实现锁的功能。 channel
的可扩展性非常高,除了共享内存,还能满足协程之间数据通信场景,控制共享内存仅仅是channel
的一个功能
channel的使用
几种异常情况
有缓冲的channel
标题 | 已关闭无数据 | 已关闭有数据 | 未关闭无数据 | 未关闭有数据 |
---|---|---|---|---|
读 | 零值 | channel中的数据 | panic | channel中的数据 |
写 | panic | panic | \ | \ |
无缓冲的channel
无缓冲的channel是阻塞模型,写入的数据需要被读取之后, channel才能再次被写入
标题 | 已关闭无数据 | 已关闭有数据 | 未关闭无数据 | 未关闭有数据 |
---|---|---|---|---|
读 | 零值 | panic | panic | channel中的数据 |
写 | panic | panic | panic | panic |
遍历channel中的元素
使用for-range遍历channel中的元素. 注意这种方法是阻塞
的
data, ok := <-ch // 阻塞的
func forRange() {
ch := make(chan int, 1)
go read(ch)
go write(ch)
time.Sleep(time.Second)
log.Println("休眠1s")
go write(ch)
time.Sleep(time.Minute)
}
func write(ch chan int) {
for i := 0; i < 1; i++ {
ch <- i
log.Printf("send: [%d]", i)
break
}
}
func read(ch chan int) {
for {
data, ok := <-ch // 阻塞的
if ok {
log.Printf("recv: [%d]", data)
} else {
log.Println("channel close ")
break
}
}
}
read
方法中,程序会阻塞在 data, ok := <-ch
这里,仅当ch被close时,ok返回false
使用select监听channel
golang中 select是专门为channel设计的, 用来做channel多路复用的一种技术 。有关select的基础部分可以戳 golang-select详解
需要注意一点,select 语句中,case是随机执行的,如果case条件都不满足,那么执行default。
func readV2(ch chan int) {
for {
select{
case data , ok := <- ch:
if ok {
log.Printf("recv:[%v]",data)
}else{
log.Printf("ok-false")
}
default:
log.Println("into-default")
}
}
}
这一段代码 ,会无限执行default
,造成CPU空转
常见的生产者-消费者模型
func write(ch chan int, times int) {
for i := 0; i < times; i++ {
ch <- i
log.Printf("send: [%d]", i)
break
}
}
func read(ch chan int) {
for {
data, ok := <-ch // 阻塞的
if ok {
log.Printf("recv: [%d]", data)
} else {
log.Println("channel close ")
break
}
}
}
以上练习的代码我都放在我的github里,欢迎star channel demo
channel的优化
由于channel底层还是使用了互斥锁Mutex,超高并发的场景下性能并不理想,因此社区主要有两种优化方案
无锁channel
无锁channel底层使用cas实现,由于在多核场景下性能并不理想,并且不提供FIFO的特性,暂时被搁浅
分段锁
分段锁 + channel
是使用较多的方法,比较类似Java中的concurrentHashMap。可以按照消息ID做分片