vault backup: 2026-04-24 23:45:19
This commit is contained in:
@@ -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详解]]
|
||||
@@ -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/内存分配与逃逸分析]]
|
||||
@@ -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泄漏排查]]
|
||||
@@ -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泄漏排查]]
|
||||
@@ -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垃圾回收]]
|
||||
@@ -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垃圾回收]]
|
||||
Reference in New Issue
Block a user