vault backup: 2026-04-24 23:45:19

This commit is contained in:
2026-04-24 23:45:19 +08:00
parent 388d4439de
commit f05be3890e
21 changed files with 3816 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
---
tags: [go, golang, channel, mutex, 并发, 同步]
create time: 2026-04-24 10:30
---
# Channel vs Mutex:为什么 Go 发明了 Channel?
## 概述
Mutex 已经能做同步了,Go 为什么还要发明 Channel?本文从抽象层次、安全性、组合性等角度分析 Channel 相比 Mutex 的核心优势,以及两者的适用场景。
## 核心差异:锁"状态" vs 传递"数据"
Mutex 保护的是**共享状态(数据)**,语义是"互斥访问":拿到锁 → 读写共享变量 → 释放锁。
Channel 传递的是**数据本身(消息)**,语义是"同步地传递":发送 → 接收。数据的所有权随 channel 转移,而不是共享。
> **核心区别:Mutex 让你"共享内存",Channel 让你"传递内存"。**
## Channel 相比 Mutex 的四大优势
### 1. 数据所有权转移,比"共享访问"更安全
```go
// Mutex 模式:数据仍然在外部被共享
var data []int
mu.Lock()
data = append(data, x)
mu.Unlock()
// Channel 模式:数据随 channel 转移
ch <- x // 发送后,main goroutine 就不再持有这份数据
```
Mutex 方案下,任何持有 mu 的 goroutine 都能读写 data —— 必须在每一处都正确加锁。Channel 方案下,**数据发出去就归接收方所有**,不存在"谁在什么时候访问"的问题。
### 2. select + 多路复用:Mutex 做不到
```go
select {
case v := <-ch1:
// 处理 ch1
case v := <-ch2:
// 处理 ch2
case <-time.After(timeout):
// 超时
}
```
用 Mutex 无法实现"从多个通道中等待任意一个就绪"的模式。
### 3. 类型层面的方向约束
```go
func processor(ch <-chan int) { ... } // 只能收,不能发
func producer(ch chan<- int) { ... } // 只能发,不能收
```
在编译期就明确了数据流向。Mutex 做不到这一点 —— `*sync.Mutex` 不告诉你它保护的是哪个变量。
### 4. 同步 + 通信,一举两得
Mutex 只做同步(互斥),不传递数据。Channel 同时完成**同步**(发送阻塞到接收就绪)和**通信**(传递数据值)。
## 什么时候该用 Mutex?
Channel 不是万能药,Mutex 也有它的价值:
| 场景 | 推荐 |
|------|------|
| 保护局部共享状态(struct 多个字段被并发读写) | **Mutex** |
| 高频访问的共享计数器、缓存 | **Mutex**(性能更好) |
| goroutine 间的消息传递、管道组合 | **Channel** |
| 多路复用、超时控制、取消传播 | **Channel** |
## 两者配合使用
Channel 内部实现本身就用了 mutex 来保护缓冲区操作,说明它们是**不同抽象层次的互补工具**,而非互斥关系。实际 Go 代码中经常配合使用:
- 用 Mutex 保护 Channel 的元数据
- 用 Channel 做 goroutine 间通信,用 Mutex 保护内部共享状态
## 关联笔记
- [[DEV/GO/Channel详解]]
+333
View File
@@ -0,0 +1,333 @@
---
tags: [go, golang, runtime, channel, 并发, sync]
create time: 2026-04-24 10:00
---
# Channel 详解
## 概述
Channel 是 Go 中最核心的并发原语之一,遵循 **"不要通过共享内存来通信,而是通过通信来共享内存"** 的设计哲学。它是 goroutine 之间传递数据的同步机制,也是 Go 并发编程的灵魂。
> **思考**:既然有 Mutex 可以做同步,为什么还要发明 Channel?Channel 相比 Mutex 的抽象层次有什么优势?
> 详见 [[DEV/GO/Channel vs Mutex]]。
Channel 的本质是一个**线程安全的 FIFO 队列**,支持三种操作:**发送**、**接收**、**关闭**。
## Channel 的基础类型
```go
// 声明三种 channel
var ch1 chan int // nil channel(未初始化)
var ch2 chan int = make(chan int) // 无缓冲 channel
var ch3 chan int = make(chan int, 10) // 有缓冲 channel,容量 10
```
## 无缓冲 Channel
无缓冲 channel 的 `len() == 0`、`cap() == 0`。
**核心语义:发送和接收必须同时完成,是同步的。**
```go
func main() {
ch := make(chan int) // 无缓冲
go func() {
ch <- 42 // 发送:如果没人接收,会阻塞
fmt.Println("sent 42")
}()
val := <-ch // 接收:如果没人发送,会阻塞
fmt.Println("received:", val) // 输出: received: 42
}
```
**无缓冲 channel 的行为图解**:
```mermaid
sequenceDiagram
participant Sender as 发送方 goroutine
participant Channel as 无缓冲 channel
participant Receiver as 接收方 goroutine
Sender->>Channel: ch <- 42
Note over Sender,Channel: 双方同时就绪
Channel->>Receiver: 传递 42
Receiver->>Channel: <-ch 接收完成
Note over Receiver,Channel: 发送和接收同时完成
Sender->>Sender: 继续执行
Receiver->>Receiver: 继续执行
```
> **关键点**:无缓冲 channel 实现了 goroutine 之间的**同步屏障**。发送方阻塞到接收方 ready,接收方阻塞到发送方 ready。
## 有缓冲 Channel
有缓冲 channel 的 `len()` 是当前元素数量,`cap()` 是缓冲区容量。
**核心语义:缓冲区未满时发送不阻塞,缓冲区非空时接收不阻塞。**
```go
func main() {
ch := make(chan int, 3) // 缓冲容量 3
ch <- 1 // len=0→1, 不阻塞(缓冲区有空位)
ch <- 2 // len=1→2, 不阻塞
ch <- 3 // len=2→3, 不阻塞(缓冲区满)
fmt.Println(len(ch)) // 输出: 3
fmt.Println(cap(ch)) // 输出: 3
// 缓冲区已满,下一条发送会阻塞
// ch <- 4 // ❌ 阻塞!
val := <-ch // len=3→2, 不阻塞
fmt.Println("received:", val) // 输出: received: 1
fmt.Println(len(ch)) // 输出: 2
}
```
**缓冲区的内部结构**:
```mermaid
graph LR
subgraph Buffer["channel 缓冲区 (cap=4)"]
B1[元素 1]
B2[元素 2]
B3[元素 3]
B4[空位]
end
subgraph Send["发送方"]
S["ch <- 4"]
end
subgraph Recv["接收方"]
R["<- ch"]
end
S -->|"缓冲区未满,直接写入"| Buffer
Buffer -->|"缓冲区非空,直接读取"| R
classDef buf fill:#e3f2fd,stroke:#1565c0
classDef send fill:#fff3e0,stroke:#e65100
classDef recv fill:#e8f5e9,stroke:#2e7d32
class Buffer buf
class S send
class R recv
```
### len() vs cap()
| 属性 | 含义 | 示例 |
|------|------|------|
| `len(ch)` | 当前缓冲区中的元素数量 | `0 ~ cap` 之间变化 |
| `cap(ch)` | 缓冲区的最大容量 | 创建时确定,不可改变 |
> **思考**:`len()` 和 `cap()` 分别在什么时机更新?如果一个 goroutine 在发送,另一个在接收,`len()` 会正确反映当前值吗?
## Channel 的关闭
```go
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch) // 关闭 channel
// 关闭后还能接收已发送的数据
val, ok := <-ch // val=1, ok=true
val, ok := <-ch // val=2, ok=true
val, ok := <-ch // val=0 (零值), ok=false // 缓冲区空了,收到零值
// 从关闭的 channel 接收,ok 始终为 false
val, ok := <-ch // val=0, ok=false
```
**close() 的规则**:
| 操作 | 合法? | 后果 |
|------|--------|------|
| `close(ch)` — 正常关闭 | ✅ | 接收方可以读完残留数据,后续接收返回零值和 `false` |
| 向已关闭的 channel 发送 | ❌ | panic: send on closed channel |
| 关闭 nil channel | ❌ | panic: close of nil channel |
| 关闭已关闭的 channel | ❌ | panic: close of closed channel |
> **核心原则**:**发送方负责关闭 channel,接收方不负责关闭。** 多个发送方场景下,建议用 `sync.Once` 确保只关闭一次。
## nil Channel
nil channel 是一个未初始化的 channel(值为 `nil`)。
```go
var ch chan int // nil channel
// 向 nil channel 发送 → 永远阻塞
// ch <- 1 // ❌ 死锁
// 从 nil channel 接收 → 永远阻塞
// <-ch // ❌ 死锁
// 关闭 nil channel → panic
// close(ch) // ❌ panic: close of nil channel
```
**nil channel 的实际用途**:用于 `select` 中禁用某个分支。
```go
func main() {
ch1 := make(chan int)
var ch2 chan int // nil
select {
case v := <-ch1:
fmt.Println("received from ch1:", v)
case <-ch2:
fmt.Println("from ch2 (never reaches here)")
}
// select 永远阻塞在 ch1 上,ch2 分支被静默禁用
}
```
> **思考**:为什么 nil channel 是"永远阻塞"而不是"立即返回"?这体现了 Go 对 nil 的哪种设计哲学?
## Channel 死锁场景
### 场景 1:向无人读的 channel 写
```go
func main() {
ch := make(chan int)
ch <- 42 // 阻塞:没有接收方
// 输出: all goroutines are asleep - deadlock!
}
```
### 场景 2:读无人写的 channel
```go
func main() {
ch := make(chan int)
<-ch // 阻塞:没有发送方
// 输出: all goroutines are asleep - deadlock!
}
```
### 场景 3:读写 nil channel
```go
func main() {
var ch chan int // nil
<-ch // 永远阻塞 → 死锁
}
```
### 场景 4:goroutine 中读写已关闭的 channel
```go
func main() {
ch := make(chan int)
close(ch)
go func() {
ch <- 1 // panic: send on closed channel
}()
<-ch // 正常接收
}
```
## for-range 与 Channel
```go
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch) // 必须关闭,for-range 才知道何时结束
for v := range ch { // 自动读取直到 channel 关闭且空
fmt.Println(v) // 输出: 1, 2
}
}
```
> **思考**:如果不对 channel 调用 `close()`,`for range` 会发生什么?这与无缓冲 channel 的死锁有什么区别?
## 常见模式
### Worker Pool(工作池)
```go
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
fmt.Printf("worker %d done\n", id)
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// 启动 3 个 worker
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// 发送任务
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs) // 发送完毕后关闭
// 等待结果
for i := 1; i <= 5; i++ {
<-results
}
}
```
### 超时控制
```go
select {
case v := <-ch:
fmt.Println("received:", v)
case <-time.After(5 * time.Second):
fmt.Println("timed out after 5s")
}
```
## Channel 内部实现要点
```go
// Go 源码简化版 channel 结构
type hchan struct {
buf unsafe.Pointer // 缓冲区(有缓冲时)
elemsize uint16 // 每个元素的大小
closed uint32 // 是否关闭
elemtype *type // 元素类型
sendx uint // 发送索引
recvx uint // 接收索引
recvq waitq // 等待接收的 goroutine 队列
sendq waitq // 等待发送的 goroutine 队列
lock mutex // 保护所有字段的互斥锁
}
```
- Channel 是**并发安全**的,内部使用 `mutex` 保护所有操作
- 无缓冲 channel:直接 sender ↔ receiver 握手(锁粒度极小)
- 有缓冲 channel:先写入缓冲区(`buf` 循环缓冲区),满时才阻塞
- `recvq` 和 `sendq` 是 **Goroutine 等待队列**(与 GMP 模型联动)
> **关键理解**:Channel 的锁只保护缓冲区操作本身,不影响 goroutine 的调度——GMP 模型负责调度等待队列中的 goroutine。
## 关联笔记
- [[DEV/GO/GMP调度模型]]
- [[DEV/GO/Goroutine泄漏排查]]
- [[DEV/GO/内存分配与逃逸分析]]
+234
View File
@@ -0,0 +1,234 @@
---
tags: [go, golang, runtime, GC, garbage-collection, 三色标记]
create time: 2026-04-24 10:00
---
# GC 垃圾回收
## 概述
Go 的垃圾回收(GC)是**自动、并发、不可移动**的标记-清除算法。它的核心目标是:**在保证正确性的前提下,尽可能减少 STW(Stop-The-World)时间**。
> **思考**:为什么 Go 选择"不可移动"的 GC 策略?对象不可移动对 GC 的实现和性能分别有什么影响?
Go GC 的演进:
| Go 版本 | 特性 |
|---------|------|
| 1.1~1.3 | 半并发 GC(并发标记,STW 终结) |
| 1.5+ | 全并发 GC(并发标记 + 并发清理,STW 仅用于启动和切换) |
| 1.8+ | 混合写屏障(Hybrid Write Barrier),消除扫描阶段的 STW |
| 1.9+ | 更精细的并发控制,STW 时间显著缩短 |
## 三色标记法
三色标记是 Go GC 的核心算法。所有对象被标记为三种颜色之一:
| 颜色 | 含义 |
|------|------|
| **白色** | 未被标记(可能可达,也可能不可达) |
| **灰色** | 已被标记,但其引用的对象尚未扫描 |
| **黑色** | 已被标记,且其引用的对象已全部扫描完毕 |
### 标记规则
```
初始:所有对象 = 白色
根扫描开始:
- 根对象(全局变量、栈上的引用)标记为灰色
- 放入灰色队列
标记循环(并发执行):
1. 从灰色队列取出一个灰色对象 → 标记为黑色
2. 扫描黑色对象引用的所有对象:
- 如果引用对象是白色 → 标记为灰色,加入灰色队列
- 如果引用对象是灰色或黑色 → 不做操作
终止条件:灰色队列为空 → 标记阶段结束
```
### 三色不变式
Go GC 依赖两个不变式来保证正确性:
```
强不变式(Strong Invariant):
黑色对象不能直接引用白色对象
弱不变式(Weak Invariant):
如果黑色对象引用了白色对象,
那么那个白色对象一定是通过其他灰色对象可达的
```
> **核心原理**:如果一个白色对象被黑色对象引用,但它没有通过灰色对象可达 → 说明这个白色对象**真正不可达**,可以安全回收。
```mermaid
flowchart LR
subgraph Roots["根对象 (灰色)"]
R1[灰色对象 R1]
R2[灰色对象 R2]
end
subgraph Objects["所有对象"]
B1[黑色 B1]
B2[黑色 B2]
W1[白色 W1]
W2[白色 W2]
end
R1 -->|"引用"| B1
R1 -->|"引用"| W1
R2 -->|"引用"| B2
B1 -->|"引用"| W2
B2 -->|"引用"| W2
classDef gray fill:#ffa726,color:#fff
classDef black fill:#616161,color:#fff
classDef white fill:#eeeeee,stroke:#333
class R1,R2 gray
class B1,B2 black
class W1,W2 white
```
> **思考**:上图中 B2 直接引用了白色 W2,这违反了"黑色对象不能直接引用白色对象"的强不变式。如果此时 GC 终止,W2 就会被误回收——这就是为什么需要写屏障。
## 写屏障(Write Barrier)
写屏障是并发 GC 的核心机制。在并发标记期间,如果用户代码修改了指针,GC 可能来不及追踪这些变化。写屏障确保:
> **任何指向白色对象的指针写入,都会被记录为指向灰色对象**
### 插入写屏障(Go 1.5+)
在指针赋值**之前**检查:
```go
// 伪代码:插入写屏障逻辑
func writePointer(field, newVal) {
if newVal 是白色 {
newVal 重新标记为灰色 // 确保白色对象不被误回收
}
}
// 实际场景
*p = newObj // 写屏障在这里介入
```
**插入写屏障的局限**:无法处理删除引用(将指针设为 nil 或指向其他对象)的场景。
### 删除写屏障(Go 1.8+)
```go
// 伪代码:删除写屏障逻辑
func writePointer(field, newVal) {
oldVal = *field // 旧值
*field = newVal // 新值
// 如果旧值是黑色,且它引用的白色对象可能只通过它可达
if oldVal 是黑色 and 旧引用对象是白色 {
旧引用对象重新标记为灰色 // 确保白色对象不被漏掉
}
}
```
### 混合写屏障(Go 1.8+,Go 默认使用)
**插入写屏障 + 删除写屏障**的组合:
```
赋值前检查:新值如果是白色 → 标灰(插入屏障)
赋值后检查:旧值如果是黑色 → 旧引用的白色对象标灰(删除屏障)
效果:无论指针如何变化,白色对象都不会被漏标
```
```mermaid
flowchart TD
A["🏁 GC 启动 (STW 极短)"] --> B["📝 并发标记阶段"]
B -->|"标记循环\n(灰色队列→黑色)"| C{"灰色队列\n是否为空?"}
C -->|"否"| B
C -->|"是"| D["🛡️ 写屏障介入\n混合写屏障: 插入+删除"]
D --> E["🏁 标记结束 (STW 极短)"]
E --> F["🗑️ 并发清理阶段"]
F --> G["🏁 GC 完成"]
classDef start fill:#e53935,color:#fff
classDef process fill:#1e88e5,color:#fff
classDef barrier fill:#43a047,color:#fff
classDef done fill:#8e24aa,color:#fff
class A,E start
class B,F process
class D barrier
class G done
```
## 标记-清除 vs 标记-复制
Go 采用**标记-清除(Mark-Sweep)**而非标记-复制的原因:
| 维度 | 标记-清除 | 标记-复制 |
|------|-----------|-----------|
| 内存碎片 | 会产生碎片 | 无碎片 |
| 内存利用率 | 50%(需要空半区) | 100%(碎片由整理消除) |
| GC 速度 | 与存活对象数相关 | 与总对象数相关 |
| 对象移动 | 不移动(简化指针) | 需要移动和更新指针 |
| Go 的选择 | ✅ | ❌ |
> **思考**:既然标记-清除会产生内存碎片,Go 是如何处理的?这与 [内存分配与逃逸分析](./内存分配与逃逸分析.md) 有什么关系?
## Go 的 GC 触发方式
Go 使用**比例触发(Ratelimiting)**而非固定阈值:
```
触发条件:距上次 GC 结束后,分配的数据量达到上次 GC 时存活数据量的 P%
P = GCPercent(可通过 GCPercent() 查询)
Go 1.8+ 目标:单次 GC 的 STW 时间 < 100μs
```
```go
import (
"runtime"
"runtime/debug"
)
func main() {
// 查询当前 GC 比例
p := runtime.GCPercent()
fmt.Println("GCPercent:", p)
// 调整 GC 比例(值越小,GC 越频繁)
debug.SetGCPercent(20) // 默认 100
// 强制触发 GC(仅用于测试/调试)
runtime.GC()
// 查看 GC 统计
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("GC 次数: %d\n", m.NumGC)
fmt.Printf("分配总量: %d bytes\n", m.Alloc)
fmt.Printf("系统分配: %d bytes\n", m.Sys)
}
```
> **关键理解**:`GCPercent` 不是"存活 X% 时触发 GC",而是增量比例——每次 GC 后,系统会根据当前存活量计算触发阈值。
## 总结
Go GC 的设计哲学:
- **并发优先**:标记和清理几乎全部并发执行,STW 时间极短
- **写屏障保障正确性**:混合写屏障确保并发标记的准确性
- **不可移动**:简化运行时,避免指针更新开销
- **增量回收**:GC 开销分摊到每次对象分配上
> **思考**:为什么 Go 不采用分代 GC(Generational GC)?在什么场景下分代 GC 会有更好的表现?
## 关联笔记
- [[DEV/GO/GMP调度模型]]
- [[DEV/GO/内存分配与逃逸分析]]
- [[DEV/GO/Goroutine泄漏排查]]
+168
View File
@@ -0,0 +1,168 @@
---
tags: [go, golang, runtime, GMP, goroutine, scheduler]
create time: 2026-04-24 10:00
---
# GMP 调度模型
## 概述
Go 的并发模型建立在 **GMP 调度架构**之上。与传统的 OS 线程 1:1 模型不同,Go 引入了 G(Goroutine)、M(Machine/Worker)、P(Processor)三层抽象,实现了高效的**用户态调度**。
> **思考**:如果 Go 直接使用 OS 线程(1:1 模型),同时启动百万级 goroutine 会发生什么?内存开销和上下文切换代价分别有多大?
GMP 的核心目标是:**用最小的调度开销,最大化多核 CPU 的并行利用率**。
## G — Goroutine(协程)
G 是 Go 并发执行的最小单元——协程。它的核心特征:
- **初始栈空间 2KB**,可动态伸缩(最大可达 1GB)
- 栈的伸缩采用**分段扩展**策略(segment),每次增长时翻倍
- 每个 G 包含:程序计数器(PC)、寄存器状态、栈指针、等待队列指针
```go
// 创建 goroutine 的代价极低
go func() {
// 这个匿名函数运行在一个独立的协程中
fmt.Println("running in goroutine")
}()
// 对比:创建 OS 线程的代价
// runtime/debug.SetThreadThreadsLimit() 限制线程数
// 通常 OS 线程栈默认 1~8MB,且不可动态伸缩
```
> **为什么 2KB 起步?** 大多数函数不需要很大的栈空间。小默认值让创建海量协程成为可能。
## M — Machine(执行器)
M 代表**工作线程**,绑定一个 OS 线程,真正执行 G 的代码。
- **M : OS Thread = 1 : 1**,M 是 G 的执行载体
- M 负责执行 G 的代码,同时参与 GMP 调度循环
- 当 G 阻塞(如网络 IO、channel 操作)时,M 会被 P 释放去执行其他 G
```go
// 限制 Go 使用的 OS 线程数,理解 M 的规模
// GOPTHREADS=1000 go run main.go
// runtime.NumGoroutine() 返回 goroutine 数量
// runtime.NumCPU() 返回逻辑 CPU 数量
// M 的数量默认是 GOMAXPROCS(默认 NumCPU)
```
## P — Processor(处理器)
P 是**调度器本地状态**的抽象,可以理解为一个"局部调度器"。
- P 维护一个**本地 goroutine 队列**(runq),最多缓存 256 个待执行的 G
- P 的数量由 `GOMAXPROCS` 控制(Go 1.5+ 默认等于 CPU 逻辑核心数)
- **每个时刻,一个 P 只能绑定一个 M** 来执行代码
P 的核心职责:
| 职责 | 说明 |
|------|------|
| 本地调度 | 从自己的 runq 取出 G 分配给绑定的 M 执行 |
| 工作窃取(Work Stealing)| 当自己的队列为空时,从其他 P 的队列中"偷"一半的 G |
| 全局队列 | 所有 P 共享一个全局 G 队列(最多容纳 64 个),用于负载均衡 |
| 系统调用处理 | 当绑定的 M 进入系统调用时,P 会"脱落"(handoff),找一个空闲 M 绑定 |
## GMP 调度循环
```mermaid
flowchart TD
subgraph GQueue["G 队列"]
G1["G1 待执行"]
G2["G2 待执行"]
G3["G3 待执行"]
G4["G4 待执行"]
G5["G5 待执行"]
end
subgraph GlobalQ["全局队列 (最多64个)"]
GQ["Global run queue"]
end
subgraph LocalQ["P 本地队列 (最多256个)"]
PQL["P local runq"]
end
subgraph MExec["M 执行 G"]
ME["执行 G 的代码"]
end
subgraph OSys["操作系统"]
NET["网络 IO / 系统调用"]
SYS["系统调用 - 阻塞"]
end
GQueue --> |入队| GlobalQ
GlobalQ --> |P 空闲时取| PQL
PQL --> |分配给 M| ME
ME --> |执行完 G| ME
ME --> |新建 G| PQL
ME --> |阻塞 channel/IO | NET
ME --> |系统调用| SYS
SYS --> |P 脱落, 另找 M| PQL
NET --> |完成阻塞| PQL
PQL --> |队列太长, 移到全局| GQ
GQ --> |工作窃取: 从其他 P 偷一半| PQL
classDef queue fill:#e3f2fd,stroke:#1565c0
classDef exec fill:#fff3e0,stroke:#e65100
classDef sys fill:#fce4ec,stroke:#c62828
class GlobalQ,PQL queue
class ME exec
class NET,SYS sys
```
**调度流程**:
1. **新建 G** → 先尝试放入 P 的本地队列(`runq`),满了则放入全局队列
2. **P 取 G** → 从本地队列取(优先),本地为空时从全局队列取,再空则"工作窃取"其他 P
3. **M 执行 G** → M 拿着 P 分配的 G 执行代码
4. **G 阻塞** → G 进入等待队列,M 继续执行下一个 G,P 寻找新的 M 绑定
5. **G 恢复** → G 被放回到某个 P 的队列中,等待下次被调度
## 为什么不用 M:N 模型?
早期 Go 讨论过 M:N 模型(N 个 G 映射到 M 个 OS 线程),最终选择了 G:M ≈ 1:1 + P 用户态调度,原因:
| 对比维度 | M:N 模型 | Go 的 GMP 模型 |
|----------|----------|----------------|
| 系统调用阻塞 | M 被阻塞,N 个 G 全部卡住 | G 阻塞,M 脱手,P 找新 M |
| 抢占式调度 | 需要内核协作 | 用户态即可,P 完全控制 |
| 实现复杂度 | 极高(需要内核干预) | 相对简洁 |
| Go 版本 | 1.1 之前实验过 | 1.1+ 确定方案 |
> **关键点**:Go 1.5 引入 GOMAXPROCS = NumCPU,每个 P 绑定一个逻辑 CPU 核心,实现了真正的并行执行。
## 关键参数
```go
// 获取和设置 GOMAXPROCS
import "runtime"
func main() {
n := runtime.GOMAXPROCS(0) // 获取当前值
runtime.GOMAXPROCS(4) // 设置为 4
fmt.Println("M 的最大并行度:", n)
}
// 其他 runtime 参数
fmt.Println("Goroutine 数量:", runtime.NumGoroutine())
fmt.Println("逻辑 CPU 数量:", runtime.NumCPU())
```
> **思考**:`GOMAXPROCS=1` 时,Go 还是并行的吗?还是只是并发?
## 关联笔记
- [[DEV/GO/GC垃圾回收]]
- [[DEV/GO/Channel详解]]
- [[DEV/GO/内存分配与逃逸分析]]
- [[DEV/GO/Goroutine泄漏排查]]
+355
View File
@@ -0,0 +1,355 @@
---
tags: [go, golang, runtime, goroutine, 泄漏, 并发, channel, waitgroup]
create time: 2026-04-24 10:00
---
# Goroutine 泄漏排查
## 概述
Goroutine 泄漏是指 goroutine 被创建后,由于代码逻辑问题,**永远无法结束**,导致其占用的栈内存和其他资源无法回收。Goroutine 是泄漏检测的"隐形杀手"——它不会像进程那样消耗 PID,也不会像内存泄漏那样有明显的 `Out Of Memory` 错误,而是表现为 `runtime.NumGoroutine()` 持续增长。
> **思考**:如果一个 goroutine 泄漏了,它会一直占用内存吗?goroutine 初始栈只有 2KB,泄漏 10 万个 goroutine 会占用多少内存?
## 泄漏场景与排查
### 场景 1:向无人读的 channel 写
```go
func producer(ch chan<- int) {
for i := 0; ; i++ {
ch <- i // 如果没有接收方,goroutine 永远阻塞
}
}
func main() {
ch := make(chan int)
go producer(ch) // 泄漏!没有人从 ch 读取
time.Sleep(1 * time.Second)
fmt.Println("goroutines:", runtime.NumGoroutine()) // 至少 2 个
}
```
**修复方式**:确保有对应的接收方,或者在适当的时候关闭 channel。
```go
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 10; i++ {
ch <- i // 发送有限数量的数据
}
close(ch) // 发送完毕后关闭
}()
// 用 range 消费,channel 关闭后自动退出
for v := range ch {
fmt.Println(v)
}
}
```
### 场景 2:读无人写的 channel
```go
func main() {
ch := make(chan int)
val := <-ch // 永远阻塞,goroutine 泄漏
fmt.Println(val)
}
```
**修复方式**:确保有发送方,或者用 `select` 加超时。
```go
func main() {
ch := make(chan int)
select {
case val := <-ch:
fmt.Println(val)
case <-time.After(5 * time.Second):
fmt.Println("timeout, no data received")
}
}
```
### 场景 3:读写 nil channel
```go
func main() {
var ch chan int // nil
// 以下任何一行都会导致永久阻塞
// ch <- 1 // 向 nil channel 发送 → 永远阻塞
// <-ch // 从 nil channel 接收 → 永远阻塞
// close(ch) // 关闭 nil channel → panic
}
```
**修复方式**:在使用前确保 channel 已初始化。
```go
func main() {
var ch chan int
if someCondition {
ch = make(chan int)
}
select {
case ch <- 1:
fmt.Println("sent")
default:
fmt.Println("channel not initialized, skipped")
}
}
```
### 场景 4:WaitGroup 计数错误
```go
func main() {
var wg sync.WaitGroup
// ❌ 错误:在 goroutine 外部调用 wg.Add(1) 是正确的
// 但在 goroutine 内部调用 Add(1) 会导致 panic
// 或者忘记调用 wg.Done()
wg.Add(1)
go func() {
defer wg.Done() // 如果 goroutine 提前 return 忘记调用 Done → 泄漏
for {
// 没有退出条件 → goroutine 永远不会结束
}
}()
wg.Wait() // 永远等待
}
```
**修复方式**:确保每个 `Add(1)` 都有对应的 `Done()`,且 goroutine 有明确的退出条件。
```go
func main() {
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return // 有退出条件
default:
// 正常处理
}
}
}()
time.Sleep(1 * time.Second)
cancel() // 触发退出
wg.Wait()
}
```
### 场景 5:sync.Mutex 拿锁未释放
```go
var mu sync.Mutex
func leaky() {
mu.Lock()
// 如果这里 panic 或者永久阻塞
// mu.Unlock() 永远不会执行 → 所有等待 mu.Lock() 的 goroutine 泄漏
panic("boom") // mu 永远不会被释放
}
func main() {
go leaky() // 拿锁后 panic,锁不释放
go func() {
mu.Lock() // 永远等不到锁 → goroutine 泄漏
mu.Unlock()
}()
time.Sleep(1 * time.Second)
fmt.Println(runtime.NumGoroutine()) // 持续增加
}
```
**修复方式**:确保在任何路径下都能释放锁。
```go
func safe() {
mu.Lock()
defer mu.Unlock() // 即使 panic,defer 也会执行
panic("boom")
}
```
### 场景 6:select 中没有默认分支
```go
func main() {
ch := make(chan int)
// 如果 ch 没有数据,也没有其他 case 能就绪
// goroutine 会永远阻塞在 select 上
select {
case <-ch:
fmt.Println("received")
// 缺少 default 和 time.After → 永远等待
}
}
```
## 泄漏排查工具
### 1. pprof goroutine profile
```bash
# 方法一:导入 net/http/pprof
import _ "net/http/pprof"
# 方法二:使用 runtime/pprof
import (
"runtime"
"runtime/pprof"
"os"
)
func dumpGoroutines() {
f, _ := os.Create("goroutine.prof")
pprof.WriteProfile(f)
f.Close()
}
```
```bash
# 分析 goroutine 泄漏
go tool pprof goroutine.prof
# 进入交互界面后执行:
top # 查看最顶层的 goroutine
top -cum # 按累积时间排序
list main.leaky # 查看具体函数的 goroutine 阻塞位置
```
### 2. 监控 NumGoroutine
```go
import (
"runtime"
"time"
)
func monitorGoroutines() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
n := runtime.NumGoroutine()
if n > 100 { // 设定阈值
fmt.Printf("⚠️ High goroutine count: %d\n", n)
// dump stack
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
fmt.Printf("Goroutine stacks:\n%s\n", buf[:n])
}
}
}
```
### 3. pprof 常用命令速查
```bash
# 启动 web UI(推荐)
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine?debug=1
# 文本模式
go tool pprof -text goroutine.prof
# 查看具体函数阻塞位置
go tool pprof -list=main.worker goroutine.prof
```
## 预防最佳实践
| 实践 | 说明 |
|------|------|
| **每个 goroutine 必须有退出条件** | 用 `context.Context` 控制生命周期 |
| **Channel 用完必须关闭** | 发送方负责关闭,防止接收方永远等待 |
| **WaitGroup 配对使用** | `Add` 和 `Done` 一一对应,用 `defer` 确保 `Done` 执行 |
| **Mutex 用 defer 释放** | `defer mu.Unlock()` 防止 panic 导致锁不释放 |
| **select 加超时** | 永远不要 `select` 中只有阻塞 channel 而无 `default`/`timeout` |
| **启动时加监控** | 定期采样 `runtime.NumGoroutine()`,异常时 dump stack |
| **context 传递取消信号** | 让所有 goroutine 都能响应取消 |
```go
// ✅ 推荐的 goroutine 模板
func worker(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return // 正常退出
default:
// 执行任务
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go worker(ctx, &wg)
}
wg.Wait()
}
```
## 排查流程图
```mermaid
flowchart TD
A["发现 goroutine 持续增长"] --> B{"NumGoroutine 持续增长?"}
B -->|"是"| C["go tool pprof goroutine profile"]
C --> D["top / top -cum 查看类型分布"]
D --> E{"泄漏的 goroutine 类型?"}
E -->|"chan send / chan receive"| F["检查 channel 发送方/接收方配对"]
F --> G["确保有对应的读取/写入"]
G --> H["使用 select + timeout 防止永久等待"]
E -->|"sync.Mutex lock"| I["检查 mutex 是否被正确释放"]
I --> J["所有路径都使用 defer Unlock"]
E -->|"sleep / IO"| K["检查是否有阻塞的 IO 操作"]
K --> L["确保有超时和取消机制"]
E -->|"goroutine 数量少且稳定"| M["可能是正常的工作 goroutine"]
M --> N["确认是否为预期行为"]
B -->|"否"| O["没有泄漏,goroutine 数量正常"]
classDef leak fill:#e53935,color:#fff
classDef fix fill:#43a047,color:#fff
classDef normal fill:#1e88e5,color:#fff
class C,D,F,I,K,M,O normal
class G,H,J,L fix
class A leak
```
## 关联笔记
- [[DEV/GO/GMP调度模型]]
- [[DEV/GO/Channel详解]]
- [[DEV/GO/GC垃圾回收]]
+325
View File
@@ -0,0 +1,325 @@
---
tags: [go, golang, runtime, 内存分配, 逃逸分析, 虚拟内存]
create time: 2026-04-24 10:00
---
# 内存分配与逃逸分析
## 概述
Go 的内存管理分为两个层面:**运行时的内存分配**(由 Malloc 实现)和**编译期的逃逸分析**(决定变量分配在栈还是堆)。理解这两个机制对编写高性能 Go 代码至关重要。
> **思考**:为什么大多数语言把局部变量分配在栈上,而把 new/malloc 出来的分配在堆上?Go 的逃逸分析打破了这个规则——它如何让编译器决定分配位置?
>
> 详见 → [[DEV/GO/内存分配与逃逸分析/为什么Go打破栈堆分工规则.md]]
## 多级存储模型
计算机的存储层次决定了数据的访问速度,距离 CPU 越近越快:
```mermaid
graph LR
subgraph Fast["速度快 / 容量小 / 昂贵"]
REG[寄存器]
L1["L1 缓存 (~30ns)"]
L2["L2 缓存 (~100ns)"]
L3["L3 缓存 (~300ns)"]
end
subgraph Slow["速度慢 / 容量大 / 便宜"]
MEM["物理内存 (~100ns)"]
DISK["磁盘 / SSD"]
SWAP["Swap 区域"]
end
REG --> L1 --> L2 --> L3 --> MEM --> SWAP --> DISK
classDef fast fill:#4caf50,color:#fff
classDef slow fill:#ff9800,color:#fff
class REG,SWAP fast
class L1,L2,L3,MEM slow
class DISK slow
```
> **核心认知**:CPU 寄存器的访问速度是纳秒级,而内存是百纳秒级,磁盘是毫秒级。三者相差百万倍。
### 虚拟内存的好处
Go 程序运行在**虚拟内存**之上,每个进程拥有独立的虚拟地址空间(64 位系统为 128TB)。
> **拓展**:Go 的虚拟内存使用方式和其他语言有什么不同?详见 → [[DEV/GO/内存分配与逃逸分析/虚拟内存与各语言对比.md]]
| 特性 | 说明 |
|------|------|
| **地址隔离** | 每个进程的虚拟地址空间互不影响 |
| **页面机制** | 内存按页(4KB)管理,未使用的页面不占用物理内存 |
| **SWAP 冷热切换** | 不常用的页(冷页)换出到磁盘,常用的页(热页)留在内存 |
> **思考**:SWAP 机制让"冷热动态切换"成为可能。这为什么是操作系统最精妙的设计之一?如果所有数据都常驻物理内存,会发生什么?
虚拟内存的好处:
1. **无需物理连续**:虚拟地址可以是连续的,物理页可以分散
2. **按需分配**:`malloc` 并不真正分配物理内存,只是映射虚拟地址
3. **冷热分页**:频繁访问的页常驻内存,低频页换出到 Swap
## Golang 内存模型:三级缓存架构
Go 的内存分配器采用**三级缓存 + 中央池 + 大对象分配**的分层架构,详见 → [[DEV/GO/内存分配与逃逸分析/三级缓存架构.md]]
```mermaid
flowchart TD
subgraph ThreadLocal["线程局部 (无锁)"]
MCACHE["mcache per-P\n每 P 一个 mcache"]
end
subgraph CentralPool["中央池 (细粒度锁)"]
MCENTRAL["mcentral\n固定大小的对象池"]
end
subgraph Heap["堆 (全局重锁)"]
MHEAP["mheap\n向 OS 申请内存"]
end
subgraph OS["操作系统"]
MEM["mmap 映射虚拟内存\n默认 64MB 起始"]
end
MCACHE -->|"缓存不够时"| MCENTRAL
MCENTRAL -->|"所有规格都空时"| MHEAP
MHEAP -->|"mmap 向 OS 申请"| MEM
classDef local fill:#4caf50,color:#fff
classDef central fill:#2196f3,color:#fff
classDef heap fill:#ff9800,color:#fff
classDef os fill:#9e9e9e,color:#fff
class MCACHE local
class MCENTRAL central
class MHEAP heap
class MEM os
```
### 第一级:mcache(线程级,无锁)
每个 P 拥有独立的 `mcache`,是 Goroutine 最近的内存分配点。
- **无锁设计**:因为每个 P 独占自己的 mcache,无需竞争
- **缓存固定大小的对象**:每种大小类(size class)缓存一个 object 链表
- Go 定义了 **67 种对象大小类别**,覆盖 0 ~ 32KB 的对象
```
mcache 内部结构(简化):
mcache.span[class0] → 缓存大小为 8 字节对象的 span
mcache.span[class1] → 缓存大小为 16 字节对象的 span
...
mcache.span[class66] → 缓存大小为 32768 字节对象的 span
```
### 第二级:mcentral(池级,细粒度锁)
`mcentral` 管理多种规格(span class)的内存池,每种规格有独立的 `mspan`。
- **按大小分组**:同样大小的对象放在同一个 `mspan` 中
- **细粒度锁**:每种规格的 `mspan` 有独立的锁,而非全局一把大锁
- 当 mcache 的缓存不够时,向 mcentral 请求新的 span
```
mcentral 内部结构(简化):
mcentral.spans[class0] → mspan (管理大小为 8 字节的对象)
mcentral.spans[class1] → mspan (管理大小为 16 字节的对象)
...
```
### 第三级:mheap(堆级,全局锁)
`mheap` 是整个程序的"大仓库",直接向操作系统申请内存。
- **全局锁保护**:申请新内存时需要获取全局锁
- **mmap 映射**:通过 `mmap` 向 OS 请求内存(初始约 64MB,可增长)
- 分配 span 给 mcentral,形成完整的分配链路
```
mheap 内部结构(简化):
mheap.all[] → 管理所有 span 的数组
mheap.pageAlloc → 位图,标记每个页面是否已分配
```
### 大对象分配(>32KB)
超过 32KB 的对象**跳过 mcache 和 mcentral**,直接从 mheap 分配:
```go
// 超过 32KB 的对象直接走 mheap 路径
func benchmarkLargeAlloc(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = make([]byte, 64*1024) // 64KB,直接走 mheap
}
}
// 小于 32KB 的对象走 mcache 路径(更快)
func benchmarkSmallAlloc(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = make([]byte, 1024) // 1KB,走 mcache 路径
}
}
```
> **思考**:为什么 32KB 是一个分界线?这与 CPU 缓存行(cache line,通常 64 字节)和页大小(4KB)有什么关系?
## 锁粒度对比
| 层级 | 锁粒度 | 竞争程度 | 适用场景 |
|------|--------|----------|----------|
| **mcache** | 无锁(每个 P 独立) | 零竞争 | 频繁的小对象分配 |
| **mcentral** | 按大小类细粒度锁 | 低竞争 | 中等频率分配 |
| **mheap** | 全局锁 | 高竞争 | 大对象分配、扩容 |
> **设计精妙之处**:Go 通过分层 + 无锁设计,让最常见的内存分配(小对象、同一 P 内)完全避免锁竞争。
## 逃逸分析
逃逸分析是**编译器在编译期进行的静态分析**,决定变量分配在**栈**还是**堆**上。
```
变量分配策略:
- 栈:函数返回后自动释放,零 GC 开销
- 堆:GC 管理生命周期,有 GC 开销但可超越函数作用域
逃逸规则(简化版):
如果变量的生命周期在函数返回后仍然需要 → 逃逸到堆
否则 → 留在栈上
```
### 逃逸场景
#### 场景 1:闭包引用局部变量
```go
func main() {
x := 10 // 逃逸到堆!因为匿名函数在 main 返回后仍可能运行
f := func() int {
return x // 闭包持有对 x 的引用
}
fmt.Println(f())
}
```
编译时 `go build -gcflags="-m"` 可以看到:
```
go build -gcflags="-m" main.go
# 输出:
# ./main.go:4:2: x escapes to heap
```
#### 场景 2:返回局部变量的指针
```go
func newInt() *int {
x := 10 // 逃逸到堆!返回了 &x
return &x // 如果留在栈上,函数返回后 x 就被销毁了
}
```
#### 场景 3:空接口(interface{})
```go
func main() {
x := 100
var i interface{} = x // x 逃逸到堆!
// 因为 interface{} 是动态类型,编译器无法在编译期确定 x 的生命周期
}
```
#### 场景 4:切片长度在运行时才确定
```go
func main() {
n := 10
s := make([]int, n) // n 是运行时确定的 → 切片逃逸到堆
// 如果长度是常量:make([]int, 10) → 小切片可能留在栈上
}
```
#### 场景 5:接口参数传递
```go
func process(v interface{}) {
// 参数 v 传入时,其底层数据可能逃逸
_ = v
}
func main() {
x := 42
process(x) // x 可能逃逸
}
```
### 逃逸分析实战
```bash
# 查看逃逸分析结果
go build -gcflags="-m" ./...
# 更详细的输出
go build -gcflags="-m -m" ./...
```
```go
package main
func main() {
// 不逃逸 - 分配在栈上
x := 10
// 逃逸 - 分配在堆上
p := &x // 取地址 → x 逃逸
_ = *p
}
```
编译输出:
```
./main.go:7:2: &x escaped to heap
```
### 栈 vs 堆对比
| 维度 | 栈 | 堆 |
|------|-----|-----|
| 分配速度 | 极快(只需移动栈指针) | 较慢(需要锁和内存管理) |
| 释放速度 | 自动(函数返回时栈帧弹出) | GC 回收(有延迟) |
| GC 开销 | 无 | 有 |
| 大小限制 | 有限(goroutine 栈初始 2KB) | 几乎无限(受限于虚拟内存) |
| 生命周期 | 函数作用域内 | 可超越函数作用域 |
> **优化建议**:尽量减少逃逸到堆的变量数量,可以让 GC 负担更轻。
```go
// ❌ 不推荐:不必要的指针传递导致逃逸
func getData() *Data {
return &Data{Name: "test"} // Data 逃逸到堆
}
// ✅ 推荐:值传递,避免不必要的指针
func getData() Data {
return Data{Name: "test"} // 小结构体留在栈上
}
```
## 总结
Go 内存管理的核心设计理念:
1. **分层分配**:mcache(无锁)→ mcentral(细锁)→ mheap(全局锁),按需降级
2. **大对象快速通道**:>32KB 的对象跳过中间层,直接走 mheap
3. **编译期逃逸分析**:尽可能让变量留在栈上,减少 GC 压力
4. **虚拟内存抽象**:按需映射,冷热分页,让程序拥有 128TB 地址空间
## 关联笔记
- [[DEV/GO/GMP调度模型]]
- [[DEV/GO/GC垃圾回收]]
- [[DEV/GO/Channel详解]]
- [[DEV/GO/Goroutine泄漏排查]]
@@ -0,0 +1,137 @@
---
tags: [go, golang, runtime, 内存分配, mcache, mcentral, mheap]
create time: 2026-04-24 10:30
---
# 三级缓存架构详解
## 概述
Go 的内存分配器采用 **三级缓存 + 中央池 + 大对象快速通道** 的分层架构,核心理念是"让最常见的操作最快"。本文通过生活化类比和底层实现两层视角,帮你彻底理解这个设计。
## 生活化类比:连锁便利店的仓储体系
### 🏬 个人抽屉 → mcache
每个店长(P / Goroutine)都有自己的抽屉,里面常备着最常用的商品(固定大小的小物件)。
- **不需要请示任何人**,直接伸手拿,零等待
- 抽屉空间有限,装不了太多
- 90% 的日常需求在这里就满足了
### 📦 门店后仓 → mcentral
店长的抽屉快空了,去后仓拿。
- 后仓按**商品类别**整齐排列(8 字节的放一起、16 字节的放一起……共 67 个类别)
- 拿的时候可能需要稍等(要拿钥匙 / 找店员,对应细粒度锁)
- 容量比抽屉大得多
### 🏭 区域总仓 → mheap
门店后仓也空了?打电话给总仓库。
- 总仓库什么都有,容量巨大
- 但**手续繁琐**(全局锁,要等)
- 只有实在缺货时才走这条路
### 🚚 超大订单(>32KB)→ 专车直送
如果你要订 1000 箱货(大对象),店长懒得经手中转,**直接从总仓库专车送到你店里**,跳过抽屉和后仓。
## 为什么这么设计?
核心思想就是:**让最常见的操作最快**。
| 场景 | 占比 | 处理方式 | 等待时间 |
|------|------|---------|---------|
| 小物件、同一个 goroutine | ~90% | 抽屉直接拿 | 0 |
| 偶尔缺货 | ~9% | 去后仓拿 | 轻微 |
| 真正缺大牌货 | ~1% | 找总仓库 | 明显 |
对比一下,如果所有东西都要去总仓库拿(比如某些语言的每次 `malloc` 都全局加锁),那整个体系就**堵死了**。
## 底层实现
### 第一级:mcache(线程级,无锁)
每个 P 拥有独立的 `mcache`,是 Goroutine 最近的内存分配点。
- **无锁设计**:因为每个 P 独占自己的 mcache,无需竞争
- **缓存固定大小的对象**:每种大小类(size class)缓存一个 object 链表
- Go 定义了 **67 种对象大小类别**,覆盖 0 ~ 32KB 的对象
```
mcache 内部结构(简化):
mcache.span[class0] → 缓存大小为 8 字节对象的 span
mcache.span[class1] → 缓存大小为 16 字节对象的 span
...
mcache.span[class66] → 缓存大小为 32768 字节对象的 span
```
### 第二级:mcentral(池级,细粒度锁)
`mcentral` 管理多种规格(span class)的内存池,每种规格有独立的 `mspan`。
- **按大小分组**:同样大小的对象放在同一个 `mspan` 中
- **细粒度锁**:每种规格的 `mspan` 有独立的锁,而非全局一把大锁
- 当 mcache 的缓存不够时,向 mcentral 请求新的 span
```
mcentral 内部结构(简化):
mcentral.spans[class0] → mspan (管理大小为 8 字节的对象)
mcentral.spans[class1] → mspan (管理大小为 16 字节的对象)
...
```
### 第三级:mheap(堆级,全局锁)
`mheap` 是整个程序的"大仓库",直接向操作系统申请内存。
- **全局锁保护**:申请新内存时需要获取全局锁
- **mmap 映射**:通过 `mmap` 向 OS 请求内存(初始约 64MB,可增长)
- 分配 span 给 mcentral,形成完整的分配链路
```
mheap 内部结构(简化):
mheap.all[] → 管理所有 span 的数组
mheap.pageAlloc → 位图,标记每个页面是否已分配
```
### 大对象分配(>32KB)
超过 32KB 的对象**跳过 mcache 和 mcentral**,直接从 mheap 分配:
```go
// 超过 32KB 的对象直接走 mheap 路径
func benchmarkLargeAlloc(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = make([]byte, 64*1024) // 64KB,直接走 mheap
}
}
// 小于 32KB 的对象走 mcache 路径(更快)
func benchmarkSmallAlloc(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = make([]byte, 1024) // 1KB,走 mcache 路径
}
}
```
> **思考**:为什么 32KB 是一个分界线?这与 CPU 缓存行(cache line,通常 64 字节)和页大小(4KB)有什么关系?
## 锁粒度对比
| 层级 | 锁粒度 | 竞争程度 | 适用场景 |
|------|--------|----------|----------|
| **mcache** | 无锁(每个 P 独立) | 零竞争 | 频繁的小对象分配 |
| **mcentral** | 按大小类细粒度锁 | 低竞争 | 中等频率分配 |
| **mheap** | 全局锁 | 高竞争 | 大对象分配、扩容 |
> **设计精妙之处**:Go 通过分层 + 无锁设计,让最常见的内存分配(小对象、同一 P 内)完全避免锁竞争。
## 关联笔记
- [[DEV/GO/内存分配与逃逸分析]]
- [[DEV/GO/GMP调度模型]]
- [[DEV/GO/GC垃圾回收]]
@@ -0,0 +1,97 @@
# 为什么 Go 打破了"栈/堆分工"规则?
## 传统语言的设计哲学:静态可知
C/C++/Java 等语言遵循一个简单规则:**编译期能确定生命周期的放栈上,不能确定的放堆上**。
```
传统语言的直觉逻辑:
┌──────────────────────────────────────────┐
│ 局部变量 → 编译期知道作用域 → 栈 │
│ new/malloc → 用户显式申请 → 堆 │
│ │
│ 规则是"语法驱动"的:new 就是堆, │
│ 声明就是栈。程序员一目了然。 │
└──────────────────────────────────────────┘
```
这个设计的好处是**直观**:程序员清楚地知道自己在哪分配内存,什么时候释放。
## Go 的突破:编译器做决定
Go 的逃逸分析把决策权从**程序员**转移到了**编译器**:
```
Go 的逻辑:
┌──────────────────────────────────────────┐
│ 编译器问:这个变量的生命周期多长? │
│ │
│ · 函数返回后不再用到 → 栈(快,零GC) │
│ · 函数返回后还要用 → 堆(慢,有GC) │
│ │
│ 规则是"语义驱动"的: │
│ new/malloc 出来的也可能在栈上! │
│ 局部变量也可能逃到堆上! │
└──────────────────────────────────────────┘
```
## 打破规则的关键机制
### 1. `make` / `new` 不一定是堆分配
```go
func f() {
// 如果这个切片在函数返回后不再使用,
// 编译器可以把它留在栈上!
s := make([]int, 1000)
_ = s[0]
} // s 在这里销毁,不需要 GC 介入
// 只有当它"逃逸"时才会去堆上
func g() []int {
s := make([]int, 1000)
return s // 逃逸!调用者返回后还要用
}
```
### 2. 局部变量取地址也可能逃逸
```go
func f() {
x := 10
_ = &x // 取地址后,编译器必须把 x 放堆上
}
```
## 为什么 Go 敢这么做?
核心原因是 **Go 有 GC**。
传统语言(特别是 C/C++)没有自动 GC,一旦变量逃逸到栈外就是 UB(未定义行为)。所以只能让用户用 `malloc` 显式控制。
Go 有 GC 兜底,编译器可以放心地说:
> "不管我把你放哪,你都不会被提前释放——我分析过你的生命周期了。"
这带来了三个优势:
| 优势 | 说明 |
|------|------|
| **自动优化** | 编译器自动选择最优位置,减少手动调优 |
| **零 GC 开销** | 大部分局部变量留在栈上,不产生 GC 压力 |
| **安全性** | 不会出现悬垂指针(dangling pointer),编译器保证不会引用已释放的栈内存 |
## 本质区别
```
传统语言:语法决定分配位置
局部变量声明 → 栈(默认)
new/malloc → 堆(显式)
→ 程序员负责生命周期管理
Go 语言:语义决定分配位置
编译器分析生命周期 → 选最优位置
→ 编译器负责,程序员专注正确性
```
这正是 Go 的设计哲学缩影:**把程序员容易出错的低层细节交给编译器,让程序员关注更高层的语义正确性。**
@@ -0,0 +1,142 @@
---
tags: [go, golang, 虚拟内存, 内存管理, 操作系统]
create time: 2026-04-24 10:00
---
# 虚拟内存:Go 与其他语言的差异
## 概述
虚拟内存本身是操作系统提供的抽象,所有语言共享同一套底层机制(页表、MMU、page fault、swap)。但不同语言在**如何与虚拟内存交互**、**谁来管理分配**上有显著差异。理解这些差异,能帮助你更好地把握 Go 内存管理的独特之处。
> **思考**:既然虚拟内存是 OS 的特性,那语言的"内存管理"到底在管什么?——语言管的是**从虚拟内存到对象**之间的分配策略,而虚拟内存只是舞台背景。
## 内存分配"控制权"的归属
不同语言的内存分配器层级不同:
| 语言 | 分配链路 | 谁决定物理内存何时分配? |
|------|---------|------------------------|
| **C/C++** | 程序 → `malloc/new` → 系统库 → OS `mmap/brk` | 程序员 + malloc 库实现 |
| **Go** | 程序 → Go `malloc` → mcache→mcentral→mheap → `mmap` | Go 运行时(自己当"二道贩子") |
| **Java** | 程序 → JVM 堆管理器 → `mmap` 一大块 → 对象分配 | JVM(启动时 mmap 一大块,不主动归还) |
| **Python** | 程序 → CPython pymalloc → `malloc` | CPython(小对象 pymalloc,大对象直接 malloc) |
### Go 的独特之处:自己当二道贩子
C 语言直接调系统 `malloc`(底层可能调 `mmap` 或 `brk`),而 Go 运行时**自己实现了从 OS 到对象的全套分配逻辑**:
```
Go 的分配链路:
程序 malloc → Go mcache → mcentral → mheap → mmap(64MB) → OS
C 的分配链路:
程序 malloc → glibc/tcmalloc → mmap/brk → OS
```
Go 先 `mmap` 一大块虚拟内存(默认 64MB),再自己管理 span 怎么分片。好处是**不需要依赖系统 malloc 的行为**,每一块内存的分配和回收都在 Go 自己的掌控之中。
## 栈 vs 堆:谁来决定?
这是 Go 和很多语言最大的区别:
```go
// Go:编译器在编译期做逃逸分析,自动决定
x := 10 // 可能留在栈上(零 GC 开销)
p := &x // 逃逸到堆(GC 管理)
// Java:几乎全部对象都在堆上,栈上只有引用
Object o = new Object(); // new 的一定在堆上
// C:完全由程序员决定
int x = 10; // 栈(默认)
int *p = malloc(4); // 堆,不 free 就泄漏
```
Go 打破了"局部变量在栈、动态分配在堆"的传统分工——编译器会根据**生命周期**自动决定,程序员几乎不用操心。
> **思考**:如果你能交给编译器做决定,为什么不是所有语言都这么做?——答案在传统语言没有 GC,一旦变量逃逸到栈外就是 UB(未定义行为),所以只能让用户显式控制。
## GC 与虚拟内存的协作
| 语言 | GC 策略 | 和虚拟内存的关系 |
|------|---------|-----------------|
| **Go** | 并发三色标记-复制 | GC 后把不用的页 **`munmap` 还给 OS**,降低 RSS |
| **Java** | CMS/G1/ZGC 等 | 通常**不主动还内存给 OS**(除非特殊配置) |
| **C/C++** | 无 GC | 程序员 `free` 后,库可能还回 OS,也可能留着下次用 |
### Go 的"弹性"行为
当 GC 回收了大量内存后,Go 运行时会把空闲的页通过 `munmap` 直接还给操作系统,让 RSS 降下来:
```go
// Go 进程的内存占用是弹性的
func main() {
data := make([]byte, 100<<20) // 100MB
_ = data // RSS 涨
data = nil
runtime.GC() // RSS 回落!munmap 还给了 OS
}
```
这意味着 Go 进程的内存占用**随 workload 动态变化**——忙的时候涨,闲的时候退。而 Java 的 JVM 通常会"拿了就不还",吃内存比较"霸道"。
## 内存增长模式对比
```
Go: [||||||||||||][ ][||||| ]
↑ mmap 64MB → GC 后 munmap 还一部分 → 再次增长
Java: [|||||||||||||||||||||||||||||||||||][ ]
↑ 启动时 mmap 一大块,几乎只增不减
C: [||][ ][|||||||][ ]
↑ malloc/free 自由穿插,取决于程序逻辑
```
Go 介于 Java 的"霸道"和 C 的"随意"之间——有自动增长的策略,但也有弹性收缩的机制。
## 大对象处理差异
Go 对 **>32KB 的对象**有特殊处理:跳过 mcache/mcentral,直接从 mheap 分配。这是因为大对象不值得放进细粒度池里管理。
| 语言 | 大对象处理 |
|------|-----------|
| **Go** | >32KB 直接走 mheap,跳过中间层 |
| **Java** | G1 GC 把大对象放在 "humongous regions" |
| **C** | glibc 对大对象通常直接 `mmap` |
## 按需分配的直观验证
```go
// 这两行并不会真正占用 1GB 物理内存
data := make([]byte, 1<<30) // 1GB 虚拟地址空间
// 直到你真正写入那一刻,操作系统才开始分配物理页
data[0] = 1 // 触发 page fault,分配第一页(4KB)
data[1024*1024] = 1 // 触发第二页
// ps 看 RSS 可能只有几 KB
```
你 `make` 了 1GB,但实际物理内存只用了几 KB。这就是虚拟内存的 **Lazy Allocation** 机制——你**承诺**要用多大,操作系统**实际**只给多少。
> **思考**:既然物理内存没真正分配,那为什么 `make([]byte, 1<<30)` 还是会让进程变慢?——因为建立页表条目本身是有开销的,而且一旦触发 page fault,性能会有瞬时抖动。
## 总结
| 维度 | Go 的特色 |
|------|----------|
| **分配器** | 自己实现从 mmap 到对象的全链路,不依赖系统 malloc |
| **栈/堆决策** | 编译期逃逸分析自动决定,程序员专注正确性 |
| **内存弹性** | GC 后主动 munmap 还给 OS,RSS 可回落 |
| **并发友好** | mcache 每 P 一份无锁缓存,天然配合 GMP 模型 |
| **按需分配** | 和所有语言一样利用虚拟内存,但 Go 自己的分配策略更细粒度 |
**虚拟内存本身没变,但 Go 把它用得更"自动化"了**——你不需要像 C 那样担心泄漏,也不需要像 Java 那样忍受僵化的内存占用。
## 关联笔记
- [[DEV/GO/内存分配与逃逸分析]]
- [[DEV/GO/GMP调度模型]]
- [[DEV/GO/GC垃圾回收]]