目录

Go-15 channel 与 select 原理

goroutine 是 Go 并发的执行单元,而 channel 则是 goroutine 之间通信与同步的"管道"。理解 channel 的底层 hchan 结构、发送/接收的阻塞与唤醒机制、关闭规则以及 select 多路复用原理,是写出正确、高效并发程序的前提。本章从 CSP 模型讲起,一直深入到运行时源码级别的实现。


1. CSP 并发模型

Go 的并发哲学源于 Tony Hoare 在 1978 年提出的 CSP(Communicating Sequential Processes,通信顺序进程) 模型。它的核心思想被 Rob Pike 总结成一句广为流传的话:

Do not communicate by sharing memory; instead, share memory by communicating. 不要通过共享内存来通信,而要通过通信来共享内存。

1.1 两种并发范式对比

传统的多线程编程依赖共享内存 + 锁:多个线程访问同一块内存,用互斥锁保护临界区。这种模型的问题在于:数据被多个执行体"共享拥有",谁都能改,正确性完全依赖程序员正确地加锁、解锁,极易出现数据竞争、死锁。

CSP 则强调:数据在同一时刻只被一个 goroutine 拥有,goroutine 之间通过 channel 传递数据的所有权。发送方把数据放进 channel 后就不再碰它,接收方拿到后独占使用。

共享内存模型(易出错):G1、G2 抢锁读写同一块共享变量

G1 ─┐
    ├─► ┌──────────────────┐
G2 ─┘   │ shared var + lock│   ← 都要加锁访问同一变量
        └──────────────────┘

CSP 模型(Go 推崇):数据通过 channel 从 G1 交给 G2,所有权转移

┌──────┐   send data     ┌──────┐
│ G1   │ ──────────────► │ G2   │   ← 接收后独占,无需锁
└──────┘                 └──────┘

1.2 Go 的选择

需要强调:Go 并不禁止共享内存 + 锁(sync.Mutexsync.atomic 都在标准库里),CSP 只是被推荐的首选。原则是:

  • 传递数据所有权、编排流程 → 用 channel。
  • 保护某个共享状态的读写(如计数器、缓存)→ 用 Mutex/atomic 往往更简单高效。

第 10 节会专门讨论两者的取舍。


2. channel 基础

2.1 创建与基本收发

channel 是引用类型,必须用 make 创建;零值是 nil

package main

import "fmt"

func main() {
	// 无缓冲 channel:容量为 0
	ch1 := make(chan int)

	// 有缓冲 channel:容量为 3
	ch2 := make(chan int, 3)

	go func() {
		ch1 <- 42 // 发送:把 42 送入 channel
	}()

	v := <-ch1 // 接收:从 channel 取出值
	fmt.Println("收到:", v)

	// 有缓冲 channel 在未满时发送不会阻塞
	ch2 <- 1
	ch2 <- 2
	fmt.Println("len:", len(ch2), "cap:", cap(ch2)) // len: 2 cap: 3
}
  • ch <- v:发送,把 v 送入 channel。
  • v := <-ch:接收。
  • v, ok := <-ch:接收并判断 channel 是否已关闭(ok == false 表示已关闭且无数据)。
  • len(ch):缓冲区中当前元素个数;cap(ch):缓冲区容量。

2.2 有缓冲 vs 无缓冲

特性 无缓冲 make(chan T) 有缓冲 make(chan T, n)
容量 0 n
发送阻塞条件 没有接收者时立即阻塞 缓冲区满时才阻塞
接收阻塞条件 没有发送者时立即阻塞 缓冲区空时才阻塞
同步性 同步(发送和接收必须"碰面") 异步(缓冲区解耦收发速度)
典型用途 信号同步、保证时序 流量削峰、生产者消费者

无缓冲 channel 的发送与接收是一次握手(rendezvous):发送方会一直阻塞,直到有接收方同时就绪,数据直接从发送方拷贝到接收方。这保证了强同步语义——发送成功即意味着对方已收到。

package main

import (
	"fmt"
	"time"
)

