Go 源码阅读与原理追问详解(GMP/Channel/Map/GC)

2022-02-11T10:00:00+08:00 | 10分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@
源码与原理追问Runtime 源码sync 包源码标准库源码GMP关系工作窃取hand-offMutex源码RWMutexWaitGroupchannelmap抢占调度sync.Mapselect栈扩容PoolhashGC三色WriteBarrier扩容InterfaceContextTimerdefer实现

学习目标

读完本文,你应该能够:

  • 完整描述 GMP 调度器的工作窃取和 hand-off 机制
  • 解释 Channel 的 hchan 结构体及各字段含义
  • 说出 select 语句的随机选择机制
  • 分析 Map 的底层实现:buckets、hash、扩容触发条件
  • 解释 GC 三色标记法及写屏障的作用
  • 理解 Interface 的 itab 结构
  • 掌握 sync 包核心源码的关键实现细节

一、GMP 调度器源码深度解析

1.1 G/M/P 结构体

应用场景

在实际项目中,这个知识点经常应用于:

  1. 典型场景一:说明具体应用场景
  2. 典型场景二:说明另一个应用场景
  3. 面试要点:提醒面试中可能被问到的点
// src/runtime/proc.go
type g struct {
    stk       [2]uintptr     // 栈边界
    sched     gsched         // 恢复执行点
    sp        uintptr        // 栈指针
    pc        uintptr        // 程序计数器
    stackbase uint32         // 栈底
    gopc      uintptr        // goroutine 创建点
    startpc   uintptr        // 起始函数 PC
    ...
}

type m struct {
    g0        *g            // 系统栈 goroutine
    p         puintptr      // 绑定的 P
    nextp     puintptr
    id        int32
    params    unsafe.Pointer
    ...
}

type p struct {
    mutex
    status      uint32        // pidle/prunning/pacquire
    runq        gqueue        // 本地 G 队列(256个)
    runqsize    int32
    ldthreshold int32
    ...
}

GMP 调度器结构:P 本地队列 + 全局队列 + M 执行 G + work-stealing 回边

sysmon:抢占扫描 / 网络轮询 / GCP0(处理器 · 绑定一个 M)本地运行队列 runq:G G G …(容量 256)M0(OS 线程)系统调用阻塞时释放 P(hand-off)G0(协程 · sched 保存 PC/SP)被 M 取出执行P1(处理器)本地运行队列 runq:G G G …M1(OS 线程)空闲 M 可被唤醒 acquire PG1(协程)被 M 取出执行全局运行队列 Global Run Queue本地队列空时从这里取,新 G 也先放这里全局队列注入 / 补充work-stealing:偷一半 Gsysmon 异步抢占(基于信号打断 G)

看图要点:GMP 三实体关系是面试必画点——P 是"资源载体",M 必须绑 P 才能跑 G;本地 runq 快、全局 runq 兜底。两条虚线最容易被追问:① work-stealing 偷一半保证负载均衡;② hand-off——M 陷入系统调用时把 P 让给其他空闲 M,避免 P 跟着 M 一起阻塞。还要能讲 sysmon 如何做异步抢占(Go 1.14+ 基于信号)。

1.2 工作窃取(Work Stealing)

// src/runtime/proc.go
func findrunnable() (gp *g, inheritTime bool) {
    _g_ := getg()
    _p_ := _g_.m.p.ptr()
    
    // 1. 先从本地队列取 G
    if gp := runqget(_p_); gp != nil {
        return gp, false
    }
    
    // 2. 从其他 P 偷取一半的 G
    for try := 0; try < 4; try++ {
        stealOrder := stealOrderStart[_p_.id%4]
        for i := 0; i < len(stealOrder); i++ {
            _om := stealOrder[i]
            if _om == _p_.id {
                continue
            }
            if gp := runqsteal(_p_, _om, false) {
                return gp, true
            }
        }
    }
    return nil, false
}

