[Golang] Channel原理分析

696 阅读6分钟

基本概念

Don’t communicate by sharing memory, share memory by communicating

基本架构

channel本质上的是一个环形队列,参考下面的代码:

golang-analyze-hchan-v2.jpg

type hchan struct {
    qcount   uint                   // 队列中持有的数据数量
    dataqsiz uint                   // 环形队列的大小,创建带buffer的channel,值为第2个参数
    buf      unsafe.Pointer         // 环形队列指针,使用带buffer的channel时,用于缓存数据
    elemsize uint16                 // channel类型大小
    closed   uint32                 // channel是否已经关闭:0=未关闭,1=关闭
    elemtype *_type                 // 元素类型
    sendx    uint                   // 环形队列发送端的元素下标
    recvx    uint                   // 环形队列接收端的元素下标
    recvq    waitq                  // 阻塞的goroutine的等待队列,尝试从channel读取数据
    sendq    waitq                  // 阻塞的goroutine的等待队列,尝试向channel写入数据
    
    lock mutex                      // 锁,保证线程安全
}

// 表示一个在等待链表中的goroutine,该结构中存储了两个分别指向前后 runtime.sudog 的指针以构成链表。
type waitq struct {
    first *sudog                    // g链表头部
    last  *sudog                    // g链表尾部
}

// sudog是对goroutine上下文的封装,表示被挂起的goroutine对象
// 一个goroutine可能在多个等待列表中,所以多个sudog可能等待一个goroutine
type sudog struct {
	g *g

	next *sudog
	prev *sudog
	elem unsafe.Pointer // data element (may point to stack)
	...
}

sendxrecvx会用来计算缓冲区下一个元素写入或读取的位置,源码如下:

// ...
qp := chanbuf(c, c.sendx)
if raceenabled {
	racenotify(c, c.sendx, nil)
}
typedmemmove(c.elemtype, qp, ep)
// ...

func chanbuf(c *hchan, i uint) unsafe.Pointer {
    return add(c.buf, uintptr(i)*uintptr(c.elemsize))
}

func add(p unsafe.Pointer, x uintptr) unsafe.Pointer {
    return unsafe.Pointer(uintptr(p) + x)
}

计算方法的原理是缓冲区buf的指针位置加上索引下标i(sendx/recvx)乘以chan元素大小的值:

nextElemPtr = bufPtr + i * elemsize

相当于

c.buf[c.sendx]
c.buf[c.recex]

随后调用typedmemmove方法操作这个地址对应的元素:

  • typedmemmove(c.elemtype, ep, qp) 表示buf中的当前可读元素拷贝到接收变量的地址处。
  • typedmemmove(c.elemtype, qp, sg.elem)表示将sendqgoroutine等待发送的数据拷贝到buf中。

创建channel

安全检查

创建channel分为2个方法:runtime.makechanruntime.makechan64,后者是在前者的基础上添加了d检查chan size范围的逻辑:

func makechan64(t *chantype, size int64) *hchan {
    // int范围等于操作系统位置(64位系统为8字节,32位系统位4字节)
	if int64(int(size)) != size {
		panic(plainError("makechan: size out of range"))
	}

	return makechan(t, int(size))
}
创建流程
  1. 安全检查: channel能存的元素类型大小是否超过2^16
elem := t.elem

// compiler checks this but be safe.
if elem.size >= 1<<16 {
	throw("makechan: invalid channel element type")
}
  1. 判断是否内存对齐(即hchan实际大小是否为maxAlgign=8的整数倍)
hchanSize = unsafe.Sizeof(hchan{}) + uintptr(-int(unsafe.Sizeof(hchan{}))&(maxAlign-1))
  • 上面的代码可以看成hchanSize = chansize + maxAlign - (size % maxAlgin)
  • 关于uintptr(-int(unsafe.Sizeof(hchan{}))&(maxAlign-1))的含义,实际是这是位运算中,2的n次幂减去hchan size对2的n次幂取模的值:
    • 这里2^n = 8
    • x为hchan实际的size大小
    • 对2的n次幂取模,公式为x & (2^n - 1)

