Go Channel:底层结构、并发通信与 select

2022-02-11T10:00:00+08:00 | 44分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@

学习目标

阅读本章后,你将具备以下能力:

  1. 画出 hchan 结构体内部结构,说清 buf 环形队列、sendq/recvq 等待队列、mutex 各自的作用,并能口述数据发送与接收的完整流程。
  2. 判断 channel 在什么条件下阻塞,掌握 select+default、select+time.After、buffered channel 三种避免阻塞的手段。
  3. 讲清 select 的底层机制——scase 数组、pollorder 随机排列、lockorder 按地址排序加锁——并理解"两个 channel 稳定、三个 channel 抖动"的成因。
  4. 独立编写 6 种 channel 通信模式(信号通知、超时控制、退出信号、扇出、扇入、Pipeline)的完整可运行代码。
  5. 在面试白板中讲清 Channel vs Mutex 的选型原则,结合 Go 的 CSP 哲学给出判断依据。

前置知识: 了解 goroutine 基本用法、sync.Mutex 的 Lock/Unlock、基本数据类型与指针。建议先阅读本系列关于 goroutine 调度的笔记。

动手做 3 件事: 把本章"交替打印奇偶数"代码敲一遍并运行;用 go run -race 运行扇入示例观察 race detector;纸笔画 hchan 结构图并复述收发流程。

1、Channel 底层结构与收发流程

1.1 用生活类比先建立直觉

把 channel 想象成邮局的信箱投递窗口:

  • buf(环形队列) = 窗口后面那排信箱格子,信件可以暂存,等你来取。
  • sendq(等待发送队列) = 一群人排队想寄信,但信箱满了,只能在外面等。
  • recvq(等待接收队列) = 一群人等着收信,但信箱是空的,也只能等。
  • mutex(互斥锁) = 窗口工作人员,同一时刻只允许一个人操作信箱。
  • closed 标志 = “停止营业"牌子,挂上去后不能再寄信(panic),但还能取走剩余的信。

无缓冲 channel 就像没有信箱的交接窗口:寄信人必须和收信人面对面,一手交钱一手交货,谁先到谁等对方。有缓冲 channel 就是窗口后面有信箱格子,寄信人丢进格子就能走,格子满了才需要等。

graph TB
    subgraph hchan["hchan 结构体"]
        mutex["mutex 互斥锁
保护所有字段"] buf["buf 环形队列
data data 空 空"] sendq["sendq
等待发送的 g 队列"] recvq["recvq
等待接收的 g 队列"] closed["closed
关闭标志"] meta["elemtype / elemsize
元素类型与大小"] end mutex -.->|"保护"| buf mutex -.->|"保护"| sendq mutex -.->|"保护"| recvq buf -->|"满则阻塞"| sendq buf -->|"空则阻塞"| recvq

桥接: 上图每个方块对应 runtime.hchan 的一个字段。接下来看真实的 Go 源码定义。

1.2 工程要点

hchan 结构体定义

channel 在运行时由 runtime.hchan 结构体表示,简化后的核心字段:

// hchan 是 channel 在运行时的底层结构体
type hchan struct {
    qcount   uint           // 当前 buf 中已有元素数量
    dataqsiz uint           // buf 容量(环形队列长度)
    buf      unsafe.Pointer // 指向环形队列底层数组
    elemsize uint16         // 单个元素大小(字节)
    closed   uint32         // 是否已关闭,0=未关闭 1=已关闭
    elemtype *_type         // 元素类型信息
    sendx    uint           // 下一次发送写入 buf 的索引(环形)
    recvx    uint           // 下一次接收读取 buf 的索引(环形)
    recvq    waitq          // 等待接收的 goroutine 队列(双向链表)
    sendq    waitq          // 等待发送的 goroutine 队列(双向链表)
    lock     mutex          // 互斥锁,保护 hchan 所有字段
}
字段作用面试一句话
buf环形队列,仅缓冲 channel 有有缓冲 channel 的数据暂存区
sendq发送方阻塞队列buf 满了,发送者在这排队
recvq接收方阻塞队列buf 空了,接收者在这排队
lock互斥锁channel 并发安全靠这把锁
closed关闭标志关了不能再发,发了就 panic
sendx/recvx环形索引实现"环形"队列的关键

有缓冲 vs 无缓冲

package main

import "fmt"

func main() {
    // 步骤1:无缓冲 channel —— 发送和接收必须同时就绪(同步)
    unbuf := make(chan int)
    go func() { unbuf <- 42 }() // 步骤2:发送方阻塞,直到有人接收
    fmt.Println("收到:", <-unbuf) // 步骤3:接收方就绪,双方交接

    // 步骤4:有缓冲 channel —— 缓冲区未满时发送不阻塞(异步)
    buf := make(chan int, 3)
    buf <- 1 // 步骤5:直接放入 buf,不阻塞
    buf <- 2
    buf <- 3
    // buf <- 4 // 步骤6:取消注释会阻塞(缓冲区已满)
    fmt.Println("元素数:", len(buf), "容量:", cap(buf))
}
特性无缓冲 make(chan T)有缓冲 make(chan T, n)
同步/异步同步(发送方等接收方)异步(缓冲区满才阻塞)
buf有(大小为 n 的环形队列)
交接方式直接从发送者拷贝到接收者先入 buf,再从 buf 取出
典型场景信号同步、保证顺序削峰填谷、解耦生产消费速率

数据发送流程 ch <- value

graph TD
    Start["发送 ch 低于 value"] --> Lock["步骤1:加锁 lock"]
    Lock --> CheckClosed{"closed == 1?"}
    CheckClosed -->|"是"| Panic["panic: send on closed channel"]
    CheckClosed -->|"否"| CheckRecv{"recvq 有
等待的接收者?"} CheckRecv -->|"是"| Direct["步骤2:直接把数据
拷贝给接收者并唤醒"] CheckRecv -->|"否"| CheckBuf{"buf 有空位?"} CheckBuf -->|"是"| PutBuf["步骤3:写入 buf[sendx]
sendx 环形推进"] CheckBuf -->|"否"| Enqueue["步骤4:当前 g 加入 sendq
调用 gopark 阻塞"] Direct --> Unlock["解锁"] PutBuf --> Unlock Enqueue --> Unlock Panic --> End["结束"] Unlock --> End
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string, 2)
    ch <- "hello" // 步骤1:buf 有空位,直接写入不阻塞
    ch <- "world"
    go func() { // 步骤2:第三次发送阻塞(buf 满,加入 sendq)
        ch <- "third"
        fmt.Println("third 发送成功")
    }()
    time.Sleep(100 * time.Millisecond)
    fmt.Println("取出:", <-ch) // 步骤3:取走一个,sendq 中的 g 被唤醒
    time.Sleep(100 * time.Millisecond)
}

⚠️ 新手必踩的坑: 很多初学者以为 ch <- value 一定不阻塞。实际上只有 buf 有空位(或刚好有接收者在等)时才不阻塞。无缓冲 channel 每次发送都会阻塞直到接收方就绪。在主 goroutine 中向无缓冲 channel 发送但没有接收方,会死锁报 fatal error: all goroutines are asleep - deadlock!

数据接收流程 value := <-ch