// runqsteal 从远程 P 偷取一半任务
func runqsteal(_p_ *p, _om *p, batch bool) *g {
    n := atomic.Load(& _om.runqsize)
    if n == 0 {
        return nil
    }
    n = n / 2  // 只偷一半
    
    var gp *g
    for i := int32(0); i < n; i++ {
        gp = runqget(_om)
        if gp == nil {
            break
        }
    }
    return gp
}

1.3 Hand-off 机制

// 当 G 阻塞在 syscall 时:
// 1. G 进入 SyscallBlock 状态
// 2. M 尝试 release 当前的 P
// 3. 其他空闲 M 通过 acquirep 获取 P
// 4. P 被移交后,可以继续调度其他 G

// src/runtime/proc.go
func goreleasem() {
    _g_ := getg()
    _p_ := _g_.m.p.ptr()
    
    // 将 P 置为 id

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

le
    pclear(_p_)
    
    // 唤醒等待 P 的 M
    if sched.npidle != sched.mcount {
        wakep()
    }
}

二、Channel 底层实现

2.1 hchan 结构体

// src/runtime/chan.go
type hchan struct {
    qcount   uint           // 队列中元素总数
    dataqsiz uint           // 环形缓冲区大小
    buf      unsafe.Pointer // 环形缓冲区
    elemsize uint16         // 元素大小
    closed   uint32         // 是否已关闭
    elemtype *_type         // 元素类型
    sendx  

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

  uint           // 发送索引
    recvx    uint           // 接收索引
    recvq    waitq          // 等待接收的 G 队列(sigqueue)
    sendq    waitq          // 等待发送的 G 队列(sigqueue)
    lock     mutex          // 保护上述所有字段
}

2.2 无缓冲 Channel 流程

sequenceDiagram
    participant S as Sender(G1)
    participant C as Channel(hchan)
    participant R as Receiver(G2)
    
    S->>C: lock.acquire()
    C->>C: 检查 buf、sendq、recvq
    Note over C: 无缓冲 + sendq为空 + recvq有等待者
直接传递数据,不经过buf C->>R: 唤醒等待的 Receiver C->>C: lock.release() R->>S: 数据直接拷贝(通过 memmove)

2.3 select 实现原理

// src/runtime/select.go 简化版
func sigselect(ncase int, PCSlice, cases *hchan) (sel int, gotOK bool) {
    // 1. 计算候选集
    var selectedCases [maxSelCase]*sudog
    var shuffledIndices [maxSelCase]int
    var randomIndex int
    
    for i := 0; i < ncase; i++ {
        c := cases[i]
        if c == nil {
            continue
        }
        if c.ch.closed != 0 {
            // 关闭的 channel 可以读取(返回零值)
            selectedCases[randomIndex] = c
            shuffledIndices[randomIndex] = i
            randomIndex++
        } else if !chansend(c.ch, nil, nil, nil, 0) {
            // 可以发送
            selectedCases[randomIndex] = c
            shuffledIndices[randomIndex] = i
            randomIndex++
        } else if chanrecv(c.ch, nil, nil, nil, 0, true) {
            // 可以接收
            selectedCases[randomIndex] = c
            shuffledIndices[randomIndex] = i
            randomIndex++
        }
    }
    
    // 2. 随机选择一个候选 case
    if randomIndex > 0 {
        idx := fastrand() % uint32(randomIndex)
        return shuffledIndices[idx], true
    }
    
    // 3. 没有候选:如果有 default,执行 default
    for i := 0; i < ncase; i++ {
        if cases[i].dir == _SELECTdefault {
            return i, false
        }
    }
    
    // 4. 没有 default:挂起当前 goroutine
    gopark(...)
    return 0, false
}
复习提示:select 公平性:如果没有可执行的 case,Go 会随机选择。这是为了防止某个 channel 一直优先被选中而导致饥饿。

三、Map 底层实现

3.1 hmap 结构体

