Golang 杂项:make 与 select 深度总结
📚 Golang 教程系列
- 入门与基础类型
- 字符串、变量与常量
- 流程控制
- 集合类型
- 函数、指针与类型
- 结构体
- 接口
- 错误处理、并发与泛型
- 测试与工程实践
- 杂项:make 与 select(本文)
- 杂项:embed 与资源嵌入
make 是唯一带初始化语义的内置分配函数,只服务于 slice/map/channel;select 是唯一专门做 channel 多路复用的控制结构。两者散落在系列各篇(集合类型、并发篇),这篇把它们抽出来统一讲透:make 在编译器层面被重写成了什么、三类用途的共性陷阱;select 运行时如何实现「随机公平」、nil channel 为什么能动态启停分支、循环里有多少种泄漏姿势。读完你应该能回答 make([]int, 10) 和 make([]int, 0, 10) 到底分配了什么、为什么 new([]int) 拿不到能用的切片、select 多个 case 同时就绪时为什么不会饿死某个分支。
上篇:make – 带初始化的分配
一、make 的定位:为什么 Go 要发明 make
大多数语言里「分配内存」和「初始化」是同一件事。Go 把它们拆成两个内置函数:new(T) 只分配一片零值内存并返回指针,make(T, args...) 则分配并初始化,返回一个已可用的值(不是指针)。拆分的原因是 slice、map、channel 的「零值」不可用:
1 | |
这三种类型内部都不是「一段连续内存」那么简单:
- slice 是三元组
{data指针, len, cap},data 必须指向真实的底层数组; - map 是指向
runtime.hmap的指针,hmap 挂着桶数组、计数、哈希种子; - channel 是指向
runtime.hchan的指针,hchan 挂着环形缓冲区、收发等待队列、互斥锁。
new(T) 对它们只把结构体内存清零,data/hmap/hchan 指针全是 nil–正是「零值不可用」状态。make 的职责是把这些内部结构真正建立起来,做的是 new 做不了的事。
一个推论:make 只接受这三种类型。make(int) 或 make(MyStruct) 直接编译错误–编译器在语法阶段就拒绝了,用类型系统把「需要初始化的类型」和「不需要的类型」区分开。
横向对比:Java 的
new int[]给零值数组,new HashMap<>()调用构造函数;Go 把这两件事分别交给new/字面量和make。Rust 用Vec::new()、HashMap::new()关联函数。Go 的make是内置函数中唯一「参数类型受限、行为由编译器改写」的特例。
二、make 的三种签名与参数语义
make 的签名随目标类型变化,编译器根据第一个参数的类型选择重写路径:
1 | |
共同规矩:第二参数都是「大小」类信息,第三参数都是「容量/缓冲」类信息。记牢这个对称性,就不会把 make([]int, 5, 10) 的 5 和 10 搞反。
| 目标类型 | 第二参数含义 | 第三参数含义 | 省略第三参数 |
|---|---|---|---|
[]T | len(可见长度) | cap(底层数组容量) | cap = len |
map[K]V | hint(初始容量提示) | 无 | – |
chan T | buffer(缓冲区容量) | 无 | 无缓冲 |
map 的 hint 只是提示,运行时向上取整到 2 的幂次,所以 make(map[string]int, 100) 和 make(map[string]int, 128) 分配的桶数量一样。
channel 的第二参数是「缓冲容量」不是「长度」。make(chan int, 3) 表示缓冲区能放 3 个元素,且一开始都是空的(不像 slice 的 len 会预填零值)。这是 slice 和 channel 在 make 语义上最易混淆的点:
1 | |
三、make 切片:底层数组与容量的精确含义
make([]T, len, cap) 做两件事:分配长度为 cap 的底层数组,返回一个 len 长、cap 容的切片头指向它。
1 | |
三个关键认知:
1. make([]int, n) 给的是 n 个零值元素,不是 n 的容量。 初学者最高频的误用。想要「空但预留容量」必须写 make([]int, 0, 5):
1 | |
一个真实事故:results := make([]Record, N) 后 results[i] = row,接着 results = append(results, extraRow) 会得到 N+1 个元素–因为 make([]Record, N) 已有 N 个零值。正确写法是 make([]Record, 0, N) 后 append,或 make([]Record, N) 后用索引赋值不再 append。
2. 省略 cap 时 cap == len,没有预留空间。 第一次 append 就会扩容。已知最终大小时给 cap 是几乎零成本却能消除多次扩容的优化:
1 | |
3. make 分配的底层数组元素是类型零值。 make([]*Foo, 10) 给的是 10 个 nil 指针,不是 10 个 &Foo{}。要已初始化的对象得循环:
1 | |
[]int{1, 2, 3} 编译期被改写成「构造临时数组 [3]int{1,2,3} 再切出 tmp[0:3]」,等价于 make + 赋值。字面量的大小是编译期常量,make 的大小可以是运行时变量–动态大小必须用 make。
四、make map:hmap、预分配与 nil 陷阱
make(map[K]V, hint) 创建指向 runtime.hmap 的指针。hmap 内部维护桶数组(每个桶装 8 个键值对)、元素计数、哈希种子等。hint 的作用是预分配桶,避免随元素增加反复扩容搬迁。
1 | |
map 的扩容是渐进式的:扩容时分配新桶,旧桶元素分摊到后续每次 map 操作中搬一个桶,避免单次写入因大规模搬迁而卡顿。所以 hint 预分配的价值不只是省内存,更是避免渐进搬迁期间新旧桶同时存在的双倍内存占用。
小知识:
make(map[K]V)在 hint 很小(≤8)时走快路径makemap_small,连 hmap 都延迟分配,等真正写入时才建 hmap。
nil map 是 map 侧最大的陷阱:
1 | |
读 nil map 安全、写 nil map panic–读操作天然幂等,零值可作为「不存在」的合理返回;写操作必须有真实存储。所以声明 map 后、写入前必须 make。常见 bug 是结构体字段忘初始化:
1 | |
设计惯例:map 字段永远在构造函数里 make。和 slice 不同,slice 的 nil 零值可以 append(append 检测到 nil 会分配新底层数组),但 map 写入没有等价的「自动建 hmap」路径–每次写入都检查 nil 会拖慢热路径。
make 出来的 map 依然不是并发安全的。并发读写 map 触发运行时致命错误(不是 panic,是 fatal error: concurrent map writes,无法 recover)。并发场景要用 sync.Map 或 sync.RWMutex 包裹。
五、make channel:hchan、缓冲与 nil 的妙用
make(chan T) 和 make(chan T, n) 分别创建无缓冲和有缓冲 channel。channel 是引用类型–make 返回的其实是指向 runtime.hchan 的指针(语法上表现为 chan T 值),所以把 channel 传给函数不需要取地址:传值传的是指针拷贝,指向同一个 hchan。
1 | |
无缓冲和有缓冲的本质区别在 并发篇 已详述(同步握手 vs 异步队列),这里聚焦 make 视角。
缓冲容量是性能与延迟的权衡。 容量越大生产消费解耦越彻底,但内存占用越高、「数据滞留」越久。常见经验值:
make(chan struct{})或make(chan struct{}, 1):信号/事件,0 或 1;make(chan T, 1):结果回传,生产者发一个就完事;make(chan T, N):限流/批量,N 根据下游消费速率定;- 无缓冲
make(chan T):强同步,要求收发方同时在场。
没有「最优容量」,它是吞吐和延迟的调节旋钮。缓冲设得特别大希望不阻塞,往往掩盖了生产消费速率不匹配的根本问题,让问题表现为「内存涨 + 延迟变大」而非「立刻阻塞暴露问题」。
nil channel 是 select 里的隐藏武器。 make 永远不返回 nil channel,但你可以显式用 nil:
1 | |
对 nil channel 的发送和接收会永久阻塞。这听起来像 bug,但在 select 里它变成「禁用某个分支」的开关。这是 make(创建可用 channel)和 nil(创建永久阻塞 channel)的语义对偶,理解它对掌握 select 模式至关重要。
六、make 的编译器重写:它不是普通函数
make 看起来像函数调用,实际编译器在类型检查阶段就把它改写成对 runtime 的直接调用。可以在 go tool compile -S 的输出里看到这个改写:
| 源码 | 编译期重写为(近似) | 作用 |
|---|---|---|
make([]T, len) | runtime.makeslice(typ, len, len) | 分配底层数组,构造切片头 |
make([]T, len, cap) | runtime.makeslice(typ, len, cap) | 同上,cap 可大于 len |
make(map[K]V) | runtime.makemap_small() | 小 map 快路径 |
make(map[K]V, hint) | runtime.makemap(typ, hint, ...) | 按 hint 预分配桶 |
make(chan T) | runtime.makechan(typ, 0) | 无缓冲 |
make(chan T, n) | runtime.makechan(typ, n) | 有缓冲 |
这意味着两件事:
1. make 的参数在编译期就被检查。 make([]int, -1) 是编译错误不是运行时 panic;cap < len 在常量时编译期可查。运行时才确定的非法值则在 makeslice 里做溢出和内存检查,超过可用内存会 panic makeslice: len out of range。
2. make 的对象几乎总是逃逸到堆。 返回的 slice/map/channel 内部都持有指向堆分配的指针,逃逸分析会让它们分配在堆上。栈上 map 在 Go 里没有实现,map 的内部结构依赖堆分配器。
1 | |
「用 make 会比字面量慢」是误区–[]int{1,2,3} 同样被改写成底层数组分配 + 赋值。性能差异只来自「是否预分配 cap」和「元素是否需要逐个赋值」。
七、new vs make:一张表终结纠结
new 和 make 的差异不是「返回指针 vs 返回值」那么简单,而是职责完全不同:
| 维度 | new(T) | make(T, args) |
|---|---|---|
| 适用类型 | 任意类型 | 仅 slice/map/channel |
| 返回值 | *T(指针) | T(已初始化的值) |
| 内存状态 | 清零(零值) | 清零 + 建立内部结构 |
| 能否直接用 | 取决于 T(结构体可用,slice/map/chan 不可用) | 可直接用 |
| 实际使用频率 | 很低(&T{} 更常用) | slice/map/channel 标配 |
最关键的对比是 new([]int) vs make([]int, 0):
1 | |
new([]int) 分配了「切片头」内存并清零,等于一个 nil slice–你拿到 *[]int 指向 nil,还得自己 make 或 append 才能用。对 slice/map/channel,永远用 make,不要用 new。new 在现代 Go 里很少见,&T{} 和 var x T 更直观;它真正还有点用的场景是泛型里不知道 T 的构造方式时。
八、make 的陷阱汇总
按踩坑频率排序:
陷阱 1:make([]T, n) 误当容量。 make([]int, 5) 是 5 个零值元素,不是容量 5 的空切片。想要空切片预留容量,用 make([]T, 0, n)。
陷阱 2:nil map 写入 panic。 var m map[K]V 后直接 m[k] = v 崩溃。map 字段必须在构造函数里 make。
陷阱 3:make([]T, n) 后再 append,得到 n+1 个元素。 因为已有 n 个零值。要么 make([]T, 0, n) + append,要么 make([]T, n) + 索引赋值,不要混用。
陷阱 4:make([]*T, n) 是 n 个 nil 指针。 不是 n 个对象。要逐个 &T{}。
陷阱 5:超大 make 导致内存爆炸或 panic。 make([]byte, 1<<40) 触发 makeslice: len out of range panic;make([]byte, 1<<30) 可能被 OOM。动态大小来自外部输入时要校验上界。
陷阱 6:make 的 map 并发不安全。 并发读写触发 fatal error: concurrent map writes,无法 recover,进程崩溃。
陷阱 7:循环里反复 make 大对象压 GC。 热循环里 make([]byte, 1<<20) 每次迭代分配 1MB。应复用 sync.Pool 或在循环外分配。
陷阱 8:误以为 make(chan T, 0) 和 make(chan T) 不同。 它们完全等价,都是无缓冲。
陷阱 9:slice 共享底层数组的别名问题。 make 出来的切片若被切出子切片,子切片和原切片共享底层数组,互相修改会串。用 slices.Clone 或 append([]T(nil), s...) 做独立拷贝。
陷阱 10:make(map[K]V, hint) 误以为 hint 是精确容量。 hint 是提示,运行时取整到 2 的幂。
九、make 最佳实践
- 已知大小就预分配。 slice 给
cap,map 给hint,channel 给合理buffer。 - 空切片用
make([]T, 0, n)而非var s []T后反复 append。 但若 n 未知,var s []T+ append 也合理。 - map 字段在构造函数
make。 不要依赖零值。 - 并发 map 用
sync.Map或加锁。 - 动态大小校验上界。 防内存爆炸。
- 热循环复用而非反复
make。 大对象用sync.Pool。 - 要独立拷贝用
slices.Clone。 Go 1.21+ 标准库,比手写make+copy简洁。
下篇:select – channel 的多路复用
十、select 的定位:唯一的多路复用控制结构
select 是 Go 里关键字级别的控制结构(和 if/for/switch 同级),专门用于 channel 的多路复用。语义类似 Unix 的 select/poll/epoll:同时监听多个 channel,哪个先就绪就执行对应分支。
1 | |
几条硬性规则:
select的case只能是 channel 操作(发送<-或接收<-ch),不能是任意表达式。这是它和switch的根本区别–switch的 case 是值比较,select的 case 是 channel 就绪事件。select一次只执行一个 case,没有fallthrough。select没有优先级–多个 case 同时就绪时随机选一个,不按代码顺序。select是一次性的–执行完一个 case 就退出。要持续监听必须外面套for。
「随机而非顺序」最反直觉。新手常以为 select 会「从上到下找第一个就绪的」,写出依赖顺序的代码,结果在多 case 就绪时行为不可预测。
十一、select 的核心语义:阻塞、随机、default
把 select 的行为拆成三个正交维度:
1. 阻塞语义。 没有 default 时,select 阻塞直到至少一个 case 就绪。如果所有 case 的 channel 都不可就绪(缓冲空且无发送方、或缓冲满且无接收方),当前 goroutine 被挂起。
1 | |
2. 随机公平。 多个 case 同时就绪时,select 随机挑一个执行,不保证代码顺序–避免某个 case 因写在前面就总是被选中而饿死后面的。
1 | |
需要「优先级」–优先处理某个 channel,只在它没数据时才处理另一个–select 直接做不到,要用嵌套 select + default 技巧:
1 | |
外层 select 用 default 非阻塞地试 ch1;ch1 没数据才进入内层同时等 ch1 和 ch2。这是「优先级 channel」的标准模式,但不是严格优先级(内层仍可能先选 ch2),只是「倾向于 ch1」。
3. default 非阻塞。 有 default 时,没有任何 case 就绪就立即执行 default,不阻塞。这把 select 从「等待」变成「尝试」。但 default 用在 for 循环里且没有其他让出点,会变成忙轮询(busy loop),CPU 打满:
1 | |
十二、select 的运行时实现:selectgo
select 在运行时由 runtime.selectgo 函数实现,过程相当精巧。理解它能解释很多「为什么」。
selectgo 的大致流程:
第 1 步:收集所有 case,构造 scase 数组。 每个 case 记录 channel 指针、操作类型(发送/接收)、数据指针。default 也是一个特殊的 scase。
第 2 步:打乱 case 顺序。 这是「随机公平」的实现–用运行时伪随机数生成器打乱 case 的处理顺序,后续扫描按打乱后的顺序进行,代码书写顺序不影响选中概率。
第 3 步:第一次扫描–找立即可执行的 case。 按打乱后的顺序逐个检查有没有 channel 已经就绪(接收时有数据或已关闭、发送时有空位)。找到就立即执行并返回。这一步是无锁优先的。
第 4 步:如果有 default 且第一次扫描没找到就绪 case,执行 default。 这就是 default「非阻塞」的原理–它在注册等待之前就被选中。
第 5 步:没有 default 且没有就绪 case–注册等待。 把当前 goroutine 挂到所有 case 涉及 channel 的等待队列上,然后挂起。哪个 channel 先就绪,就被该 channel 的发送/接收方唤醒。
第 6 步:被唤醒后,从所有等待队列摘除自己,执行被唤醒的那个 case。 可能有多个 channel 同时就绪,运行时仍按打乱顺序选第一个就绪的,并清理其他等待注册。
这套流程解释了几个关键现象:
- 为什么随机:第 2 步打乱了顺序,「同时就绪时选谁」由打乱结果决定,与代码顺序无关。
- 为什么 default 非阻塞:第 4 步在注册等待之前就检查了 default。
- 为什么 nil channel 的 case 永不被选:nil channel 在第 3 步被判定为「不可就绪」(永远阻塞),不会在第 3 步被选中,也不会在第 5 步注册等待(没有等待队列可挂)。于是 nil channel 的 case 形同虚设–这正是「禁用分支」的原理。
1 | |
把 nil channel 的 case 想象成「开关」:ch2 = nil 关闭这个分支,ch2 = someChan 打开它。这让你能在运行时动态增删 select 的监听分支,是 select 最强大的模式之一。
十三、select 的经典模式
把散在并发篇和实战中的 select 模式系统化:
模式 1:超时控制。 用 time.After 或 context.WithTimeout 给操作设上限。
1 | |
陷阱:
time.After每次调用都创建一个定时器,密集循环里会泄漏(定时器和它的 goroutine 直到超时才释放)。改用context.WithTimeout或 Go 1.23+ 的time.AfterFunc,能在提前返回时及时释放资源。
模式 2:退出信号。 用 ctx.Done() 广播退出。context.Done() 返回的 channel 在 context 被取消时关闭,<-ctx.Done() 立即就绪,取代了手工 done channel。
1 | |
模式 3:非阻塞尝试。 default 做能拿就拿、拿不到就走的尝试。
1 | |
模式 4:永久阻塞。 空 select{} 没有任何 case,永久阻塞当前 goroutine。
1 | |
用于 main 末尾让其他 goroutine 继续运行,但生产代码更推荐 signal.Notify + WaitGroup 优雅退出–select{} 无法响应信号、无法清理资源。
模式 5:扇入(fan-in)。 多个 channel 汇聚成一个,用 select 同时监听多个来源。
1 | |
这是 nil channel 技巧的典型应用:某个来源关闭后置 nil,对应 case 自动禁用。所有来源都置 nil 后循环条件为假,退出。没有这个技巧,关闭的 channel 会持续返回零值,select 陷入「零值风暴」死循环。
模式 6:动态启停分支。 用 nil channel 在运行时控制 select 监听哪些分支。
1 | |
currentJobs 在 nil 和真实 channel 之间切换,实现「暂停/恢复接收」而无需重建 select 结构。这种动态性是 select 区别于普通多路等待的核心能力。
模式 7:限速。 用 time.Tick + select 控制处理速率。
1 | |
模式 8:result channel + 超时聚合。 并发发多个请求,取第一个返回的或等全部。
1 | |
十四、select 的陷阱汇总
陷阱 1:time.After 在循环里泄漏。 每次 time.After 创建定时器+goroutine,直到超时才回收。改用 time.NewTicker(可 Stop)或 context.WithTimeout。
陷阱 2:close 的 channel 在 select 中持续就绪。 关闭的 channel 接收立即返回零值(ok=false),case v := <-closedCh 持续就绪,select 疯狂选中它(零值风暴)。必须在 case 里检查 ok 并置 nil 禁用分支。
1 | |
陷阱 3:default 导致忙轮询。 for { select { case ...: default: } } 没有让出点,CPU 100%。去掉 default 让 select 阻塞,或在 default 里 runtime.Gosched()。
陷阱 4:以为 select 会优先选某 case。 多 case 就绪是随机的。需要优先级要用嵌套 select + default。
陷阱 5:for-select 漏退出条件,goroutine 泄漏。 循环里的 select 必须有退出 case(ctx.Done() 或 done channel)。生产代码泄漏 goroutine 的头号原因。
陷阱 6:向 nil channel 发送/接收导致永久阻塞(非 select 场景)。 在 select 外,nil channel 操作是死锁。只有放进 select 的 case 里,nil 才是「禁用分支」。
陷阱 7:select{} 在 main 末尾无法优雅退出。 不响应 SIGINT/SIGTERM,无法清理资源。用 signal.Notify + WaitGroup 替代。
陷阱 8:select 的 case 里做重活阻塞其他 case。 select 选中一个 case 后,执行该 case 代码期间不再监听其他 channel。重活应丢给另一个 goroutine,case 里只做轻量收发。
陷阱 9:忘记 for 导致只处理一次。 裸 select 只执行一次就退出。要持续消费 channel 必须套 for。
陷阱 10:多个 case 引用同一 channel。 合法但几乎无意义–同一 channel 的多个接收 case,就绪时随机选一个,行为等同一个 case。通常是代码写错了。
十五、select 与 context 的关系
context 包的取消传播,底层就是 channel + select。context.Context 的 Done() 方法返回一个 <-chan struct{}:context 未取消时阻塞,取消时(取消或超时)close 这个 channel,所有 <-ctx.Done() 立即就绪。
1 | |
所以 select { case <-ctx.Done(): } 能响应取消,本质就是「close 的 channel 在 select 中立即就绪」。context 把这机制包装成树形传播–父 context 取消,所有子 context 的 Done channel 都被 close,让整条调用链上的 select 同时就绪。
这就是为什么 select 是 Go 并发的「底层控制流」:channel 提供通信,select 提供基于通信的控制(等待、超时、退出、多路选择),context 在 select 之上提供结构化的取消传播。Go 官方强烈推荐用 context 而非手工 done channel–context 的 Done 复用了 select 的所有语义(close 即就绪、可多路复用、可 nil 禁用),又额外提供超时、值传递、树形取消,是 select 模式的工程化封装。
十六、select 最佳实践
- 循环里的 select 必须有退出 case。
ctx.Done()是首选,没有 context 至少要有个donechannel。 - 用 context 替代手工 done channel。 context 提供超时、树形取消,是 done channel 的超集。
time.After慎用于密集循环。 改context.WithTimeout或time.NewTicker+Stop。- 不要用
default做忙等待。 要么去掉 default 让 select 阻塞,要么在 default 里让出 CPU。 - 关闭的 channel 在 case 里检查
ok并置 nil。 避免零值风暴死循环。 - case 里只做轻量操作。 重活丢给 goroutine,保持 select 响应及时。
- 需要优先级用嵌套 select + default。 别依赖代码顺序。
- nil channel 是动态启停分支的开关。 善用它实现 fan-in、暂停/恢复等模式。
select{}只用于临时阻塞,生产用信号等待。 优雅退出靠signal.Notify。
串联与总结
make 和 select 共享 Go 的核心设计哲学:用少量、正交、可组合的原语覆盖大量场景。make 只服务三种类型却覆盖了几乎所有「集合与通信」需求,通过 cap/hint/buffer 把性能调优旋钮交给你;select 只做多路复用 channel 一件事,却衍生出超时、退出、扇入、限速、动态分支等一整套并发模式。两者结合就是 Go 并发的完整图景:make(chan T, n) 建立通信管道(数据平面),select 在管道上做控制流(控制平面)。理解了这两个原语的运行时实现(hchan 的收发队列、selectgo 的打乱与两遍扫描),你写出的并发代码就不再是「照着模式抄」,而是「知道每行为什么这么写」。
最后给一张速查表,把全文要点压缩成一屏:
| 主题 | 要点 | 头号陷阱 |
|---|---|---|
make([]T, n) | n 个零值元素,非容量 | 误当容量 |
make([]T, 0, n) | 空切片预留容量 | 与上面搞混 |
make(map[K]V, hint) | hint 是提示,取整到 2 的幂 | nil map 写入 panic |
make(chan T, n) | n 是缓冲容量,非长度 | n=0 即无缓冲 |
new(T) vs make | new 只清零返回指针,make 初始化返回值 | new([]int) 不可用 |
select 阻塞 | 无 default 时等就绪 | select{} 无法优雅退出 |
select 随机 | 多就绪随机选,不按顺序 | 误以为有优先级 |
select default | 无就绪立即走 default | 循环里忙轮询 |
| nil channel | select 中禁用分支 | select 外永久阻塞 |
| close 的 channel | 接收持续就绪返回零值 | 零值风暴死循环 |
for-select | 必须有退出 case | 漏退出致 goroutine 泄漏 |
time.After | 每次新建定时器 | 密集循环泄漏 |
| context | Done 是 close 的 channel | 用手工 done 替代 context |
掌握 make 与 select,你就掌握了 Go 里「分配」与「并发控制」两个最基础也最微妙的原语。剩下的工程问题,大多是它们的组合与应用。