所以可以抽象为x mod=2^n + (-(x & (2^n - 1)))

随后是对chan size是否对齐的校验

if hchanSize%maxAlign != 0 || elem.align > maxAlign {
	throw("makechan: bad alignment")
}
  1. 计算和判断所需空间mem是否超过最大可申请空间,判断size是否小于0(非法)
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
if overflow || mem > maxAlloc-hchanSize || size < 0 {
    panic(plainError("makechan: size out of range"))
}
  1. 申请hchan所需内存空间
  • 无缓冲channel:给hchan申请大小为hchansize的内存空间
  • 有缓冲channel:
    • 元素为非指针类型(elem.ptrdata == 0),申请hchansize+mem大小的连续内存空间, 并将hchanSize之后的首地址赋值给buf
    • 元素类型是指针,为hchan和底层buf申请连续的内存空间(默认分配策略)
	var c *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)
	}
  1. 更新chanelemsizeelemtypedataqsiz 字段以及初始化lock
c.elemsize = uint16(elem.size)
c.elemtype = elem
c.dataqsiz = uint(size)
lockInit(&c.lock, lockRankHchan)

向channel发送消息

向channel发送消息时,处理逻辑的主体方法为chansend:

  • c *hchan发送消息的目标channel
  • ep unsafe.Pointer发送元素的指针对象
  • block是否为阻塞发送
  • callerpc是创建channel对应go语句的地址
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    // ...
}
  1. 初始化判断
// channel是否已经关闭
if c == nil {
    if !block {
        return false
    }
    gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
    throw("unreachable")
}

// channel是否已经准备好发送消息
if raceenabled {
	racereadpc(c.raceaddr(), callerpc, abi.FuncPCABIInternal(chansend))
}

// 如果channel没有准备好发送且已经关闭,同时channel被阻塞
if !block && c.closed == 0 && full(c) {
	return false
}

//加上锁
lock(&c.lock)

// channel已经关闭,panic
if c.closed != 0 {
	unlock(&c.lock)
	panic(plainError("send on closed channel"))
}

  1. 消息发送 向channel消息发送时,存在3种情况:
    1. 同步发送:当前recvq刚好有等待的goroutine,直接调用send发送消息
    2. 异步发送:当前channel中的元素个数小于其循环队列的长度,则将数据放入队列中
    3. 阻塞发送:如果不满足上面的2种情况,会创建sudog并将其加入channelsendq队列中,阻塞当前 goroutine等待其他goroutinechannel接收数据

同步发送

golang-analyze-chan-sync-send.jpg

  • 加锁保证线程安全
  • 检查 channel 是否关闭。如果关闭则抛出 panic。
  • 尝试从recvq队列获取等候groutine, 直接唤醒goroutine并发送数据
lock(&c.lock)

if c.closed != 0 {
	unlock(&c.lock)
	panic(plainError("send on closed channel"))
}

if sg := c.recvq.dequeue(); sg != nil {
    // 直接把当前消息发给的goroutine
    send(c, sg, ep, func() { unlock(&c.lock) }, 3)
    return true
}
  • sendDirect执行将将发送的数据(eq)拷贝到接收变量的内存地址上
  • goready执行将等待接收的阻塞goroutine的状态从Gwaiting或者Gscanwaiting设置为 Grunnable。下一轮调度时会唤醒这个接收的goroutine
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)
}

关于goready的实现,会把当前goroutine绑定到本地可运行的队列中,等待下次调度便立即运行。这样做可以保证goroutine的线程安全,但是在读取数据方面存在延迟。

异步发送

