首页 > 后端开发 > Golang > Go 频道解锁:它们是如何工作的

Go 频道解锁:它们是如何工作的

Mary-Kate Olsen
发布: 2025-01-17 02:11:10
原创
355 人浏览过

深入Golang Channel:实现原理及性能优化建议

Golang的Channel是其CSP并发模型的关键组成部分,也是Goroutine之间通信的桥梁。Channel在Golang中被频繁使用,深入了解其内部实现原理至关重要。本文将基于Go 1.13源码分析Channel的底层实现。

Channel基础用法

在正式分析Channel实现之前,先回顾其基本用法:

<code class="language-go">package main
import "fmt"

func main() {
    c := make(chan int)

    go func() {
        c <- 1 // 发送操作
    }()

    x := <-c // 接收操作
    fmt.Println(x)
}</code>
登录后复制
登录后复制
登录后复制

这段代码展示了Channel的两个基本操作:

  • 发送操作:c <- 1
  • 接收操作:x := <-c

Channel分为缓冲Channel和非缓冲Channel。上述代码使用了非缓冲Channel。在非缓冲Channel中,如果当前没有其他Goroutine接收数据,发送方会在发送语句处阻塞。

初始化Channel时可以指定缓冲区大小,例如make(chan int, 2)指定缓冲区大小为2。在缓冲区未满之前,发送方可以无阻塞地发送数据,无需等待接收方准备好。但如果缓冲区已满,发送方仍然会阻塞。

Channel底层实现函数

在深入Channel源码之前,需要找到Golang中Channel的具体实现位置。使用Channel时,实际上调用的是runtime.makechanruntime.chansendruntime.chanrecv等底层函数。

可以使用go tool compile -N -l -S hello.go命令将代码转换为汇编指令,或者使用在线工具Compiler Explorer (例如:go.godbolt.org/z/3xw5Cj)。通过分析汇编指令,可以发现:

  • make(chan int)对应runtime.makechan函数。
  • c <- 1对应runtime.chansend函数。
  • x := <-c对应runtime.chanrecv函数。

这些函数的实现都位于Go源码的runtime/chan.go文件中。

Channel构造

make(chan int)会被编译器转换为runtime.makechan函数,其函数签名如下:

<code class="language-go">func makechan(t *chantype, size int) *hchan</code>
登录后复制
登录后复制
登录后复制

其中,t *chantype是Channel元素类型,size int是用户指定的缓冲区大小(未指定则为0),返回值是*hchanhchan是Golang中Channel的内部实现结构体,定义如下:

<code class="language-go">type hchan struct {
        qcount   uint           // 缓冲区中已放入元素的数量
        dataqsiz uint           // 用户构造Channel时指定的缓冲区大小
        buf      unsafe.Pointer // 缓冲区
        elemsize uint16         // 缓冲区中每个元素的大小
        closed   uint32         // Channel是否关闭,==0表示未关闭
        elemtype *_type         // Channel元素的类型信息
        sendx    uint           // 缓冲区中发送元素的索引位置(发送索引)
        recvx    uint           // 缓冲区中接收元素的索引位置(接收索引)
        recvq    waitq          // 等待接收的Goroutine列表
        sendq    waitq          // 等待发送的Goroutine列表

        lock mutex
}</code>
登录后复制
登录后复制

hchan中的属性大致分为三类:

  • 缓冲区相关属性: 如buf, dataqsiz, qcount等。当Channel的缓冲区大小不为0时,缓冲区用于存储待接收的数据,使用环形缓冲区实现。
  • 等待队列相关属性: recvq包含等待接收数据的Goroutine,sendq包含等待发送数据的Goroutine。waitq使用双向链表实现。
  • 其他属性: 如lock, elemtype, closed等。

makechan函数主要进行一些合法性检查和缓冲区、hchan等属性的内存分配,这里不再深入讨论。

基于hchan属性的简单分析,可以看出其中有两个重要的组成部分:缓冲区和等待队列。hchan的所有行为和实现都围绕这两个组成部分展开。

Channel数据发送

Channel的发送和接收过程非常相似。先分析Channel的发送过程(例如c <- 1)。

尝试向Channel发送数据时,如果recvq队列不为空,则会先从recvq头部取出一个等待接收数据的Goroutine,并将数据直接发送给该Goroutine。代码如下:

<code class="language-go">package main
import "fmt"

func main() {
    c := make(chan int)

    go func() {
        c <- 1 // 发送操作
    }()

    x := <-c // 接收操作
    fmt.Println(x)
}</code>
登录后复制
登录后复制
登录后复制

recvq包含等待接收数据的Goroutine。当一个Goroutine使用接收操作(例如x := <-c)时,如果此时sendq不为空,则会从sendq中取出一个Goroutine,并将数据发送给它。

如果recvq为空,则表示此时没有Goroutine等待接收数据,Channel会尝试将数据放入缓冲区:

<code class="language-go">func makechan(t *chantype, size int) *hchan</code>
登录后复制
登录后复制
登录后复制

这段代码的功能很简单,就是将数据放入缓冲区。这个过程涉及环形缓冲区的操作,dataqsiz表示用户指定的缓冲区大小(未指定则默认为0)。

如果使用的是非缓冲Channel或者缓冲区已满(c.qcount == c.dataqsiz),则会将待发送的数据和当前Goroutine打包成sudog对象,放入sendq,并将当前Goroutine设置为等待状态:

<code class="language-go">type hchan struct {
        qcount   uint           // 缓冲区中已放入元素的数量
        dataqsiz uint           // 用户构造Channel时指定的缓冲区大小
        buf      unsafe.Pointer // 缓冲区
        elemsize uint16         // 缓冲区中每个元素的大小
        closed   uint32         // Channel是否关闭,==0表示未关闭
        elemtype *_type         // Channel元素的类型信息
        sendx    uint           // 缓冲区中发送元素的索引位置(发送索引)
        recvx    uint           // 缓冲区中接收元素的索引位置(接收索引)
        recvq    waitq          // 等待接收的Goroutine列表
        sendq    waitq          // 等待发送的Goroutine列表

        lock mutex
}</code>
登录后复制
登录后复制

goparkunlock会解锁输入的互斥锁并挂起当前Goroutine,将其设置为等待状态。goparkgoready是成对出现的,是互逆的操作。

从用户角度来看,调用gopark后,发送数据的代码语句会阻塞。

Channel数据接收

Channel的接收过程与发送过程基本类似,这里不再赘述。接收过程中涉及的缓冲区相关操作会在后面详细描述。

需要注意的是,Channel的整个发送和接收过程都使用了runtime.mutex进行加锁。runtime.mutex是runtime相关源码中常用的轻量级锁,整个过程并非最高效的无锁方案。Golang中存在一个关于无锁Channel的issue:go/issues#8899。

Channel环形缓冲区实现

Channel使用环形缓冲区缓存写入的数据。环形缓冲区具有诸多优点,非常适合实现固定长度的FIFO队列。

Channel中环形缓冲区的实现如下:

hchan中与缓冲区相关的两个变量:recvxsendxsendx表示缓冲区中可写的索引,recvx表示缓冲区中可读的索引。recvxsendx之间的元素表示已正常放入缓冲区的数据。

Go Channel Unlocked: How They Work

可以直接使用buf[recvx]读取队列的第一个元素,使用buf[sendx] = x将元素放入队列的末尾。

缓冲区写入

当缓冲区未满时,将数据放入缓冲区的操作如下:

<code class="language-go">package main
import "fmt"

func main() {
    c := make(chan int)

    go func() {
        c <- 1 // 发送操作
    }()

    x := <-c // 接收操作
    fmt.Println(x)
}</code>
登录后复制
登录后复制
登录后复制

chanbuf(c, c.sendx)等价于c.buf[c.sendx]。上述过程很简单,就是将数据复制到缓冲区sendx位置。

然后,将sendx移动到下一个位置。如果sendx到达最后一个位置,则将其设置为0,这是一种典型的首尾相连的方法。

缓冲区读取

当缓冲区未满时,sendq也必须为空(因为如果缓冲区未满,发送数据的Goroutine不会排队,而是直接将数据放入缓冲区)。此时Channel的读取逻辑chanrecv比较简单,可以直接从缓冲区读取数据,也是一个移动recvx的过程,与上面的缓冲区写入基本相同。

sendq中有等待的Goroutine时,缓冲区此时一定已满。此时Channel的读取逻辑如下:

<code class="language-go">func makechan(t *chantype, size int) *hchan</code>
登录后复制
登录后复制
登录后复制

ep是接收数据的变量对应的地址(例如,在x := <-c中,ep就是x的地址)。sg表示从sendq中取出的第一个sudog。代码中:

  • typedmemmove(c.elemtype, ep, qp)表示将缓冲区中当前可读的元素复制到接收变量的地址。
  • typedmemmove(c.elemtype, qp, sg.elem)表示将sendq中Goroutine等待发送的数据复制到缓冲区。因为后面执行了recv ,所以相当于将sendq中的数据放在队列的末尾。

简单来说,这里Channel将缓冲区中的第一个数据复制到对应的接收变量,同时将sendq中的元素复制到队列的末尾,从而实现FIFO(先进先出)。

总结

Channel作为Golang中最常用的设施之一,理解其源码有助于更好地使用和理解Channel。同时,也不要过度迷信和依赖Channel的性能,当前Channel的设计仍有很大的优化空间。

优化建议:

  • 使用更轻量级的锁机制或无锁方案,以提高性能。
  • 优化缓冲区管理,减少内存分配和复制操作。

Leapcell:Golang Web 应用最佳 Serverless 平台

Go Channel Unlocked: How They Work

最后,推荐一个非常适合部署Go服务的平台:Leapcell

  1. 多语言支持: 支持JavaScript、Python、Go或Rust开发。
  2. 免费部署无限项目: 只按使用付费,无请求则无费用。
  3. 极高的性价比: 按需付费,无空闲费用。例如:25美元可支持694万次请求,平均响应时间为60毫秒。
  4. 流畅的开发者体验: 直观的UI,轻松设置;全自动CI/CD管道和GitOps集成;实时指标和日志,提供可操作的洞察。
  5. 轻松扩展和高性能: 自动扩展以轻松处理高并发;零运营开销,专注于构建。
Go Channel Unlocked: How They Work

更多信息请查看文档!

Leapcell Twitter: https://www.php.cn/link/7884effb9452a6d7a7a79499ef854afd

以上是Go 频道解锁:它们是如何工作的的详细内容。更多信息请关注PHP中文网其他相关文章!

来源:php.cn
本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
作者最新文章
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板