graph TD
    Start["接收 value := 低于 ch"] --> Lock["步骤1:加锁 lock"]
    Lock --> CheckBuf{"buf 有数据?"}
    CheckBuf -->|"是"| TakeBuf["步骤2:从 buf[recvx] 读取
recvx 推进,qcount--"] CheckBuf -->|"否"| CheckSend{"sendq 有
等待的发送者?"} CheckSend -->|"有"| DirectRecv["步骤3:直接从发送者
拷贝数据并唤醒"] CheckSend -->|"无"| CheckClosed2{"closed == 1?"} CheckClosed2 -->|"是"| ReturnZero["返回零值,ok=false"] CheckClosed2 -->|"否"| Enqueue["步骤4:加入 recvq
gopark 阻塞"] TakeBuf --> Unlock["解锁"] DirectRecv --> Unlock ReturnZero --> End["结束"] Enqueue --> End Unlock --> End
package main

import "fmt"

func main() {
    ch := make(chan int) // 无缓冲 channel
    go func() { ch <- 100 }() // 步骤1:发送方先到,加入 sendq 阻塞等待
    fmt.Println("收到:", <-ch) // 步骤2:接收方就绪,直接从发送者拷贝数据

    close(ch)
    val2, ok := <-ch // 步骤3:buf 空 + 已关闭,返回零值 + false
    fmt.Println("关闭后:", val2, ok) // 输出: 0 false
}

close 的行为

package main

import "fmt"

func main() {
    ch := make(chan int, 3)
    ch <- 1
    ch <- 2
    ch <- 3
    close(ch) // 步骤1:关闭后仍可接收,直到 buf 排空
    for v := range ch { // 步骤2:for range 自动检测 close
        fmt.Println("收到:", v) // 输出: 1, 2, 3
    }
    v, ok := <-ch // 步骤3:排空后返回零值 + false
    fmt.Println("排空后:", v, ok) // 输出: 0 false
    // ch <- 999  // panic: send on closed channel
    // close(ch)  // panic: close of closed channel
}

⚠️ 新手必踩的坑: close 后的 channel 可以继续接收(先取完 buf 数据,再返回零值),但绝对不能发送。close 的语义是"不再有数据写入”,不是"清空数据"。

2、Channel 阻塞与避免

2.1 用生活类比先建立直觉

想象一个停车场:缓冲区满 = 停车场满了,车在门口排队(sendq 阻塞);缓冲区空 = 停车场空了,来接人的车等着(recvq 阻塞);无缓冲未配对 = 没有停车场的路边交接点,双方必须同时到场。避免阻塞就像装了"车位显示屏"——看到满了就不进去(select+default),或设一个"最多等 5 分钟"的闹钟(time.After),或把停车场修大(buffered channel)。

graph LR
    subgraph 阻塞场景["三种阻塞场景"]
        A["缓冲满 发送阻塞"] --> AS["加入 sendq"]
        B["缓冲空 接收阻塞"] --> BS["加入 recvq"]
        C["无缓冲 未配对"] --> CS["谁先到谁等"]
    end

    subgraph 解法["三种避免手段"]
        D["select 加 default"]
        E["select 加 time.After"]
        F["buffered channel"]
    end

    AS -.-> D
    AS -.-> F
    BS -.-> D
    CS -.-> F

桥接: 上图把"为什么阻塞"和"怎么避免"放在一起。下面用代码逐一演示。

2.2 工程要点

什么时候会阻塞

三种阻塞场景:缓冲满 + 发送(ch1 <- 3 死锁)、缓冲空 + 接收(<-ch2 死锁)、无缓冲未配对(ch3 <- 1 死锁)。

避免阻塞的 3 种方法

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 1)

    // 方法1:select+default —— 非阻塞发送
    select {
    case ch <- 42:
        fmt.Println("发送成功")
    default:
        fmt.Println("满了,跳过")
    }
    // 方法2:select+time.After —— 超时控制
    slowCh := make(chan int)
    go func() { time.Sleep(2 * time.Second); slowCh <- 99 }()
    select {
    case v := <-slowCh:
        fmt.Println("收到:", v)
    case <-time.After(500 * time.Millisecond):
        fmt.Println("超时,不等了") // 500ms 内没结果就走这里
    }
    // 方法3:buffered channel —— 增大缓冲区延缓阻塞
    bigBuf := make(chan int, 100)
    for i := 0; i < 100; i++ {
        bigBuf <- i // 缓冲区内不阻塞
    }
}

缓冲区满了丢弃 / 先判断再写入

package main

import "fmt"

func main() {
    ch := make(chan int, 3)
    for i := 0; i < 3; i++ {
        ch <- i // 步骤1:填满缓冲区
    }

    // 步骤2:满了用 select+default 丢弃后续数据
    for i := 3; i < 10; i++ {
        select {
        case ch <- i:
            fmt.Println("写入:", i)
        default:
            fmt.Println("丢弃:", i) // 满了就丢
        }
    }

    // 步骤3:推荐 select+default(原子无竞态),不推荐 len() 判断(有竞态)
    ch2 := make(chan int, 2)
    ch2 <- 1
    ch2 <- 2
    select {
    case ch2 <- 3:
        fmt.Println("写入成功")
    default:
        fmt.Println("写入失败:满")
    }
    // if len(ch2) < cap(ch2) { ch2 <- 3 } // 有竞态:判断和写入之间可能被其他 g 修改
}

⚠️ 新手必踩的坑:len(ch) < cap(ch) 判断再写入有竞态条件——判断和写入之间另一个 goroutine 可能已填满缓冲区。正确做法是 select + default,底层加锁后判断,是原子操作。

3、select 机制

3.1 用生活类比先建立直觉

select 就像银行取号系统:你同时关注好几个窗口,哪个先叫到你的号就去哪个。多个窗口同时空闲时系统随机挑一个,保证公平。窗口贴了"暂停服务"(nil channel)就永远不会被选中。底层做了两件聪明事:pollorder(随机排列 case 保证公平)和 lockorder(按地址排序加锁防死锁)。

graph TD
    Start["select 开始"] --> Gen["步骤1:生成 pollorder
随机打乱 case 顺序"] Gen --> Sort["步骤2:生成 lockorder
按 channel 地址排序"] Sort --> LockAll["步骤3:按 lockorder 加锁所有 channel"] LockAll --> Scan{"步骤4:按 pollorder 遍历
有 case 就绪?"} Scan -->|"有"| Pick["随机选一个就绪 case 执行"] Scan -->|"全部未就绪"| HasDefault{"有 default?"} HasDefault -->|"有"| RunDef["执行 default"] HasDefault -->|"无"| Park["加入所有 channel
等待队列,gopark 阻塞"] Pick --> Finish["结束"] RunDef --> Finish Park --> Finish

桥接: 这张图是 select 的执行骨架。下面拆解每个环节。

3.2 工程要点

select 基本语法

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1, ch2 := make(chan string), make(chan string)
    go func() { time.Sleep(100 * time.Millisecond); ch1 <- "来自 ch1" }()
    go func() { time.Sleep(200 * time.Millisecond); ch2 <- "来自 ch2" }()

    // 步骤1:select 多路复用,谁先就绪执行谁
    for i := 0; i < 2; i++ {
        select {
        case msg := <-ch1:
            fmt.Println(msg)
        case msg := <-ch2:
            fmt.Println(msg)
        }
    }
}

select 底层结构

select 由 runtime.selectgo 实现,核心数据结构是 scase 数组:

// scase 代表 select 中的一个 case 分支
type scase struct {
    c    *hchan         // 该 case 操作的 channel(nil 表示永不就绪)
    elem unsafe.Pointer // 数据指针
    kind uint16         // CaseRecv / CaseSend / CaseDefault
}

// selectgo 关键步骤:构造 scase 数组 → 生成 pollorder(随机排列)→ 生成 lockorder(按地址排序)
// → 按 lockorder 加锁 → 按 pollorder 遍历找就绪 case → 无就绪则 default 或阻塞
底层结构作用为什么这么做
scase 数组记录每个 case 的 channel、数据指针、类型一个 case 对应一个 channel 操作
pollordercase 索引的随机排列保证公平,避免总选第一个 case
lockorderchannel 按地址升序排列防止多个 select 交叉加锁导致死锁

select 的 4 个关键点

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 1)

    // 关键点1:随机选择 —— 多个 case 就绪时随机挑一个
    select {
    case ch <- 1:
        fmt.Println("发送 1")
    case ch <- 2:
        fmt.Println("发送 2")
    default:
        fmt.Println("都不行")
    }

    // 关键点2:default 非阻塞 —— 有 default 就不阻塞
    select {
    case v := <-ch:
        fmt.Println("收到:", v)
    default:
        fmt.Println("没数据,不等")
    }
    // 关键点3:nil channel 永不就绪
    var nilCh chan int // 未 make 的 nil channel
    select {
    case <-nilCh: // 永远不会被选中
    case <-time.After(50 * time.Millisecond):
        fmt.Println("超时退出") // 走这里
    }
    // 关键点4:所有 case 阻塞且无 default —— select 自身阻塞
    emptyCh := make(chan int)
    go func() { time.Sleep(50 * time.Millisecond); emptyCh <- 777 }()
    select {
    case v := <-emptyCh:
        fmt.Println("收到:", v) // 阻塞直到有数据
    }
}

两个 channel 稳定,三个 channel 抖动

package main

import "fmt"

func main() {
    // 步骤1:3 个 channel 高频 select
    chs := make([]chan int, 3)
    for i := range chs {
        chs[i] = make(chan int, 100)
        go func(idx int) {
            for j := 0; j < 1000; j++ {
                chs[idx] <- j
            }
            close(chs[idx])
        }(i)
    }
    // 步骤2:消费者用 select 多路复用,关闭后置 nil
    count := 0
    openCount := len(chs)
    for openCount > 0 {
        select {
        case _, ok := <-chs[0]:
            if !ok { chs[0] = nil; openCount-- }
        case _, ok := <-chs[1]:
            if !ok { chs[1] = nil; openCount-- }
        case _, ok := <-chs[2]:
            if !ok { chs[2] = nil; openCount-- }
        }
        count++
    }
    fmt.Println("共处理:", count)
}

抖动原因:case 越多,每次 selectgo 遍历和加锁的 channel 越多(调度开销上升);多个 channel 分散在内存不同位置(缓存局部性下降);pollorder 随机排列需打乱更多元素;lockorder 要锁更多 channel(锁竞争增大)。

