channel原理解析(流程图+源码解读)

1.Channel的基本概念及使用

在Channel模块所有chan源码都是Go版本1.20.10

Go 奉行通过通信来共享内存,而不是共享内存来通信。所以,channel 是协程之间互相通信的通道,协程之间可以通过它发送消息和接收消息。

通道是进程内的通信方式,因此通过通道传递对象的行为与函数调用时参数传递行为比较一致,比如也可以传递指针等。

通道消息传递与消息类型也有关系,一个通道只能传递(发送send或接收receive)类型的值,这需要在声明通道时指定。

默认情况下,通道是阻塞的 (叫做无缓冲的通道)。

  1. 创建channel
Go 复制代码
// 无缓冲 channel
ch1 := make(chan int)

// 有缓冲 channel
ch2 := make(chan string, 5)

无缓冲 channel:发送必须等接收,接收必须等发送(同步特性)

有缓冲 channel:允许在缓冲区未满时发送立即返回(异步特性)

  1. 发送与接收
Go 复制代码
ch := make(chan int)

// 发送
go func() {
    ch <- 10 // 把 10 发送到 channel
}()

// 接收
v := <-ch
fmt.Println(v)
  • chan<- 表示数据进入通道,要把数据写进通道,对于调用者就是发送

  • <-chan 表示数据从通道出来,对于调用者就是得到通道的数据,当然就是接收

  1. 关闭channel
YAML 复制代码
close(ch)

关闭 channel

  • 继续发送会 panic。

  • 继续接收会返回零值,并且 ok = false

Go 复制代码
v, ok := <-ch
if !ok {
    fmt.Println("channel closed")
}
  1. for-range读取
Go 复制代码
for v := range ch {
    fmt.Println(v)
}

遍历 channel,直到 channel 被关闭

  1. select 多路复用
Go 复制代码
select {
case v := <-ch1:
    fmt.Println("收到 ch1:", v)
case ch2 <- 20:
    fmt.Println("发送到 ch2")
default:
    fmt.Println("都没准备好")
}

select 类似于 switch,但用于处理 多个channel操作

default 分支避免阻塞

1.1总结

  • 无缓冲的通道(unbuffered channel)是指在接收前没有能力保存任何值的通道。

这种类型的通道要求发送协程和接收协程同时准备好,才能完成发送和接收操作。如果两个协程没有同时准备好,通道会导致先执行发送或接收操作的协程阻塞等待。

这种对通道进行发送和接收的交互行为本身就是同步的。

  • 有缓冲的通道(buffered channel)是一种在被接收前能存储一个或者多个值的通道。

这种类型的通道并不强制要求协程之间必须同时完成发送和接收。通道会阻塞发送和接收动作的条件也会不同。只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。

这导致有缓冲的通道和无缓冲的通道之间的一个很大的不同:无缓冲的通道保证进行发送和接收的协程会在同一时间进行数据交换;有缓冲的通道没有这种保证。

如果给定了一个缓冲区容量,通道就是异步的。只要缓冲区有未使用空间用于发送数据,或还包含可以接收的数据,那么其通信就会无阻塞地进行。

可以通过内置的close函数来关闭通道实现。

  • 通道不需要经常去关闭,只有当没有任何可发送数据时才去关闭通道;

  • 关闭通道后,无法向通道再发送数据(引发panic 错误后导致接收立即返回零值);

  • 关闭通道后,可以继续向通道接收数据,不能继续发送数据;

  • 对于nil 通道,无论收发都会被阻塞。

2.Channel的原理分析

