golang中Channel详解

1. 为什么需要 Channel?(设计目的)

在传统编程语言(如 Java、C++)中,多线程并发编程通常使用 共享内存,需要使用 互斥锁(Mutex) 或 条件变量(Cond) 进行同步,避免数据竞争:

  • 共享内存:多个线程访问同一块内存,必须加锁保护。
  • 锁的缺点:
    • 代码复杂,容易出现 死锁、饥饿、优先级反转。
    • 性能开销大,需要 CPU 维护锁状态。

Go 采用 CSP 模型 (参考质料:(Go并发原理 [ 菜刚RyuGou的博客 ]))

Go 语言设计者(Rob Pike)认为:

“不要通过共享内存来通信,而是应该通过通信来共享内存。”

即:

  1. Goroutine 之间不共享数据,而是通过 channel 传递数据,避免竞争条件(Race Condition)。
  2. 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) 流程
  1. 加锁

    • 对 hchan.lock 加锁,保证后续操作的原子性。

    • 如果 Channel 已关闭,触发 panic: send on closed channel

  2. 快速路径 (Fast Path)

    • 直接投递:如果接收队列 recvq 非空,直接将数据拷贝给第一个等待的接收者,并唤醒该 goroutine。

    • 缓冲写入:如果缓冲区未满,将数据存入 buf,更新 qcount 和 sendx

  3. 慢速路径 (Slow Path)

    • 如果缓冲区已满或无缓冲 Channel,将当前 goroutine 加入 sendq 队列。

    • 调用 gopark 挂起当前 goroutine,释放锁,进入阻塞状态。

  4. 解锁

    • 操作完成后释放 hchan.lock

2、接收操作 (<-ch) 流程
  1. 加锁

    • 对 hchan.lock 加锁。

    • 如果 Channel 已关闭且缓冲区为空,返回零值和 ok=false

  2. 快速路径 (Fast Path)

    • 直接接收:如果发送队列 sendq 非空,直接从第一个等待的发送者获取数据,并唤醒该 goroutine。

    • 缓冲读取:如果缓冲区非空,从 buf 中取出数据,更新 qcount 和 recvx

  3. 慢速路径 (Slow Path)

    • 如果缓冲区为空或无缓冲 Channel,将当前 goroutine 加入 recvq 队列。

    • 调用 gopark 挂起当前 goroutine,释放锁,进入阻塞状态。

  4. 解锁

    • 操作完成后释放 hchan.lock

3、关闭操作 (close(ch)) 流程
  1. 加锁

    • 对 hchan.lock 加锁。

    • 如果 Channel 已关闭,触发 panic: close of closed channel

  2. 标记关闭状态

    • 设置 hchan.closed = 1

  3. 唤醒所有等待的 goroutine

    • 接收者:收到零值和 ok=false

    • 发送者:触发 panic: send on closed channel

  4. 清理资源

    • 释放缓冲区内存(如果有)。

  5. 解锁

    • 释放 hchan.lock

4、底层流程示意图
+-----------------------+
|       hchan           |
|-----------------------|
| buf → [][][*][][]     | 环形缓冲区
| sendq → G1 → G2       | 等待发送的 goroutine
| recvq → G3 → G4       | 等待接收的 goroutine
+-----------------------+

  • 发送阻塞:当 buf 满时,G1 和 G2 加入 sendq

  • 接收阻塞:当 buf 空时,G3 和 G4 加入 recvq

  • 唤醒机制:当有新的接收者/发送者时,优先处理等待队列中的 goroutine。

5、关键优化点
  1. 无锁快速路径

    • 在无竞争的情况下(如无缓冲 Channel 直接匹配发送/接收者),操作无需锁竞争。

  2. 批量唤醒

    • 当缓冲区有空间时,唤醒多个等待的发送者(反之亦然)。

  3. 内存复用

    • 已分配的缓冲区内存会被复用,减少 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) 中,并且被挂起来,直到对应的条件满足时(例如缓冲区有空间),它会被唤醒并继续执行。

 

注意:处于等待队列中的协程会在其他协程操作管道时被唤醒,具体如下,

  1. 因读阻塞的协程会被向管道写人数据的协程唤醒。
  2. 因写阻塞的协程会被从管道读数据的协程唤醒。

注意:一般不会出现 

读消息协程队列(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 的 非阻塞特性 体现在:

  1. 随机选择一个就绪的 case

    • 如果多个 case 同时就绪,select 会随机选择一个执行。

    • 如果没有 case 就绪,且存在 default 分支,则执行 default

  2. 避免阻塞

    • 如果某个 case 的操作会阻塞(如无缓冲 Channel 的发送或接收),select 会跳过该 case,选择其他就绪的 case 或执行 default


三、select 如何避免死锁

通过 select 的非阻塞特性,可以在无缓冲 Channel 中实现以下逻辑:

  1. 尝试发送:如果 Channel 有接收者,发送成功。

  2. 尝试接收:如果 Channel 有发送者,接收成功。

  3. 默认操作:如果发送和接收都无法立即完成,执行 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 的工作原理
  1. 监听多个 Channel

    • select 会同时监听所有 case 中的 Channel 操作。

    • 如果某个 Channel 操作可以立即完成(发送或接收),则执行该 case。

  2. 随机选择

    • 如果多个 case 同时就绪,select 会随机选择一个执行(避免饥饿问题)。

  3. 默认分支

    • 如果所有 case 都未就绪,且存在 default 分支,则执行 default

五、select 的底层实现

select 的底层实现涉及以下步骤:

  1. 遍历所有 case

    • 检查每个 case 中的 Channel 是否可立即操作。

    • 如果某个 Channel 可操作,则执行对应的 case。

  2. 挂起 goroutine

    • 如果没有 case 就绪,且没有 default 分支,则将当前 goroutine 挂起,加入 Channel 的等待队列(sendq 或 recvq)。

  3. 唤醒 goroutine

    • 当某个 Channel 就绪时,唤醒对应的 goroutine。


六、总结
机制无缓冲 Channelselect 的作用
发送阻塞无接收者时阻塞尝试发送,失败则跳过或执行 default
接收阻塞无发送者时阻塞尝试接收,失败则跳过或执行 default
死锁风险同一协程连续读写会导致死锁通过非阻塞机制避免死锁
适用场景需要严格同步的简单场景需要非阻塞或多路复用的复杂场景

通过 select,可以避免无缓冲 Channel 的死锁问题,同时实现更灵活的并发控制。

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值