go chan底层分析

go chan底层分析

底层源码

hchan

go 复制代码
type hchan struct {
    qcount   uint           // 当前队列中剩余元素个数
    dataqsiz uint           // 环形队列长度,即可以存放的元素个数,也就是通道的缓冲区大小
    buf      unsafe.Pointer // 是一个指向环形队列的指针
    elemsize uint16         // 存储通道中每个元素的大小,单位是字节。
    closed   uint32         // 标识关闭状态
    elemtype *_type         // 元素类型
    sendx    uint           // 队列下标,指示元素写入时存放到队列中的位置
    recvx    uint           // 队列下标,指示元素从队列的该位置读出
    recvq    waitq          // 等待读消息的goroutine队列
    sendq    waitq          // 等待写消息的goroutine队列
    lock mutex              // 互斥锁,chan不允许并发读写
}

读消息协程队列(recvq) 和 写消息协程队列(sendq) 分别是接收(<- channel))和 发送(channel <- xxx)的 协程 抽象出来的结构体(sudog)的队列,是个双向链表。

go 复制代码
type waitq struct {
	first *sudog
	last  *sudog
}

makechan 方法

makechan 是一个内部方法,用于创建通道。它位于 src/runtime 目录下,负责通道内存的分配、初始化通道结构体等操作。makechan 方法是由 Go 运行时调用的,它不会直接出现在普通用户代码中,而是与 Go 的低级运行时管理密切相关。

创建一个管道会在 heap 中实例化一个 hchan 对象,并返回这个对象的指针。

go 复制代码
func makechan(t *chantype, size int) *hchan {
    // t 是由 Go 编译器在编译时生成的。
    // elem 是通道元素的类型描述符(*chantype),它包含了关于通道元素类型的各种信息。
    // elem.Size_ 是 elem 结构体中的字段,表示通道元素类型的大小(以字节为单位)。
    // 例如:如果通道的元素类型是 int,那么 elem.Size_ 就是 int 类型的大小(通常是 4 字节或 8 字节,具体取决于平台)。大小是编译时确定的,并通过 elem.Size_ 字段存储在 chantype 中。
    elem := t.Elem

    // 1.元素大小检查:查通道中元素的大小是否超过了 64KB(1 << 16)。通道中每个元素的大小不能超过 64KB,超出此限制会导致不合法的元素类型。
    if elem.Size_ >= 1<<16 {
       throw("makechan: invalid channel element type")
    }
    // 2.对齐条件检查:检查 hchan 结构体的大小和元素的对齐要求是否符合系统的对齐规则。如果不符合,将抛出异常。
    if hchanSize%maxAlign != 0 || elem.Align_ > maxAlign {
       throw("makechan: bad alignment")
    }

    // 3.计算所需内存:计算通道缓冲区所需的内存大小。elem.Size_ 是单个元素的大小,size 是通道的大小(即缓冲区中的元素个数)。如果计算结果溢出或者超出了最大分配内存限制,代码会抛出异常。
    mem, overflow := math.MulUintptr(elem.Size_, uintptr(size))
    if overflow || mem > maxAlloc-hchanSize || size < 0 {
       panic(plainError("makechan: size out of range"))
    }

    // 4.内存分配:(mallocgc 函数,它会在 Go 的垃圾回收器中分配内存。mallocgc 会根据需要将内存注册到垃圾回收系统,并处理内存的初始化)
    var c *hchan
    switch {
    // mem == 0:如果元素大小为 0(即元素为零字节),则只分配 hchan 结构体所需的内存。
    case mem == 0:
       c = (*hchan)(mallocgc(hchanSize, nil, true))
       c.buf = c.raceaddr()

    // elem.PtrBytes == 0:如果元素类型不包含指针(即元素是简单数据类型),则通道和缓冲区内存会一次性分配。
    case elem.PtrBytes == 0:
       c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
       c.buf = add(unsafe.Pointer(c), hchanSize)

    // 其他情况:如果元素类型包含指针,则首先为 hchan 分配内存,然后单独为元素数据(缓冲区)分配内存。
    default:
       c = new(hchan)
       c.buf = mallocgc(mem, elem, true)
    }

    // 5.初始化通道信息
    c.elemsize = uint16(elem.Size_)  // 存储单个元素的大小
    c.elemtype = elem                // 存储元素类型的描述信息
    c.dataqsiz = uint(size)          // 存储缓冲区的大小(即通道中可以存储的元素数量)
    lockInit(&c.lock, lockRankHchan) // 初始化 hchan 结构体中的锁,用于保证并发操作时的同步

    // 6.调试输出:如果启用了调试模式,Go 运行时会打印通道创建的信息,用于调试。
    if debugChan {
       print("makechan: chan=", c, "; elemsize=", elem.Size_, "; dataqsiz=", size, "\n")
    }
    return c
}

环形队列

chan内部实现了一个环形队列作为其缓冲区,队列的长度是创建chan时指定的。

下图展示了一个可缓存6个元素的channel示意图:

  • dataqsiz 表示了队列长度为6,即可缓存6个元素;
  • buf 指向队列的内存;
  • qcount 表示队列中还有两个元素;
  • sendx 表示后续写入的数据存储的位置,取值[0, 6);
  • recvx 表示从该位置读取数据, 取值[0, 6);