⚠️ 新手必踩的坑: 不要在 select 里塞大量 case。超过 10 个时考虑用"扇入"模式合并成一个 channel 再消费。

4、Channel 通信典型案例(6种模式)

4.1 用生活类比先建立直觉

把 6 种模式类比成工厂协作:信号通知 = 组长喊"收工"(done channel);超时控制 = 打卡有截止时间(time.After);退出信号 = 老板按紧急停止按钮(context);扇出 = 一个出货口多个快递员取货分发;扇入 = 多个供应商货送到一个总仓合并;Pipeline = 流水线,上道工序输出是下道工序输入。

graph LR
    subgraph 扇出["fan-out 扇出"]
        FO_P["生产者"] --> FO_C1["消费者1"]
        FO_P --> FO_C2["消费者2"]
        FO_P --> FO_C3["消费者3"]
    end

    subgraph 扇入["fan-in 扇入"]
        FI_P1["生产者1"] --> FI_M["合并器"]
        FI_P2["生产者2"] --> FI_M
        FI_M --> FI_C["消费者"]
    end

    subgraph 流水线["Pipeline 流水线"]
        PL_S["阶段1 生成"] --> PL_A["阶段2 加工"]
        PL_A --> PL_B["阶段3 输出"]
    end

桥接: 上图展示三种核心拓扑。下面逐一给出完整可运行代码。

4.2 工程要点

模式1:信号通知(done channel)

package main

import (
    "fmt"
    "time"
)

func worker(done <-chan struct{}) {
    for {
        select {
        case <-done:
            fmt.Println("worker 退出") // 步骤1:收到停止信号
            return
        default:
            fmt.Println("worker 工作中...")
            time.Sleep(200 * time.Millisecond)
        }
    }
}

func main() {
    done := make(chan struct{}) // 步骤2:空结构体不占内存
    go worker(done)
    time.Sleep(1 * time.Second)
    close(done) // 步骤3:close 广播,通知 worker 退出
    time.Sleep(100 * time.Millisecond)
}

模式2:超时控制(select + time.After)

package main

import (
    "fmt"
    "time"
)

func slowOperation() <-chan string {
    result := make(chan string)
    go func() { time.Sleep(2 * time.Second); result <- "完成" }()
    return result
}

func main() {
    select {
    case res := <-slowOperation():
        fmt.Println("结果:", res)
    case <-time.After(1 * time.Second):
        fmt.Println("操作超时!") // 步骤1:1 秒内没结果就超时
    }
    // 注意:超时后 goroutine 仍在运行(泄漏),生产环境应配合 context
}

⚠️ 新手必踩的坑: time.After 每次调用都创建新 timer。高频循环中大量使用会导致 timer 不被及时回收,造成内存泄漏。用 time.NewTimer 手动管理或用 context。

模式3:退出信号(context + channel)

package main

import (
    "context"
    "fmt"
    "time"
)

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d 退出: %v\n", id, ctx.Err()) // 步骤1:ctx.Done() 返回关闭的 channel
            return
        default:
            fmt.Printf("worker %d 工作中\n", id)
            time.Sleep(300 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    for i := 1; i <= 3; i++ {
        go worker(ctx, i) // 步骤2:3 个 worker 共享同一个 ctx
    }
    time.Sleep(1 * time.Second)
    cancel() // 步骤3:所有 worker 同时收到信号
    time.Sleep(100 * time.Millisecond)
}

模式4:扇出(fan-out)—— 一个生产者多个消费者

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    tasks := make(chan int, 10)
    var wg sync.WaitGroup

    // 步骤1:启动 3 个消费者
    for w := 1; w <= 3; w++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for task := range tasks { // 步骤2:从同一 channel 取任务
                fmt.Printf("worker %d 处理 %d\n", id, task)
                time.Sleep(100 * time.Millisecond)
            }
        }(w)
    }
    // 步骤3:一个生产者放任务,放完关闭
    for i := 1; i <= 10; i++ { tasks <- i }
    close(tasks) // 步骤4:消费者 range 完自动退出
    wg.Wait()
}

模式5:扇入(fan-in)—— 多个生产者一个消费者

package main

import (
    "fmt"
    "sync"
)

// 步骤1:fanIn 把多个 channel 合并成一个
func fanIn(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v // 步骤2:转发到 out
            }
        }(ch)
    }
    go func() { wg.Wait(); close(out) }() // 步骤3:全部完成后关闭 out
    return out
}
func main() {
    ch1, ch2 := make(chan int), make(chan int)
    go func() {
        defer close(ch1)
        for i := 1; i <= 3; i++ { ch1 <- i * 10 }
    }()
    go func() {
        defer close(ch2)
        for i := 1; i <= 3; i++ { ch2 <- i * 100 }
    }()

    for v := range fanIn(ch1, ch2) { // 步骤4:扇入合并后消费
        fmt.Println("收到:", v)
    }
}

模式6:Pipeline(流水线)

package main

import "fmt"

// 步骤1:阶段1 —— 生成数据
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums { out <- n }
    }()
    return out
}

// 步骤2:阶段2 —— 平方加工
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in { out <- n * n } // 从上一阶段读,加工后写
    }()
    return out
}

func main() {
    // 步骤3:像水管一样串联
    for v := range square(generate(1, 2, 3, 4, 5)) {
        fmt.Println("结果:", v) // 输出: 1, 4, 9, 16, 25
    }
}

5、Channel 并发控制实战

5.1 用生活类比先建立直觉

交替打印奇偶数像两人接力赛:A 跑完第一棒(奇数)把接力棒交给 B,B 跑完第二棒(偶数)再交回 A。接力棒就是 channel 的信号——谁拿到棒谁跑。10 个协程按顺序打印则像 10 个人排成一列传包裹,每个人只有收到前一个人的信号才行动,天然有序。

graph LR
    A["奇数 g
打印 1 3 5 7 9"] -->|"channel 通知"| B["偶数 g
打印 2 4 6 8 10"] B -->|"channel 通知"| A

桥接: 接力棒的传递就是 channel 收发。下面用代码实现这两个经典面试题。

5.2 工程要点

两个 goroutine 交替打印奇偶数

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(2)

    // 步骤1:两个无缓冲 channel 作为"接力棒"
    oddCh := make(chan struct{})
    evenCh := make(chan struct{})
    maxNum := 10

    // 步骤2:奇数 goroutine
    go func() {
        defer wg.Done()
        for i := 1; i <= maxNum; i += 2 {
            <-evenCh             // 步骤3:等待信号
            fmt.Printf("奇数: %d\n", i)
            oddCh <- struct{}{}  // 步骤4:通知偶数 g
        }
    }()
    // 步骤5:偶数 goroutine
    go func() {
        defer wg.Done()
        for i := 2; i <= maxNum; i += 2 {
            <-oddCh              // 步骤6:等待奇数 g 的信号
            fmt.Printf("偶数: %d\n", i)
            evenCh <- struct{}{} // 步骤7:通知奇数 g
        }
    }()

    evenCh <- struct{}{} // 步骤8:初始信号启动链条
    wg.Wait()
    fmt.Println("交替打印完成")
}

原理:无缓冲 channel 保证同步——发送方必须等接收方就绪,形成"你打印完我才打印"的严格顺序。struct{}{} 作为信号不占内存。

10 个协程按顺序打印 0-9

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(10)

    // 步骤1:每个 goroutine 一个 channel,串成环形链
    chans := make([]chan struct{}, 10)
    for i := range chans {
        chans[i] = make(chan struct{}, 1) // 步骤2:缓冲 1 防死锁
    }

    for i := 0; i < 10; i++ {
        go func(id int) {
            defer wg.Done()
            <-chans[id] // 步骤3:等前一个 goroutine 信号
            fmt.Printf("g%d: %d\n", id, id)
            next := (id + 1) % 10            // 步骤4:环形通知下一个
            chans[next] <- struct{}{}
        }(i)
    }
    chans[0] <- struct{}{} // 步骤5:给第 0 个发初始信号
    wg.Wait()
    fmt.Println("顺序打印完成")
}

⚠️ 新手必踩的坑: 环形结构中如果用无缓冲 channel,发送方等接收方但接收方还没启动,可能死锁。加 1 的缓冲可避免此问题。

channel 在项目中的 4 种典型应用

应用场景说明典型用法
信号通知通知 goroutine 停止/开始/完成chan struct{},close 广播
并发控制限制并发数、等待所有 g 完成带缓冲 channel 做信号量 + WaitGroup
数据传递goroutine 间传递数据,解耦缓冲 channel + for range
多路复用同时监听多个数据源/事件select + 多个 channel

并发限流核心思路:用带缓冲 channel 做信号量,sem <- struct{}{} 获取(满则阻塞),<-sem 释放,配合 WaitGroup 等待全部完成。