func main() {
	done := make(chan struct{}) // 无缓冲,常用作信号

	go func() {
		fmt.Println("worker: 开始工作")
		time.Sleep(100 * time.Millisecond)
		fmt.Println("worker: 完成")
		done <- struct{}{} // 发送信号
	}()

	<-done // 阻塞等待,直到 worker 发送信号
	fmt.Println("main: 收到完成信号")
}

chan struct{} 表示纯信号,struct{}{} 不占内存,语义清晰。

2.3 单向 channel(方向约束)

channel 可以约束方向,用于函数签名,增强类型安全与可读性:

// chan<- T:只能发送(send-only)
func producer(out chan<- int) {
	for i := 0; i < 5; i++ {
		out <- i
	}
	close(out)
}

// <-chan T:只能接收(receive-only)
func consumer(in <-chan int) {
	for v := range in {
		fmt.Println("消费:", v)
	}
}

func main() {
	ch := make(chan int) // 双向
	go producer(ch)      // 自动转为 chan<- int
	consumer(ch)         // 自动转为 <-chan int
}

双向 channel 可隐式转换为单向 channel,反之不行。方向约束在编译期检查:向 <-chan 发送会直接编译报错,能把很多误用挡在编译阶段。


3. channel 底层结构 hchan

channel 的运行时实现是 runtime/chan.go 中的 hchan 结构体:

// runtime/chan.go(简化)
type hchan struct {
	qcount   uint           // 缓冲区中当前元素个数(len)
	dataqsiz uint           // 缓冲区容量(cap)
	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 所有字段
}

type waitq struct {
	first *sudog // 队首
	last  *sudog // 队尾
}

3.1 各字段职责

  • buf:一块连续内存,作为环形缓冲区。仅有缓冲 channel 才分配。
  • sendx / recvx:环形缓冲区的写指针和读指针。写到末尾后回绕到 0。
  • qcount / dataqsiz:当前元素数 / 总容量,即 len / cap
  • recvq / sendq:因收发被阻塞的 goroutine 组成的等待队列,元素是 sudog(对 goroutine 的封装,记录了要收发的数据地址)。
  • lock:一把互斥锁。channel 的所有操作(发送、接收、关闭)都要先抢这把锁,因此 channel 本身是并发安全的。

3.2 环形缓冲区 ASCII 图

make(chan int, 4) 为例,已发送 3 个元素、接收 1 个后的状态:

hchan
┌────────────────────────────────────────────┐
│ qcount=2  dataqsiz=4  closed=0  lock       │
│ sendx=3   recvx=1                          │
│ recvq: [] (empty)                          │
│ sendq: [] (empty)                          │
│ buf ─────────────┐                         │
└──────────────────┼─────────────────────────┘
                   ▼
      索引:  0     1     2     3
          ┌─────┬─────┬─────┬─────┐
     buf: │ old │  B  │  C  │  -  │
          └─────┴─────┴─────┴─────┘
                   ▲           ▲
                 recvx=1     sendx=3
        下次接收从这里读     下次发送写这里

qcount=2 → 缓冲区里还有 B、C 两个元素待接收
sendx 与 recvx 都会在写/读后 +1,到达 dataqsiz 后回绕到 0

环形缓冲区避免了元素移动:出队不需要把后面的元素往前搬,只需移动 recvx 指针,O(1) 完成。

3.3 sudog

sudog 是"等待中的 goroutine"在 channel 队列里的表示:

type sudog struct {
	g        *g             // 关联的 goroutine
	next     *sudog         // 队列中下一个
	prev     *sudog
	elem     unsafe.Pointer // 数据地址:发送时指向要发的值,接收时指向接收变量
	c        *hchan         // 所属 channel
	// ...
}

正是 elem 字段记录了收发数据的地址,才使得后面要讲的"跨 goroutine 直接内存拷贝"成为可能。


4. 发送流程(chansend)

