go并发的核心数据结构--channel

开篇:go并发的核心数据结构--channel

go语言并发的核心哲学是:不要通过共享内存来通信,而要通过通信来共享内存。channel(通道)就是go语言提供的协程间的通信方式,用来传递数据、同步状态、协调并发,从底层避免了共享内存带来的竞态问题,是 Go 原生并发编程的标准方案。

一、channel 基础语法

1. 声明与创建
  • 仅声明nil通道,读写都会永久阻塞
go 复制代码
var ch chan int
  • 创建无缓冲通道(同步)
go 复制代码
ch := make(chan int)
  • 创建有缓冲通道(异步),先入先出
go 复制代码
ch := make(chan int, 5) // 容量为5的有缓冲int通道
2. 三大核心操作
操作 语法 说明
发送 ch <- value 将数据写入通道
接收 value := <-ch 从通道读取数据
关闭 close(ch) 关闭通道

补充:接收操作支持双返回值写法 value, ok := <-ch,ok 为 false 表示通道已关闭且缓冲区已空。

二、核心分类:无缓冲通道 vs 有缓冲通道

这是 channel 最核心的分类,两者的行为模式和适用场景完全不同。

1. 无缓冲通道(同步通道)
  • 定义:顾名思义,无缓冲就是数据在经过通道时不能停止,此通道创建时没有容量,make(chan int)
  • 核心特性:简单来说,发送和接收的操作是同步完成,一方没有就绪,另一方就会阻塞。
  • 适用场景:goroutine 协程之间的同步等待、一对一即时通信。

下面看一个具体例子:用无缓冲通道实现主 goroutine 等待子 goroutine 完成任务

go 复制代码
func main() {
    done := make(chan struct{}) // 空结构体不占用内存,专用于传递信号

    go func() {
        // 子goroutine执行业务逻辑
        fmt.Println("子goroutine:任务执行中...")
        time.Sleep(1 * time.Second)
        fmt.Println("子goroutine:任务完成")
        done <- struct{}{} // 发送完成信号
    }()

    <-done // 阻塞等待,直到收到信号
    fmt.Println("主goroutine:收到完成信号,继续执行")
}
2. 有缓冲通道(异步通道)
  • 定义:顾名思义,有缓冲就是数据在经过通道时可以停留等待,此通道创建时指定的缓冲区容量决定可以停留的数据个数,make(chan int, 5)
  • 核心特性:缓冲区空时,可发送不可接收,接收会阻塞;缓冲区未满时,可发送可接收,不会阻塞;缓冲区满时,不可发送可接收,发送会阻塞。
  • 适用场景:生产者消费者解耦、并发限流、异步任务队列。

下面看一个具体例子:简单生产者消费者模型

go 复制代码
func main() {
    ch := make(chan int, 3) // 容量为3的缓冲通道

    // 生产者:发送数据
    go func() {
        for i := 1; i <= 5; i++ {
            ch <- i
            fmt.Printf("生产者:发送数据%d\n", i)
        }
        close(ch) // 发送完毕关闭通道
    }()

    // 消费者:接收数据
    for num := range ch { // for range 会持续监听ch管道,直到ch管道关闭且缓冲区为空
        fmt.Printf("消费者:收到数据%d\n", num)
    }
    fmt.Println("所有数据处理完成")
}
3. 单向通道

补充一点,Go 语言中默认的通道是双向通道,可以进行发送和接收操作,而为了提升代码安全性,Go 支持单向通道,限制通道在函数参数中只能发送或只能接收:

  • chan<- T:只写通道,只能发送数据,不能接收

  • <-chan T:只读通道,只能接收数据,不能发送