在了解 channel 的底层实现之前,我们可以先从它的使用方式出发,来大致推测它可能的数据结构:

  1. chan 本身 不管底层怎么实现,make(chan T) 的时候,Go 肯定会在内存中开辟一块空间,生成一个 chan 对象,并返回它的引用。
  2. 有无缓冲 从使用上看,channel 分为无缓冲和有缓冲两种:
    • 无缓冲 channel 的特性是:发送和接收必须配对,否则就会阻塞。
    • 有缓冲 channel 则可以在一定容量内先存数据,只有缓冲区满了才阻塞。所以我们可以推断:有缓冲的 channel 一定在内部维护了一块存储区域(buf)来保存数据,而 chan 本身应该持有一个指向这块缓冲区的指针。
  3. FIFO 特性 我们知道有缓冲 channel 在接收时一定是"先进先出"的,所以缓冲区的实现一定是能保证 FIFO 的数据结构。最常见的就是 环形数组或者双向链表,配合头尾指针,就能高效地进行入队和出队。
  4. 阻塞与唤醒 channel 最重要的特性之一是"阻塞"。如果发送时没有接收方,goroutine 会被阻塞;如果接收时没有数据,goroutine 也会被阻塞。并且,当 close(chan) 时,所有等待接收的 goroutine 都会被唤醒。这说明:channel 内部必须维护一个队列,记录所有等待发送和等待接收的 goroutine,并且能够在合适的时机挂起或唤醒它们。

2.1Channel的数据结构

根据上述的描述,其实也能够分析出chan大致的数据结构,实际上chan的数据结构也和上述大差不差hchan就是chan本身的数据结构(位置:runtime/chan.go)

Go 复制代码
type hchan struct {
        qcount   uint           // chan中元素数量
        dataqsiz uint           // chan中的元素容量(底层循环数组的长度)
        buf      unsafe.Pointer // 指向底层循环数组的指针 只针对有缓冲的channel
        elemsize uint16 // chan 中元素大小(存储的这种数据类型的大小)
        closed   uint32  // chan 是否被关闭的标志
        elemtype *_type // chan 中元素类型
        sendx    uint   // 已发送元素在循环数组中的下标索引
        recvx    uint   // 已接收元素在循环数组中的下标索引
        recvq    waitq  // 等待接收的 goroutine 队列
        sendq    waitq  // 等待发送的 goroutine 队列

        lock mutex //互斥锁
}

下面来看一下waitq这个结构体,本质上就是去绑定读写协程的,和上面分析的一样会有一个头尾指针去控制所有被阻塞的协程挂起和运行

Go 复制代码
type waitq struct {
    first *sudog
    last  *sudog
}

sudog结构体才是真正对goroutine的包装,本质上就是通过一个双向链表去维护了goroutine

Go 复制代码
type sudog struct {

    g *g // 真正的协程

    next *sudog // 指向下一个元素的指针
    prev *sudog // 指向上一个元素的指针
    
    isSelect bool // 这个属性来标识这个goroutine是否是多路复用的

    c    *hchan // 回指向从属的channel
}

2.2Channel的构建

Channel创建主要就是通过makechan来做的

Go 复制代码
func makechan(t *chantype, size int) *hchan {
    elem := t.elem

    /**
    在这里做了一系列的校验,这边就略过了
    **/
    if elem.size >= 1<<16 {
       throw("makechan: invalid channel element type")
    }
    if hchanSize%maxAlign != 0 || elem.align > maxAlign {
       throw("makechan: bad alignment")
    }

    // 1.在这里会计算出缓冲区的大小,其实就是元素的大小*元素的容量,当然如果没有设置缓冲区大小或者元素的类型是struct{}那么mem就是0
    mem, overflow := math.MulUintptr(elem.size, uintptr(size))
    if overflow || mem > maxAlloc-hchanSize || size < 0 {
       panic(plainError("makechan: size out of range"))
    }
    
    // 2.开始真正的chan初始化
    var c *hchan
    switch {
    
    // 2.1走进这个case就表示要么是无缓冲区的channel要么是缓冲区的元素内存占用是0(struct{})
    case mem == 0:
       // 这里就只分配channel的空间
       c = (*hchan)(mallocgc(hchanSize, nil, true))
       c.buf = c.raceaddr()
       
    // 2.2进入到这个case里面表示需要分配缓冲区的大小,并且缓冲区的元素是像int,string这种简单数据类型的,并不是指针或者说是struct这种类型的
    case elem.ptrdata == 0:
       // 为chan分配它自己的内存大小,并且加上缓冲区的内存大小
       c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
       // 这里就是将chan中指向缓冲区的指针初始化,让他指向缓冲区起始位置
       c.buf = add(unsafe.Pointer(c), hchanSize)
       
    // 2.3进入到这个分支就表示需要分配缓冲区,并且缓冲区的元素是复杂数据类型的或者是指针类型的
    default:
       // 先分配channel的内存地址
       c = new(hchan)
       // 再分配缓冲区的内存地址,并且让buf指向这个缓冲区
       c.buf = mallocgc(mem, elem, true)
    }

    // 3.填充channel元数据
    c.elemsize = uint16(elem.size)
    c.elemtype = elem
    c.dataqsiz = uint(size)
    lockInit(&c.lock, lockRankHchan)

    if debugChan {
       print("makechan: chan=", c, "; elemsize=", elem.size, "; dataqsiz=", size, "\n")
    }
    return c
}

