channel底层原理

函数通信

多个线程之间交换数据无非是两种方式

  • 共享内存加互斥锁或信号量等同步机制防止资源竞争;
  • 先进先出(FIFO)将资源分配给等待时间最长的线程。

Go采用channel通信的原因

  1. 不需要考虑竞争 :向共享内存写入必须用锁保护,否则容易出现数据竞争的问题。channel内部已经处理了同步问题(维护了一个FIFO的队列(sendqrecvq)。
  2. 顺序一致性:共享内存中的多个线程间需要自己解决读取写入的顺序问题。channel保证了收发顺序一致。
  3. 数据交换方式上更明确(数据同步):channel是点对点的,指定了数据流向,从一个goroutine发送到另一个goroutine共享内存需要自己维护数据流向。send/receive两个操作就完成了数据同步,不需要额外的同步措施。向共享内存写入数据后,要通过其他方式通知等待线程。
  4. 不需要手动管理同步工具:共享内存需要显示创建和管理同步工具如互斥锁、信号量等。channel内部已封装了这些细节。

Go 中用于并发协程同步数据的组件

一个是 sync 和sync/atomic包里面的,如sync.Mutex、sync.RWMutex、sync.WaitGroup等,另一个是 channel。只有channel才是Go语言推荐的并发同步的方式,是一等公民,用户使用channel甚至不需要引入包名。

Channel结构

hchan结构体

channel的底层数据结构是hchan,在src/runtime/chan.go 中。

go 复制代码
type hchan struct {
  qcount   uint                 // 队列中所有数据总数
  dataqsiz uint                 // 循环队列大小
  buf      unsafe.Pointer       // 指向循环队列的指针
  elemsize uint16               // 循环队列中元素的大小
  closed   uint32               // chan是否关闭的标识
  elemtype *_type               // 循环队列中元素的类型
  sendx    uint                 // 已发送元素在循环队列中的位置
  recvx    uint                 // 已接收元素在循环队列中的位置
  recvq    waitq                // 等待接收的goroutine的等待队列
  sendq    waitq                // 等待发送的goroutine的等待队列
  lock mutex                    // 控制chan并发访问的互斥锁
}

qcount代表chan中已经接收但还没被读取的元素的个数;

dataqsiz代表循环队列的大小(若是无缓冲通道,则默认为1);

buf 是指向循环队列的指针,循环队列是大小固定的用来存放chan接收的数据的队列;

elemtype 和 elemsiz 表示循环队列中元素的类型和元素的大小;

sendx:待发送的数据在循环队列buffer中的位置索引;

recvx:待接收的数据在循环队列buffer中的位置索引;

recvq 和 sendq 分别表示等待接收数据的 goroutine 与等待发送数据的 goroutine;

阻塞协程队列waitq与sudog结构体

sendqrecvq 存储了当前 Channel 由于缓冲区空间不足而阻塞的 Goroutine 列表,这些等待队列使用双向链表 waitq 表示,链表中所有的元素都是 sudog 结构

go 复制代码
type waitq struct {             //阻塞的协程队列 
    first *sudog              //队列头部 
    last *sudog               //队列尾部 }
rust 复制代码
type sudog struct {		//sudog:包装协程的节点
    g *g				//goroutine,协程;

    next *sudog			//队列中的下一个节点;
    prev *sudog			//队列中的前一个节点;
    elem unsafe.Pointer //读取/写入 channel 的数据的容器;
    
    isSelect bool		//标识当前协程是否处在 select 多路复用的流程中;
    
    c        *hchan 	//标识与当前 sudog 交互的 chan.
}

Channel类型

  1. 同步synchronous)(无缓冲)通道:没有缓冲区,发送端要等待接收端准备好才能发送数据。
  2. 异步asynchronous)(有缓冲)通道:有缓冲的 pointer 型Channel。可以设置缓冲区大小,发送端发送数据不会阻塞,只有缓冲区满了才阻塞。
  3. 异步通道带值元素:有缓冲的 struct 型Channel

