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合辑

相关推荐
兔爷眼红了6 分钟前
Swift语言的物联网
开发语言·后端·golang
LucianaiB15 分钟前
C语言之装甲车库车辆动态监控辅助记录系统
android·c语言·开发语言·低代码
我想吃余28 分钟前
高阶C语言|库函数qsort的使用以及用冒泡排序实现qsort的功能详解
c语言·开发语言·数据结构·算法
摇光9333 分钟前
js实现数据结构
开发语言·javascript·数据结构
码上艺术家1 小时前
手摸手系列之 Java 通过 PDF 模板生成 PDF 功能
java·开发语言·spring boot·后端·pdf·docker compose
小禾苗_1 小时前
单片机存储器和C程序编译过程
c语言·开发语言
earthzhang20211 小时前
《深入浅出HTTPS》读书笔记(29):TLS/SSL协议
开发语言·网络协议·算法·https·ssl
程序员一诺1 小时前
【Django开发】django美多商城项目完整开发4.0第12篇:商品部分,表结构【附代码文档】
后端·python·django·框架
m0_dawn2 小时前
算法(蓝桥杯)贪心算法7——过河的最短时间问题解析
开发语言·python·算法·职场和发展·蓝桥杯
java熊猫2 小时前
Kotlin语言的数据库交互
开发语言·后端·golang