6、Channel 使用注意事项

6.1 用生活类比先建立直觉

channel 的注意事项就像交通规则:向已关闭的 channel 发送 = 对着已关门的邮局塞信,被保安按住(panic);重复关闭 = 对已关的门再关一次,门把手断了(panic);nil channel 在 select 中 = 一扇封死的门,永远不会有人进出,但可以用来"临时禁用某个 case";for range channel = 排队取号,号取完了(close 了)自动离开。

桥接: 这些规则的本质是 channel 内部状态机的约束。下面用代码逐一演示。

6.2 工程要点

不要向已关闭的 channel 发送 / 不要重复关闭

package main

func main() {
    ch := make(chan int, 1)
    ch <- 1
    close(ch)
    // ch <- 2   // panic: send on closed channel(关闭后不能发送)
    // close(ch) // panic: close of closed channel(不能重复关闭)
    v := <-ch     // 关闭后仍可接收
    println("收到:", v)
}

⚠️ 新手必踩的坑: 谁创建 channel 谁负责关闭。多个 goroutine 都可能关闭时,用 sync.Once 保证只关一次。

nil channel 在 select 中的妙用

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1, ch2 := make(chan int), make(chan int)
    go func() { time.Sleep(100 * time.Millisecond); ch1 <- 1 }()
    go func() { time.Sleep(200 * time.Millisecond); ch2 <- 2 }()

    active1, active2 := ch1, ch2
    for i := 0; i < 2; i++ {
        select {
        case v := <-active1:
            fmt.Println("ch1:", v)
            active1 = nil // 步骤1:置 nil,该 case 永不再被选中
        case v := <-active2:
            fmt.Println("ch2:", v)
            active2 = nil
        }
    }
    // 步骤2:动态启停 select case 的经典技巧
}

channel 传值 vs 传指针

package main

import "fmt"

type Data struct{ ID int; Name string }

func main() {
    // 步骤1:传值 —— channel 拷贝整个结构体,修改原变量不影响副本
    valCh := make(chan Data, 1)
    d := Data{ID: 1, Name: "test"}
    valCh <- d
    d.Name = "changed"
    fmt.Println("传值:", (<-valCh).Name) // 输出: test
    // 步骤2:传指针 —— 只拷贝指针,通过指针修改会影响 channel 中的数据
    ptrCh := make(chan *Data, 1)
    d2 := &Data{ID: 2, Name: "hello"}
    ptrCh <- d2
    d2.Name = "world"
    fmt.Println("传指针:", (<-ptrCh).Name) // 输出: world
}
方式优点缺点适用场景
传值无竞态,数据隔离大结构体拷贝开销小数据、不可变数据
传指针零拷贝,高效共享内存有竞态风险大结构体、需共享修改

for range channel 与 channel 方向

package main

import "fmt"

// 步骤1:chan<- 只发,<-chan 只收 —— 编译期类型安全
func producer(out chan<- int) { out <- 42 }
func consumer(in <-chan int)  { fmt.Println("收到:", <-in) }

func main() {
    ch := make(chan int, 5)
    go func() {
        defer close(ch) // 步骤2:生产完成后关闭
        for i := 1; i <= 5; i++ { ch <- i }
    }()
    for v := range ch { // 步骤3:for range 自动检测 close
        fmt.Println("收到:", v)
    }
    ch2 := make(chan int, 1)
    producer(ch2) // 步骤4:双向 channel 自动转为单向
    consumer(ch2)
}

用 channel + context 控制 goroutine 生命周期

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go func(ctx context.Context) {
        ticker := time.NewTicker(200 * time.Millisecond)
        defer ticker.Stop()
        for {
            select {
            case <-ctx.Done():
                fmt.Println("goroutine 退出") // 步骤1:收到取消信号
                return
            case t := <-ticker.C:
                fmt.Println("触发:", t.Format("15:04:05"))
            }
        }
    }(ctx)
    time.Sleep(1 * time.Second)
    cancel() // 步骤2:主协程取消,通知 goroutine
    time.Sleep(100 * time.Millisecond)
}

7、协程间通信的方法

7.1 用生活类比先建立直觉

goroutine 间通信有三种方式,就像公司同事协作:共享内存 + Mutex = 大家共用一个白板,谁要写就锁门;Channel 通信 = 每人一个信箱,把信息扔进对方信箱;Context = 公司广播系统,一声令下全员收到通知,主要用于取消和超时。

graph TB
    subgraph 共享内存["共享内存 加 Mutex"]
        M1["g A"] -->|"Lock 写"| Shared["共享变量"]
        M2["g B"] -->|"Lock 读"| Shared
    end

    subgraph Channel通信["Channel CSP"]
        C1["g A"] -->|"ch 低于 data"| Ch["channel"]
        Ch -->|"data 低于 ch"| C2["g B"]
    end

    subgraph Context["Context 取消传播"]
        CTX["context"] -->|"cancel"| G1["g 1"]
        CTX -->|"cancel"| G2["g 2"]
        CTX -->|"cancel"| G3["g 3"]
    end

桥接: 三种方式各有最佳场景,不是互斥的。下面逐一分析。

7.2 工程要点

三种方式对比

package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

func main() {
    // 方式1:共享内存 + Mutex
    var mu sync.Mutex
    count := 0
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()
            count++ // 步骤1:加锁后安全修改
            mu.Unlock()
        }()
    }
    wg.Wait()
    fmt.Println("Mutex count:", count)
    // 方式2:Channel 通信
    ch := make(chan int, 10)
    for i := 0; i < 10; i++ {
        go func(id int) { ch <- id }(i) // 步骤2:通过 channel 传递
    }
    total := 0
    for i := 0; i < 10; i++ {
        total += <-ch
    }
    fmt.Println("Channel total:", total)
    // 方式3:Context —— 取消信号传播
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel()
    go func() {
        <-ctx.Done() // 步骤3:超时自动取消
        fmt.Println("Context 取消:", ctx.Err())
    }()
    time.Sleep(600 * time.Millisecond)
}
通信方式核心思想适用场景缺点
共享内存 + Mutex加锁保护共享变量缓存、计数器、配置容易死锁、忘解锁
Channel 通信通过通信共享内存(CSP)生产消费、流水线、数据传递有开销,不适合高频细粒度同步
Context取消信号树状传播超时控制、取消传播、请求生命周期只适合取消/超时,不适合传业务数据

8、Channel vs Mutex 选型

8.1 用生活类比先建立直觉

Go 并发哲学名言:“不要通过共享内存来通信,而要通过通信来共享内存。” 类比理解:共享内存通信 = 两人共用一个笔记本,要写时先抢锁(Mutex);通信共享内存 = 笔记本只在一人手里,谁要看让那人拍照发过来(channel),数据所有权随消息流动,天然避免竞态。但这不代表 channel 永远比 mutex 好——改一个计数器用 mutex 远比开 channel 来回传值高效。

桥接: 选型的核心是判断"数据归属权是否需要转移"。

8.2 工程要点

什么时候用 channel vs mutex

package main

import (
    "fmt"
    "sync"
)

// 步骤1:mutex 适合保护共享状态(缓存、计数器等固定在一处的数据)
type SafeCache struct {
    mu    sync.RWMutex
    store map[string]string
}

func (c *SafeCache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.store[key] // 读锁,允许多个 g 同时读
    return val, ok
}
func (c *SafeCache) Set(key, val string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.store[key] = val // 写锁,独占访问
}

// 步骤2:channel 适合传递数据所有权(见第4节 fan-out/fan-in/Pipeline 模式)

func main() {
    cache := &SafeCache{store: make(map[string]string)}
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            cache.Set(fmt.Sprintf("k%d", n), fmt.Sprintf("v%d", n))
        }(i)
    }
    wg.Wait()
    fmt.Println("缓存写入完成")
}

选型决策表

判断维度用 Channel用 Mutex
数据所有权需要在 goroutine 间转移数据固定在一处共享读写
通信模式一对一、一对多、多对一传递多个 goroutine 读写同一份数据
典型场景Pipeline、fan-out/fan-in、信号通知缓存、计数器、连接池、配置
性能要求中等(有 channel 开销)高频细粒度同步
复杂度中等(需防泄漏)简单状态保护

两者可以组合使用——channel 传递数据流,mutex 保护共享状态。不要为了"用 channel 而用 channel":简单计数器用 mutex 几行搞定,硬改 channel 反而更复杂。

⚠️ 新手必踩的坑: Go 哲学的核心是"用合适的工具",不是"channel 万能"。

9、Channel 总述:定义、用途与 goroutine 通信

9.1 用生活类比先建立直觉

channel 就像工厂车间之间的"传送履带":每个工人(goroutine)只管把自己手上的零件放上传送带,或从动子上取走零件。至于零件最终去了哪个车间、由谁加工,放的人不需要知道。两条履带之间天然隔着一道"隔离带"——零件在传送带上流动,而不是两个工人抢同一个零件(共享内存)。