向 channel 发送数据 ch <- v,运行时调用 chansend。加锁后按以下优先级处理:

                     ch <- v
                        │
        ┌───────────────▼───────────────────┐
        │ channel == nil?                   │──是──► 永久阻塞(gopark)
        └───────────────┬───────────────────┘
                        │否
        ┌───────────────▼───────────────────┐
        │ closed?                           │──是──► panic("send on closed channel")
        └───────────────┬───────────────────┘
                        │否
        ┌───────────────▼───────────────────┐
        │ ① recvq has waiting receiver?     │──是──► 直接把 v 拷贝给接收者,唤醒它
        └───────────────┬───────────────────┘        (跳过缓冲区!)
                        │否
        ┌───────────────▼───────────────────┐
        │ ② buf not full (qcount<dataqsiz)? │──是──► 拷贝到 buf[sendx],sendx++,qcount++
        └───────────────┬───────────────────┘
                        │否
        ┌───────────────▼───────────────────┐
        │ ③ else: pack sudog -> sendq,      │
        │ gopark blocks current goroutine   │
        └───────────────────────────────────┘

4.1 情况①:直接拷贝给接收者(关键优化)

recvq 中已有等待的接收者时,发送方不经过缓冲区,直接把数据从发送方的栈拷贝到接收方的接收变量,然后唤醒接收方。这就是 Go channel 著名的直接内存拷贝优化

// runtime/chan.go(示意)
func send(c *hchan, sg *sudog, ep unsafe.Pointer, ...) {
	// ep:发送方数据地址;sg.elem:接收方变量地址
	if sg.elem != nil {
		sendDirect(c.elemtype, sg, ep) // 直接 memmove,绕过 buf
	}
	goready(gp, ...) // 唤醒接收者 goroutine
}

对无缓冲 channel,这是唯一的正常发送路径(因为没有 buf);对有缓冲 channel,只有在缓冲区为空且恰好有接收者阻塞时才会走这条路。少一次拷贝(本应"发送方→buf→接收方"两次,现在只需一次),是重要的性能收益。

4.2 情况②③示例

package main

import (
	"fmt"
	"time"
)

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

	ch <- 1 // ② 缓冲区未满,写入 buf,不阻塞
	ch <- 2 // ② 缓冲区满
	fmt.Println("已放入 2 个,未阻塞")

	go func() {
		time.Sleep(time.Second)
		fmt.Println("接收:", <-ch) // 腾出一个空位
	}()

	ch <- 3 // ③ 缓冲区已满且无接收者 → 阻塞,1 秒后被唤醒
	fmt.Println("第 3 个也放进去了")
}

5. 接收流程(chanrecv)

接收 v := <-ch 调用 chanrecv,与发送对称:

                     v, ok := <-ch
                        │
        ┌───────────────▼───────────────┐
        │ channel == nil?               │──是──► 永久阻塞
        └───────────────┬───────────────┘
                        │否
        ┌───────────────▼───────────────┐
        │ ① sendq has waiting sender?   │──是──► 交给等待的发送者(无缓冲直接拷贝, 有缓冲取 buf[recvx] 后补位)
        └───────────────┬───────────────┘
                        │否
        ┌───────────────▼───────────────┐
        │ ② buffer has data (qcount>0)? │──是──► 取 buf[recvx] 给接收方
        └───────────────┬───────────────┘
                        │否
        ┌───────────────▼───────────────┐
        │ ③ closed and buffer empty?    │──是──► 返回零值, ok=false
        └───────────────┬───────────────┘
                        │否(未关闭且无数据)
        ┌───────────────▼───────────────┐
        │ ④ enter recvq, gopark block   │
        └───────────────────────────────┘

5.1 情况①的巧妙处理

当有缓冲 channel 的缓冲区已满、sendq 里堆着等待发送者时,接收方会:先取出 buf[recvx] 给自己,然后把队首发送者的值补进刚空出的 buf 位置,最后唤醒该发送者。这样保证了FIFO 顺序且缓冲区始终被充分利用。

package main

import "fmt"

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

	// 从已关闭 channel 接收
	ch2 := make(chan int)
	close(ch2)
	v, ok := <-ch2
	fmt.Println(v, ok) // 0 false —— 情况③

	fmt.Println(<-ch) // 100 —— 情况②
}

6. 关闭 channel 的规则

close(ch) 用于关闭 channel,通常表达"不会再有数据发送了"。它有一套必须牢记的规则,违反会引发 panic。

6.1 五条规则