go 复制代码
// 生产者函数,参数为只写通道
func producer(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

// 消费者函数,参数为只读通道
func consumer(ch <-chan int) {
    for num := range ch {
        fmt.Println(num)
    }
}

三、select 多路并发控制

前面讲解了channle 通道的基本使用方法,在这里会展示,当需要同时监听多个通道的状态(比如同时等待多个任务结果、处理多路信号)时,使用 select 可以同时监听多个通道的读写事件,哪个通道就绪就执行对应分支,是 Go 并发控制的核心语法。

go 复制代码
func main() {
	// 使用select case多路并发控制
	ch1 := make(chan int)
	ch2 := make(chan int)
	select {
	case ch1 <- 1:
		fmt.Println("ch1可写")
	case val := <-ch2:
		fmt.Println("ch2可读", val)
	default:
		fmt.Println("其它情况")
	}
}
官方规范
  1. 就绪即执行:哪个 case 的通道操作不阻塞(就绪),就执行对应分支。

  2. 随机选择:多个 case 同时就绪时,随机选择一个执行,不会按顺序优先匹配。

  3. default 非阻塞:存在 default 分支时,所有通道都阻塞则执行 default,不会挂起 goroutine;没有 default 则整体阻塞。

  4. nil 通道忽略:nil 通道对应的 case 会永远阻塞,相当于被排除在监听之外。

  5. 禁用 fallthrough:每个 case 独立,不能穿透。

@小结:channel 规范

内置函数 len() 和 cap() 可以作用于通道,分别查询缓冲区中数据的个数、缓冲区的大小。

通道实现一种 FIFO(先入先出)的队列,数据总是按照写入的顺序流出通道。

goroutine 泄漏:goroutine 因为永久阻塞无法退出,占用的栈内存等资源无法释放,长期运行会耗尽系统资源。

1. 阻塞
  • 向 nil 通道发送或接收数据,永久阻塞。
  • 无缓冲通道,没有配对的接收 / 发送操作,对应端永久阻塞。
  • 有缓冲通道,缓冲区空,接收端阻塞;缓冲区满,发送端阻塞。
2. 通道关闭
  • 关闭操作只能由发送方执行,接收方不应关闭通道。
  • 重复关闭已关闭的通道、关闭nil通道都会触发panic
  • 向已关闭的通道发送数据,触发panic。
  • 向已关闭的通道接收数据,如缓冲区有数据则正常读取,无则返回对应类型零值,双返回值的 ok 为false。

四、实现原理

1. 数据结构

源码包中定义了通道的数据结构,channel 的底层是 hchan 结构体:

go 复制代码
type hchan struct {
	qcount   uint           // 当前队列元素数量
	dataqsiz uint           // 环形缓冲区总容量(make(chan T, N) 的N)
	buf      unsafe.Pointer // 环形缓冲区数组起始地址,仅带缓冲chan才有
	elemsize uint16         // 单个元素占用字节大小
	closed   uint16        // 是否关闭:0未关闭,1已关闭
	elemtype *_type        // 元素类型信息(GC、赋值、大小)
	sendx    uint          // 发送索引:下一个存入buf的位置
	recvx    uint          // 接收索引:下一个取出buf的位置

	// 等待队列:双向链表,阻塞的goroutine
	recvq waitq // 等待接收的G队列(<-ch 阻塞的G)
	sendq waitq // 等待发送的G队列(ch<-x 阻塞的G)

	lock mutex // 全局互斥锁,保护所有字段并发修改
}

可以看到 hchan 结构体的字段还是比较多的,但在这里我们也不是每个字段都需要深入了解。

2. 无缓冲通道

比如,我们创建的通道是无缓冲 Channel ( make(chan int) ),它没有缓冲区,因此只需要注意 recvqsendq 这两个字段。

接着,先执行发送操作,这时如果没有对应接收操作,那么当前协程(G)就会加入 sendq 阻塞,等待接收 G 唤醒;

先执行接收操作,这时如果没有对应发送操作,那么当前协程(G)就会加入 recvq 阻塞,等待发送 G 唤醒。

3. 有缓冲通道

而对于有缓冲 Channel ( make(chan int, 5) ),则要复杂一些:

  • 环形队列

chan 内部实现了一个环形队列作为其缓冲区,队列的长度(dataqsiz)是在创建 chan 时指定的。

下图展示了一个可以缓存 5 个元素的通道:

图中的缓冲队列缓存了 qcount = 2 个元素,dataqsiz = 5 代表缓冲队列可以缓存 5 个元素,buf 则是指向这个缓冲队列的起始地址;

sendx 代表下一个写入数据的索引,recvx 代表第一个读取数据的索引。

  • 写入数据

向一个通道中写入数据的简单过程如下:

  1. 如果缓冲区中有空余位置,则将数据写入缓冲区,结束发送过程;
  2. 如果缓冲区中没有空余位置,则将当前协程加入 sendq 队列,进入睡眠并等待被读协程唤醒。

但如果缓冲区为空且 recvq 队列中有协程在等待,此时会将数据直接传递给 recvq 队列中的第一个协程,而不必再写入缓冲区。

  • 读取数据

从一个通道中读取数据的简单过程如下:

  1. 如果缓冲区中有数据,则从缓冲区中取出数据,结束读取过程;
  2. 如果缓冲区中没有数据,则将当前协程加入 recvq 队列,进入睡眠并等待被写协程唤醒。

但如果没有缓冲区且 sendq 队列中有协程在等待,此时会直接从 sendq 队列中的第一个协程中读取数据。

  • 关闭通道

队列中有协程在等待,此时会将数据直接传递给 recvq 队列中的第一个协程,而不必再写入缓冲区。

  • 读取数据

从一个通道中读取数据的简单过程如下:

  1. 如果缓冲区中有数据,则从缓冲区中取出数据,结束读取过程;
  2. 如果缓冲区中没有数据,则将当前协程加入 recvq 队列,进入睡眠并等待被写协程唤醒。

但如果没有缓冲区且 sendq 队列中有协程在等待,此时会直接从 sendq 队列中的第一个协程中读取数据。

  • 关闭通道

会把 recvq 中的协程全部唤醒,并返回对应数据的零值;把 sendq 中协程全部唤醒,但都会触发panic

相关推荐
AI人工智能集结号1 天前
AI回答采集数据清洗:Python实现空回答、拒绝与无关样本识别
开发语言·人工智能·python
jaysee-sjc1 天前
【JavaWeb】JDBC与Mybatis零基础入门详解
java·开发语言·前端·mybatis
星恒随风1 天前
C++ STL 详解:map 与 multimap 的使用、operator[]
开发语言·c++·笔记·学习·状态模式
森叶1 天前
用 DataTransfer 模拟“用户选择本地文件“:不用系统对话框,也能给 `<input type=“file“>` 塞进真实文件
java·开发语言·log4j
鲜花飘飘扬1 天前
C# 与 .NET 跨平台开发实战(第一章:开发环境搭建与.NET概述-上篇)
开发语言·c#·.net
嵌入式小能手1 天前
飞凌嵌入式ElfBoard-spi编程示例之spi通信编程示例
开发语言·c++
Li Ming&1 天前
Python连接MySQL数据库:从入门到实战(PyMySQL详解
开发语言·数据库·python·mysql
long3161 天前
LLD与HLD
java·数据结构·spring boot
倒流时光三十年1 天前
第一阶段 06 · 常用查询类速查与示例(每类一个可运行例子)
开发语言·windows·python
霸道流氓气质1 天前
接口安全三层防护体系 — 从原理到实现
开发语言·安全·php