注意: 不包含指针的元素: hchan 结构体和缓冲区一次性分配,内存布局更加紧凑、效率更高。 包含指针的元素: 由于需要让垃圾回收器跟踪指针,hchan 结构体和缓冲区分开分配,内存分配相对复杂一些,但这是为了正确处理指针和垃圾回收。

2.2.1总结

makechan 的主要工作:

  1. 检查: 元素大小和对齐是否合法

  2. 计算****内存:元素大小 × 缓冲区容量

  3. 分配****内存:根据元素是否含指针选择分配策略

    • 零大小 → 直接分配 hchan

    • 无指针元素 → 一次性分配 hchan + buf

    • 有指针元素 → 分开分配,便于 GC

  4. 初始化 hchan:存储元素大小、类型、容量、锁等信息

  5. 返回 hchan,供上层使用

2.3Channel发送数据

下面channel发送数据会有阻塞和非阻塞模式,在应用层面先解释一下什么是阻塞和非阻塞模式 阻塞/非阻塞是调用者决定的:

  • 普通语句 → 阻塞模式
  • select + default → 非阻塞模式
  • select 没有 default,但多个 case → 会阻塞直到有一个 case 准备好
Go 复制代码
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {

    // 1.首先判断当前channel是否初始化,没有初始化的就会将当前协程挂起
    if c == nil {
        // 1.1这个if其实就是判断是否要阻塞当前协程,主要的应用场景就是多路复用的情况下,比如说select{}下你不可能直接阻塞掉其中一个分支,导致其他的case不能够判断
       if !block {
          return false
       }
       // 1.2真正的挂起协程操作
       gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
       throw("unreachable")
    }

    // 2.不加锁下面的一个逻辑判断,如果是非阻塞模式并且channel没有关闭并且缓冲区已满直接返回false
    if !block && c.closed == 0 && full(c) {
       return false
    }
    
    // 3.下面是会对临界资源进行操作,所以一开始就会加锁
    lock(&c.lock)

    // 3.1判断当前channel是否关闭
    if c.closed != 0 {
       unlock(&c.lock)
       panic(plainError("send on closed channel"))
    }

    // 3.2判断当前是否有读阻塞的协程,如果有的话就直接将数据拷贝给读的协程,并且重新唤醒这个阻塞的协程
    if sg := c.recvq.dequeue(); sg != nil {
       send(c, sg, ep, func() { unlock(&c.lock) }, 3)
       return true
    }

    // 3.3判断当前的缓存池是否已满,如果没有满就直接往缓存池子里面写入数据
    if c.qcount < c.dataqsiz {
       qp := chanbuf(c, c.sendx)
       if raceenabled {
          racenotify(c, c.sendx, nil)
       }
       typedmemmove(c.elemtype, qp, ep)
       c.sendx++
       if c.sendx == c.dataqsiz {
          c.sendx = 0
       }
       c.qcount++
       unlock(&c.lock)
       return true
    }

    // 3.4判断是否为阻塞的channel,并且此时的缓冲区已满,如果不是的话直接返回
    if !block {
       unlock(&c.lock)
       return false
    }

    // 3.5上面的判断情况都不是的话,就是当前channel有缓存,但是缓存已经写满了的情况
    // 下面的代码就是获取当前协程并且将协程放到写的双向链表中去,并且将这个协程挂起
    
    gp := getg()
    mysg := acquireSudog()
    mysg.elem = ep
    mysg.g = gp
    mysg.c = c
    
    // 3.5.1将mysg(简单说就是当前的协程以及包装的一些参数)加入到sendq队列中
    c.sendq.enqueue(mysg)
    
    gp.parkingOnChan.Store(true)
    
    // 3.5.2调用gopark,挂起当前协程,等待当前协程被唤醒
    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
   
    // 4.协程被唤醒后的一些处理
    // 当接收方把值取走后,会唤醒当前 goroutine
    // 如果唤醒时发现 channel 已关闭 → panic
    // 否则发送成功
    closed := !mysg.success
    releaseSudog(mysg)
    if closed {
        panic(plainError("send on closed channel"))
    }
    return true
}