为什么 Go 偏爱这种"传送带"而非"共用工作台"?因为共享内存要多把锁、容易死锁;而传送带让数据的所有权随消息流动——谁收到零件谁就拥有它,从根上消除了竞态。

graph LR
    subgraph G1["goroutine A(生产者)"]
        PA["生产零件"]
    end
    subgraph G2["goroutine B(消费者)"]
        PB["加工零件"]
    end
    CH["channel
传送带"] PA -->|"ch 低于 data"| CH CH -->|"data 低于 ch"| PB

桥接: 上图就是 channel 最核心的用途——让两个 goroutine 通过一根"管道"交换数据,彼此不共享变量、不需要加锁。下面给出定义与三种典型用途。

9.2 工程要点

什么是 channel(定义)

channel 是 Go 内置的、用于在 goroutine 之间传递数据的类型化管道。它在语法上是一种复合类型,make(chan T, n) 创建:

  • T 是流经管道的元素类型(可以是 int、struct、指针等);
  • n 是可选的缓冲区大小,n=0(或不写)为无缓冲 channel,n>0 为有缓冲 channel。

channel 是引用类型 + 并发安全的:多个 goroutine 同时收发,运行时内部用一把锁(hchan.lock)保证安全,使用者无需自己加锁。

channel 的三大用途

用途一句话典型形态
goroutine 间通信把数据从一个 goroutine 送到另一个ch <- v / v := <-ch
同步与信号用"有没有收到信号"协调执行顺序close(done) 广播、chan struct{}
解耦生产/消费生产者与消费者速率不必一致缓冲 channel + for range

最小可运行示例:两个 goroutine 通过 channel 通信

package main

import "fmt"

func main() {
    ch := make(chan string) // 步骤1:无缓冲 channel

    // 步骤2:启动一个 goroutine 作为"发送方"
    go func() {
        ch <- "hello from goroutine" // 步骤3:发送,会阻塞直到有人接收
    }()

    msg := <-ch // 步骤4:主 goroutine 接收,双方在此交接
    fmt.Println("收到:", msg)
}

⚠️ 新手必踩的坑: channel 的"通信"是 goroutine 之间、通过拷贝完成的(发送方拷贝元素进 buf 或直接拷贝给接收方),而不是共享同一个变量。这也是它与共享内存最本质的区别——发送后修改原变量,接收方看不到变化(除非传的是指针)。

考点总结

  • channel 是 Go 实现 CSP(Communicating Sequential Processes)并发模型的载体:“不要通过共享内存来通信,而要通过通信来共享内存”
  • 它具备两个身份:类型化管道(只能传特定类型)+ 并发安全的同步原语(内部有锁)。
  • 用途三件套:通信(传数据)、同步(控制顺序)、解耦(缓冲削峰)。
  • 记住一个对比:共享内存是"多个工人抢同一张桌子",channel 是"工人之间用传送带递活",后者把数据所有权随消息转移,从根上减少竞态。

10、Channel 的 Ring Buffer 实现

10.1 用生活类比先建立直觉

有缓冲 channel 的 buf 就像餐厅门口的环形取餐转盘:盘子(数据)从一侧放上转盘,顾客从另一侧取下。转盘是固定大小的圆圈,放盘子的位置(sendx)和取盘子的位置(recvx)各自沿着圆圈往前走;转到尽头就回到起点(环形回绕)。因为转盘是"圈",所以永远不需要把后面所有的盘子整体往前挪——放和取都是 O(1)。

graph LR
    subgraph 转盘["环形 buf(dataqsiz=4)"]
        direction LR
        C0["buf[0]
recvx→"] C1["buf[1]"] C2["buf[2]
sendx→"] C3["buf[3]"] end C0 --> C1 --> C2 --> C3 --> C0 P["生产者放盘
sendx 推进"] --> C2 Q["消费者取盘
recvx 推进"] --> C0

桥接: 上图的 sendx/recvx 就是 hchan 里那两个环形索引。下面拆解它们的推进规则与底层数组结构。

10.2 工程要点

环形队列的三个核心字段

回顾 hchan 里与缓冲相关的字段:

type hchan struct {
    qcount   uint           // 当前 buf 中元素个数(转盘上现有几盘菜)
    dataqsiz uint           // buf 容量(转盘一圈能放几盘)
    buf      unsafe.Pointer // 指向底层环形数组(连续内存)
    elemsize uint16         // 单个元素大小(字节)
    sendx    uint           // 下一个写入位置(环形)
    recvx    uint           // 下一个读取位置(环形)
    // ... 其余字段略
}

关键点:

  • buf 不是"链"而是一段连续数组,长度固定为 dataqsiz
  • sendx 指"下次往 buf[sendx] 写";recvx 指"下次从 buf[recvx] 读"。
  • 写/读完成后,索引不是简单 +1,而是 (idx + 1) % dataqsiz,到达末尾回到 0,这就是"环形"。
  • qcount 记录当前元素数,用于快速判断"满"(qcount == dataqsiz)和"空"(qcount == 0),避免每次都去比较 sendx/recvx。

为什么用环形队列(而不是队列整体前移)

普通数组队列,每取走队首元素就要把后面所有元素往前挪一位,取 N 次是 O(N²)。环形队列让头尾指针各自回绕,入队/出队都是 O(1),且无需搬移内存,对 channel 这种高频收发场景至关重要。

sequenceDiagram
    participant P as 生产者(sendx)
    participant B as buf 环形数组
    participant Q as 消费者(recvx)
    P->>B: 写入 buf[sendx],sendx=(sendx+1)%N
    Q->>B: 从 buf[recvx] 读取,recvx=(recvx+1)%N
    Note over P,Q: 两指针独立回绕
互不搬移元素

用一段迷你代码还原环形推进逻辑(教学演示)

下面这段不是 runtime 源码,而是用最简 Go 代码把 sendx/recvx 的环形回绕逻辑还原出来,帮助理解:

package main

import "fmt"

// miniRing 用最简形式模拟 channel 的环形 buf
type miniRing struct {
    buf      []int // 底层连续数组,对应 hchan.buf
    dataqsiz int   // 容量,对应 hchan.dataqsiz
    sendx    int   // 下一个写入位置
    recvx    int   // 下一个读取位置
    qcount   int   // 当前元素个数
}

func newRing(n int) *miniRing {
    return &miniRing{buf: make([]int, n), dataqsiz: n}
}

// send:写入后 sendx 环形推进,满了返回 false
func (r *miniRing) send(v int) bool {
    if r.qcount == r.dataqsiz {
        return false // 缓冲区满
    }
    r.buf[r.sendx] = v
    r.sendx = (r.sendx + 1) % r.dataqsiz // 环形回绕:到末尾回 0
    r.qcount++
    return true
}

// recv:读取后 recvx 环形推进,空了返回 false
func (r *miniRing) recv() (int, bool) {
    if r.qcount == 0 {
        return 0, false // 缓冲区空
    }
    v := r.buf[r.recvx]
    r.recvx = (r.recvx + 1) % r.dataqsiz // 环形回绕
    r.qcount--
    return v, true
}

func main() {
    r := newRing(3)
    r.send(1)
    r.send(2)
    r.send(3)
    fmt.Println(r.recv()) // 1,recvx 推进到 1
    // 关键:取走 1 后,再 send 会写到 buf[0](环形回绕),而非 buf[3](越界)
    r.send(4)
    fmt.Println("底层数组:", r.buf) // [4 2 3] —— 4 占据了被读走的位置 0
    fmt.Println(r.recv()) // 2
    fmt.Println(r.recv()) // 3
    fmt.Println(r.recv()) // 4
}

运行后 r.buf[4 2 3]:数字 4 没有追加到末尾,而是填回了被 recvx 让出的 buf[0]——这正是"环形"的体现。

考点总结

  • channel 的有缓冲数据区 buf一段固定长度的连续数组,而非链表;dataqsiz 决定长度。
  • 环形靠两个索引:sendx(下次写)、recvx(下次读),每次推进都是 (idx+1) % dataqsiz,到末尾回绕到 0。
  • qcount 记录实时元素数,发送/接收前先拿它判断"满/空",O(1) 且无需比较指针。
  • buf unsafe.Pointer 指向这段数组首地址,运行时按 elemsize 做指针算术定位每个槽位——所以 channel 只能传同一种类型 T
  • 环形队列的价值:入队/出队 O(1),不搬移任何元素,是高并发收发的性能基石。

11、空的 select{} 永久阻塞

11.1 用生活类比先建立直觉

select {} 就像把一个人关进一间没有任何门窗、也没有任何呼叫器的房间——他既不能被任何外部事件唤醒,也没有 default 当"兜底出口",于是只能一直待着(永久阻塞当前 goroutine)。它常被用来"吊住" main goroutine,让后台 worker 持续运行,又不占用 CPU。

graph TD
    S["select {}"] --> N["没有任何 case 分支"]
    N --> B["编译器识别为永久阻塞"]
    B --> Wait["当前 g 调用 gopark
让出 CPU、不空转"]

桥接: 空 select 是 select 的一个特例,底层直接让出 CPU 而非忙等。下面看代码与对比。

11.2 工程要点

package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        for i := 0; i < 3; i++ {
            fmt.Println("worker 工作中:", i)
            time.Sleep(300 * time.Millisecond)
        }
    }()

    // 步骤1:空 select{} 让 main goroutine 永久阻塞
    // 没有 case、没有 default,main 在此一直 park,
    // 把 CPU 让给上面的 worker 运行
    select {}
    // 注意:这行之后永远不会执行
}

对比其他"吊住主协程"的写法:

// 写法1:空 select(推荐,零 CPU 占用,语义清晰)
select {}

// 写法2:死循环(不推荐,会空转占满一个 CPU 核)
// for {}

// 写法3:等一个永不关闭/永不发送的 channel(等价于永久阻塞)
// done := make(chan struct{})
// <-done

⚠️ 新手必踩的坑: select {}永久阻塞,不是"等一会儿"。若你只是想临时让出 CPU 或等一小会儿,应该用 time.Sleepruntime.Gosched()。误用空 select 会导致 main 永远不退出(除非进程被信号杀死)。

考点总结

  • select {}(零 case、无 default)会让所在 goroutine 永久阻塞,常用于"吊住" main 让后台任务运行。
  • 它与 for {} 的区别:空 select 调用 gopark 让出 CPU、不占核;for {} 是忙等、占满一个核。
  • 面试常问"如何阻塞 main 而不忙等"——答案就是 select {} 或等一个永不就绪的 channel。

12、channel 的 happens-before 内存可见性保证

12.1 用生活类比先建立直觉

并发世界里两个 goroutine 各自看自己的"草稿纸",彼此看不见对方写的字(CPU 缓存 / 指令重排导致)。happens-before 就像一份快递签收单:只要 A 把数据"交给" channel(发送),B 从 channel"取出"数据(接收),签收单就保证——B 取到数据那一刻,A 在发送之前写的一切,B 都必然能看到。channel 不仅是传数据的管道,更是 Go 内存模型里最重要的同步边界

sequenceDiagram
    participant A as goroutine A
    participant Ch as channel
    participant B as goroutine B
    A->>A: 写共享变量 a=1
    A->>Ch: 发送(交接点)
    Note over A,Ch: 发送完成 happens-after a=1
    Ch->>B: 接收(交接点)
    Note over Ch,B: 接收 happens-before 发送完成
    B->>B: 读 a(必然看到 1)

桥接: Go 内存模型对 channel 有明确的 happens-before 规则。下面逐条拆解。

12.2 工程要点

无缓冲 channel 的 happens-before 规则

Go 内存模型规定(关键两条):

  1. 对无缓冲 channel 的接收,happens before 该 channel 上的发送完成。
  2. 对带缓冲 channel 的接收,happens before 第 k 次发送完成(k 为缓冲区容量,当缓冲区满、被迫同步时)。

白话:无缓冲 channel 是"面对面交接"——接收方先到位,发送方才能把数据交出去并继续。因此发送方在发送前做的任何写,接收方取数后一定可见。

package main

import "fmt"

var c = make(chan int) // 无缓冲
var a int

func f() {
    a = 1   // 步骤1:先写共享变量
    <-c     // 步骤2:在无缓冲 channel 上接收(会阻塞,等发送方)
    // 走到这里时,下面的 c <- 0 已经完成
}

func main() {
    go f()
    c <- 0      // 步骤3:发送,会阻塞直到 f 里的 <-c 就绪
    fmt.Println(a) // 步骤4:输出 1 —— 必然看到 a=1
}

为什么一定是 1?因为 a = 1(步骤1)happens-before <-c(步骤2),而 <-c 在无缓冲 channel 上 happens-before c <- 0 完成(步骤3),步骤4 在步骤3 之后。三者串成链,所以步骤4 必然看到 a == 1

对比:没有 channel 同步,结果不确定

package main

import "fmt"

var a int

func f() {
    a = 1 // 没有任何同步原语保护
}

func main() {
    go f()
    fmt.Println(a) // 可能输出 0(f 还没来得及写),也可能 1 —— 数据竞态
}

这里没有 happens-before 关系,main 读到 af 可能还没写。用 go run -race 能检测出来。

带缓冲 channel 的边界

package main

import "fmt"

func main() {
    ch := make(chan int, 1) // 缓冲 1
    ch <- 1                 // 发送不阻塞(缓冲有空位)
    // 此时发送已完成,但还没有接收者——
    // 缓冲 channel 的 happens-before 只在"缓冲区满、被迫同步"时才建立
    fmt.Println(<-ch) // 接收,看到 1
}

带缓冲 channel 不强制收发双方见面,所以 happens-before 仅在缓冲被填满、发送方被迫等接收方时才成立。需要强同步语义时应优先用无缓冲 channel 或 sync 原语。

考点总结

  • channel 不只是传数据,更是 Go 内存模型的同步边界:无缓冲 channel 上,接收 happens-before 对应发送完成。
  • 推论:发送方在发送前的所有写,接收方取数后必然可见——这是比 mutex 更"轻量"的内存可见性保证。
  • 带缓冲 channel 只在缓冲区满、双方被迫交接时才建立 happens-before;想用 channel 做同步就别依赖缓冲。
  • 常见面试坑:把"数据通过 channel 传过去了"误以为"之前的写都可见"。真正保证可见的是交接动作本身建立的 happens-before,而非单纯"传了指针"。

13、nil channel 与 close 的边界语义

13.1 用生活类比先建立直觉

一个 nil channel(只声明没 make)好比一口根本不存在的井:你既不能从里面打水(接收),也不能往里面倒水(发送),连"关井盖"(close)都会摔跤(panic)。而且——关键区别——对着不存在的井打水不是"打到空桶返回",而是永远卡在那儿(永久阻塞);而一口"已关闭的井"打完剩的水会返回空桶(零值, false)。

一个 close 用错的 channel 则像违规操作:对已关闭的井再关一次(重复 close)、对一口只准往外抽水的井去关(close 只收 channel)、对不存在的井关(close nil)——统统出问题。

graph LR
    subgraph nil["nil channel(未 make)"]
        N1["接收 <-nilCh"] --> NB["永久阻塞,不返回"]
        N2["发送 nilCh<-v"] --> SB["永久阻塞,不返回"]
        N3["close(nilCh)"] --> NP["panic"]
    end
    subgraph closed["已关闭 channel"]
        C1["接收 <-ch, ok"] --> CR["返回 零值, false"]
        C2["发送 ch<-v"] --> CP["panic: send on closed channel"]
        C3["再次 close(ch)"] --> CP2["panic: close of closed channel"]
    end

桥接: 这张图把 nil 与 closed 的语义并排对比——这是面试最高频的混淆点。下面逐一用代码验证。

13.2 工程要点

nil channel 的收发:永久阻塞,不返回零值

package main

import (
    "fmt"
    "time"
)

func main() {
    var ch chan int // 步骤1:声明但没 make —— 这是 nil channel

    // 步骤2:在 select 中 nil channel 永远不被选中(永不就绪)
    select {
    case v, ok := <-ch:
        fmt.Println("收到:", v, ok) // 永远不会走这里
    case <-time.After(100 * time.Millisecond):
        fmt.Println("超时,nil channel 未被选中") // 走这里
    }

    // 步骤3:直观验证——nil channel 收发都永久阻塞(不放主 goroutine,否则死锁)
    done := make(chan struct{})
    go func() {
        // ch <- 1 // 取消注释:永久阻塞,goroutine 泄漏
        close(done)
    }()
    <-done
}

核心区别(必背):

操作nil channel已关闭 channel
接收 <-ch永久阻塞,不返回返回 (零值, false)
发送 ch<-v永久阻塞,不返回panic: send on closed channel
close(ch)panic: close of nil channelpanic: close of closed channel

⚠️ 新手必踩的坑: 很多人把"nil channel 接收"和"closed channel 接收"搞混。closed 返回 (零值, false),nil 是卡死不返回。所以"想临时禁用某个 select case"用 nil 是安全的(永不选中),但绝不能指望从 nil channel 收到 false 来退出。

close 的三种非法用法

package main

func main() {
    // 情形1:close 一个只接收方向的 channel —— 编译期错误
    // var recv <-chan int = make(<-chan int)
    // close(recv) // 编译报错:cannot close receive-only channel

    // 情形2:close nil channel —— panic
    var nilCh chan int
    // close(nilCh) // panic: close of nil channel

    // 情形3:重复 close —— panic
    ch := make(chan int, 1)
    ch <- 1
    close(ch)
    // close(ch) // panic: close of closed channel
    _ = nilCh
}

close 只在双向或只发送(chan<-) channel 上合法,且只能调用一次。工程中"谁创建谁关闭" + 需要多处可能关闭时用 sync.Once 兜底,避免重复 close panic。

nil channel 在 select 中动态启停 case(巩固)

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan int)
    go func() { time.Sleep(100 * time.Millisecond); ch1 <- 1 }()

    var active chan int = ch1
    for i := 0; i < 1; i++ {
        select {
        case v := <-active:
            fmt.Println("收到:", v)
            active = nil // 步骤1:置 nil 后该 case 永远不被选中(动态禁用)
        }
    }
}