// src/runtime/map.go
type hmap struct {
    count     int           // 元素个数
    flags     uint8         // 迭代中的标志
    B         uint8         // buckets 的对数(2^B = bucket数量)
    noverflow uint16        // 溢出桶数量
    hash0     uint32        // hash 种子
    buckets   unsafe.Pointer // 主 bucket 数组(2^B 个)
    oldbuckets unsaf

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

e.Pointer // 扩容时的旧 buckets
    nevacuate   uintptr     // 扩容进度
    extra     *mapextra     // 额外信息(overflow 桶指针)
}

type mapbucket struct {
    tophash [8]uint8  // 每个 bucket 的 top8 hash 值
    keys    [8]keytype // key 数组
    values  [8]valtype // value 数组(可能缺失)
    pad     uintptr
    overflow *mapoverlay
}

Map 底层结构:hmap → buckets 桶数组 + 溢出桶链

hmap(map 头)B:桶数 = 2^B · count:元素个数buckets → 桶数组 · oldbuckets(扩容时非空)· extra → 溢出桶
<rect x="40" y="150" width="520" height="250" rx="12" stroke-width="3"/>
<text x="300" y="178" text-anchor="middle" fill="#B03030" font-size="14" font-weight="700" stroke="none">buckets 数组(2^B 个 · 连续内存)</text>
<rect x="55" y="195" width="115" height="180" rx="6"/>
<rect x="170" y="195" width="115" height="180" rx="6"/>
<rect x="285" y="195" width="115" height="180" rx="6"/>
<rect x="400" y="195" width="115" height="180" rx="6"/>
<text x="112" y="213" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700" stroke="none">bucket[0]</text>
<text x="227" y="213" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700" stroke="none">bucket[1]</text>
<text x="342" y="213" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700" stroke="none">bucket[2]</text>
<text x="457" y="213" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700" stroke="none">bucket[3]</text>
<text x="112" y="245" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">tophash[8]</text>
<text x="112" y="275" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">keys[8]</text>
<text x="112" y="305" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">values[8]</text>
<text x="112" y="340" text-anchor="middle" fill="#B03030" font-size="11" stroke="none">overflow →</text>
<text x="227" y="245" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">tophash[8]</text>
<text x="227" y="275" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">keys[8]</text>
<text x="227" y="305" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">values[8]</text>
<text x="342" y="245" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">tophash[8]</text>
<text x="342" y="275" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">keys[8]</text>
<text x="342" y="305" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">values[8]</text>
<text x="457" y="245" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">tophash[8]</text>
<text x="457" y="275" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">keys[8]</text>
<text x="457" y="305" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">values[8]</text>
<rect x="600" y="195" width="170" height="170" rx="8" stroke-dasharray="7 4"/>
<text x="685" y="222" text-anchor="middle" fill="#B03030" font-size="12.5" font-weight="700" stroke="none">溢出桶(overflow)</text>
<text x="685" y="252" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">tophash[8]</text>
<text x="685" y="282" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">keys[8]</text>
<text x="685" y="312" text-anchor="middle" fill="#1a1a1a" font-size="11" stroke="none">values[8]</text>
buckets 指针overflow 指针(同桶冲突时串起下一个桶)定位:idx = hash(key) & (2^B - 1);tophash 快速比对,冲突走 overflow 链

看图要点:map 不是哈希表"一个数组"那么简单——hmap 只存元信息,真实数据在 buckets 连续数组里,每个桶装 8 个 K/V 且用 tophash 做快速预筛。面试官必追问:① 定位公式为什么是 hash & (2^B-1)(等价于取模,2^B 个桶);② 一个桶装不下怎么办(溢出桶链);③ 扩容时 oldbuckets 与渐进式搬迁(nevacuate)如何避免卡顿;④ 为什么并发读写会 panic(搬迁时指针被改导致野指针)。

3.2 Hash 计算

func memhash(p unsafe.Pointer, s, h uintptr) uintptr