2.3.1总结

chansend 的执行路径可以简化为:

  1. nil channel:阻塞or直接返回

  2. closed channel:panic

  3. 有接收者等待:直接把数据交给它(绕过缓冲区)

  4. 缓冲区有空位:把数据写进缓冲区

  5. 非阻塞 & 缓冲区满:返回失败

  6. 阻塞 & 缓冲区满:把自己挂起,等接收者唤醒

2.4Channel接收数据

Go 复制代码
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {

    // 1.判断当前channel是否为空
    if c == nil {
       // 1.1判断当前是否为阻塞的channel
       if !block {
          return
       }
       // 1.2将当前协程挂起
       gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
       throw("unreachable")
    }

    // 2.如果当前是非阻塞接收(!block)并且channel没有可接收的数据(empty(c))
    if !block && empty(c) {   
     
        // 2.1channel未关闭
        // 这种情况下非阻塞接收无法成功,直接返回 
        // 返回默认零值,意味着 selected=false, received=false    
        if atomic.Load(&c.closed) == 0 {
            return
        }
    
        // 2.2channel已关闭,需要判断缓冲区是否还有残留数据
        // selected=true 表示 select case 被选中(如果在 select 里)
        // received=false 表示没有真正接收到数据
        if empty(c) {               
            if raceenabled {        
                raceacquire(c.raceaddr())
            }
            if ep != nil {          
                typedmemclr(c.elemtype, ep) 
            }
            return true, false
        }
    }


    // 3.加锁开始情况的判断
    lock(&c.lock)

    // 3.1判断当前channel是否被关闭
    if c.closed != 0 {
        // 如果关闭后缓冲中没有数据则返回0,false,如果缓冲区中仍然有数据则继续处理数据
       if c.qcount == 0 {
          if raceenabled {
             raceacquire(c.raceaddr())
          }
          unlock(&c.lock)
          if ep != nil {
             typedmemclr(c.elemtype, ep)
          }
          return true, false
       }
    } else {
       // 3.2如果当前写阻塞队列中有协程,则将数据获取,并唤醒写阻塞队列,并返回
       if sg := c.sendq.dequeue(); sg != nil {
          recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
          return true, true
       }
    }

    // 3.3如果缓冲区有数据,就直接取一个元素返回
    if c.qcount > 0 {
       qp := chanbuf(c, c.recvx)
       if raceenabled {
          racenotify(c, c.recvx, nil)
       }
       if ep != nil {
          typedmemmove(c.elemtype, ep, qp)
       }
       typedmemclr(c.elemtype, qp)
       c.recvx++
       if c.recvx == c.dataqsiz {
          c.recvx = 0
       }
       c.qcount--
       unlock(&c.lock)
       return true, true
    }

    // 3.4判断当前是否为非阻塞协程,并且缓冲区已满或者无缓冲区,返回失败
    if !block {
       unlock(&c.lock)
       return false, false
    }

    // 3.5开始获取当前协程并阻塞
    gp := getg()
    mysg := acquireSudog()
    mysg.releasetime = 0
    if t0 != 0 {
       mysg.releasetime = -1
    }
    mysg.waitlink = nil
    gp.waiting = mysg
    mysg.g = gp
    mysg.isSelect = false
    mysg.c = c
    gp.param = nil
    
    // 3.6将当前协程添加到recvq中
    c.recvq.enqueue(mysg)
    gp.parkingOnChan.Store(true)
    
    // 3.7将当前协程挂起
    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)

    // 4.唤醒后,清理状态
    if mysg != gp.waiting {
       throw("G waiting list is corrupted")
    }
    gp.waiting = nil
    gp.activeStackChans = false
    if mysg.releasetime > 0 {
       blockevent(mysg.releasetime-t0, 2)
    }
    success := mysg.success
    gp.param = nil
    mysg.c = nil
    releaseSudog(mysg)
    return true, success
}

