Go 语言学习笔记

Go 语言学习笔记

Go 语言学习笔记


一、基础语法

1.1 结构体与接口

Go 中结构体和接口通过**隐式实现(Duck Typing)**关联,不需要implements关键字。

typeSpeakerinterface{Speak()string}typeDogstruct{Namestring}func(d Dog)Speak()string{returnd.Name+": Woof!"}// Dog 自动满足 Speaker 接口,无需声明funcMakeItSpeak(s Speaker){fmt.Println(s.Speak())}

方法集规则

  • T值只能调用值接收者方法
  • *T指针可调用值+指针接收者方法

编译期验证

var_Speaker=Dog{}// 验证值接收者var_Speaker=(*Dog)(nil)// 验证指针接收者

1.2 不同接口同名方法

场景结果
同名同签名一次实现,同时满足两个接口
同名同签名,语义不同编译通过但逻辑错误,需拆分类型
同名不同签名无法同时满足,编译错误

Go 只看方法签名,不看接口名字或语义。

1.3 切片(Slice)

底层结构
typeslicestruct{ptr*array// 指向底层数组的指针lenint// 当前长度capint// 容量}
len vs cap
  • len:当前可读写的元素个数(s[i]0 <= i < len合法)
  • cap:当前不重新分配底层数组,最多能装多少个元素
底层数组: [1] [2] [3] [4] [5] [6] [7] [8] ↑ ↑ ↑ ptr len cap
  • [0, len)范围:可读可写
  • [len, cap)范围:空间已分配,但无法直接访问,只能通过append逐个扩展
  • 超过 cap:自动扩容,不是硬限制
扩容规则(Go 1.18+)
条件最终容量
期望容量 > 旧容量 * 2直接用期望容量
旧容量 < 256新容量 = 旧容量 * 2
旧容量 >= 256新容量 = 旧容量 + 旧容量/4 + 192(平滑增长,趋近 ~1.25x)

计算后还会进行内存对齐,实际 cap 可能更大。

扩容本质:分配新底层数组 + memmove 批量拷贝旧数据。

常见陷阱
// 1. 共享底层数组a:=[]int{1,2,3,4,5}b:=a[1:3]b=append(b,99)// cap够,不扩容,覆盖 a[3]!// 2. 三索引切割限制 capb:=a[1:3:3]// len=2, cap=2,限制 capb=append(b,99)// cap不够,扩容,不影响 a
排序

sort.Slice(推荐)

sort.Slice(people,func(i,jint)bool{returnpeople[i].Age<people[j].Age// 升序})

slices.SortFunc(Go 1.21+)

slices.SortFunc(people,func(a,b Person)int{returna.Age-b.Age// 升序:a-b,降序:b-a})

cmp(a, b)返回负数 -> a 排前面,正数 -> b 排前面,0 -> 相等。

1.4 数组 vs 切片

数组切片
长度固定,编译期确定动态,运行时可变
类型[3]int[5]int是不同类型[]int统一类型
传参值拷贝传切片头(廉价)

[...]int{1,2,3}是数组初始化的语法糖,编译器自动推断长度,结果等同于[3]int{1,2,3},仍是固定长度数组。不能用于函数参数或类型声明。

1.5 可变参数

funcsum(nums...int)int{...}// nums 内部是 []intsum(1,2,3)// 直接传sum(nums...)// 展开切片传入

1.6 基本数据类型

分类类型
布尔bool
有符号整数int8int16int32int64int
无符号整数uint8uint16uint32uint64uintuintptr
浮点float32float64
复数complex64complex128
字符串string
别名byte(uint8)rune(int32)

1.7 rune 与 string

  • rune:Unicode 码点,int32,一个整数编号
  • string:UTF-8 编码后的字节序列
varrrune='中'// 码点: 20013 (0x4E2D)varsstring="中"// UTF-8: E4 B8 AD(3字节)s:=string(r)// 码点 → UTF-8 编码r,_:=utf8.DecodeRuneInString(s)// UTF-8 → 码点

字符串索引s[i]返回byte(uint8),range遍历返回rune(int32)。


二、make vs new

new(T)make(T)
返回值*T指针T
初始化零值(不初始化内部结构)完整初始化(可直接使用)
适用范围任何类型仅 slice、map、channel
类型make字面量var 声明var 后能否直接用
切片可 nil切片append 可,读写可
Map可 nil map读取可,写入 panic
Channel不可可 nil channel发送/接收永久阻塞,close panic

2.2 切片五种声明方式对比

方式类型lencap== nil能直接用
make([]int, 0)[]int00false
[]int{}[]int00false
new([]int)*[]int00*s == niltrue需解引用
var a []int[]int00trueappend 可
var a *[]int*[]inta == niltruepanic!
new([]int)vsvar a *[]int
// new([]int) → 指针非 nil,指向一个 nil 切片b:=new([]int)// b != nil(指针本身有值)// *b == nil(指向的切片是 nil)// *b = append(*b, 1) ✅ 可以用// var a *[]int → 指针本身是 nil,什么都没指向vara*[]int// a == nil// *a → panic! 解引用 nil 指针// 必须先分配:a = new([]int) 或 a = &[]int{} 才能用

三、并发编程

3.1 Channel

Channel 是 goroutine 之间传递数据和同步的管道。

Go 哲学:不要通过共享内存来通信,而要通过通信来共享内存。

无缓冲 vs 有缓冲
无缓冲有缓冲
创建make(chan int)make(chan int, N)
发送阻塞没人接收就阻塞缓冲区满才阻塞
接收阻塞没人发送就阻塞缓冲区空才阻塞
本质同步,一对一握手异步,生产者-消费者
关闭 Channel
规则说明
谁发送谁关闭只有发送方关闭
不能关闭已关闭的通道panic
不能向已关闭的通道发送panic

不是必须关闭,唯一必须关闭的场景是接收方用range遍历时。不关闭range会死锁。

方向限定
funcsend(chchan<-int){}// 只写funcrecv(ch<-chanint){}// 只读

3.2 获取协程执行结果

goroutine 的返回值会被丢弃,只能通过 channel 获取结果。

typeResultstruct{IDintValueintErrerror}ch:=make(chanResult,3)fori:=0;i<3;i++{gofunc(idint){val,err:=doWork(id)ch<-Result{ID:id,Value:val,Err:err}}(i)}fori:=0;i<3;i++{r:=<-ch fmt.Printf("任务%d: 结果=%d\n",r.ID,r.Value)}

3.3 GMP 调度模型

G - Goroutine 协程(用户态轻量线程) M - Machine 操作系统线程(内核态) P - Processor 逻辑处理器(调度上下文)

P 是 GMP 的灵魂:M 必须持有 P 才能执行 G。

M 从哪拿 G(优先级)
1. P 的本地队列(LRQ) ← 优先,无锁 2. 全局队列(GRQ) ← 每 61 次取 1 个,有锁但极少 3. 其他 P 偷(work stealing) ← 本地空了才偷 4. 网络轮询器(netpoll) ← 等 IO 的 G 5. 阻塞等待 ← 实在没有就休眠
两种阻塞的区别
系统调用阻塞Channel/IO 阻塞
谁阻塞了M 阻塞(线程卡在内核)G 阻塞(协程挂起)
P 要不要解绑必须,M 卡住了不需要,M 还活着
M 的去向等系统调用返回 [系统调用返回后,M 优先找回原 P,原 P 忙就找其他空闲 P,都没有就把 G 放全局队列、M 自己休眠。M 不会干等着,G 也不会丢。]继续执行其他 G
P 的去向找新 M 绑定(hand off)不动,继续用当前 M
全局队列的锁竞争

全局队列确实有锁,但访问频率极低:

  • P 优先从本地无锁队列取 G(99%)
  • 每 61 次调度才从全局队列取 1 个
  • 取时批量获取,减少访问次数
  • 本地队列满溢出时才放全局队列
抢占式调度
  • G 执行超过 10ms 被标记为抢占
  • Go 1.14+ 基于信号的抢占,即使死循环也能被踢走

3.4 Goroutine 轻量在哪里

3.4.1 栈空间小
操作系统线程:固定栈 1~8MB(创建即分配) Goroutine:初始栈 2KB,按需增长,最多到 1GB
1GB 内存能跑多少? 线程:1GB / 8MB ≈ 125 个 Goroutine:1GB / 2KB ≈ 50 万个

Goroutine 的栈是动态增长的:初始 2KB,不够用时自动扩容(复制到更大的栈)。

3.4.2 创建/销毁成本低
操作系统线程:创建要陷入内核(系统调用),分配栈内存,初始化内核数据结构 Goroutine:纯用户态创建,只分配一个 G 结构体 + 2KB 栈
3.4.3 切换成本低
线程切换Goroutine 切换
陷入内核不要
切换页表不要(共享地址空间)
保存恢复寄存器全部(16+个)少量(3~5个)
缓存影响大(TLB 刷新)
耗时1~10μs100~200ns
3.4.4 调度在用户态
线程调度:内核调度器决定,无法控制何时切换 Goroutine调度:Go runtime(用户态)决定,GMP 模型调度
  • 线程阻塞 → 内核切换,整个线程卡住
  • Goroutine 阻塞(channel)→ 只挂起 G,M 继续跑其他 G
总结对比
线程Goroutine
栈大小1~8MB 固定2KB 起步,动态增长
创建成本系统调用,重用户态,轻
切换成本1~10μs100~200ns
调度者内核Go runtime(用户态)
1GB 内存~125 个~50 万个

一句话:Goroutine 轻量在于初始栈仅 2KB(线程 1~8MB)、创建和切换都在用户态(不陷入内核)、切换只保存少量寄存器(约 100ns vs 线程 1~10μs),所以一台机器能轻松跑几十万个 Goroutine。

3.5 GOMAXPROCS

3.5.1 核心含义
GOMAXPROCS = P 的数量 = 最多同时运行的 M 数量 = 并行度
// 默认值 = CPU 逻辑核心数runtime.NumCPU()// 比如 8runtime.GOMAXPROCS(0)// 查看当前值,返回 8// 手动设置runtime.GOMAXPROCS(4)// 限制只用 4 个线程
3.5.2 对 GMP 的影响
GOMAXPROCS = 4 创建 4 个 P:P0 P1 P2 P3 最多 4 个 M 同时并行执行 G 即使你有 10 万个 goroutine,同时跑的也只有 4 个
GOMAXPROCS=1 时: P0—M0 → 所有 G 排队轮流跑(并发,不并行) GOMAXPROCS=4 时: P0—M0 → G1 G3 G5 ... P1—M1 → G2 G4 G6 ... ← 4 个真正同时跑(并行) P2—M2 → G7 G9 ... P3—M3 → G8 G10 ...
3.5.3 注意:M 的数量可以超过 GOMAXPROCS
runtime.GOMAXPROCS(4)// 4 个 P// 但 M 可能多于 4 个!// 原因:系统调用阻塞时 M 卡住,P 会找新 M// 所以 M 数量 = 活跃 M(≤P数量) + 阻塞中 M
P0—M0(G1 系统调用阻塞) ← M0 卡住 P0—M1(G2) ← P0 解绑后找新 M1 此时 M 数量 = 2,但 P 仍然 = 1
3.5.4 可以设超过 CPU 核心数吗

技术上可以,但不推荐

4核 CPU,设 GOMAXPROCS=100 → Go 创建 100 个 P,最多 100 个 M 同时抢 4 个核 → 4个真正在跑,96个在等待 → 内核线程切换开销暴涨(1~10μs/次) → TLB 缓存频繁失效 → CPU 缓存命中率下降 → 实际性能反而下降
3.5.5 什么时候要改
场景设置
日常开发不用改,默认 = CPU 核心数
CPU 密集型保持默认,多了反而增加切换开销
IO 密集型可以适当调大(如 4 核设 6),让更多 G 并行等 IO
容器环境注意!默认取宿主机核心数,不是容器限制
3.5.6 容器中的坑
// 宿主机 32 核,容器限制 2 核runtime.GOMAXPROCS(0)// 返回 32!不是 2// Go 1.5~1.21 的坑:默认创建 32 个 P,但容器只有 2 核// → 线程竞争严重,性能下降// 解决方案runtime.GOMAXPROCS(2)// 手动设为容器限制的核心数// Go 1.22+ 自动识别 cgroup 限制,不再需要手动设

一句话GOMAXPROCS= P 的数量 = 最大并行度,默认等于 CPU 核心数。它限制的是同时执行 Go 代码的线程数,不限制 goroutine 数量,也不限制因系统调用阻塞而产生的额外线程数。设超过 CPU 核心数只会增加线程竞争和切换开销,降低性能。

四、垃圾回收(GC)

4.1 核心特点

特点说明
算法三色标记清除
触发方式并发,不 Stop The World(大部分时间)
不支持无分代、无压缩

4.2 三色标记法

颜色含义
白色未被访问,GC 结束后回收
灰色已访问,但引用的对象还没全访问
黑色已访问,且引用的对象都已访问

过程:从根对象出发,标灰 → 标黑 + 引用标灰 → 直到没有灰色 → 剩余白色 = 垃圾。

4.3 golang V1.5

插入写屏障机制:并发标记时,用户代码可能修改引用关系导致漏标。写屏障在每次修改引用时,强制将被引用的对象标灰,防止漏标。

插入写屏障只对堆生效,栈上没有写屏障(栈操作太频繁,加屏障性能损失大)。

GC 完整流程:

1. 标记准备(STW,极短) → 开启插入写屏障,根对象标灰 2. 并发标记(和用户代码并行)→ 扫描灰色对象,将灰色对象标黑,且将其引用的对象标灰,插入写屏障保护,发生引用修改即新增一个对象引用另外一个对象时,将被引用对象标灰 3. 标记终止(STW,极短) → 重扫描栈,处理剩余灰色,关闭写屏障 4. 并发清除(和用户代码并行)→ 回收白色对象

STW(Stop The World):暂停所有用户代码,只让 GC 干活。Go 只在开头和结尾各 STW 极短时间(微秒级)。

为什么不对栈上的对象开启写屏障: 因为go在并发运行时,大部分的操作都发生在栈上,函数的调用会非常频繁。数十万goroutine的栈堆进行屏障保护自然会有性能问题。

缺点:Go 1.5 的插入写屏障要求堆上对象在标记结束前必须是灰色或黑色,但栈上对象没有写屏障保护,所以必须在 STW 期间重新扫描所有栈。这是 Go 1.5 STW 偏长的根本原因。1.5 的标记终止 STW 不是微秒级,因为要重扫描所有 goroutine 的栈,通常在 10~100ms 级别。V1.8以后达到微妙级别。

4.4 golang V1.8

混合写屏障机制=插入写屏障+删除写屏障

  • [1] GC刚开始的时候,会将栈上可达对象全部标记为黑色
  • [2] GC期间,任何在栈上新创建的对象均为黑色
  • [3] 堆上指针被覆盖时,旧值(被删除的引用)标灰 ← Yuasa 删除屏障
  • [4] 堆上写入新指针时,新值(新引用的对象)标灰 ← Dijkstra 插入屏障

[1][2]步只有一个目的,将栈上的可达对象全部标黑,最后无需对栈进行STW[也就是V1.5的第三步的STW],就可以保证栈上的对象不会丢失。有人说,一直是黑色的对象,那么不就永远清除不掉了,这里强调一下,标记为黑色的是可达对象,不可达的对象一直会是白色,直到最后被回收。

GC完整流程:

1. 标记准备(STW,极短) → 开启混合写屏障,堆的根对象标灰,栈的所有对象标黑 2. 并发标记(和用户代码并行)→ 堆:扫描灰色对象,将灰色对象标黑,且将其引用的对象标灰,混合写屏障保护,新创建和删除的对象标灰;栈: 所有新创建的对象标黑 3. 标记终止(STW, 极短) → 关闭混合写屏障,无需重扫描栈,相对与V1.5可达到微妙级别 4. 并发清除(和用户代码并行)→ 回收白色对象

4.5 面试角度总结

如果你准备面试,核心要记住这个演进线索:

Go 1.5:插入写屏障(仅堆)
→ 栈没有写屏障 → 标记终止要 STW 重扫描所有栈 → STW 10~100ms

Go 1.8:混合写屏障(插入+删除,仅堆)
→ GC 开始时栈全部标黑 + 新对象标黑 → 不需要重扫描栈
→ 标记终止 STW 降到微秒级

Go 1.14:基于信号的抢占式调度
→ 消除了死循环导致 GC 无法触发的问题

面试一句话答法:Go 1.5 用 Dijkstra 插入写屏障只保护堆,栈没屏障所以标记终止要 STW 重扫描栈,停顿在 10~100ms;Go 1.8 改用混合写屏障(插入+删除),GC 开始时把栈上可达对象全部标黑,期间新对象也标黑,这样就不需要重扫描栈了,STW 降到微秒级。

4.6 GC 触发条件

条件说明
堆内存增长到阈值默认 GOGC=100,堆翻倍时触发
2分钟未触发强制触发一次
手动调用runtime.GC()

4.7 减少GC压力

  • 减少堆分配,用值类型
  • sync.Pool复用对象
  • 预分配切片make([]int, 0, N)
  • strings.Builder代替字符串拼接

五、init 函数

  • 在 main 函数之前,包被导入时自动执行
  • 不能被调用,不能有参数和返回值
  • 一个文件可有多个 init,按定义顺序执行
  • 跨包:依赖的包先初始化
  • 只执行一次,即使包被多次导入

执行顺序:全局变量初始化 → init → main


六、defer

  • 延迟执行,函数返回前调用,先进后出(栈)
  • 参数在 defer 时就确定
  • 最常用场景:资源释放(Close/Unlock)
  • defer 可以修改命名返回值
  • 循环中 defer 会堆积,改用匿名函数
funcwork(){mu.Lock()defermu.Unlock()// 不怕中间 return 忘记解锁}

七、recover

只有在 defer 中调用 recover 才能捕获 panic,其他地方调用返回 nil。

deferfunc(){ifr:=recover();r!=nil{fmt.Println("捕获:",r)}}()

八、装饰器

Go 没有装饰器语法,用函数包装实现:

functimer(fnfunc())func(){returnfunc(){start:=time.Now()fn()fmt.Printf("耗时: %v\n",time.Since(start))}}work:=timer(work)// 包装work()

HTTP 中间件是最典型的装饰器模式。


九、JSON 处理

typeUserstruct{Namestring`json:"name"`Ageint`json:"age"`Emailstring`json:"email,omitempty"`Passstring`json:"-"`}// 解析varuser User json.Unmarshal([]byte(jsonStr),&user)// 必须传指针// 序列化data,_:=json.Marshal(user)
tag作用
json:"name"JSON 字段名为 name
json:"-"忽略该字段
json:"name,omitempty"为零值时省略

注意:数字默认解析为float64


十、反射(reflect)

10.1 TypeOf vs ValueOf

reflect.TypeOf(x)→ reflect.Type → 类型信息(名字、字段、tag、方法签名) reflect.ValueOf(x)→ reflect.Value → 值信息(读值、改值、调方法)

10.2 操作结构体

t:=reflect.TypeOf(u)// Type 上v:=reflect.ValueOf(u)// Value 上// 字段t.NumField()// 字段数量t.Field(i).Name// 字段名t.Field(i).Tag.Get("json")// tagv.Field(i)// 字段值v.FieldByName("Name")// 按名字取值// 方法t.NumMethod()// 方法数量t.Method(i).Name// 方法名v.MethodByName("X").Call(args)// 调用方法

10.3 Map 相关

// Type 上t.Key()// map 的 key 类型t.Elem()// map 的 value 类型 / slice 元素类型 / chan 元素类型// Value 上v.MapKeys()// 所有 keyv.MapIndex(key)// 根据 key 取 value

10.4 修改值(必须传指针)

v:=reflect.ValueOf(&x)v=v.Elem()// 解引用v.SetInt(100)// 修改

10.5 Kind 种类

reflect.Int/reflect.String/reflect.Bool/reflect.Struct/reflect.Map/reflect.Slice/reflect.Ptr/reflect.Chan/reflect.Func

十一、内存分配三级结构

11.1 整体架构

┌──────────────────────────────────────────────────┐ │ mheap │ │ 全局唯一,从 OS 申请大块内存,按类别管理 │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │mcentral │ │mcentral │ │mcentral │ ... │ │ │ (8B) │ │ (16B) │ │ (32B) │ │ │ └────┬────┘ └────┬────┘ └────┬────┘ │ │ │ │ │ │ │ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │ │ │mcache │ │mcache │ │mcache │ ... │ │ │(P0) │ │(P1) │ │(P2) │ │ │ └─────────┘ └─────────┘ └─────────┘ │ └──────────────────────────────────────────────────┘

11.2 mcache(每个 P 一个)

类比:每个人的工具箱,无锁拿工具。

  • 每个 P 绑定一个 mcache,无锁
  • 分配速度最快,大部分小对象从这里分配
  • mcache 没货了才找 mcentral
P0—mcache: [8B块][16B块][32B块][64B块]... P1—mcache: [8B块][16B块][32B块][64B块]...

每种规格只有一个 mspan,用满了就找 mcentral 换一个新的(旧的还回去),一换一。

11.3 mcentral(每个规格一个)

类比:公共仓库,某个规格的工具箱空了来进货。

  • 每种规格一个 mcentral(8B、16B、32B… 共 67 种)
  • 有锁,但竞争不严重(只有 mcache 空了才来)
  • mcentral 没货了才找 mheap
mcentral(8B): [span1][span2][span3]... ← 8字节规格 mcentral(16B): [span1][span2][span3]... ← 16字节规格 mcentral(32B): [span1][span2][span3]... ← 32字节规格 ...67种规格

11.4 mheap(全局唯一)

类比:总仓库,从工厂(OS)进货。

  • 全局唯一,大锁
  • 管理所有内存页(8KB/页)
  • 从 OS 申请内存(mmap)
  • 大对象(>32KB)直接从 mheap 分配

11.5 mspan 是什么

mspan 是内存分配的基本单位,是连续的内存页。

一个 mspan = N 个连续页(每页 8KB) mspan(32B规格, 8页): ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐ │ 32B │ 32B │ 32B │ 32B │ 32B │ 32B │ 32B │ 32B │ ... │ 空闲 │ 已用 │ 空闲 │ 已用 │ 已用 │ 空闲 │ 空闲 │ 已用 │ └──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘ 8页 × 8KB = 64KB ÷ 32B = 2048 个槽位
11.6 内存规格(Size Class)

Go 把小对象分成 67 种规格:

class 0: 微小对象(≤16B),用 tiny 缓存 class 1: 8B class 2: 16B class 3: 32B class 4: 48B class 5: 64B ... class 67: 32768B (32KB)
// 分配 24B → 找最接近的规格 → 32B// 分配 50B → 找最接近的规格 → 64B// 会有内部碎片,但避免了外部碎片

11.7 分配流程

分配对象 │ ├─ 微小对象(≤16B) → mcache 的 tiny 缓存 │ ├─ 小对象(16B~32KB) → mcache → mcentral → mheap │ └─ 大对象(>32KB) → 直接从 mheap 分配
分配 24 字节对象 │ │ 1. 确定规格:24B → 32B 规格 ↓ mcache(32B) 有空闲? ├─ 有 → 直接分配,无锁 ✅(99%走这里) │ │ 没有 ↓ mcentral(32B) 有空闲 mspan? ├─ 有 → 拿一个 mspan 给 mcache,再分配(有锁,但极少) │ │ 没有 ↓ mheap 有空闲页? ├─ 有 → 切成 mspan 给 mcentral → 给 mcache → 分配 │ │ 没有 ↓ 向 OS 申请内存(mmap) → 切成 mspan → mcentral → mcache → 分配

11.8 回收流程

对象不被引用 │ ├─ GC 标记为垃圾 ↓ mcache 中的空闲 mspan │ ├─ 归还给 mcentral ↓ mcentral 中大量空闲的 mspan │ ├─ 归还给 mheap ↓ mheap 空闲页 │ ├─ 大量空闲时归还给 OS(madvise)
11.9 三级对比
mcachemcentralmheap
数量每个 P 一个每种规格一个全局唯一
无锁有锁(每规格独立)全局大锁
速度最快较快最慢
分配对象小对象(≤32KB)给 mcache 补货大对象 + 给 mcentral 补货
类比个人工具箱规格仓库总仓库(从OS进货)
11.10 为什么这样设计
核心目标:减少锁竞争 无 mcache(所有分配都找 mcentral): P0 → mcentral 🔒 P1 → mcentral 🔒 ← 所有 P 抢同一把锁 P2 → mcentral 🔒 有 mcache: P0 → mcache(无锁)✅ P1 → mcache(无锁)✅ ← 各取各的 P2 → mcache(无锁)✅ 只有 mcache 空了才找 mcentral(极少)

和 GMP 的设计思路一样:本地优先,无锁快路径 + 全局慢路径兜底。