操作 结果
close 已关闭的 channel panic:close of closed channel
close 值为 nil 的 channel panic:close of nil channel
向已关闭 channel 发送 panic:send on closed channel
从已关闭 channel 接收(缓冲区仍有数据) 正常返回剩余数据,ok == true
从已关闭 channel 接收(缓冲区已空) 立即返回零值ok == false,不阻塞
package main

import "fmt"

func main() {
	ch := make(chan int, 2)
	ch <- 1
	ch <- 2
	close(ch)

	// 关闭后仍可取出缓冲区中已有的数据
	fmt.Println(<-ch) // 1, ok=true
	fmt.Println(<-ch) // 2, ok=true

	v, ok := <-ch
	fmt.Println(v, ok) // 0 false —— 缓冲区空且已关闭

	// 以下每一行单独执行都会 panic:
	// close(ch)      // panic: close of closed channel
	// ch <- 3        // panic: send on closed channel
	// var nc chan int
	// close(nc)      // panic: close of nil channel
}

6.2 close 的广播特性

close唤醒所有阻塞在 recvq 上的接收者,它们都会得到零值 + ok=false。这个特性使 close 成为一对多"广播"的绝佳工具(见第 9 节优雅退出)。

package main

import (
	"fmt"
	"sync"
)

func main() {
	done := make(chan struct{})
	var wg sync.WaitGroup

	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			<-done // 3 个 goroutine 同时阻塞在这里
			fmt.Printf("worker %d 收到退出信号\n", id)
		}(i)
	}

	close(done) // 一次 close,三个 goroutine 全被唤醒
	wg.Wait()
}

6.3 谁来关闭:发送者原则

核心原则:channel 应该由发送者关闭,而不是接收者关闭;且不要在有多个发送者时随意 close

原因:向已关闭 channel 发送会 panic。只有发送者知道"是否还会再发",因此由它来 close 才安全。接收者关闭可能导致其他发送者写入 panic。

  • 一个发送者、一个/多个接收者:由该发送者 close,最简单安全。
  • 多个发送者:不能让任一发送者 close(可能别人还在发)。惯用做法是引入一个专门的 stop channel 作为退出信号,由接收方 close(stop),发送方通过 select 感知后自行退出,不再 close 数据 channel。
// 多发送者场景:用独立的 stop channel 广播退出,谁都不 close(dataCh)
func multiSender(dataCh chan<- int, stop <-chan struct{}) {
	for i := 0; ; i++ {
		select {
		case <-stop:
			return // 收到退出信号,直接返回,绝不 close(dataCh)
		case dataCh <- i:
		}
	}
}

7. nil channel 的用途

nil channel 的操作有明确定义:

操作 行为
发送到 nil channel 永久阻塞
从 nil channel 接收 永久阻塞
close(nil) panic

“永久阻塞"看似无用,实则在 select 中极其有用:把某个 case 的 channel 置为 nil,就能动态"禁用"这个 case(因为 nil channel 永远不就绪,select 会忽略它)。

package main

import "fmt"

// 合并两个输入流,任一关闭后就"关掉"对应 case,避免空转
func merge(in1, in2 <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for in1 != nil || in2 != nil { // 两个都关了才退出
			select {
			case v, ok := <-in1:
				if !ok {
					in1 = nil // 关键:置 nil 后,该 case 永不再触发
					continue
				}
				out <- v
			case v, ok := <-in2:
				if !ok {
					in2 = nil
					continue
				}
				out <- v
			}
		}
	}()
	return out
}

func main() {
	a := make(chan int)
	b := make(chan int)
	go func() { a <- 1; a <- 2; close(a) }()
	go func() { b <- 10; close(b) }()

	for v := range merge(a, b) {
		fmt.Println(v)
	}
}

若不置 nil,已关闭的 channel 会永远就绪(立即返回零值),导致 select 疯狂空转、CPU 飙升。置 nil 是处理"部分输入已结束"的标准技巧。


8. select 多路复用

select 让一个 goroutine 同时等待多个 channel 操作,哪个先就绪就执行哪个。

8.1 基本语法与随机性

package main

import "fmt"