golang-analyze-chan-send-async.jpg 如果channel中的数据没有达到上限,则根据sendx对应的元素指针将数据拷贝到缓冲区中。同时更新sendxqcount,将缓冲区形成环,这样索引值到了队尾,下标则重置为队头。 如果 qcount还没有满,则调用 chanbuf() 获取 sendx 索引的元素指针值。调用 typedmemmove() 方法将发送的值拷贝到缓冲区 buf 中。拷贝完成,需要维护 sendx 索引下标值和 qcount 个数。这里将 buf 缓冲区设计成环形的,索引值如果到了队尾,下一个位置重新回到队头。

// 如果队列还有空间, 就把数据放入到队列中
if c.qcount < c.dataqsiz {
    // 计算当前sender队列的指针位置
    qp := chanbuf(c, c.sendx)
    if raceenabled {
        racenotify(c, c.sendx, nil)
    }
    
    // 根据当前sender队列的指针位置,将发送的数据拷贝到缓冲区中
    typedmemmove(c.elemtype, qp, ep)
    
    // 发送队列下标递增
    c.sendx++
    
    // 发送队列满之后,将指针指回头部,形成环
    if c.sendx == c.dataqsiz {
        c.sendx = 0
    }
    
    // 发送数据计算器递增
    c.qcount++
    
    // 释放锁
    unlock(&c.lock)
    return true
}

阻塞发送

channel处于open状态,但是没有接收者,并且没有buf缓冲队列或者buf队列已满,这时 channel 会进入阻塞发送。即获取当前channel发送逻辑所在的goroutine,并初始化相关的信号量将其阻塞,直到条件满足的情况下唤醒当前goroutine

golang-analyze-channel-block-send.jpg

gp := getg()
//从p拿一个sudog结构
mysg := acquireSudog()
mysg.releasetime = 0
if t0 != 0 {
    mysg.releasetime = -1
}

mysg.elem = ep
mysg.waitlink = nil
//设置在等等gp是哪个.在recv的时候,会被重新加入到runq中
mysg.g = gp
mysg.isSelect = false
mysg.c = c
gp.waiting = mysg
gp.param = nil
//把这个结果加入到sendwaiter队列中
c.sendq.enqueue(mysg)

atomic.Store8(&gp.parkingOnChan, 1)
//挂起当前g
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)

// 确保发送的数据在被接受复制之前不会被GC掉
KeepAlive(ep)


gp.waiting = nil
gp.activeStackChans = false
closed := !mysg.success
gp.param = nil
if mysg.releasetime > 0 {
    blockevent(mysg.releasetime-t0, 2)
}
mysg.c = nil
//归还当前mysg
releaseSudog(mysg)
//在消费端close chan可能会引起panic
if closed {
    if c.closed == 0 {
        throw("chansend: spurious wakeup")
    }
    panic(plainError("send on closed channel"))
}
return true

select中,多个case尝试从一个channel中获取数据时,会判断当前goroutine是否被处理

// waitq 
func (q *waitq) dequeue() *sudog {
    for {
        sgp := q.first
        if sgp == nil {
            return nil
        }
        y := sgp.next
        if y == nil {
            q.first = nil
            q.last = nil
        } else {
            y.prev = nil
            q.first = y
            sgp.next = nil // mark as removed (see dequeueSudog)
        }

        // 是否已被其他chan处理
        if sgp.isSelect && !atomic.Cas(&sgp.g.selectDone, 0, 1) {
            continue
        }
        return sgp
    }
}

简单流程图如下:

golang-analyze-channel-send-process.jpg

从Channel接受消息

从channel接受消息的逻辑与发送消息类似,大体逻辑可以概括为以下几点:

  1. 当channel为nil时,挂起当前goroutine
  2. 当channel已经关闭时且channel中没有数据,直接返回
  3. 当存在挂起的发送者时,通过recv从阻塞的发送者或者缓冲区中获取数据;
  4. 当缓冲区存在数据时,直接从channel的缓冲区中接收数据;
  5. 当缓冲区中不存在数据且不存在等待的goroutine时,从管道中接收数据的操作会变成阻塞的,等待其他goroutinechannel发送数据;