Channel构造器函数源码分析

go 复制代码
func makechan(t *chantype, size int) *hchan {
    elem := t.elem	//Channel中元素类型
    
    // 每个元素的内存大小为elem.size,channel的容量为size,计算出总内存mem
    mem, overflow := math.MulUintptr(elem.size, uintptr(size))
    if overflow || mem > maxAlloc-hchanSize || size < 0 {
        panic(plainError("makechan: size out of range"))
    }

    var c *hchan
    switch {
    case mem == 0:				//无缓冲型Channel
   		 //hchanSize默认为96字节
        c = (*hchan)(mallocgc(hchanSize, nil, true))
        // 竞争检测器使用此位置进行同步。因为无缓冲,buf指向自己,用于竞争检测
        c.buf = c.raceaddr()
    case elem.ptrdata == 0:		//有缓冲, 元素类型是struct的情况
        c = (*hchan)(mallocgc(hchanSize+mem, nil, true))   // mallocgc分配hchan内存加上mem大小(mem可能是0)
        c.buf = add(unsafe.Pointer(c), hchanSize)  // c.buf指向hchan起始地址加上hchanSize,即指向buf字段
    default:					//有缓冲的 pointer 型Channel
        c = new(hchan)
        c.buf = mallocgc(mem, elem, true)      // c.buf指向缓冲区内存起始地址
    }

    // 初始化hchan
    c.elemsize = uint16(elem.size)		//每个元素在内存中占用的字节数
    c.elemtype = elem					//元素类型
    c.dataqsiz = uint(size)				//队列中元素的数量上限
    
    lockInit(&c.lock, lockRankHchan)	//初始化读写保护锁

    return c
}

elem.ptrdata就是检测chan元素类型是否是指针类型:

  • 如果elem.ptrdata == 0,表示chan元素是非指针类型,如struct(连续内存)

  • 如果elem.ptrdata != 0,表示chan元素是指针类型

发送数据

  • 首先判断通道是否为nil即未初始化,若为空则引发死锁

  • 若通道非空,由于channel是共享资源,故需要对通道进行lock加锁

  • 继续判断通道是否关闭,若关闭,则引发panic:send on closed channel

  • 通道非空未关闭,则正式进入写入流程,首先判断是否有阻塞的读协程

    • 若有阻塞的读协程,此时环形缓冲区内元素个数为0, 则唤醒读协程,直接将要发送的数据传递给它,并完成写入,进行解锁返回

    • 没有阻塞的读协程,则判断环形缓冲区是否有空间

      • 若环形缓冲区有空间,则直接将当前元素添加到环形缓冲区 sendx的位置,并更新写入位置sendx与通道元素个数qcount,解锁后返回函数。
      • 若环形缓冲区无空间,将当前协程加入阻塞写协程队列中,阻塞协程,等待被读协程唤醒,并完成解锁

更多详细的内容查看Golang Channel 实现原理与源码分析 - 掘金 (juejin.cn)

接受数据

  • 首先判断通道是否为nil即未初始化,若为空则引发死锁

  • 若通道非空,由于channel是共享资源,故需要对通道进行lock加锁

  • 继续判断通道是否关闭,若关闭,则判断环形缓冲区是否有元素,若无元素,则返回对应元素的零值。

  • 通道非空未关闭,则正式进入写入流程,首先判断是否有阻塞的写协程

    • 若有阻塞的写协程, 说明环形缓冲区为无缓冲型或已被写满,故判断channel是否为无缓冲型

      • 若 channel为无缓冲型,则直接读取写协程元素,并唤醒写协程;
      • 若 channel 为有缓冲型,则读取环形缓冲区头部元素,并将写协程元素写入缓冲区尾部后唤醒写协程,更新读写索引;
    • 若没有阻塞的写协程,则判断环形缓冲区是否有空间

      • 若环形缓冲区有空间,则直接将当前元素添加到环形缓冲区 sendx的位置,并更新写入位置sendx与通道元素个数qcount,解锁后返回函数。
      • 若环形缓冲区无空间,将当前协程加入阻塞写协程队列中,阻塞协程,等待被读协程唤醒,并完成解锁