func main() {
	c1 := make(chan string, 1)
	c2 := make(chan string, 1)
	c1 <- "来自 c1"
	c2 <- "来自 c2"

	// 两个 case 都就绪时,select 随机选一个(防止饥饿)
	select {
	case msg := <-c1:
		fmt.Println(msg)
	case msg := <-c2:
		fmt.Println(msg)
	}
}

当多个 case 同时就绪,select 随机(伪随机、均匀)选择一个执行,而不是按代码顺序。这是刻意设计,避免固定顺序造成某些 case 长期饥饿。

8.2 default:非阻塞收发

default 的 select 在没有任何 case 就绪时立即执行 default,从而实现非阻塞操作:

select {
case v := <-ch:
	fmt.Println("收到:", v)
default:
	fmt.Println("没有数据,不阻塞") // 立即走这里
}

// 非阻塞发送
select {
case ch <- v:
	fmt.Println("发送成功")
default:
	fmt.Println("channel 满了,丢弃") // 常用于限流/丢包
}

8.3 超时模式 time.After

time.After 返回一个在指定时间后收到值的 channel,配合 select 实现超时控制:

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int)
	go func() {
		time.Sleep(2 * time.Second)
		ch <- 42
	}()

	select {
	case v := <-ch:
		fmt.Println("收到:", v)
	case <-time.After(time.Second): // 1 秒超时
		fmt.Println("超时了!")
	}
}

注意:time.After 在触发前不会被 GC,在高频循环里反复调用可能造成短时内存堆积。对性能敏感或高频场景,应改用可复用的 time.NewTimer 并配合 Stop()/Reset()

8.4 空 select 与永久阻塞

select {} // 永久阻塞当前 goroutine,且不占 CPU。常用于 main 中挂起

8.5 selectgo 原理

select 由运行时函数 selectgo 实现,核心流程:

selectgo 执行步骤:
1. 生成两个随机序:pollorder(轮询顺序,打乱 case)
                   lockorder(按 channel 地址排序,用于按序加锁防死锁)
2. 按 lockorder 给所有涉及的 channel 加锁
3. 按 pollorder 遍历所有 case,检查是否有立即就绪的:
      - 有就绪 → 执行它,解锁,返回
      - 有 default 且无就绪 → 执行 default,返回
4. 无就绪、无 default:
      - 把当前 goroutine 打包成 sudog,挂到每个 case channel 的 recvq/sendq
      - gopark 阻塞
5. 被某个 channel 唤醒后:
      - 从其他 channel 的等待队列里摘除自己
      - 执行被唤醒的那个 case

要点:pollorder 的随机洗牌就是 select “随机选择"的来源lockorder 按地址排序加锁是为了避免多个 select 并发时因加锁顺序不一致导致死锁。

8.6 编译期优化:并非所有 select 都会走 selectgo

selectgo 是通用但较重的路径。编译器会先看 select 的形态,大多数简单情况会被直接改写成更轻的原语,根本不进 selectgo

select 形态 编译期改写为 说明
select{} block() 直接挂起当前 goroutine(对应 §8.4)
单个 case if ch == nil { block }; <收发> 先判 channel 是否为 nil,再执行该 case,等价于一次普通收发
单 case + default selectnbrecv / selectnbsend 非阻塞收发原语实现,不阻塞
两个及以上 case selectgo 才走上面的通用随机选择流程

所以「select 一定随机、一定加锁」是不准确的——只有多 case 才真正进 selectgo

8.7 陷阱:case 中的表达式都会求值

一个易错点:select对每个 case 的操作数表达式从上到下、从左到右全部求值,但真正"选中哪个 case 收发"仍是随机的。注意区分「表达式求值」与「收发动作」:

select {
case getChan("chan A") <- getInt("int A"): // getChan、getInt 都会被调用
	fmt.Println("A 被选中")
case getChan("chan B") <- getInt("int B"): // 同样都会被调用
	fmt.Println("B 被选中")
default:
	fmt.Println("default")
}
// 输出会先打印 4 行 "chan A / int A / chan B / int B"(求值副作用),
// 再打印最终选中的分支。

因此别把有副作用的函数调用写进 case 表达式,否则不管该 case 是否被选中,副作用都会发生。


9. 经典并发模式

9.1 生产者—消费者