2.4.1总结

核心逻辑总结

  1. 检查 channel 是否为 nil:nil channel 会阻塞或直接返回失败

  2. 非阻塞接收判断:channel 空或关闭时,直接返回

  3. channel 已关闭处理:缓冲区空则返回零值,否则继续读取

  4. 直接从缓冲区读取数据:这是缓冲 channel 的核心路径

  5. 阻塞接收:如果缓冲区无数据且阻塞,则将当前协程加入接收队列,挂起等待

2.5关闭Channel

Go 复制代码
func closechan(c *hchan) {
    // 1.判断当前channel是否被初始化过
    if c == nil {
       panic(plainError("close of nil channel"))
    }

    // 2.加锁开始处理任务
    lock(&c.lock)
    // 2.1判断当前channel是否已经被关闭过了
    if c.closed != 0 {
       unlock(&c.lock)
       panic(plainError("close of closed channel"))
    }

    if raceenabled {
       callerpc := getcallerpc()
       racewritepc(c.raceaddr(), callerpc, abi.FuncPCABIInternal(closechan))
       racerelease(c.raceaddr())
    }

    c.closed = 1

    var glist gList

    // 3.遍历读协程的链表,将所有阻塞的读协程全部取出
    for {
       sg := c.recvq.dequeue()
       if sg == nil {
          break
       }
       if sg.elem != nil {
          typedmemclr(c.elemtype, sg.elem)
          sg.elem = nil
       }
       if sg.releasetime != 0 {
          sg.releasetime = cputicks()
       }
       gp := sg.g
       gp.param = unsafe.Pointer(sg)
       sg.success = false
       if raceenabled {
          raceacquireg(gp, c.raceaddr())
       }
       glist.push(gp)
    }

    // 4.遍历写协程的链表,将所有阻塞的写协程全部取出
    for {
       sg := c.sendq.dequeue()
       if sg == nil {
          break
       }
       sg.elem = nil
       if sg.releasetime != 0 {
          sg.releasetime = cputicks()
       }
       gp := sg.g
       gp.param = unsafe.Pointer(sg)
       sg.success = false
       if raceenabled {
          raceacquireg(gp, c.raceaddr())
       }
       glist.push(gp)
    }
    unlock(&c.lock)

    // 5.将所有阻塞的协程全部重新唤醒
    for !glist.empty() {
       gp := glist.pop()
       gp.schedlink = 0
       goready(gp, 3)
    }
}

2.5.1总结

核心逻辑总结

  1. 防止重复关闭:nil 或已关闭的 channel 会直接 panic

  2. 标记 channel 已关闭c.closed = 1

  3. 唤醒所有等待的接收者:返回false

  4. 唤醒所有等待的发送者:发送失败,调用者会panic

  5. 批量唤醒:先收集,再解锁后唤醒,避免锁冲突

相关推荐
HiWorld6 小时前
Go源码学习(基于1.24.1)-slice扩容机制-实践才是真理
go
程序员爱钓鱼12 小时前
Go语言实战案例-Redis连接与字符串操作
后端·google·go
岁忧1 天前
(nice!!!)(LeetCode 每日一题) 1277. 统计全为 1 的正方形子矩阵 (动态规划)
java·c++·算法·leetcode·矩阵·go·动态规划
HyggeBest1 天前
Golang 并发原语 Sync Cond
后端·架构·go
mao毛1 天前
Go 1.25 重磅发布:性能飞跃、工具升级与新一代 GC 来袭
后端·go
郭京京1 天前
mongodb基础
mongodb·go
程序员爱钓鱼1 天前
Go语言实战案例-使用SQLite实现本地存储
后端·google·go
江湖十年1 天前
Go 1.25 终于迎来了容器感知 GOMAXPROCS
后端·面试·go
岁忧2 天前
(nice!!!)(LeetCode 每日一题) 679. 24 点游戏 (深度优先搜索)
java·c++·leetcode·游戏·go·深度优先