关闭channel流程

  • 首先判断通道是否为nil即未初始化,若关闭空channel则引发panic(plainError("close of nil channel"))
  • 若通道非空,由于channel是共享资源,故需要对通道进行lock加锁

  • 继续判断通道是否关闭,若已经关闭,则引发panic(plainError("close of closed channel"))

  • 通道非空未关闭,则正式进入关闭流程:

    • 若有阻塞读协程队列,则将阻塞读协程队列中的协程节点统一添加到 glist,此时一定无阻塞写协程队列,先通知 recvq 的原因在于确保任何等待从通道接收数据的 goroutine 都能够迅速被通知通道已关闭。这样可以防止它们在等待更多数据时无限期地阻塞。而sendq遇到通道关闭时会直接引发panic,而不是等待。因此,先关闭 recvq 可以确保接收者 goroutine 不会阻塞,同时能够及时地了解通道已关闭。

    • 若有阻塞写协程队列,则将阻塞写协程队列中的协程节点统一添加到 glist,此时一定无阻塞读协程队列

    • 唤醒 glist 当中的所有协程.

scss 复制代码
func chansend1(c *hchan, elem unsafe.Pointer) {
    chansend(c, elem, true, getcallerpc())
}

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    if c == nil {
        gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
        throw("unreachable")
    }

    lock(&c.lock)

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

不同状态下channel的执行结果

操作 \ 状态 非空、未关闭的chan 关闭状态 nil 通道
Send 结果 阻塞或正常读取数据。缓冲型 channel 为空或非缓冲型 channel 没有等待发送者时会阻塞 Panic 阻塞(deadlock)
Recv 结果 阻塞或正常写入数据。非缓冲型 channel 没有等待接收者或缓冲型 channel buf 满时会被阻塞 接收数据或返回零值 阻塞(deadlock)
close 成功 Panic Panic

for-range 读取 Channel 数据

不管是有缓冲还是无缓冲,都可以使用 for-range 从 channel 中读取数据,并且这个是一直循环读取的。

for-range 中的 range 产生的迭代值为 Channel 中发送的值,如果已经这个 channel 已经 close 了,那么首先还会继续执行,直到所有值被读取完,然后才会跳出 for 循环,因此,通过 for-range 读取 chann 数据会比较方便,因为我们只需要读取数据就行了,不需管他的退出,在 close 之后如果数据读取完了会自动帮我们退出。如果既没有 close 也没有数据可读,那么就会阻塞到 range 这里,除非有数据产生或者 chan 被关闭了。但是如果 channel 是 nil,读取会被阻塞,也就是会一直阻塞在 range 位置。

一个示例如下:

go 复制代码
ch := make(chan int) // 一直循环读取 range 中的迭代值 
for v := range ch { 
// 得到了 v 这个 chann 中的值 
fmt.Println("读取数据:",v) 
}

select 读写 Channel 数据

  • select 的 case 分支里面,可以读数据,也可以写数据。最多只允许有一个 default case,它可以放在 case 列表的任何位置,并且没有任何影响。

  • select 可以同时处理多个 channel,如果有同时多个 case 分支可以去处理,比如同时有多个 channel 可以接收数据,那么 Go 会伪随机(pseudo-random)的选择一个 case 处理。如果没有 case 需要处理,则会选择 default 分支去处理。如果没有 default case,则 select 语句会阻塞,直到某个 case 分支可以处理了。

  • 每次 select 语句的执行,是会扫描完所有的 case 后才确定如何执行,而不是说遇到合适的 case 就直接执行了。

  • 对于 nil channel 上的操作会一直被阻塞,如果没有 default case,只有 nil channel 的 select 会一直被阻塞。

  • select 语句和 switch 语句一样,它不是循环,它只会选择一个 case 来处理,如果想一直处理channel,你可以在外面加一个无限的 for 循环