func producerConsumer() {
	jobs := make(chan int, 10)

	// 生产者
	go func() {
		defer close(jobs) // 发送者负责关闭
		for i := 0; i < 20; i++ {
			jobs <- i
		}
	}()

	// 消费者:for-range 自动在 channel 关闭且取完后退出
	for j := range jobs {
		fmt.Println("处理:", j)
	}
}

9.2 fan-out / fan-in

fan-out:多个 goroutine 从同一 channel 读,分摊工作。 fan-in:把多个 channel 的输出合并到一个 channel。

package main

import (
	"fmt"
	"sync"
)

// fan-out:启动 n 个 worker 共同消费 in
func fanOut(in <-chan int, n int, work func(int) int) []<-chan int {
	outs := make([]<-chan int, n)
	for i := 0; i < n; i++ {
		out := make(chan int)
		go func() {
			defer close(out)
			for v := range in {
				out <- work(v)
			}
		}()
		outs[i] = out
	}
	return outs
}

// fan-in:合并多个 channel
func fanIn(chans ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	for _, c := range chans {
		wg.Add(1)
		go func(ch <-chan int) {
			defer wg.Done()
			for v := range ch {
				out <- v
			}
		}(c)
	}
	go func() { wg.Wait(); close(out) }() // 所有源关闭后关闭 out
	return out
}

func main() {
	in := make(chan int)
	go func() {
		defer close(in)
		for i := 1; i <= 9; i++ {
			in <- i
		}
	}()

	outs := fanOut(in, 3, func(x int) int { return x * x })
	for r := range fanIn(outs...) {
		fmt.Println(r)
	}
}

9.3 pipeline(流水线)

每个阶段是一个 goroutine,通过 channel 串联,前一阶段的输出是后一阶段的输入:

func gen(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			out <- n
		}
	}()
	return out
}

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 pipeline() {
	// gen → square → square
	for v := range square(square(gen(2, 3))) {
		fmt.Println(v) // 16, 81
	}
}

9.4 信号量(限制并发数)

用有缓冲 channel 当计数信号量,控制同时运行的 goroutine 数量:

func semaphore() {
	const maxConcurrent = 3
	sem := make(chan struct{}, maxConcurrent) // 容量 = 并发上限
	var wg sync.WaitGroup

	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			sem <- struct{}{}        // 获取令牌(满了会阻塞)
			defer func() { <-sem }() // 释放令牌
			fmt.Printf("任务 %d 运行中\n", id)
			time.Sleep(time.Second)
		}(i)
	}
	wg.Wait()
}

9.5 优雅退出(done channel + context)

关闭 done channel 广播退出信号,是控制 goroutine 生命周期的经典手法(context.Context 内部正是这个原理):

func worker(id int, done <-chan struct{}) {
	for {
		select {
		case <-done:
			fmt.Printf("worker %d 退出\n", id)
			return
		default:
			// 做一小步工作
			time.Sleep(200 * time.Millisecond)
		}
	}
}

func gracefulShutdown() {
	done := make(chan struct{})
	for i := 0; i < 3; i++ {
		go worker(i, done)
	}
	time.Sleep(time.Second)
	close(done) // 广播:所有 worker 收到并退出
	time.Sleep(300 * time.Millisecond)
}

生产代码应优先使用 context.WithCancel

ctx, cancel := context.WithCancel(context.Background())
go func() {
	for {
		select {
		case <-ctx.Done(): // 等价于 done channel
			return
		default:
			// work
		}
	}
}()
cancel() // 通知退出

9.6 worker pool(工作池)

固定数量的 worker 从任务 channel 取任务,结果写入结果 channel,兼顾并发控制与资源复用:

package main

import (
	"fmt"
	"sync"
)

func workerPool() {
	const numWorkers = 3
	jobs := make(chan int, 100)
	results := make(chan int, 100)
	var wg sync.WaitGroup

	// 启动固定数量的 worker
	for w := 1; w <= numWorkers; w++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := range jobs { // jobs 关闭后自动退出
				results <- j * 2
			}
		}(w)
	}

	// 投递任务
	for j := 1; j <= 9; j++ {
		jobs <- j
	}
	close(jobs) // 所有任务投递完毕

	// 等所有 worker 完成后关闭 results
	go func() { wg.Wait(); close(results) }()

	sum := 0
	for r := range results {
		sum += r
	}
	fmt.Println("结果总和:", sum) // 90
}