考点总结

  • nil channel 收发永久阻塞、不返回closed channel 接收返回 (零值, false)。这是两个最易混的语义,务必区分。
  • close 三禁:不能 close nil channel(panic)、不能重复 close(panic)、不能 close 只接收方向 channel(编译错误)。
  • nil channel 在 select 中永不就绪,是"运行时动态启用/禁用某个 case"的标准技巧。
  • 工程中 close 的原则:谁创建谁关闭;多方可能关闭时用 sync.Once 防重关。

14、channel 可作为元素类型(嵌套 channel)

14.1 用生活类比先建立直觉

channel 本身也是一种类型,所以它完全可以当"货物"在另一个 channel 里传递——就像快递站之间不仅能寄包裹,还能寄"另一个快递站的取件凭证"。这种"channel 里的 channel"(高阶 channel)在需要把通信能力本身传递出去时非常有用,比如把某个专属回复通道交给子任务。

graph LR
    subgraph 内层["内层 channel(回复通道)"]
        R["reply chan int"]
    end
    subgraph 外层["外层 channel(传递能力)"]
        O["chan chan int"]
    end
    O -->|"把回复通道当数据传出"| R

桥接: 下面用代码演示"外层 channel 传递内层 channel"这一模式。

14.2 工程要点

嵌套 channel 的基本形态