同步接收

golang-analyze-channel-sync-recv.jpg 尝试在channel的sendq队列中获取等待发送的goroutine。取出队头等待的 goroutine

  • 如果缓冲区的大小为 0,则直接从发送方接收值。
if sg := c.sendq.dequeue(); sg != nil {
    recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
    return true, true
}

golang-analyze-sync-recv-full.jpg

  • 否则,对应缓冲区满的情况,从队列的头部接收数据,发送者的值添加到队列的末尾(此时队列已满,因此两者都映射到缓冲区中的同一个下标)。

同步接收的核心逻辑见下面 recv() 函数:

func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
    // channel中没有缓冲的数据
	if c.dataqsiz == 0 {
		if raceenabled {
			racesync(c, sg)
		}
		if ep != nil {
			// 将sender复制到当前goroutine的堆栈中
			recvDirect(c.elemtype, sg, ep)
		}
	} else {
		// Queue is full. Take the item at the
		// head of the queue. Make the sender enqueue
		// its item at the tail of the queue. Since the
		// queue is full, those are both the same slot.
		qp := chanbuf(c, c.recvx)
		if raceenabled {
			racenotify(c, c.recvx, nil)
			racenotify(c, c.recvx, sg)
		}
		// copy data from queue to receiver
		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)
		}
		// copy data from sender to queue
		typedmemmove(c.elemtype, qp, sg.elem)
		c.recvx++
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
	}
	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)
}

异步接收

如果 channel 的缓冲区中包含一些数据时,从 channel 中接收数据会直接从缓冲区中 recvx 的索引位置中取出数据进行处理

golang-analyze-channel-async-recv.jpg

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
}

阻塞接收

封装当前goroutine上下文,状态设置为等待,加入recvq队列。调用gopark方法挂起当前 goroutine,状态为waitReasonChanReceive,阻塞等待channel

golang-analyze-channel-block-recv.jpg

gp := getg()                // 调用 getg() 方法获取当前 goroutine 的指针,用于绑定给一个 sudog
mysg := acquireSudog()      // 调用 acquireSudog() 方法获取一个 sudog,可能是新建的 sudog,也有可能是从缓存中获取的
mysg.elem = ep
mysg.waitlink = nil
gp.waiting = mysg
mysg.g = gp
mysg.isSelect = false
mysg.c = c
gp.param = nil  
c.recvq.enqueue(mysg)      // 调用 c.recvq.enqueue 方法将配置好的 sudog 加入待发送的等待队列
 
atomic.Store8(&gp.parkingOnChan, 1)
// 挂起goroutine
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
// ...

简单流程图如下:

golang-analyze-channel-recv-process.jpg

关闭channel

当调用close方法关闭channel时,核心流程如下:

  1. 安全检查
// 关闭未初始化的channel
if c == nil {
	panic(plainError("close of nil channel"))
}

lock(&c.lock)
// 关闭已经关闭的channel
if c.closed != 0 {
	unlock(&c.lock)
	panic(plainError("close of closed channel"))
}
  1. 释放所有读写channelg并加入待清除队列glist
var glist gList

// release all readers
for {
	// ...
	glist.push(gp)
}

// release all writers (they will panic)
for {
    // ...
	glist.push(gp)
}
  1. 最后会为所有被阻塞的g调用goready触发调度。将所有glist中的g状态从_Gwaiting 设置为_Grunnable状态,等待调度器的调度。
for !glist.empty() {
	gp := glist.pop()
	gp.schedlink = 0
	goready(gp, 3)
}

正确关闭channel

ChannelStatusResult
closenilpanic
close打开且非空关闭 Channel;读取成功,直到 Channel 耗尽数据,然后读取产生值的默认值
close打开但为空关闭 Channel;读到生产者的默认值
close关闭panic
close只读Compile Error

参考