阻塞机制

  1. 一个协程向一个 管道读数据,如果管道缓冲区为空或者没有缓冲区,当前的协程会被加入到 读消息协程队列(recvq)中,并且被挂起来,直到对应的条件满足时(例如缓冲区有数据),它会被唤醒并继续执行;

  2. 一个协程向一个管道写数据,如果管道缓冲区已经满了或者没有缓冲区,当前的协程会被加入到 写消息协程队列(sendq) 中,并且被挂起来,直到对应的条件满足时(例如缓冲区有空间),它会被唤醒并继续执行。

注意:处于等待队列中的协程会在其他协程操作管道时被唤醒,具体如下,

  1. 因读阻塞的协程会被向管道写人数据的协程唤醒。
  2. 因写阻塞的协程会被从管道读数据的协程唤醒。
    注意:一般不会出现 读消息协程队列(recvq) 和 写消息协程队列(sendq) 中同时有协程排队的情况,只有一个例外,那就是同一个协程使用 select 语句向管道一边写数据、一边读数据,此时协程会分别位于两个等待队列中。

向管道写数据

向一个管道中写数据的过程如下:

  1. 如果缓冲区中有空余位置,则将数据写人缓冲区,结束写消息过程。
  2. 如果缓冲区中没有空余位置,则将 写消息协程 加人 写消息协程队列(sendq),进入睡眠并等待被 读协程 唤醒。
  3. 特殊情况:直接将准备写的数据传递给 读消息协程队列(recvq) 。具体如下,当 读消息协程队列(recvq) 中有协程等待时,会将准备写的数据直接传递给 读消息协程队列(recvq) 中的第一个 读消息协程,而不需要通过缓冲区。这是一个优化手段,避免了无谓的缓冲区操作。

流程图

注意:写消息的时候,如果是无缓冲管道,直接写消息,而且 读消息协程队列 中没有协程,这个时候就会直接阻塞报错,要确保在写消息前 读消息协程队列 不为空。

源码

特殊情况 :

直接将数据传递给 读消息协程队列(recvq) 。如果 读消息协程队列(recvq) 中有 读消息协程 等待接收数据,那么直接将发送的数据传递给 读消息协程 ,跳过缓冲区。

阻塞操作 :

如果管道缓冲区已满,并且没有 读消息协程队列(recvq) 中的 读消息协程 在等待,则发送操作会被阻塞,直到有空间可以写入数据或者接收协程完成了数据接收。

协程阻塞和唤醒机制 :

在 写消息协程 被阻塞时(即没有缓冲区了,而且 读消息协程队列(recvq) 中为空),程序会将其添加到 写消息协程队列(sendq) 中,并通过 gopark 将其置于等待状态。

从管道读数据

从一个管道读数据的简单过程如下:

  1. 如果缓冲区中有数据,则从缓冲区取出数据,结束读消息过程。
  2. 如果缓冲区中没有数据,则将 读消息协程 加入 读消息协程队列(recvq),进入睡眠并等待被 写消息协程 唤醒。

同样,在实现时有个小技巧:如果 写消息协程队列(sendq) 不为空,且没有缓冲区,那么此时将直接从 写消息协程队列(sendq) 的第一个写消息协程 中获取数据。

流程图

源码

通道已关闭且没有数据:如果通道已关闭,且缓冲区没有数据(qcount == 0),接收者会清理数据(如果存在的话),释放锁,然后返回。

通道已关闭但缓冲区有数据:如果通道已关闭,但缓冲区中有数据,接收者可以正常接收数据。

通道未关闭且有等待发送的数据:如果通道未关闭,并且 写消息协程队列(sendq) 中有等待写的消息,接收者将直接从 写消息协程队列(sendq) 中获取数据。

略...

关闭通道

关闭管道时会把 读消息协程队列(recvq) 中的 读消息协程 全部唤醒,这些协程获取的数据都为对应类型的零值。同时还会把 写消息协程队列(sendq) 中的 写消息协程 全部唤醒,但这些协程会触发 panic。

除此之外,其他会触发 panic 的操作还有:

  1. 关闭值为nil 的管道。
  2. 关闭已经被关闭的管道。
  3. 向已经关闭的管道写入数据。

参考:
go专家编程

图解Go的channel底层实现

【幼麟实验室】Golang合辑

相关推荐
weixin_307779132 小时前
C++代码实现MATLAB中的dlarray函数功能
开发语言·c++·算法·matlab
一水鉴天5 小时前
映射、哈希表与哈斯图:计算机科学的三种基线 20261003(元宝)
开发语言·人工智能
Frank_refuel6 小时前
C++11之一场名为“搬家”的 C++ 之旅
开发语言·c++
Wang's Blog7 小时前
Java 项目实战: 外卖平台优化-Nginx配置文件结构与块层级
java·开发语言·nginx
2601_962071577 小时前
类变量和全局变量的查找路径有什么区别?
开发语言·python
卷无止境8 小时前
独立开发者的"富矿地带":哪些垂直领域值得你押注一辈子?
后端·python
\光辉岁月/8 小时前
5.java-数组
java·开发语言
Java后端的Ai之路8 小时前
Python进阶探索29_eval内置函数
开发语言·python·探索·eval·内置函数
谢亮_vipxieliang8 小时前
Spring 事务失效的常见场景
java·开发语言·数据库·spring boot
时间的拾荒人9 小时前
Qt 多线程详解:从 QThread 到实战
开发语言·qt·面试