Channel in Go

320 阅读3分钟

Go语言中的Goroutine是通过Channel来传递数据的,在CSP(通信顺序模型)中,Goroutine是实体,而Channel是传递信息的媒介。

Channel中无论是接收数据还是发送数据,都遵循先进先出的原则。

乐观锁是一种并发控制的思想,而不是真正的锁。

某种意义上,Channel是一个用于同步和通信的有锁队列。

Channel的类型

  • 同步Channel
    • 无需缓冲区,发送方直接将数据发送接收方。
  • 异步Channel
    • 基于环形缓冲区的传统生产者消费者模型。
  • chan struct{}类型的异步Channel
    • struct{} 类型不占用内存空间,无需实现缓冲区和直接发送的语义。

Channel的数据结构

type hchan struct {
	qcount   uint							// channel中的元素格式
	dataqsiz uint							// 循环队列的长度
	buf      unsafe.Pointer   // 缓冲区指针
	elemsize uint16 					// channel能够收发的大小
	closed   uint32
	elemtype *_type						// channel能够收发的类型
	sendx    uint  						// 发送操作处理到的位置
	recvx    uint							// 接收操作处理到的位置
	recvq    waitq						// 存储当前channel由缓冲区不足而阻塞的goroutine列表,是双向链表
	sendq    waitq

	lock mutex
}

其中,qcount,dataqsiz,buf,sendx,recv构成底层的循环队列。

Create CHANNEL

Channel通过make进行创建,如make(chan int, 10),Go的编译器会将表达式转换成OMAKE类型的节点,在类型检查阶段将OMAKE类型的节点转换成OMAKECHAN类型,最终OMAKECHAN在SSA中间代码生成阶段之前会被转换成调用makechan或者makechan64的函数,后者用力啊处理缓冲区大小大于2^32的情况。

makechan的实现逻辑主要是根据Channel中收发元素的类型和缓冲区的大小来初始化hchan结构体和缓冲区:

  • 如果当前 Channel 中不存在缓冲区,那么就只会为 runtime.hchan 分配一段内存空间;
  • 如果当前 Channel 中存储的类型不是指针类型,就会为当前的 Channel 和底层的数组分配一块连续的内存空间;
  • 在默认情况下会单独为 runtime.hchan 和缓冲区分配内存;

最后再更新相应的字段。

SEND DATA

通道中的数据发送用 ch <- i 表示,也就是将 i 中的数据发送到通道中。最终 <- 符号会被转换成OSEND节点,该节点最终在代码中被转化成chansend1函数,其调用了chansend函数并传入通道和所需发送的数据。也就是在chansend函数中实现了发送数据给通道的所有逻辑。

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool

其中,block是为了设定当前发送操作是否为阻塞的。为了防止竞态条件的产生,在执行逻辑前需要先加锁。

chansend的具体逻辑如下:

  • 当存在等待的接收者时,通过 runtime.send 直接将数据发送给阻塞的接收者;

    • 其中send又会调用sendDirect和goready,从名字不难看出是如果操作的
  • 当缓冲区存在空余空间时,将发送的数据写入 Channel 的缓冲区;

    • 首先会检查缓冲区中可存放的下一个地址,然后拷贝数据到相应位置,并增加索引和计数器
  • 当不存在缓冲区或者缓冲区已满时,等待其他 Goroutine 从 Channel 接收数据;

    • 相对复杂一点,具体逻辑如下

    func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    	...
    	if !block {
    		unlock(&c.lock)
    		return false
    	}
    
    	gp := getg()
    	mysg := acquireSudog()
    	mysg.elem = ep
    	mysg.g = gp
    	mysg.c = c
    	gp.waiting = mysg
    	c.sendq.enqueue(mysg)
    	goparkunlock(&c.lock, waitReasonChanSend, traceEvGoBlockSend, 3)
    
    	gp.waiting = nil
    	gp.param = nil
    	mysg.c = nil
    	releaseSudog(mysg)
    	return true
    }
    

总结一下就是:

  • 如果当前 Channel 的 recvq 上存在已经被阻塞的 Goroutine,那么会直接将数据发送给当前的 Goroutine 并将其设置成下一个运行的 Goroutine;
  • 如果 Channel 存在缓冲区并且其中还有空闲的容量,我们就会直接将数据直接存储到当前缓冲区 sendx 所在的位置上;
  • 如果不满足上面的两种情况,就会创建一个 runtime.sudog 结构并将其加入 Channel 的 sendq 队列中,当前 Goroutine 也会陷入阻塞等待其他的协程从 Channel 接收数据;

RECIVE Data

接收数据有两种方式,分别是 i <- ch 和 i, ok <- ch。这两种方法最终都会被编译器处理为ORECV类型的节点,而后者会在类型检查阶段被转换成OAS2RECV类型。这两种方式最终会被转换成对chanrecv1和chanrecv2的调用,但本质上都会调用chanrecv函数。

chanrecv基本实现如下

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
	if c == nil {
		if !block {
			return
		}
		gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
		throw("unreachable")
	}

	lock(&c.lock)

	if c.closed != 0 && c.qcount == 0 {
		unlock(&c.lock)
		if ep != nil {
			typedmemclr(c.elemtype, ep)
		}
		return true, false
	}

总结起来就是:

  • 如果 Channel 为空,那么就会直接调用 runtime.gopark 挂起当前 Goroutine;
  • 如果 Channel 已经关闭并且缓冲区没有任何数据,runtime.chanrecv 函数会直接返回;
  • 如果 Channel 的 sendq 队列中存在挂起的 Goroutine,就会将 recvx 索引所在的数据拷贝到接收变量所在的内存空间上并将 sendq 队列中 Goroutine 的数据拷贝到缓冲区;
  • 如果 Channel 的缓冲区中包含数据就会直接读取 recvx 索引对应的数据;
  • 在默认情况下会挂起当前的 Goroutine,将 runtime.sudog 结构加入 recvq 队列并陷入休眠等待调度器的唤醒;

Ref