1. 为什么需要 Channel?(设计目的)
在传统编程语言(如 Java、C++)中,多线程并发编程通常使用 共享内存,需要使用 互斥锁(Mutex) 或 条件变量(Cond) 进行同步,避免数据竞争:
- 共享内存:多个线程访问同一块内存,必须加锁保护。
- 锁的缺点:
- 代码复杂,容易出现 死锁、饥饿、优先级反转。
- 性能开销大,需要 CPU 维护锁状态。
Go 采用 CSP 模型 (参考质料:(Go并发原理 [ 菜刚RyuGou的博客 ]))
Go 语言设计者(Rob Pike)认为:
“不要通过共享内存来通信,而是应该通过通信来共享内存。”
即:
- Goroutine 之间不共享数据,而是通过 channel 传递数据,避免竞争条件(Race Condition)。
- channel 内置同步机制,不需要手动加锁。
2. Channel 的基本用法
2.1 创建 Channel
channel 是一个 类型安全 的管道,必须指定数据类型:
ch := make(chan int) // 创建一个 int 类型的 channel
bufCh := make(chan int, 5) // 缓冲容量为 5
默认情况下,channel 是无缓冲的,数据发送和接收必须同步进行。
2.2 发送和接收数据
package main
import "fmt"
func main() {
ch := make(chan int) // 创建 channel
go func() {
ch <- 42 // 发送数据
}()
value := <-ch // 接收数据
fmt.Println("Received:", value)
}
注意:
ch 发送数据,如果没有接收端,会阻塞。
接收数据,如果没有发送端,会阻塞。
3. 关闭 Channel
close(ch)
val, ok := <-ch // ok == false 表示 Channel 已关闭
3、Channel 的底层结构
Channel 的底层实现是一个名为 hchan 的结构体(定义在 runtime/chan.go 中)
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 // 互斥锁(非 Go 的 sync.Mutex)
}
读消息协程队列(recvq) 和 写消息协程队列(sendq) 分别是接收(结构体(sudog)的队列。 其类型也是一个结构体
type waitq struct {
first *sudog
last *sudog
}
关键组件解析:
-
环形缓冲区 (
环形缓冲区的核心是一个固定大小的数组,用于存储数据。这个数组的大小在创建通道时确定,并且不能动态扩展。buf
): 用于存储缓冲 Channel 的数据(无缓冲 Channel 的buf
为nil
) -
等待队列 (
sendq
/recvq
): 双向链表,存储因 Channel 满/空而被阻塞的 goroutine。 -
锁 (
lock
): 保证 Channel 操作的原子性,非 Go 的sync.Mutex
,而是 runtime 内部的轻量级锁。
4、Channel 操作底层流程
1、发送操作 (ch <- data
) 流程
-
加锁:
-
对
hchan.lock
加锁,保证后续操作的原子性。 -
如果 Channel 已关闭,触发 panic:
send on closed channel
。
-
-
快速路径 (Fast Path):
-
直接投递:如果接收队列
recvq
非空,直接将数据拷贝给第一个等待的接收者,并唤醒该 goroutine。 -
缓冲写入:如果缓冲区未满,将数据存入
buf
,更新qcount
和sendx
。
-
-
慢速路径 (Slow Path):
-
如果缓冲区已满或无缓冲 Channel,将当前 goroutine 加入
sendq
队列。 -
调用
gopark
挂起当前 goroutine,释放锁,进入阻塞状态。
-
-
解锁:
-
操作完成后释放
hchan.lock
。
-
2、接收操作 (<-ch
) 流程
-
加锁:
-
对
hchan.lock
加锁。 -
如果 Channel 已关闭且缓冲区为空,返回零值和
ok=false
。
-
-
快速路径 (Fast Path):
-
直接接收:如果发送队列
sendq
非空,直接从第一个等待的发送者获取数据,并唤醒该 goroutine。 -
缓冲读取:如果缓冲区非空,从
buf
中取出数据,更新qcount
和recvx
。
-
-
慢速路径 (Slow Path):
-
如果缓冲区为空或无缓冲 Channel,将当前 goroutine 加入
recvq
队列。 -
调用
gopark
挂起当前 goroutine,释放锁,进入阻塞状态。
-
-
解锁:
-
操作完成后释放
hchan.lock
。
-
3、关闭操作 (close(ch)
) 流程
-
加锁:
-
对
hchan.lock
加锁。 -
如果 Channel 已关闭,触发 panic:
close of closed channel
。
-
-
标记关闭状态:
-
设置
hchan.closed = 1
。
-
-
唤醒所有等待的 goroutine:
-
接收者:收到零值和
ok=false
。 -
发送者:触发 panic:
send on closed channel
。
-
-
清理资源:
-
释放缓冲区内存(如果有)。
-
-
解锁:
-
释放
hchan.lock
。
-
4、底层流程示意图
+-----------------------+
| hchan |
|-----------------------|
| buf → [][][*][][] | 环形缓冲区
| sendq → G1 → G2 | 等待发送的 goroutine
| recvq → G3 → G4 | 等待接收的 goroutine
+-----------------------+
-
发送阻塞:当
buf
满时,G1 和 G2 加入sendq
。 -
接收阻塞:当
buf
空时,G3 和 G4 加入recvq
。 -
唤醒机制:当有新的接收者/发送者时,优先处理等待队列中的 goroutine。
5、关键优化点
-
无锁快速路径:
-
在无竞争的情况下(如无缓冲 Channel 直接匹配发送/接收者),操作无需锁竞争。
-
-
批量唤醒:
-
当缓冲区有空间时,唤醒多个等待的发送者(反之亦然)。
-
-
内存复用:
-
已分配的缓冲区内存会被复用,减少 GC 压力。
-
6、特殊场景处理
场景 | 行为 |
---|---|
向已关闭 Channel 发送 | 触发 panic: send on closed channel |
从已关闭 Channel 接收 | 返回零值,ok=false (缓冲区为空时) |
重复关闭 Channel | 触发 panic: close of closed channel |
Select 多路操作 | 通过 scase 结构体实现,随机选择一个就绪的 case(避免饥饿) |
7、性能影响
-
无缓冲 Channel:每次操作涉及 goroutine 切换,适合低频同步。
-
缓冲 Channel:批量处理数据,减少锁竞争,适合高频异步。
-
锁粒度:
hchan.lock
保护整个结构,高并发下可能成为瓶颈。
5、Channel 的三种状态
状态 | 无缓冲 Channel | 有缓冲 Channel(未满) | 有缓冲 Channel(已满) |
发送操作 | 阻塞直到有接收者 | 直接写入缓冲区 | 阻塞直到有空间 |
接收操作 | 阻塞直到有发送者 | 直接从缓冲区读取 | 阻塞直到有新数据 |
关闭后 | 接收者立即返回零值 | 接收者读完缓冲区后返回零值 | 同上 |
7.阻塞机制
一个协程向一个 管道读数据,如果管道缓冲区为空或者没有缓冲区,当前的协程会被加入到 读消息协程队列(recvq)中,并且被挂起来,直到对应的条件满足时(例如缓冲区有数据),它会被唤醒并继续执行;
一个协程向一个管道写数据,如果管道缓冲区已经满了或者没有缓冲区,当前的协程会被加入到 写消息协程队列(sendq) 中,并且被挂起来,直到对应的条件满足时(例如缓冲区有空间),它会被唤醒并继续执行。
注意:处于等待队列中的协程会在其他协程操作管道时被唤醒,具体如下,
- 因读阻塞的协程会被向管道写人数据的协程唤醒。
- 因写阻塞的协程会被从管道读数据的协程唤醒。
注意:一般不会出现
读消息协程队列(recvq) 和 写消息协程队列(sendq) 中同时有协程排队的情况,只有一个例外,那就是同一个协程使用 select 语句向管道一边写数据、一边读数据,此时协程会分别位于两个等待队列中。
8.发送和接受(参考视频:(https://www.youtube.com/watch?v=KBZlN0izeiY))
func main(){
...
for _, task := range hellaTasks {
ch <- task //sender
}
...
}
//G2
func worker(ch chan Task){
for {
//接受任务
task := <- ch //recevier
process(task)
}
}
其中G1是发送者,G2是接收,因为ch是长度为3的带缓冲channel,初始的时候hchan结构体的buf为空,sendx和recvx都为0,当G1向ch里发送数据的时候,会首先对buf加锁,然后将要发送的数据copy到buf里,并增加sendx的值,最后释放buf的锁。然后G2消费的时候首先对buf加锁,然后将buf里的数据copy到task变量对应的内存里,增加recvx,最后释放锁。整个过程,G1和G2没有共享的内存,底层通过hchan结构体的buf,使用copy内存的方式进行通信,最后达到了共享内存的目的,这完全符合CSP的设计理念:
Do not comminute by sharing memory;instead, share memory by communicating
9.无缓冲 Channel 的死锁问题
在 Go 中,
select
是一种多路复用的机制,允许同时监听多个 Channel 的操作(发送或接收)。通过select
的 非阻塞特性,可以避免无缓冲 Channel 的死锁问题。以下是详细解释:
一、无缓冲 Channel 的死锁问题
无缓冲 Channel 的特点是:
-
发送操作:必须有接收者,否则发送者会阻塞。
-
接收操作:必须有发送者,否则接收者会阻塞。
如果在同一个 goroutine 中连续进行发送和接收操作,会导致死锁:
ch := make(chan int)
ch <- 42 // 阻塞(无接收者)
val := <-ch // 永远不会执行
二、select
的非阻塞特性
select
的 非阻塞特性 体现在:
-
随机选择一个就绪的 case:
-
如果多个 case 同时就绪,
select
会随机选择一个执行。 -
如果没有 case 就绪,且存在
default
分支,则执行default
。
-
-
避免阻塞:
-
如果某个 case 的操作会阻塞(如无缓冲 Channel 的发送或接收),
select
会跳过该 case,选择其他就绪的 case 或执行default
。
-
三、select
如何避免死锁
通过 select
的非阻塞特性,可以在无缓冲 Channel 中实现以下逻辑:
-
尝试发送:如果 Channel 有接收者,发送成功。
-
尝试接收:如果 Channel 有发送者,接收成功。
-
默认操作:如果发送和接收都无法立即完成,执行
default
分支。
func main() {
ch := make(chan int)
go func() {
time.Sleep(time.Second) // 模拟延迟
val := <-ch
fmt.Println("Received:", val)
}()
select {
case ch <- 42: // 尝试发送
fmt.Println("Sent")
case val := <-ch: // 尝试接收
fmt.Println("Received:", val)
default: // 默认操作
fmt.Println("No activity")
}
time.Sleep(2 * time.Second) // 等待 goroutine 完成
}
输出结果
-
如果接收者 goroutine 在
select
执行时已经就绪,输出:复制
Sent Received: 42
-
如果接收者 goroutine 未就绪,输出:
复制
No activity
四、select
的工作原理
-
监听多个 Channel:
-
select
会同时监听所有 case 中的 Channel 操作。 -
如果某个 Channel 操作可以立即完成(发送或接收),则执行该 case。
-
-
随机选择:
-
如果多个 case 同时就绪,
select
会随机选择一个执行(避免饥饿问题)。
-
-
默认分支:
-
如果所有 case 都未就绪,且存在
default
分支,则执行default
。
-
五、select
的底层实现
select
的底层实现涉及以下步骤:
-
遍历所有 case:
-
检查每个 case 中的 Channel 是否可立即操作。
-
如果某个 Channel 可操作,则执行对应的 case。
-
-
挂起 goroutine:
-
如果没有 case 就绪,且没有
default
分支,则将当前 goroutine 挂起,加入 Channel 的等待队列(sendq
或recvq
)。
-
-
唤醒 goroutine:
-
当某个 Channel 就绪时,唤醒对应的 goroutine。
-
六、总结
机制 | 无缓冲 Channel | select 的作用 |
---|---|---|
发送阻塞 | 无接收者时阻塞 | 尝试发送,失败则跳过或执行 default |
接收阻塞 | 无发送者时阻塞 | 尝试接收,失败则跳过或执行 default |
死锁风险 | 同一协程连续读写会导致死锁 | 通过非阻塞机制避免死锁 |
适用场景 | 需要严格同步的简单场景 | 需要非阻塞或多路复用的复杂场景 |
通过 select
,可以避免无缓冲 Channel 的死锁问题,同时实现更灵活的并发控制。