// 简化版 hash
func mapHash(key interface{}) uint32 {
    h := fastrand()
    switch k := key.(type) {
    case string:
        h = stringhash(k, h)
    case int:
        h = intrand(hashInt(k), h)
    // ... 其他类型
    }
    return h
}

3.3 扩容机制

// src/runtime/map.go
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    // 检查是否需要扩容
    if !h.folding && float64(h.count) > loadFactorNum*(1<<h.B)/loadFactorDen {
        growBucket(t, h)
    }
    // 查找或创建 buck

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

et
    // ...
}

func growBucket(t *maptype, h *hmap) {
    // 翻倍扩容:B+1
    // 等量扩容:B不变,extra.bypass=true
    
    newB := h.B
    if !h.folding && h.count > bucketShift(newB)*loadFactorNum/loadFactorDen {
        newB++
    }
    
    newbuckets := mallocgc(bucketShift(newB), nil, true)
    h.extra = &mapextra{oldbuckets: h.buckets}
    h.buckets = newbuckets
    h.nevacuate = 0
    h.flags |= sameSizeGrow
}

3.4 删除与内存释放

// 删除操作不会立即释放内存
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

) {
    // 1. 将 tophash 设置为 emptyOne
    // 2. 设置 dirty 标志
    // 3. 计数 -1
    
    // 内存释放需要等到:
    // - 下次扩容时将 oldbuckets 的脏数据迁移走
    // - 或者手动调用 runtime_map_clear
}
面试官
如何彻底释放一个 map 占用的内存?
面试官
候选人

map 删除元素后内存不会立即释放。几种方法:

  1. m = make(MapType):重新分配一个新的空 map
  2. runtime.mapclear(t, h):内部函数,清空所有 bucket
  3. 让 oldbuckets 被 GC 回收(等待扩容完成后 oldbuckets 变成 garbage)

    生产环境推荐方案 1,简单直接。

四、GC 三色标记法

4.1 三色定义

// GC 颜色
type GCColor uint8

const (
   

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

 White  GCColor = iota // 未被扫描,可能是存活或垃圾
    Gray                  // 已被扫描,但子节点未全部扫描
    Black                 // 已被扫描,子节点全部扫描完毕
)

4.2 标记算法

// 初始:所有对象为 White
1. 从 roots 开始,将可达对象标为 Gray
2. 取出 Gray 对象,将其引用的对象标为 Gray,自身标为 Black
3. 重复步骤 2 直到没有 Gray 对象
4. 所有剩余的 White 对象为垃圾,进入 Sweep 阶段

4.3 写屏障(Write Barrier)

// 伪代码:STW 期间的写屏障
func writeBarrier(oldRef, newRef unsafe.Pointer) {
    if oldRef.color == Black && newRef.color == White {
        // 黑色对象指向了白色对象,将新对象标为 gray
        newRef.color = Gray
        grayObjectQueue.push(newRef)
    }
}

// Go 使用混合写屏障(Hybrid Write Barrier)
// 结合三色不变式保证正确性

4.4 GC 流程

graph LR
    A[Mark Start] --> B[Mark Roots]
    B --> C[Scan Objects]
    C --> D{All Scanned?}
    D -->|No| C
    D -->|Yes| E[Mark Termination]
    E --> F[Sweep]
    F --> G[GC Done]

五、sync 包源码

5.1 sync.Mutex 饥饿模式

// src/sync/mutex.go
const (
    mutexLocked uint32 = 1 << iota
    mutexWoken
    mutexStarving
    mutexWaiterShift = iota
)

func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex
    if atomic.CompareAndSwapUint32(&m.state, 0, mutexLocked) {
        return
    }
    
    // Slow path
    m.lockSlow()
}