10. 优化与陷阱

10.1 channel 泄漏(goroutine 泄漏)

最常见的坑:一个 goroutine 阻塞在 channel 收发上,永远等不到对端,导致它和它占用的资源永不释放。

// 泄漏示例:超时后 main 走了,但 goroutine 还阻塞在 ch<-,永远无法退出
func leak() {
	ch := make(chan int) // 无缓冲
	go func() {
		result := doWork()
		ch <- result // 若下面已超时不再接收,这里永久阻塞 → 泄漏
	}()

	select {
	case <-ch:
	case <-time.After(time.Second): // 超时后没人再收 ch
	}
}

修复:给 channel 一个缓冲位,让发送方即使无人接收也能发完退出:

ch := make(chan int, 1) // 缓冲 1,发送方永不阻塞,可正常退出

或用 context 让 goroutine 感知取消并主动返回。排查手段pprof 的 goroutine profile,或 runtime.NumGoroutine() 观察数量是否持续增长。

10.2 缓冲大小如何选

  • 0(无缓冲):需要强同步/握手语义,或信号通知。默认首选,语义最清晰。
  • 1:解耦一步,常用于"发一个信号但不想阻塞发送者”(如上面修复泄漏)。
  • n(较大):生产消费速率有波动、需要削峰填谷。但缓冲不是"越大越好”——过大的缓冲会掩盖背压问题、增加内存和延迟,还可能让 bug(如消费者已死)更晚暴露。

原则:缓冲区是为了平滑速率波动,不是为了"存数据"。若你在纠结缓冲设多大,往往说明设计需要重新考量(可能该用队列/中间件)。

10.3 channel vs Mutex 的取舍

场景 推荐
传递数据所有权 / 编排流程 channel
保护共享状态的读写(计数器、map、缓存) Mutex / atomic
一对多 / 多对多的事件通知、广播 channel(close 广播)
高频、低延迟地保护一小段临界区 Mutex(更轻,无调度开销)
需要 select 多路等待、超时 channel

经验法则(Go 官方 wiki):优先用 channel 表达数据流动和协作;当你只是要保护一块状态、且用 channel 会让代码更绕时,果断用 Mutex。 二者不是对立的,实际项目常混用。channel 每次收发都要抢锁 + 可能触发调度,因此纯粹的计数/状态保护,Mutex 或 atomic 往往快一个数量级。

10.4 其他常见陷阱

// 陷阱1:向已关闭 channel 发送 → panic。多发送者时用 stop channel(见 6.3)
// 陷阱2:range 一个永不关闭的 channel → 永久阻塞
// 陷阱3:无缓冲 channel 在同一 goroutine 内自收自发 → 立即死锁
func deadlock() {
	ch := make(chan int)
	ch <- 1  // fatal error: all goroutines are asleep - deadlock!
	<-ch
}

11. 高频面试题

Q1:channel 的底层数据结构是什么?

hchan 结构体,核心字段:buf(环形缓冲区)、sendx/recvx(读写索引)、qcount/dataqsiz(当前元素数/容量)、recvq/sendq(收发等待队列,元素是 sudog)、closed(关闭标志)、lock(互斥锁,保证并发安全)。

Q2:有缓冲和无缓冲 channel 的区别?

无缓冲容量为 0,发送和接收必须同时就绪才能完成(同步握手,数据直接从发送方拷到接收方);有缓冲在缓冲区未满时发送不阻塞、未空时接收不阻塞(异步,解耦收发速率)。无缓冲保证"发送成功即已被接收",有缓冲不保证。

Q3:close 一个 channel 会发生什么?哪些操作会 panic?

close 后:接收方能取完缓冲区剩余数据,取完后接收立即返回零值 + ok=false(不阻塞);close 会唤醒所有阻塞的接收者(广播)。三种 panic:close 已关闭的 channel、close 为 nil 的 channel、向已关闭 channel 发送。

Q4:向 nil channel 收发会怎样?有什么用?

