开篇: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("其它情况")
}
}
官方规范
-
就绪即执行:哪个 case 的通道操作不阻塞(就绪),就执行对应分支。
-
随机选择:多个 case 同时就绪时,随机选择一个执行,不会按顺序优先匹配。
-
default 非阻塞:存在 default 分支时,所有通道都阻塞则执行 default,不会挂起 goroutine;没有 default 则整体阻塞。
-
nil 通道忽略:nil 通道对应的 case 会永远阻塞,相当于被排除在监听之外。
-
禁用 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) ),它没有缓冲区,因此只需要注意 recvq 和 sendq 这两个字段。
接着,先执行发送操作,这时如果没有对应接收操作,那么当前协程(G)就会加入 sendq 阻塞,等待接收 G 唤醒;
先执行接收操作,这时如果没有对应发送操作,那么当前协程(G)就会加入 recvq 阻塞,等待发送 G 唤醒。
3. 有缓冲通道
而对于有缓冲 Channel ( make(chan int, 5) ),则要复杂一些:
- 环形队列
chan 内部实现了一个环形队列作为其缓冲区,队列的长度(dataqsiz)是在创建 chan 时指定的。
下图展示了一个可以缓存 5 个元素的通道:

图中的缓冲队列缓存了 qcount = 2 个元素,dataqsiz = 5 代表缓冲队列可以缓存 5 个元素,buf 则是指向这个缓冲队列的起始地址;
sendx 代表下一个写入数据的索引,recvx 代表第一个读取数据的索引。
- 写入数据
向一个通道中写入数据的简单过程如下:
- 如果缓冲区中有空余位置,则将数据写入缓冲区,结束发送过程;
- 如果缓冲区中没有空余位置,则将当前协程加入
sendq队列,进入睡眠并等待被读协程唤醒。
但如果缓冲区为空且 recvq 队列中有协程在等待,此时会将数据直接传递给 recvq 队列中的第一个协程,而不必再写入缓冲区。
- 读取数据
从一个通道中读取数据的简单过程如下:
- 如果缓冲区中有数据,则从缓冲区中取出数据,结束读取过程;
- 如果缓冲区中没有数据,则将当前协程加入
recvq队列,进入睡眠并等待被写协程唤醒。
但如果没有缓冲区且 sendq 队列中有协程在等待,此时会直接从 sendq 队列中的第一个协程中读取数据。
- 关闭通道
队列中有协程在等待,此时会将数据直接传递给 recvq 队列中的第一个协程,而不必再写入缓冲区。
- 读取数据
从一个通道中读取数据的简单过程如下:
- 如果缓冲区中有数据,则从缓冲区中取出数据,结束读取过程;
- 如果缓冲区中没有数据,则将当前协程加入
recvq队列,进入睡眠并等待被写协程唤醒。
但如果没有缓冲区且 sendq 队列中有协程在等待,此时会直接从 sendq 队列中的第一个协程中读取数据。
- 关闭通道
会把 recvq 中的协程全部唤醒,并返回对应数据的零值;把 sendq 中协程全部唤醒,但都会触发panic