Channel 的读写超时机制【select + timeout】

我们的一般常见场景就是,当我们从 chann 中进行读取数据,或者写入数据的时候,想要快速返回得到是否成功的结果,如果被 chann 阻塞后,需要指定一定的超时时间,然后如果在超时时间内还没有返回,那么就超时退出,不能一直阻塞在读写 chann 的流程中。

Go 的 time 库里面,提供了 time.NewTimer()、time.After()、time.NewTicker() 等方法,最终都可以通过这些方法来返回或者得到一个 channel,然后向这个 channel 中发送数据,就可以实现定时器的功能。

channel 可以通过 select + timeout 来实现阻塞超时的使用姿势,超时读写的姿势如下:

go 复制代码
// 通过 select 实现读超时,如果读 chann 阻塞 timeout 的时间后就会返回
func ReadWithSelect(ch chan int) (x int, err error) {
	timeout := time.NewTimer(time.Microsecond * 500)

	select {
	case x = <-ch:
		return x, nil
	case <-timeout.C:
		return 0, errors.New("read time out")
	}
}

// 通过 select 实现写超时,如果写 chann 阻塞 timeout 的时间后就会返回
func WriteChWithSelect(ch chan int) error {
	timeout := time.NewTimer(time.Microsecond * 500)

	select {
	case ch <- 1:
		return nil
	case <-timeout.C:
		return errors.New("write time out")
	}
}

TryEnqueue 无阻塞写 Channel 数据

有些场景,我们期望往缓冲队列中写入数据的时候,如果队列已满,那么不要进行写阻塞,而是写完发现队列已满就抛错,那么我们可以通过如下机制的封装来实现,原理是通过一个 select 和 一个 default 语句去实现,有一个 default 就不会阻塞了:

go 复制代码
var jobChan = make(chan int, 3)

func TryEnqueue(job int) bool {
	select {
	case jobChan <- job:
		fmt.Printf("true\n")  // 队列未满
		return true
	default:
		fmt.Printf("false\n") // 队列已满
		return false
	}
}

参考文献

作者:AllenWu

链接:juejin.cn/post/716954...

来源:稀土掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

作者:Pistachiout

链接:juejin.cn/post/726589...

来源:稀土掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

作者:腾讯云开发者

链接:juejin.cn/post/717133...

来源:稀土掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

相关推荐
豆浆Whisky17 小时前
Go泛型实战指南:从入门到工程最佳实践|Go语言进阶(12)
后端·go
元亓亓亓17 小时前
SSM--day4--SpringMVC(补充)
java·后端·ssm
沐雨橙风ιε18 小时前
Spring Boot整合Apache Shiro权限认证框架(应用篇)
java·spring boot·后端·apache shiro
考虑考虑18 小时前
fastjson调用is方法开头注意
java·后端·java ee
小蒜学长18 小时前
springboot基于javaweb的小零食销售系统的设计与实现(代码+数据库+LW)
java·开发语言·数据库·spring boot·后端
brzhang19 小时前
为什么 OpenAI 不让 LLM 生成 UI?深度解析 OpenAI Apps SDK 背后的新一代交互范式
前端·后端·架构
EnCi Zheng19 小时前
JPA 连接 PostgreSQL 数据库完全指南
java·数据库·spring boot·后端·postgresql
brzhang19 小时前
OpenAI Apps SDK ,一个好的 App,不是让用户知道它该怎么用,而是让用户自然地知道自己在做什么。
前端·后端·架构
LucianaiB19 小时前
从玩具到工业:基于 CodeBuddy code CLI 构建电力变压器绕组短路智能诊断系统
后端
武子康20 小时前
大数据-118 - Flink 批处理 DataSet API 全面解析:应用场景、代码示例与优化机制
大数据·后端·flink