golang 系列:channel 全面解析

简介: channel 是 goroutine 与 goroutine 之间通信的重要桥梁,借助 channel,我们能很轻易的写出一个**多协程**通信程序。今天,我们就来看看这个 channel 的常用用法以及底层原理。

前言

channel 是 goroutine 与 goroutine 之间通信的重要桥梁,借助 channel,我们能很轻易的写出一个多协程通信程序。今天,我们就来看看这个 channel 的常用用法以及底层原理。

一、channel 的概念

channel 是一个通道,用于端到端的数据传输,这有点像我们平常使用的消息队列,只不过 channel 的发送方和接受方是 goroutine 对象,属于内存级别的通信。

这里涉及到了 goroutine 概念,goroutine 是轻量级的协程,有属于自己的栈空间。 我们可以把它理解为线程,只不过 goroutine 的性能开销很小,并且在用户态上实现了属于自己的调度模型。

传统的线程通信有很多方式,像内存共享、信号量等。其中内存共享实现较为简单,只需要对变量进行并发控制,加锁即可。但这种在后续业务逐渐复杂时,将很难维护,耦合性也比较强。

后来提出了 CSP 模型,即在通信双方抽象出中间层,数据的流转由中间层来控制,通信双方只负责数据的发送和接收,从而实现了数据的共享,这就是所谓的通过通信来共享内存。 channel 就是按这个模型来实现的。

channel 在多并发操作里是属于协程安全的,并且遵循了 FIFO 特性。即先执行读取的 goroutine 会先获取到数据,先发送数据的 goroutine 会先输入数据。

另外,channel 的使用将会引起 Go runtime 的调度调用,会有阻塞和唤起 goroutine 的情况产生。

二、channel 的使用

在深入了解 channel 的底层之前,我们先来看看 channel 的常用用法。

channel 的创建

    ch := make(chan int)

上面是创建了无缓冲的 channel,一旦有 goroutine 往 channel 发送数据,那么当前的 goroutine 会被阻塞住,直到有其他的 goroutine 消费了 channel 里的数据,才能继续运行。

还有另外一种是有缓冲的 channel,它的创建是这样的:

ch := make(chan int, 2)

第二个参数表示 channel 可缓冲数据的容量。只要当前 channel 里的元素总数不大于这个可缓冲容量,则当前的 goroutine 就不会被阻塞住。

需要注意的是,上面 make 后返回的是一个指向 hchan 结构的指针变量,等会将会聊聊 hchan 的底层结构。

另外,我们也可以声明一个 nil 的 channel,只是创建这样的 channel 没有意义,读、写 channel 都将会被阻塞住。一般 nil channel 用在 select 上,让 select 不再从这个 channel 里读取数据,如下用法:

    ch1 := make(chan int)
    ch2 := make(chan int)

    go func() {
        if !ok { // 某些原因,设置 ch1 为 nil
            ch1 = nil
        }
    }()

    for {
        select {
        case <-ch1: // 当 ch1 被设置为 nil 后,将不会到达此分支了。
            doSomething1()
        case <-ch2:
            doSomething2()
        }
    }

使用 channel 时我们还可以控制 channel 只读只写操作:

    func readChan(ch <-chan int){
        // chan 只允许被读
    }

    func main(){
        ch := make(chan int)
        readChan(ch)
    }

反之,如果要求只写操作,则可以这样:

    func writeChan(ch chan<- int){
        // chan 只允许被写
    }

channel 的读写

往一个 channel 发送数据,可以这样

    ch := make(chan int)
    ch <- 1

对应的操作:

    data <- ch

当我们不再使用 channel 的时候,可以对其进行关闭:

    close(ch)

当 channel 被关闭后,如果继续往里面写数据,则程序会直接 panic 退出。

不过读取关闭后的 channel,不会产生 pannic,还是可以读到数据。

如果关闭后的 channel 没有数据可读取时,将得到零值,即对应类型的默认值。

为了能知道当前 channel 是否被关闭,可以使用下面的写法来判断。

    if v, ok := <-ch; !ok {
        fmt.Println("channel 已关闭,读取不到数据")
    }

还可以使用下面的写法不断的获取 channel 里的数据:

    for data := range ch {
        // get data dosomething
    }

这种用法会在读取完 channel 里的数据后就结束 for 循环,执行后面的代码。

channel 和 select

在写程序时,有时并不单单只会和一个 goroutine 通信,当我们要进行多 goroutine 通信时,则会使用 select 写法来管理多个 channel 的通信数据:

    ch1 := make(chan struct{})
    ch2 := make(chan struct{})

    // ch1, ch2 发送数据
    go sendCh1(ch1)
    go sendCh1(ch2)

    // channel 数据接受处理
    for {
        select {
        case <-ch1:
            doSomething1()
        case <-ch2:
            doSomething2()
        }
    }