package main

import "fmt"

// 步骤1:外层 channel 的元素类型,是另一个 channel
// service 收到一个"回复通道",把结果写回它
func service(req chan chan int) {
    reply := make(chan int, 1)
    reply <- 42       // 步骤2:往回复通道写结果
    req <- reply      // 步骤3:把回复通道作为"数据"传给调用方
}

func main() {
    // 步骤4:chan chan int —— 元素本身是个 chan int
    box := make(chan chan int, 1)
    go service(box)

    replyCh := <-box       // 步骤5:取出内层回复通道
    fmt.Println(<-replyCh) // 步骤6:从回复通道读结果,输出 42
}

为什么需要嵌套 channel

常见场景:调用方不想暴露自己的主 channel,只想给对方一个"私密回话线路"。把 chan 当参数/元素传递,等于把通信能力交给对方,比全局共享一个 channel 更解耦。

另一个高频写法——高阶 channel 配合 chan<- / <-chan 限定方向:

package main

import "fmt"

// 步骤1:入参是"只接收方向 channel 的发送端"——
// 调用方只能往里塞一个只收 channel,类型系统保证安全
func deliver(out chan<- <-chan int) {
    inner := make(chan int, 1)
    inner <- 7
    out <- inner // 步骤2:把只收 channel 传出
}

func main() {
    box := make(chan (<-chan int), 1)
    deliver(box)
    r := <-box
    fmt.Println(<-r) // 输出 7
}

考点总结

  • channel 是头等类型,可以作函数参数、返回值,也能作为另一个 channel 的元素类型(chan chan int)。
  • 嵌套 channel 的价值在于"传递通信能力"——把一条专属回复通道交给对方,而非共享全局 channel,耦合更低。
  • 配合方向限定(chan<-<-chan)可在编译期约束"谁只能发、谁只能收",避免误用。
  • 注意括号:chan (<-chan int) 表示"元素是只收 channel 的 channel",方向符号的绑定范围靠括号明确。

15、用 Channel 实现多生产者多消费者消息队列

15.1 用生活类比先建立直觉

想象一个快递分拣中心:

  • 带缓冲的 channel = 中心门口的暂存传送带:快递员(生产者)把包裹丢上传送带就走,不用等分拣员(消费者)当场接手;传送带满了快递员才排队等。
  • 多生产者 = 多个快递员同时往同一条传送带丢包裹——这就是扇入(fan-in):多条来源汇入一个队列。
  • 多消费者 = 多个分拣员同时从传送带取包裹处理——这就是扇出(fan-out):一个队列分发给多个处理者,谁有空谁取,天然负载均衡。
  • close 广播退出 = 下班铃:所有快递员都送完了,主管拉下电闸(close(channel)),传送带空转一圈后停止,分拣员看到"没货了 + 电闸关了"就收拾下班。关键是所有分拣员靠同一个 close 信号同时收到"收工"通知,不需要挨个去叫。
  • WaitGroup = 主管手里的点名册:记下"几个分拣员还在岗",全部签退(Done)后才能锁门走人。

桥接: 多生产者多消费者消息队列 = 一个带缓冲 channel + 多 goroutine 往里写 + 多 goroutine 从里读 + 写完后 close 广播 + WaitGroup 等所有人收工。Go 的 for range ch 在 channel close 后会自动退出,这正是"广播退出"的天然实现,比手动发 N 个退出信号优雅得多。

