golang 系列:channel 全面解析

本文涉及的产品
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
简介: 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 的精妙之处:复杂底层,优雅运用。

相关文章
|
3月前
|
Go
Golang语言之管道channel快速入门篇
这篇文章是关于Go语言中管道(channel)的快速入门教程,涵盖了管道的基本使用、有缓冲和无缓冲管道的区别、管道的关闭、遍历、协程和管道的协同工作、单向通道的使用以及select多路复用的详细案例和解释。
140 4
Golang语言之管道channel快速入门篇
|
27天前
|
存储 Go 开发者
Go语言中的并发编程与通道(Channel)的深度探索
本文旨在深入探讨Go语言中并发编程的核心概念和实践,特别是通道(Channel)的使用。通过分析Goroutines和Channels的基本工作原理,我们将了解如何在Go语言中高效地实现并行任务处理。本文不仅介绍了基础语法和用法,还深入讨论了高级特性如缓冲通道、选择性接收以及超时控制等,旨在为读者提供一个全面的并发编程视角。
|
27天前
|
安全 Go 数据处理
Go语言中的并发编程:掌握goroutine和channel的艺术####
本文深入探讨了Go语言在并发编程领域的核心概念——goroutine与channel。不同于传统的单线程执行模式,Go通过轻量级的goroutine实现了高效的并发处理,而channel作为goroutines之间通信的桥梁,确保了数据传递的安全性与高效性。文章首先简述了goroutine的基本特性及其创建方法,随后详细解析了channel的类型、操作以及它们如何协同工作以构建健壮的并发应用。此外,还介绍了select语句在多路复用中的应用,以及如何利用WaitGroup等待一组goroutine完成。最后,通过一个实际案例展示了如何在Go中设计并实现一个简单的并发程序,旨在帮助读者理解并掌
|
1月前
|
安全 Go 调度
探索Go语言的并发模型:goroutine与channel
在这个快节奏的技术世界中,Go语言以其简洁的并发模型脱颖而出。本文将带你深入了解Go语言的goroutine和channel,这两个核心特性如何协同工作,以实现高效、简洁的并发编程。
|
1月前
|
存储 安全 Linux
Golang的GMP调度模型与源码解析
【11月更文挑战第11天】GMP 调度模型是 Go 语言运行时系统的核心部分,用于高效管理和调度大量协程(goroutine)。它通过少量的操作系统线程(M)和逻辑处理器(P)来调度大量的轻量级协程(G),从而实现高性能的并发处理。GMP 模型通过本地队列和全局队列来减少锁竞争,提高调度效率。在 Go 源码中,`runtime.h` 文件定义了关键数据结构,`schedule()` 和 `findrunnable()` 函数实现了核心调度逻辑。通过深入研究 GMP 模型,可以更好地理解 Go 语言的并发机制。
|
1月前
|
Go 调度 开发者
探索Go语言中的并发模式:goroutine与channel
在本文中,我们将深入探讨Go语言中的核心并发特性——goroutine和channel。不同于传统的并发模型,Go语言的并发机制以其简洁性和高效性著称。本文将通过实际代码示例,展示如何利用goroutine实现轻量级的并发执行,以及如何通过channel安全地在goroutine之间传递数据。摘要部分将概述这些概念,并提示读者本文将提供哪些具体的技术洞见。
|
28天前
|
存储 Go PHP
Go语言中的加解密利器:go-crypto库全解析
在软件开发中,数据安全和隐私保护至关重要。`go-crypto` 是一个专为 Golang 设计的加密解密工具库,支持 AES 和 RSA 等加密算法,帮助开发者轻松实现数据的加密和解密,保障数据传输和存储的安全性。本文将详细介绍 `go-crypto` 的安装、特性及应用实例。
70 0
|
2月前
|
安全 Go 调度
探索Go语言的并发之美:goroutine与channel
在这个快节奏的技术时代,Go语言以其简洁的语法和强大的并发能力脱颖而出。本文将带你深入Go语言的并发机制,探索goroutine的轻量级特性和channel的同步通信能力,让你在高并发场景下也能游刃有余。
|
2月前
|
存储 安全 Go
探索Go语言的并发模型:Goroutine与Channel
在Go语言的多核处理器时代,传统并发模型已无法满足高效、低延迟的需求。本文深入探讨Go语言的并发处理机制,包括Goroutine的轻量级线程模型和Channel的通信机制,揭示它们如何共同构建出高效、简洁的并发程序。
|
1月前
|
安全 测试技术 Go
Go语言中的并发编程模型解析####
在当今的软件开发领域,高效的并发处理能力是提升系统性能的关键。本文深入探讨了Go语言独特的并发编程模型——goroutines和channels,通过实例解析其工作原理、优势及最佳实践,旨在为开发者提供实用的Go语言并发编程指南。 ####