channel 的 deadlock

前面提到过,往 channel 里读写数据时是有可能被阻塞住的,一旦被阻塞,则需要其他的 goroutine 执行对应的读写操作,才能解除阻塞状态。

然而,阻塞后一直没能发生调度行为,没有可用的 goroutine 可执行,则会一直卡在这个地方,程序就失去执行意义了。此时 Go 就会报 deadlock 错误,如下代码:

    func main() {
        ch := make(chan int)
        <-ch

        // 执行后将 panic:
        // fatal error: all goroutines are asleep - deadlock!
    }

因此,在使用 channel 时要注意 goroutine 的一发一取,避免 goroutine 永久阻塞!

三、channel 的底层原理

前面提及过 channel 创建后返回了 hchan 结构体,现在我们来研究下这个结构体,它的主要字段如下:

type hchan struct {
    qcount   uint   // channel 里的元素计数
    dataqsiz uint   // 可以缓冲的数量,如 ch := make(chan int, 10)。 此处的 10 即 dataqsiz
    elemsize uint16 // 要发送或接收的数据类型大小
    buf      unsafe.Pointer // 当 channel 设置了缓冲数量时,该 buf 指向一个存储缓冲数据的区域,该区域是一个循环队列的数据结构
    closed   uint32 // 关闭状态
    sendx    uint  // 当 channel 设置了缓冲数量时,数据区域即循环队列此时已发送数据的索引位置
    recvx    uint  // 当 channel 设置了缓冲数量时,数据区域即循环队列此时已接收数据的索引位置
    recvq    waitq // 想读取数据但又被阻塞住的 goroutine 队列
    sendq    waitq // 想发送数据但又被阻塞住的 goroutine 队列

    lock mutex
    ...
}

channel 在进行读写数据时,会根据无缓冲、有缓冲设置进行对应的阻塞唤起动作,它们之间还是有区别的。下面我们来捋一下这些不同之处。

无缓冲 channel

由于对 channel 的读写先后顺序不同,处理也会有所不同,所以,还得再进一步区分:

channel 先写再读

在这里,我们暂时认为有 2 个 goroutine 在使用 channel 通信,按先写再读的顺序,则具体流程如下:

channel 用法和底层原理

可以看到,由于 channel 是无缓冲的,所以 G1 暂时被挂在 sendq 队列里,然后 G1 调用了 gopark 休眠了起来。

接着,又有 goroutine 来 channel 读取数据了:

channel 用法和底层原理

此时 G2 发现 sendq 等待队列里有 goroutine 存在,于是直接从 G1 copy 数据过来,并且会对 G1 设置 goready 函数,这样下次调度发生时, G1 就可以继续运行,并且会从等待队列里移除掉。

channel 先读再写

先读再写的流程跟上面一样。

channel 用法和底层原理

G1 暂时被挂在了 recvq 队列,然后休眠起来。

G2 在写数据时,发现 recvq 队列有 goroutine 存在,于是直接将数据发送给 G1。同时设置 G1 goready 函数,等待下次调度运行。

channel 用法和底层原理

有缓冲 channel

在分析完了无缓冲 channel 的读写后,我们继续看看有缓冲 channel 的读写。同样的,我们分为 2 种情况:

channel 先写再读

这一次会优先判断缓冲数据区域是否已满,如果未满,则将数据保存在缓冲数据区域,即环形队列里。如果已满,则和之前的流程是一样的。

channel 用法和底层原理
当 G2 要读取数据时,会优先从缓冲数据区域去读取,并且在读取完后,会检查 sendq 队列,如果 goroutine 有等待队列,则会将它上面的 data 补充到缓冲数据区域,并且也对其设置 goready 函数。

channel 用法和底层原理

channel 先读再写

此种情况和无缓冲的先读再写是一样流程,此处不再重复说明。

四、总结

有缓冲 channel 和无缓冲 channel 的读写基本相差不大,只是多了缓冲数据区域的判断而已。

channel 在使用的时候大多时候得和 select 配合使用,尽管只需要简单的用 <- ch 和 ch <- 来读写数据,但它的底层还是很有讲究的,特别是涉及到调度的休眠唤起。

这也能看出 Go 的精妙之处:复杂底层,优雅运用。