graph LR
    subgraph 多生产者["多生产者 (fan-in 扇入)"]
        P1["生产者 P1"]
        P2["生产者 P2"]
        P3["生产者 P3"]
    end
    Q["带缓冲 channel
消息队列"] P1 --> Q P2 --> Q P3 --> Q subgraph 多消费者["多消费者 (fan-out 扇出)"] C1["消费者 C1"] C2["消费者 C2"] end Q --> C1 Q --> C2 close["close(queue)
广播退出"] --> Q

15.2 工程要点

知识点 15:用 Channel 实现多生产者多消费者消息队列

四个关键设计点:

设计点怎么做为什么
缓冲队列make(chan T, N) 带缓冲解耦生产/消费速率,生产者无需等消费者就绪
多生产者多个 goroutine 写同一 channel(扇入)提高投递吞吐
多消费者多个 goroutine for range 读同一 channel(扇出)提高处理并行度,天然负载均衡
close 广播退出所有生产者完成后 close(ch)for range 收到 close 自动退出,实现"广播收工"
WaitGroup 等待consumerWG.Wait() 等所有消费者收工主协程不提前退出,保证全部任务处理完

完整可运行代码:3 生产者 / 2 消费者

下面这份代码可以直接 go run:3 个生产者并发投递任务,2 个消费者并发处理,生产者全部结束后 close 广播,WaitGroup 等消费者收工。

package main

import (
	"fmt"
	"sync"
	"time"
)

// Task 是队列中传递的消息
type Task struct {
	ID   int
	From string
}

func main() {
	// 步骤1:带缓冲的 channel 充当消息队列(缓冲削峰,生产者无需等消费者就绪)
	queue := make(chan Task, 10)

	var producerWG, consumerWG sync.WaitGroup

	// 步骤2:启动 3 个生产者(多生产者 = 扇入 fan-in 到同一个队列)
	producerCount := 3
	for p := 1; p <= producerCount; p++ {
		producerWG.Add(1)
		go func(pid int) {
			defer producerWG.Done()
			for i := 1; i <= 5; i++ {
				task := Task{ID: (pid-1)*5 + i, From: fmt.Sprintf("P%d", pid)}
				queue <- task // 缓冲没满就直接入队,满了才阻塞等待消费者取走
				fmt.Printf("生产者 P%d 投递任务 %d\n", pid, task.ID)
				time.Sleep(20 * time.Millisecond)
			}
		}(p)
	}

	// 步骤3:启动 2 个消费者(多消费者 = 扇出 fan-out 从同一个队列取)
	consumerCount := 2
	for c := 1; c <= consumerCount; c++ {
		consumerWG.Add(1)
		go func(cid int) {
			defer consumerWG.Done()
			// 步骤4:用 for range 持续消费,channel 被 close 后自动退出(close 广播)
			for task := range queue {
				fmt.Printf("消费者 C%d 处理任务 %d (来自 %s)\n", cid, task.ID, task.From)
				time.Sleep(30 * time.Millisecond)
			}
			fmt.Printf("消费者 C%d 收到关闭信号,退出\n", cid)
		}(c)
	}

	// 步骤5:等所有生产者投递完毕,再 close 队列——这是“关闭广播退出”的关键
	//        close 后所有 for range 的消费者会立即收到信号并退出
	producerWG.Wait()
	close(queue)

	// 步骤6:等所有消费者处理完剩余任务并退出
	consumerWG.Wait()
	fmt.Println("所有任务处理完成,消息队列关闭")
}

运行结果(节选)会看到任务被 2 个消费者瓜分处理,且最后两个消费者都打印"收到关闭信号,退出",主协程才结束——这就是扇出 + close 广播 + WaitGroup 收工的完整闭环。

扇入 / 扇出的两种写法

上面的"多生产者写同一 channel"是扇入的最简形式。更函数式的扇入(把多个上游 channel 合并成一个)在第 4 节 fanIn 中已经演示过;这里再强调二者在本场景的关系:

  • 扇入(fan-in):多个生产者 → 一个队列。本例直接用"多 goroutine 写同一 channel"实现,无需额外合并函数。
  • 扇出(fan-out):一个队列 → 多个消费者。for range 同一 channel 的多个 goroutine 天然就是扇出,channel 内部用锁保证每个元素只被一个消费者取走,不会重复处理。

⚠️ 新手必踩的坑: channel 只能由发送方关闭,且只能关一次。多个消费者去 close 会 panic;重复 close 也会 panic;向已关闭的 channel 发送同样 panic。正确做法:用 producerWG.Wait() 等所有生产者 Done 后,由"唯一的协调者"(这里是 main)统一 close(queue),消费者只负责 for range 接收,绝不碰 close。这也是为什么不能用"消费者负责关闭"——否则多消费者场景下必 panic。

⚠️ 腾讯原题拆解: “实现消息队列(多生产者,多消费者)"。标准回答骨架:① 用带缓冲 channel 当队列 make(chan T, N);② 启 N 个 goroutine 当生产者,并发 ch <- task;③ 启 M 个 goroutine 当消费者,用 for task := range ch 持续取;④ 用一个 producerWG 等所有生产者结束,结束后 close(ch) 广播退出;⑤ 用一个 consumerWG 等所有消费者处理完剩余任务后主协程再退出。核心一句话:多写多读 + close 广播 + 两个 WaitGroup 就是 Go 版消息队列的最小骨架。

16、自测题与动手练习

自测题(5道)

1. hchan 结构体中,sendqrecvq 分别在什么条件下会有 goroutine 入队?

2. 向已关闭的 channel 发送数据会发生什么?从中接收数据呢?buf 中还有数据时接收返回什么?

3. select 中有 3 个 case 都就绪了,会选哪一个?底层靠什么机制保证公平?

4. 为什么说"用 len(ch) < cap(ch) 判断后再写入"不安全?正确方式是什么?

5. nil channel 在 select 中有什么特殊行为?如何利用它动态启停某个 case?

点击查看自测题参考答案

1. sendq 入队条件:buf 已满(qcount == dataqsiz)或无缓冲 channel,且无等待的接收者。recvq 入队条件:buf 为空且无等待的发送者。

2. 向已关闭 channel 发送会 panic: send on closed channel。接收不 panic:buf 有数据时正常返回且 ok=true;buf 空时返回零值且 ok=false

3. select 随机选一个就绪 case。靠 pollorder(case 索引随机排列)保证公平,避免饥饿。

4. len(ch) 和后续写入非原子——中间可能被其他 g 填满。正确方式是 select + default(底层加锁后判断,原子操作)。

5. nil channel 在 select 中永不就绪。运行时把 channel 变量设为 nil 可"禁用"该 case,设回非 nil 可"启用”,实现动态启停。

动手练习(3个)

练习1:实现 worker pool — 创建 3 个 worker 的协程池,主协程向 jobs channel 发送 20 个任务,worker 处理后结果写入 results channel,最后打印所有结果。注意 WaitGroup 和 close 的正确使用。

练习2:用 select 实现心跳机制 — 一个 goroutine 每 500ms 产生数据写入 channel,另一个 goroutine 用 select 同时监听数据 channel 和 1 秒间隔的 heartbeat ticker。3 秒内没收到数据则打印"超时"并退出。提示:用 time.NewTicker

练习3:用 context 实现级联取消 — 创建父 context,启动 3 个子 goroutine 各再启动 2 个孙 goroutine(共 6 个孙)。主协程等待 2 秒后调用 cancel(),用 WaitGroup 验证全部 6 个孙 goroutine 都能收到信号并退出。

练习4:实现一个带缓冲的消息队列 — 用 make(chan int, 8) 实现一个多生产者多消费者队列:启动 4 个生产者各发送 10 个整数(1~40 乱序),启动 3 个消费者把所有收到的数累加。等到所有生产者完成 close(ch),所有消费者退出后,主协程打印累加和(应为 820)。注意必须用 WaitGroup 正确等待生产者与消费者两组 goroutine。

练习5:扇入合并多路数据 — 创建 3 个生产者 goroutine,分别向 3 个独立的 channel 发送 15、610、1115;写一个 fanIn 函数(参考第 4 节)把这 3 个 channel 合并成 1 个,并在主协程 for range 合并后的 channel,验证 115 全部按任意顺序被收到、且只收到一次(无重复、无遗漏)。

17、本章小结

  • hchan 是 channel 的运行时底座buf 环形队列暂存数据,sendq/recvq 双向链表管理阻塞 goroutine,mutex 保证并发安全,closed 控制生命周期。面试时能画出结构图是基本要求。
  • 收发流程的核心是"三段式判断":发送时先看 recvq 有没有接收者(直接交接),再看 buf 有没有空位(入 buf),都不行就入 sendq 阻塞;接收时对称处理。close 后只能接收不能发送,buf 排空后返回零值。
  • 阻塞不是终点而是工具:无缓冲 channel 的同步阻塞可保证顺序,select+default 做非阻塞尝试,select+time.After 做超时控制,buffered channel 做削峰填谷。
  • select 底层三件套scase 数组记录 case,pollorder 随机排列保证公平,lockorder 按地址排序加锁防死锁。case 越多调度开销越大,两个 channel 稳定、三个开始抖动。
  • 6 种通信模式是并发编程积木:信号通知、超时控制、退出信号、扇出、扇入、Pipeline。生产消费用扇出,多源合并用扇入,多阶段加工用 Pipeline。
  • Channel vs Mutex 不是二选一:CSP 哲学倡导"通过通信共享内存",但保护简单共享状态时 mutex 更直接高效。两者组合使用是工程常态。
  • 安全使用 channel 三条铁律:不向已关闭的 channel 发送、不重复关闭、谁创建谁关闭。nil channel 在 select 中永不就绪是高级技巧。

掌握了 channel 的底层结构和通信模式后,下一章我们将进入 Go 并发调度的深水区——GMP 调度模型,看看 goroutine 如何被调度器在操作系统线程之间高效切换,以及 channel 阻塞时调度器如何让出 CPU。

复习提示:
  • channel 本质是 CSP 哲学的实现:不是共享内存后的同步工具,而是"通过通信来共享内存"。优先用 channel 传递所有权,而非用 mutex 保护共享状态。
  • nil channel 是并发控制的高级技巧select 中对 nil channel 的操作永不就绪,可用于动态启停 case(运行时将 channel 变量设为 nil 禁用,恢复为合法值启用)。
  • select 公平性依赖 pollorder 随机化:多个 case 同时就绪时随机选择,避免某些消费者永远抢不到消息。
  • channel 关闭是广播机制close(ch) 后所有阻塞的接收者立即返回零值,所有 for range 循环终止——这是多消费者协调退出的核心手段。
面试官
为什么 nil channel 在 select 中永不就绪?这个特性有什么工程价值?
候选人
原因:Go 语言规范明确规定,对 nil channel 发送数据会永久阻塞,从 nil channel 接收也会永久阻塞。

工程价值:可以用它做 动态 case 启停

// 初始时 disabledCh 为 nil
select {
  case <-enabledCh:
    // 处理 enabled 事件
  case <-disabledCh: // 永远不会就绪
    // 永远不会走到这里
}

当需要"禁用"某个 case 时,只需将 disabledCh 设为 nil;需要"启用"时再赋予合法的 channel。

典型应用场景
带条件的超时:只有在特定条件下才启用超时
动态路由:根据运行状态切换不同的处理逻辑
协程池退出:关闭某个消费者的 channel 来优雅退出

面试加分点:提到这与 context .cancel 的区别——channel nil 是 单向、不可恢复的禁用,而 context 可以取消后重新创建新的 context 重新启用。
复习提示:
  • Channel 本质是 CSP 哲学的实现:不是共享内存后的同步工具,而是"通过通信来共享内存"。优先用 channel 传递所有权,而非用 mutex 保护共享状态。
  • nil channel 是并发控制的高级技巧select 中对 nil channel 的操作永不就绪,可用于动态启停 case。
  • select 公平性依赖 pollorder 随机化:多个 case 同时就绪时随机选择,避免某些消费者永远抢不到消息。
  • channel 关闭是广播机制close(ch) 后所有阻塞的接收者立即返回零值,所有 for range 循环终止。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!