读、写都会永久阻塞(close(nil) 则 panic)。用途:在 select 中把某个 case 的 channel 置为 nil,可以"动态禁用"该 case(nil channel 永不就绪,select 忽略它),常用于处理"部分输入流已结束"。

Q5:select 是随机选择吗?为什么?

是。当多个 case 同时就绪时,selectgo 通过 pollorder 随机洗牌决定轮询顺序,从而随机(均匀)选一个执行,目的是防止某些 case 长期饥饿。另外 lockorder 按 channel 地址排序加锁,避免并发 select 死锁。只有一个就绪则选它;都不就绪且有 default 则走 default;否则阻塞。

Q6:如何优雅地关闭 channel?多个发送者怎么办?

遵循"发送者关闭"原则。单发送者:由发送者 close。多发送者:不能让任一发送者 close(可能别人还在发会 panic),改用独立的 stop/done channel 由接收方 close 广播退出,各发送者通过 select 感知 <-stop 后自行返回,不 close 数据 channel。生产中直接用 context

Q7:如何用 channel 实现超时控制?

select 配合 time.After:一个 case 等业务 channel,另一个 case 等 <-time.After(d),谁先到走谁。高频场景改用 time.NewTimer 复用以减少内存分配。

Q8:如何用 channel 实现信号量 / 限制并发数?

用容量为 N 的有缓冲 channel:进入前 sem <- struct{}{}(满了则阻塞等待),退出时 <-sem 释放。缓冲容量即最大并发数。

Q9:for-range 一个 channel 什么时候退出?

当 channel 被 close 且缓冲区数据全部取完时,for range ch 循环自动结束。若 channel 永不关闭,range 会在取完后永久阻塞——这是常见的 goroutine 泄漏来源。

Q10:无缓冲 channel 发送时,数据是怎么传给接收方的?

若接收方已在 recvq 等待,发送方通过 sendDirect 直接把数据从自己的栈内存 memmove 到接收方的接收变量,绕过缓冲区,然后 goready 唤醒接收方。这比"发送方→buf→接收方"少一次拷贝,是 channel 的关键优化之一。

Q11:channel 和 Mutex 如何选择?

传递数据所有权、编排 goroutine 协作、需要 select/超时/广播 → 用 channel;单纯保护一小块共享状态(计数器、map)、追求低延迟 → 用 Mutex/atomic。channel 每次操作都要加锁并可能触发调度,保护状态时通常比 Mutex 慢。

Q12:channel 是并发安全的吗?

是。hchan 内含 lock,所有发送、接收、关闭操作都会先加锁,因此多个 goroutine 并发操作同一 channel 是安全的,无需额外加锁。


小结

  • CSP 模型:Go 提倡"通过通信共享内存",用 channel 传递数据所有权,替代(但不禁止)共享内存 + 锁。
  • 底层 hchan:环形缓冲区 buf + 读写索引 sendx/recvx + 收发等待队列 recvq/sendq(sudog)+ 互斥锁 lock,天生并发安全。
  • 发送/接收:优先直接把数据在收发双方之间拷贝(绕过缓冲区的关键优化),其次走缓冲区,最后才阻塞入队并 gopark。
  • 关闭规则:记牢三种 panic(重复 close、close nil、向已关闭发送);close 会广播唤醒接收者;遵循发送者关闭原则,多发送者用独立 stop channel。
  • nil channel:收发永久阻塞,用于在 select 中动态禁用 case。
  • select:随机选择就绪 case(pollorder 洗牌),default 实现非阻塞,time.After 实现超时;底层 selectgo 按地址排序加锁防死锁。
  • 并发模式:生产者消费者、fan-in/fan-out、pipeline、信号量、worker pool、done channel 优雅退出,是 channel 的高频实战套路。
  • 陷阱与取舍:警惕 goroutine 泄漏(阻塞在无人对接的 channel),谨慎选缓冲大小,保护共享状态优先 Mutex,编排数据流优先 channel。

理解 channel 的阻塞—唤醒机制和 select 的调度原理,你就掌握了 Go 并发编程最核心的一把钥匙。下一章我们将深入 sync 包与内存模型,看看 Mutex、WaitGroup、Once 等同步原语的底层实现。