深入理解 Go 语言——管道

240 阅读5分钟

大家好,今天,我们要讨论的主题是 Go 语言中管道(Channel)的实现原理。Go 语言的并发模型非常独特,管道在其中扮演着重要的角色。那么,究竟什么是管道?它是如何工作的呢?

一、什么是管道?

在 Go 语言中,管道是一种特殊的类型,可以用来在不同的 Goroutine 之间传递数据。你可以把它想象成一条传送带或者一个管道,数据可以从一头进入,然后从另一头出来。 注意:每一个管道只能存储一种类型的数据。

var ch chan int // 声明管道

ch := make(chan int) // 创建一个整数类型的管道

二、管道是如何工作的?

管道的数据传输总是遵循先进先出(FIFO)的原则,也就是说,在多个 Goroutine 向同一个管道发送数据的时候,数据的出现顺序会与发送顺序一致。同样的,接收操作也是遵循这个原则。

ch <- 10  // 发送数据到管道
x := <-ch // 从管道接收数据

操作符 “<-” 表示数据流向:管道在左表示向管道写入数据,管道在右表示从管道读出数据。

当你执行 x := <-ch 时,Go 会从 hchan 的缓冲区中复制一个值到 x。如果缓冲区为空,那么接收操作会被阻塞,直到有其他 Goroutine 向管道发送了数据。

当你执行 ch <- x 时,Go 会将 x 的值复制到 hchan 的缓冲区中。如果缓冲区已满,那么发送操作会被阻塞,直到有其他 Goroutine 从管道中接收了数据。

默认的管道为双向可读写,管道在函数间传递时,可以使用操作符进行限制管道的读写,比如:

// 管道 ch 可读写
func chanRW(ch chan int) {

}

// 管道 ch 只可读
func chanR(ch <-chan int) {
    
}

// 管道 ch 只可写
func chanR(ch chan<- int) {
    
}

三、管道的实现原理

Go 语言的管道实现涉及到了很多复杂的数据结构和算法,我们这里只介绍最核心的部分。

数据结构

在 Go 的源代码中,管道是通过一个名为 hchan 的结构体实现的,它的定义包括了数据队列、等待队列等信息,它看起来是这样的:

type hchan struct {
    qcount   uint           // 当前队列中剩余的元素数量
    dataqsiz uint           // 环形队列大小,即可以存放的元素个数
    buf      unsafe.Pointer // 环形队列指针
    elemsize uint16         // 每个元素的大小
    closed   uint32         // 标识管道是否关闭
    elemtype *_type			// 元素类型
    sendx	 uint			// 队列下标,指示元素写入时存放到队列中的位置
    recvx	 uint			// 队列下标,指示下一个被读取的元素在队列中的位置
    recvq    waitq			// 等待读消息的协程队列
    sendq    waitq			// 等待写消息的协程队列
    lock     mutex			// 互斥锁,chan 不允许并发读写
}

环形队列

chan 内部实现了一个环形缓冲区,队列长度是创建 chan 时指定的,即 dataqsiz image.png

等待队列

从 channel 读取数据,如果 channel 缓存区为空或者没有缓存区,当前 goroutine 会被阻塞,向 channel 写数据,如果 channel 缓存区已满或者没有缓存区,则当前 goroutine 会被阻塞。

被阻塞的 goroutine 将会被挂在 channel 的等待队列中:

  • 因读阻塞的 goroutine 会被向 channel 写入数据的 goroutine 唤醒。
  • 因写阻塞的 goroutine 会被向 channel 读数据的 goroutine 唤醒。

image.png

向 channel 写数据

向一个 channel 中写数据简单过程:

  1. 如果等待接收队列 recvq 不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从 recvq 取出G,并把数据写入,最后把该 G 唤醒,结束发送过程。
  2. 如果缓冲区有空余位置,将数据写入缓冲区,结束发送过程。
  3. 如果缓冲区没有空余位置,将待发送数据写入 G ,将当前 G 加入 sendq,进入睡眠,等待被读 goroutine 唤醒。

image.png

向 channel 读数据

向一个 channel 中读数据简单过程:

  1. 如果等待发送队列 sendq 不为空且没有缓冲区,直接从 sendq 中取出 G ,把 G 数据读出,最后把 G 唤醒,结束读取过程。
  2. 如果等待发送队列 sendq 不为空,此时说明缓冲区已满,说明此时缓冲区已满,从缓冲区中首部读出数据,把 G 中数据写入缓冲区尾部,把 G 唤醒,结束读取过程。
  3. 如果缓冲区有数据,则从缓冲区取出数据,结束读取过程。
  4. 将当前 goroutine 加入 recvq ,进入睡眠,等待被写 goroutine 唤醒。

image.png

四、总结

管道是 Go 语言并发模型的核心组成部分,理解其实现原理对于深入理解 Go 语言是非常有帮助的。管道的实现涉及到了许多计算机科学中的基本概念,例如队列、阻塞、唤醒等,通过学习管道,你也可以增进对这些概念的理解。

希望这篇文章能帮助你理解 Go 语言的管道实现原理。如果你有任何问题或者建议,欢迎在下面留言。在下次的文章中,我们将继续探讨 Go 语言的其他知识点。

请关注公众号【Java千练】,更多干货文章等你来看!

qrcode_for_gh_e39063348296_258.jpg