相关文章
|
10月前
|
数据采集 数据挖掘 测试技术
Go与Python爬虫实战对比:从开发效率到性能瓶颈的深度解析
本文对比了Python与Go在爬虫开发中的特点。Python凭借Scrapy等框架在开发效率和易用性上占优,适合快速开发与中小型项目;而Go凭借高并发和高性能优势,适用于大规模、长期运行的爬虫服务。文章通过代码示例和性能测试,分析了两者在并发能力、错误处理、部署维护等方面的差异,并探讨了未来融合发展的趋势。
1002 0
|
8月前
|
Cloud Native 安全 Java
Go语言深度解析:从入门到精通的完整指南
🌟蒋星熠Jaxonic,Go语言探索者。深耕云计算、微服务与并发编程,以代码为笔,在二进制星河中书写极客诗篇。分享Go核心原理、性能优化与实战架构,助力开发者掌握云原生时代利器。#Go语言 #并发编程 #性能优化
647 43
Go语言深度解析:从入门到精通的完整指南
|
算法 Go 索引
【LeetCode 热题100】45:跳跃游戏 II(详细解析)(Go语言版)
本文详细解析了力扣第45题“跳跃游戏II”的三种解法:贪心算法、动态规划和反向贪心。贪心算法通过选择每一步能跳到的最远位置,实现O(n)时间复杂度与O(1)空间复杂度,是面试首选;动态规划以自底向上的方式构建状态转移方程,适合初学者理解但效率较低;反向贪心从终点逆向寻找最优跳点,逻辑清晰但性能欠佳。文章对比了各方法的优劣,并提供了Go语言代码实现,助你掌握最小跳跃次数问题的核心技巧。
546 15
|
9月前
|
Cloud Native 安全 Java
Go语言深度解析:从入门到精通的完整指南
🌟 蒋星熠Jaxonic,执着的星际旅人,用Go语言编写代码诗篇。🚀 Go语言以简洁、高效、并发为核心,助力云计算与微服务革新。📚 本文详解Go语法、并发模型、性能优化与实战案例,助你掌握现代编程精髓。🌌 从goroutine到channel,从内存优化到高并发架构,全面解析Go的强大力量。🔧 实战构建高性能Web服务,展现Go在云原生时代的无限可能。✨ 附技术对比、最佳实践与生态全景,带你踏上Go语言的星辰征途。#Go语言 #并发编程 #云原生 #性能优化
|
9月前
|
Linux Go iOS开发
IDA 9.2 发布:Golang 改进、新 UI 组件、类型解析等
IDA Pro 9.2 (macOS, Linux, Windows) - 强大的反汇编程序、反编译器和多功能调试器
1482 0
|
12月前
|
存储 设计模式 安全
Go 语言单例模式全解析:从青铜到王者段位的实现方案
单例模式确保一个类只有一个实例,并提供全局访问点,适用于日志、配置管理、数据库连接池等场景。在 Go 中,常用实现方式包括懒汉模式、饿汉模式、双重检查锁定,最佳实践是使用 `sync.Once`,它并发安全、简洁高效。本文详解各种实现方式的优缺点,并提供代码示例与最佳应用建议。
391 5
|
10月前
|
Go 开发者
Go语言实战案例:使用select监听多个channel
本文为《Go语言100个实战案例 · 网络与并发篇》第5篇,详解Go并发核心工具`select`的使用。通过实际案例讲解如何监听多个Channel、实现多任务处理、超时控制和非阻塞通信,帮助开发者掌握Go并发编程中的多路异步事件处理技巧。
|
10月前
|
数据采集 编解码 监控
Go语言实战案例:使用channel实现生产者消费者模型
本文是「Go语言100个实战案例 · 网络与并发篇」第4篇,通过实战案例详解使用 Channel 实现生产者-消费者模型,涵盖并发控制、任务调度及Go语言并发哲学,助你掌握优雅的并发编程技巧。
|
10月前
|
缓存 监控 安全
告别缓存击穿!Go 语言中的防并发神器:singleflight 包深度解析
在高并发场景中,多个请求同时访问同一资源易导致缓存击穿、数据库压力过大。Go 语言提供的 `singleflight` 包可将相同 key 的请求合并,仅执行一次实际操作,其余请求共享结果,有效降低系统负载。本文详解其原理、实现及典型应用场景,并附示例代码,助你掌握高并发优化技巧。
718 0
|
10月前
|
数据采集 JSON Go
Go语言实战案例:实现HTTP客户端请求并解析响应
本文是 Go 网络与并发实战系列的第 2 篇,详细介绍如何使用 Go 构建 HTTP 客户端,涵盖请求发送、响应解析、错误处理、Header 与 Body 提取等流程,并通过实战代码演示如何并发请求多个 URL,适合希望掌握 Go 网络编程基础的开发者。