func (m *Mutex) lockSlow() {
    var waitStartTime int64
    stolen := false
    
    for {
        old := m.state
        new := old | mutexLocked
        
        if old&(mutexLocked|mutexStarving) == mutexLocked {
            // Mutex is locked
            if old&mutexWoken == 0 {
                new |= mutexWoken
            }
            if old&mutexStarving == 0 {
                waitStartTime = monotime()
            }
            new++
        } else if old&mutexStarving == 0 {
            // Not locked and not starving: try to wake up waiter or steal
            if old != 0 || old>>mutexWaiterShift == 0 {
                new |= mutexWoken
            } else {
                // Steal!
                stolen = true
            }
            new = mutexLocked
        }
        
        if atomic.CompareAndSwapUint32(&m.state, old, new) {
            if stolen {
                runtime_Semrelease(&m.sema, false, 1)
                return
            }
            if old&mutexStarving == 0 {
                // Non-starving mode: spin
                runtime_SemacquireMutex(&m.sema, true, 8)
                return
            }
        }
    }
}

5.2 sync.Once 双重检查

// src/sync/once.go
type Once struct {
    done uint32
    m    Mutex
}

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 1 {
        return // 快速路径
    }
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 { // 双重检查
        o.call(f)
    }
}

func (o *Once) call(f func()) {
    done := uint32(1)
    var m mutex
    go func() {
        defer func() {
   

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

         if panicValue := recover(); panicValue != nil {
                m.unlockRecovery(panicValue)
                throw("sync: once failed with panic")
            }
        }()
        f()
        atomic.StoreUint32(&o.done, 1)
        m.unlock()
    }()
    m.lock()
}

六、Interface 底层结构

// src/runtime/interface.go
type iface struct {  // error 等 interface
    tab  *itab   // 类型信息表
    data unsafe.Pointer  // 数据指针
}

type eface struct {  // 空 interface {}
    _type *_type
    data  unsafe.Pointer
}

type itab struct {
    inter  *interfacetype  // 接口类型
    _type  *_type          // 具体类型
    hash   uint32          // _type 的 hash
    _      [4]byte
    fun    [1]uintptr      // 方法实现数组(变长)
}

七、timer 定时器实现

// src/runtime/time.go
type timer struct {
    seq     uintptr
    when    int64       // 到期时间(单调时钟)
    period  int64       // 周期(0 表示一次性)
    f       func(interface{}) // 回调函数
    arg     interface{}
    seq     uintptr
    nextwhen int64
    pos     int32       // 在 heap 中的位置
}

// Go 定时器使用最小堆实现
// O(logN) 插入、O(logN) 删除、O(1) 取最小
func addtimer(t *timer) {
    // 添加到最小堆
    heapPush(&timers.heap, t)
    // 唤醒 timerproc 线程
    notewakeup(&timers.g.waitnote)
}

八、自测题

面试官
map 并发读写为什么会导致 panic?panic 信息是什么?
面试官
候选人

Go 的 map 不是并发安全的。当 G1 在写 map 而 G2 在读 map 时,如果发生扩容,底层的 buckets 指针会被修改,导致 G2 读到野指针,触发 panic:

fatal error: concurrent map read and map write

或使用 race detector 时会报告 data race。

面试官
Go 1.14 前后抢占调度有什么变化?
面试官
候选人
Go 1.14 之前:协作式抢占,只在函数调用点检查是否需要让出 CPU。 Go 1.14 之后:基于信号的异步抢占,通过 raise(SIGUSR) 打断正在执行的 goroutine。 这使得无限循环也能被正确抢占,避免了单 goroutine 独占 CPU 的问题。

九、本章小结

复习提示:
  • GMP 调度器是 Go 高性能的核心,工作窃取保证了负载均衡
  • Channel 的 hchan 用锁保护所有字段,send/receive 都是原子操作
  • Map 扩容有两种策略:翻倍扩容(负载因子 > 6.5)和等量扩容(溢出桶过多)
  • GC 写屏障是并发标记的关键,确保三色不变式不被破坏
  • sync.Once 的双重检查配合 CAS,既高效又安全
  • 下一篇将讲解综合实战题:Web Framework、RPC 框架、K8s Controller 等完整项目实现

参考

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!