Go Goroutine、GMP 调度、锁与死锁

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

@

学习目标

完成本章学习后,你将能够:

  1. 画出 GMP 调度模型并讲清 G、M、P 三者的关系、本地队列(256)/全局队列/work stealing 的工作流程,能在面试白板上默写。
  2. 管理 goroutine 生命周期,使用 channel 通知和 context.Cancel 正确关闭 goroutine,能获取返回值并避免泄露。
  3. 正确使用 sync.Mutex/RWMutex,理解正常模式(自旋+排队)与饥饿模式(1ms 阈值切换)的机制,能根据读写比例选择锁策略。
  4. 掌握 atomic 五种原子操作(Add/Load/Store/Swap/CompareAndSwap)的代码写法与适用场景,能区分 atomic 与 Mutex 的性能差异。
  5. 识别并避免死锁,说出死锁四个必要条件,能用固定加锁顺序、超时等手段预防死锁,能用 pprof 定位 goroutine 泄露。

前置知识:

  • Go 基本语法(变量、函数、struct、interface)
  • channel 的基本用法(发送、接收、关闭)
  • 知道 go 关键字能启动 goroutine
  • 操作系统线程与进程的基本概念

动手做 3 件事:

  • 在本地新建一个 .go 文件,把本章"GMP 调度时机"的代码敲一遍并运行,观察 goroutine 数量变化。
  • 故意写一个 goroutine 泄露程序,用 runtime.NumGoroutine()pprof 定位泄露位置并修复。
  • 分别用 Mutex 和 atomic 实现一个并发计数器,用 time.Since() 对比两者性能差异。

1、GMP 调度模型

1.1 用生活类比先建立直觉

把 Go 的调度想象成一家大型餐厅的厨房

  • G(goroutine) = 菜单上的每一道菜(任务),数量可以成千上万
  • M(Machine) = 厨师(真正干活的人,对应操作系统线程)
  • P(Processor) = 灶台(厨师干活必须占用的工位,数量有限)

每个灶台旁边贴着一张任务便签条(本地队列),最多 256 张。厨师从便签条上取菜做。如果一个灶台的便签条空了,厨师不会闲着——他会去别的灶台偷便签条(work stealing),或者去公共公告栏(全局队列)拿。

graph TD
    GQ["全局队列
Global Queue"] --> P1["P1 灶台1
本地队列 256"] GQ --> P2["P2 灶台2
本地队列 256"] GQ --> P3["P3 灶台3
本地队列 256"] P1 --> M1["M1 厨师1
OS线程"] P2 --> M2["M2 厨师2
OS线程"] P3 --> M3["M3 厨师3
OS线程"] P1 -.->|"work stealing"| P2 P2 -.->|"work stealing"| P3 M1 --> Kernel["操作系统内核"] M2 --> Kernel M3 --> Kernel

桥接到工程:P 是 Go 调度的核心抽象,它持有本地 G 队列,把"任务调度"和"线程执行"解耦。M 只是执行载体,P 才决定"下一个执行哪个 G"。这种设计让 Go 能在用户态完成大部分调度,减少内核态切换开销,从而支撑数十万 goroutine 并发。

1.2 工程要点

G、M、P 三者的定义

组件全称本质数量
Ggoroutine用户态协程,包含栈和执行状态可达数十万
MMachine操作系统线程,真正执行 G 的载体动态创建,默认上限 10000
PProcessor逻辑处理器,持有本地 G 队列GOMAXPROCS,默认=CPU 核数

本地队列、全局队列与 work stealing

每个 P 持有一个本地队列,容量 256。新建 goroutine 时优先放入当前 P 的本地队列;队列满了则一半放入全局队列。调度时 P 优先消费本地队列,空了就执行 work stealing:

graph TD
    Check["检查本地队列"] -->|"非空"| Run["执行 G"]
    Check -->|"空"| Steal["从其他 P 偷取
work stealing"] Steal -->|"偷到"| Run Steal -->|"没偷到"| Global["从全局队列取"] Global -->|"取到"| Run Global -->|"也没有"| Park["M 休眠
等待唤醒"]

调度时机

goroutine 会在以下情况让出执行权:

调度时机说明是否切换线程
系统调用阻塞M 陷入内核等待,P 与 M 解绑是(一定发生线程切换)
channel 阻塞G 被挂起,P 执行下一个 G否(用户态切换)
时间片用完调度器抢占,G 被放回队列否(用户态切换)
runtime.Gosched()主动让出执行权否(用户态切换)

⚠️ 新手必踩的坑: 很多人以为 channel 阻塞会切换操作系统线程。实际上 channel 阻塞只切换 goroutine(用户态),M 和 P 不解绑。只有系统调用阻塞才会导致 M 和 P 解绑,P 去找另一个 M 继续执行其他 G。这是面试中区分"懂调度"和"背概念"的关键点。

什么时候一定发生线程上下文切换

当 goroutine 发起系统调用(如文件 IO、网络底层 syscall)时,流程如下:

  1. M 被阻塞在内核态
  2. 调度器将 P 与 M 解绑
  3. P 绑定另一个 M(或新建 M)继续执行队列中的 G
  4. 原始 M 系统调用返回后,尝试获取空闲 P;没有空闲 P 则把 G 放入全局队列,M 休眠
package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    // 步骤1:设置 P 的数量为 2
    runtime.GOMAXPROCS(2)

    // 步骤2:启动一个会阻塞的 goroutine(模拟系统调用)
    go func() {
        // time.Sleep 底层会调用系统调用,M 会阻塞
        time.Sleep(2 * time.Second)
        fmt.Println("阻塞 goroutine 完成")
    }()

    // 步骤3:启动一个普通计算的 goroutine
    go func() {
        sum := 0
        for i := 0; i < 1000000; i++ {
            sum += i
        }
        fmt.Println("计算 goroutine 完成, sum =", sum)
    }()

    // 步骤4:等待所有 goroutine 完成
    time.Sleep(3 * time.Second)
    fmt.Println("当前 goroutine 数量:", runtime.NumGoroutine())
}

GOMAXPROCS

GOMAXPROCS 决定 P 的数量,即同时执行 Go 代码的操作系统线程数。

// 步骤1:获取当前 GOMAXPROCS(传 0 表示不修改,只返回当前值)
fmt.Println("默认 P 数量:", runtime.GOMAXPROCS(0))

// 步骤2:设置为 4
runtime.GOMAXPROCS(4)

// 步骤3:再次获取
fmt.Println("设置后 P 数量:", runtime.GOMAXPROCS(0))

goroutine 栈

属性
初始大小2KB
扩容方式拷贝式扩容(分配更大的栈,复制旧栈内容)
最大大小1GB(64 位系统)
栈方向向下生长

goroutine 的栈是可增长的。初始只有 2KB,当栈空间不足时,运行时会分配一个两倍大的新栈,把旧栈内容拷贝过去。这比操作系统线程固定栈(通常 1MB~8MB)更节省内存,所以 Go 可以轻松创建数十万个 goroutine。

package main

import (
    "fmt"
    "sync"
)

// 步骤1:递归函数,每层占用约 1KB 栈空间
func deepRecursion(n int) int {
    if n <= 0 {
        return 0
    }
    var buf [1024]byte // 每层分配 1KB 栈空间
    buf[0] = byte(n % 256)
    return int(buf[0]) + deepRecursion(n-1)
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)

    // 步骤2:在 goroutine 中深度递归,触发栈扩容
    go func() {
        defer wg.Done()
        result := deepRecursion(10000)
        fmt.Println("递归结果:", result)
        // 步骤3:goroutine 栈从 2KB 开始,按需扩容到足够大小
        // 不会像 C 语言那样 stack overflow
    }()

    wg.Wait()
    fmt.Println("goroutine 初始栈: 2KB, 最大: 1GB")
}

2、Goroutine 生命周期管理

2.1 用生活类比先建立直觉

把 goroutine 想象成公司里的员工

  • 启动 goroutine = 招聘一个员工并分配任务
  • channel 退出信号 = 经理喊"下班了,可以走了"
  • context.Cancel = 老板下达"项目取消,全员停止"通知
  • WaitGroup = 项目经理站在门口数"还有几个人没交活"
  • goroutine 泄露 = 员工被困在会议室出不来,但没人发现
graph TD
    Start["启动 goroutine"] --> Work["执行任务"]
    Work --> Check["检查退出信号"]
    Check -->|"收到信号"| Exit["return 退出"]
    Check -->|"未收到信号"| Work
    Work -->|"任务完成"| Done["Done 通知 WaitGroup"]
    Done --> Wait["Wait 等待全部完成"]
    Exit --> Wait

桥接到工程:每个 goroutine 都应该有明确的退出路径。启动 goroutine 时就要想好两个问题——“它什么时候结束?“和"如果出错了,它还能退出吗?"。

2.2 工程要点

goroutine 使用场景

package main

import (
    "io"
    "net/http"
    "sync"
)

// 场景1:并发 IO(同时请求多个 API)
func fetchConcurrent(urls []string) []string {
    results := make([]string, len(urls))
    var wg sync.WaitGroup

    for i, url := range urls {
        // 步骤1:Add 必须在 goroutine 外部调用
        wg.Add(1)
        go func(idx int, u string) {
            defer wg.Done() // 步骤2:goroutine 结束时通知
            resp, _ := http.Get(u)
            body, _ := io.ReadAll(resp.Body)
            results[idx] = string(body)
        }(i, url)
    }

    wg.Wait() // 步骤3:等待所有请求完成
    return results
}
// 场景2:并发计算(并行求和)
func parallelSum(data []int, numWorkers int) int {
    chunkSize := len(data) / numWorkers
    results := make(chan int, numWorkers)

    for i := 0; i < numWorkers; i++ {
        // 步骤1:每个 worker 处理一个数据分片
        go func(start int) {
            sum := 0
            end := start + chunkSize
            if end > len(data) {
                end = len(data)
            }
            for j := start; j < end; j++ {
                sum += data[j]
            }
            // 步骤2:结果通过 channel 传回
            results <- sum
        }(i * chunkSize)
    }

    // 步骤3:汇总所有 worker 的结果
    total := 0
    for i := 0; i < numWorkers; i++ {
        total += <-results
    }
    return total
}

用 channel 控制退出

package main

import (
    "fmt"
    "time"
)

func worker(stop <-chan struct{}) {
    ticker := time.NewTicker(500 * time.Millisecond)
    defer ticker.Stop()

    for {
        select {
        // 步骤1:监听退出信号
        case <-stop:
            fmt.Println("worker 收到退出信号,正在停止...")
            return
        // 步骤2:正常工作逻辑
        case t := <-ticker.C:
            fmt.Println("worker 工作中:", t.Format("15:04:05"))
        }
    }
}

func main() {
    // 步骤3:创建退出信号 channel
    stop := make(chan struct{})

    go worker(stop)

    // 步骤4:运行 3 秒后发送退出信号
    time.Sleep(3 * time.Second)
    close(stop) // close 后所有接收者都能收到零值
    time.Sleep(500 * time.Millisecond)
    fmt.Println("主程序退出")
}

用 context 控制退出

package main

import (
    "context"
    "fmt"
    "time"
)

func worker(ctx context.Context, id int) {
    for {
        select {
        // 步骤1:监听 context 取消
        case <-ctx.Done():
            fmt.Printf("worker %d: 收到取消信号, 原因: %v\n", id, ctx.Err())
            return
        default:
            // 步骤2:模拟工作
            fmt.Printf("worker %d: 工作中...\n", id)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    // 步骤3:创建可取消的 context
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // 确保最终会取消

    // 步骤4:启动多个 worker
    for i := 1; i <= 3; i++ {
        go worker(ctx, i)
    }

    // 步骤5:运行 2 秒后取消所有 worker
    time.Sleep(2 * time.Second)
    cancel()
    time.Sleep(500 * time.Millisecond)
    fmt.Println("主程序退出")
}

获取 goroutine 返回值

package main

import (
    "sync"
    "time"
)

// 方式1:channel 传回(推荐)
func computeAsync(n int) <-chan int {
    ch := make(chan int, 1)
    go func() {
        // 步骤1:计算结果
        result := n * n
        // 步骤2:通过 channel 返回
        ch <- result
    }()
    return ch
}
// 使用:ch := computeAsync(42); result := <-ch

// 方式2:WaitGroup + 闭包
func computeWithWG(n int) int {
    var wg sync.WaitGroup
    var result int

    wg.Add(1)
    go func() {
        defer wg.Done()
        result = n * n
    }()

    wg.Wait()
    return result
}

// 方式3:Future 模式
type Future struct {
    result chan int
}

func NewFuture(n int) *Future {
    f := &Future{result: make(chan int, 1)}
    go func() {
        // 步骤1:异步执行计算
        time.Sleep(100 * time.Millisecond)
        f.result <- n * n
    }()
    return f
}

func (f *Future) Get() int {
    // 步骤2:按需获取结果(阻塞直到完成)
    return <-f.result
}

⚠️ 新手必踩的坑: 方式2中 result 变量被 goroutine 写入、主 goroutine 读取。虽然 WaitGroup 保证了时序(先写后读),但严格来说这是隐式数据共享。更安全的做法是用 channel 或 atomic 传递结果。另外,闭包捕获循环变量时要小心——Go 1.22+ 已修复循环变量捕获问题,但旧版本需要显式传参。

goroutine 同步控制方式对比

方式适用场景特点
sync.WaitGroup等待一组 goroutine 全部完成简单,但不传数据
channel传递数据 + 同步灵活,Go 推荐
sync.Cond等待/通知机制适合生产者-消费者
context超时/取消传播适合树状 goroutine 管理

3、Goroutine 泄露

3.1 用生活类比先建立直觉

想象一栋大楼里的电梯

  • goroutine = 电梯里的乘客
  • channel = 电梯门
  • goroutine 泄露 = 乘客进了电梯,但门一直不打开,永远困在里面

更准确地说:你启动了一个 goroutine 等待从 channel 接收数据,但永远不会有人往这个 channel 发送数据,也没有人关闭这个 channel。这个 goroutine 就永远阻塞,无法退出,占用内存直到程序结束。

graph TD
    Main["主 goroutine"] --> Launch["启动子 goroutine"]
    Launch --> Wait["子 goroutine
阻塞在 channel 接收"] Wait -->|"无人发送或关闭"| Stuck["永久阻塞"] Main --> Return["主 goroutine 继续"] Return --> Forget["忘记子 goroutine"] Forget --> Leak["goroutine 泄露
内存不释放"] Stuck --> Leak

桥接到工程:每次写 go func() 时,问自己两个问题——“这个 goroutine 什么时候退出?“和"如果出错了,它还能退出吗?“如果答不上来,大概率会泄露。

3.2 工程要点

什么是 goroutine 泄露

goroutine 泄露是指 goroutine 启动后,因为某种原因永远阻塞,既无法继续执行,也无法被回收,直到程序结束。随着泄露累积,内存持续增长,最终导致 OOM。

泄露的常见原因

// 原因1:channel 发送无接收者
func leakSend() {
    ch := make(chan int) // 无缓冲 channel
    go func() {
        // 步骤1:永远阻塞,因为 main 没有接收
        ch <- 42
        fmt.Println("这行永远不会执行")
    }()
    // 步骤2:函数返回,ch 无人引用
    // 但 goroutine 还在等接收者
}

// 原因2:channel 接收无发送者
func leakReceive() {
    ch := make(chan int)
    go func() {
        // 步骤1:永远阻塞,等待数据
        val := <-ch
        fmt.Println("收到:", val)
    }()
    // 步骤2:函数返回,没人往 ch 发数据
}

// 原因3:context 未取消
func leakContext() {
    ch := make(chan struct{})
    go func() {
        select {
        case <-ch:
            // 步骤1:等待信号,但没人关闭 ch
        }
    }()
    // 步骤2:忘记关闭 ch 或没有 context 超时
}

泄露的正确修复

package main

import (
    "context"
    "fmt"
    "time"
)

// 修复版:使用 context 超时
func safeWorker(ctx context.Context) {
    ch := make(chan int, 1)

    go func() {
        // 步骤1:模拟耗时操作
        time.Sleep(2 * time.Second)
        ch <- 42
    }()

    select {
    case val := <-ch:
        // 步骤2:正常收到结果
        fmt.Println("收到结果:", val)
    case <-ctx.Done():
        // 步骤3:超时或取消,goroutine 可以退出
        fmt.Println("超时退出:", ctx.Err())
    }
}

func main() {
    // 步骤4:设置 1 秒超时
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()

    safeWorker(ctx)
    time.Sleep(500 * time.Millisecond)
}

⚠️ 新手必踩的坑: 修复后的代码中,如果 time.Sleep(2*time.Second) 的 goroutine 在超时后才完成,它仍然会往 ch 发送数据。由于 ch 是有缓冲的(make(chan int, 1)),发送不会阻塞,goroutine 可以正常退出。如果用的是无缓冲 channel,goroutine 仍然会泄露。所以要么用有缓冲 channel,要么在 goroutine 内部也监听 ctx.Done()

如何定位 goroutine 泄露

package main

import (
    "fmt"
    "os"
    "runtime"
    "runtime/pprof"
    "time"
)

func leakyFunc() {
    ch := make(chan int)
    go func() {
        <-ch // 永久阻塞
    }()
}

func main() {
    // 步骤1:记录初始 goroutine 数量
    fmt.Println("初始 goroutine 数:", runtime.NumGoroutine())

    // 步骤2:反复调用泄露函数
    for i := 0; i < 100; i++ {
        leakyFunc()
    }

    time.Sleep(time.Second)
    fmt.Println("泄露后 goroutine 数:", runtime.NumGoroutine())

    // 步骤3:导出 goroutine profile
    f, _ := os.Create("/tmp/goroutine.prof")
    defer f.Close()
    pprof.Lookup("goroutine").WriteTo(f, 2)

    // 步骤4:用 go tool pprof /tmp/goroutine.prof 分析
    // 在 pprof 交互界面输入: top, list leakyFunc
}

goroutine 可能引发的问题

问题描述危害等级
泄露goroutine 永久阻塞无法退出高(内存持续增长)
泛滥创建速度远超消费速度高(资源耗尽)
数据竞争多个 goroutine 同时读写共享变量高(结果不确定)
死锁goroutine 互相等待对方释放资源高(程序挂起)

协程使用注意两个方面

  1. 泄露:每个 goroutine 都要有退出路径(channel 关闭或 context 取消)
  2. 并发安全:访问共享变量必须加锁或使用 atomic,或用 channel 传递数据

4、sync.Mutex 与锁

4.1 用生活类比先建立直觉

把 Mutex 想象成公共厕所的门锁

  • 加锁 = 进去后锁门
  • 解锁 = 出来后开门
  • 其他人来了发现门锁着 = 阻塞等待
  • 自旋 = 不停推门看看开了没(最多推 4 次)
  • 饥饿模式 = 有人等太久(超过 1ms),直接把钥匙递给排在最前面的人

悲观锁就像”先占坑再办事"——不管有没有人抢,先锁门再说。

乐观锁就像”先办事再检查"——先无锁操作,提交时检查中间有没有人改过(CAS)。

graph TD
    TryLock["尝试获取锁"] -->|"正常模式"| Spin["自旋等待
最多 4 次"] Spin -->|"自旋成功"| Acquire["获得锁"] Spin -->|"自旋失败"| Queue["加入等待队列"] Queue -->|"等待超过 1ms"| Starve["切换到饥饿模式"] Starve --> Handoff["直接交给队首
不自旋"] Handoff --> Acquire Acquire -->|"队首获取成功且
等待时间小于 1ms"| Normal["切回正常模式"] Queue -->|"正常获取"| Acquire Normal --> TryLock

桥接到工程:Go 的 Mutex 在"公平"和"性能"之间做了权衡。正常模式偏性能(自旋减少切换),饥饿模式偏公平(防止饿死)。理解这个切换逻辑是面试加分项。

4.2 工程要点

Mutex 是乐观锁还是悲观锁

Mutex 是悲观锁。每次访问共享资源前先加锁,确保独占访问,操作完成后才解锁。

乐观锁 vs 悲观锁

对比项悲观锁 (Mutex)乐观锁 (CAS/atomic)
核心思想先加锁再访问先操作再验证
实现机制操作系统信号量CPU 原子指令 (CAS)
适用场景写多读少、临界区长读多写少、临界区短
性能有锁开销和上下文切换无锁,但竞争激烈时重试开销大
公平性可实现公平(饥饿模式)无公平性保证

Mutex 的两种模式

package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.Mutex
    var counter int

    // 步骤1:模拟正常模式下的高并发竞争
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // 步骤2:加锁-操作-解锁
            mu.Lock()
            counter++
            mu.Unlock()
        }()
    }

    wg.Wait()
    fmt.Println("最终计数:", counter) // 1000
}

正常模式

  • 新来的 goroutine 会先尝试自旋(最多 4 次)
  • 自旋成功就直接获取锁,不用排队
  • 优点:性能好(减少上下文切换)
  • 缺点:队列中的 goroutine 可能被"插队"导致饿死

饥饿模式

  • 当一个 goroutine 等待超过 1ms 仍未获取锁时触发
  • 锁释放时直接交给队列首部的 goroutine(不自旋)
  • 优点:保证公平性
  • 缺点:性能下降(不能自旋)
  • 当队首 goroutine 获取锁后等待时间小于 1ms,切回正常模式

Mutex 最多支持多少协程排队

Mutex 没有硬性上限。等待队列通过 Go 运行时的信号量(semaphore)实现,理论上只受内存限制。但实际中,如果一个 Mutex 有数千个 goroutine 排队,说明设计有问题,应该考虑用其他并发模式(如 channel、分片锁)。

RWMutex 读写锁

package main

import (
    "fmt"
    "sync"
    "time"
)

type SafeCache struct {
    mu   sync.RWMutex
    data map[string]string
}

func NewSafeCache() *SafeCache {
    return &SafeCache{
        data: make(map[string]string),
    }
}

// 步骤1:读操作用 RLock(多读并发)
func (c *SafeCache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.data[key]
    return val, ok
}

// 步骤2:写操作用 Lock(独占)
func (c *SafeCache) Set(key, val string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = val
}

func main() {
    cache := NewSafeCache()

    // 步骤3:并发读
    for i := 0; i < 5; i++ {
        go func(id int) {
            for {
                if val, ok := cache.Get("name"); ok {
                    fmt.Printf("reader %d: %s\n", id, val)
                }
                time.Sleep(100 * time.Millisecond)
            }
        }(i)
    }

    // 步骤4:并发写
    for i := 0; i < 3; i++ {
        go func(id int) {
            for {
                cache.Set("name", fmt.Sprintf("writer-%d", id))
                time.Sleep(500 * time.Millisecond)
            }
        }(i)
    }

    time.Sleep(3 * time.Second)
}
对比项MutexRWMutex
读并发不支持(读也要互斥)支持(多个读可并发)
写并发不支持不支持
适用场景读写都多或写多读少读多写少
性能简单,开销小读多时性能更好,但锁本身更重

map 手动加锁 vs sync.Map

// 方式1:map + RWMutex(适合读多写少)
type SafeMap struct {
    mu   sync.RWMutex
    data map[string]interface{}
}

// 方式2:sync.Map(适合 key 稳定、读远多于写)
var m sync.Map
m.Store("key", "value")      // 存储
val, ok := m.Load("key")     // 读取
m.Delete("key")              // 删除

⚠️ 新手必踩的坑: 原生 map 并发读写会 panicfatal error: concurrent map read and map write)。这不是普通的 data race,而是 Go 运行时主动检测并终止程序。必须用 RWMutex 包裹或使用 sync.Map。sync.Map 的详细对比见本系列第一章。


5、atomic 原子操作

5.1 用生活类比先建立直觉

把 atomic 操作想象成银行柜台的无锁保险箱

  • Load = 查看保险箱里有多少钱
  • Store = 直接放进去一笔钱
  • Add = 往里面加钱(一步到位,不会被人打断)
  • Swap = 拿出新钱放进去,同时拿走旧钱
  • CompareAndSwap (CAS) = “如果里面是我上次看到的金额,就换成新金额”

CAS 就像你先偷看一眼保险箱里有 100 元,然后跟柜员说:“如果里面还是 100 元,就帮我换成 200 元。” 柜员打开一看,如果确实 100 元就换;如果被人改过了就说"不好意思,变了,请重新看一眼”。

graph TD
    Read["读取当前值 old"] --> Compare{"当前值等于 old?"}
    Compare -->|"相等"| Write["写入新值 new
返回 true"] Compare -->|"不相等"| Retry["重新读取当前值"] Retry --> Read Write --> Done["操作完成"]

桥接到工程:atomic 利用 CPU 的原子指令(如 x86 的 LOCK CMPXCHG),在硬件层面保证操作的不可分割性,比 Mutex 轻量得多,不需要进入内核态。

5.2 工程要点

atomic 的五种操作

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

func main() {
    var counter int64

    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // 步骤1:Add — 原子加法
            atomic.AddInt64(&counter, 1)
        }()
    }
    wg.Wait()

    // 步骤2:Load — 原子读取
    fmt.Println("Add 结果:", atomic.LoadInt64(&counter)) // 1000

    // 步骤3:Store — 原子写入
    atomic.StoreInt64(&counter, 42)
    fmt.Println("Store 后:", atomic.LoadInt64(&counter))

    // 步骤4:Swap — 原子交换,返回旧值
    old := atomic.SwapInt64(&counter, 100)
    fmt.Println("Swap 旧值:", old, "新值:", atomic.LoadInt64(&counter))

    // 步骤5:CompareAndSwap — 原子比较并交换
    success := atomic.CompareAndSwapInt64(&counter, 100, 200)
    fmt.Println("CAS 第一次 (100->200):", success, "值:", atomic.LoadInt64(&counter))

    // 步骤6:CAS 失败(期望值不匹配)
    success = atomic.CompareAndSwapInt64(&counter, 100, 300)
    fmt.Println("CAS 第二次 (100->300):", success, "值:", atomic.LoadInt64(&counter))
}

CAS 自旋实现安全计数器

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

// 用 CAS 实现原子加法(模拟 atomic.Add 的底层逻辑)
func casAdd(addr *int64, delta int64) {
    for {
        // 步骤1:读取当前值
        old := atomic.LoadInt64(addr)
        // 步骤2:计算新值
        newVal := old + delta
        // 步骤3:尝试 CAS,成功则返回
        if atomic.CompareAndSwapInt64(addr, old, newVal) {
            return
        }
        // 步骤4:CAS 失败,说明有其他 goroutine 抢先修改,重试
    }
}

func main() {
    var counter int64
    var wg sync.WaitGroup

    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            casAdd(&counter, 1)
        }()
    }

    wg.Wait()
    fmt.Println("CAS 计数器结果:", counter) // 10000
}

atomic.Value 存储任意类型

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

type Config struct {
    Host string
    Port int
}

func main() {
    var config atomic.Value

    // 步骤1:首次存储配置
    config.Store(&Config{Host: "localhost", Port: 8080})

    var wg sync.WaitGroup

    // 步骤2:并发读取
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 步骤3:Load 返回 interface{},需要类型断言
            c := config.Load().(*Config)
            fmt.Printf("reader %d: %s:%d\n", id, c.Host, c.Port)
        }(i)
    }

    // 步骤4:并发更新
    config.Store(&Config{Host: "0.0.0.0", Port: 9090})

    wg.Wait()
}

⚠️ 新手必踩的坑: atomic.Value 第一次 Store 什么类型,之后必须 Store 相同类型,否则会 panic。而且不能 Store nil。如果需要存 nil,可以用指向 nil 的指针。Go 1.19+ 推荐使用 atomic.Pointer[T] 替代 atomic.Value,类型更安全。

atomic vs Mutex

对比项atomicMutex
底层机制CPU 原子指令 (CAS)操作系统信号量
适用场景简单计数器、标志位复杂临界区、多操作组合
性能极高(无锁,纳秒级)较高(有锁开销,百纳秒级)
功能单个变量的原子读写任意代码块的互斥
公平性有(饥饿模式)
代码复杂度简单需注意 Lock/Unlock 配对

atomic 应用场景

// 场景1:并发安全的标志位(Go 1.19+ 使用 atomic.Bool)
type Service struct {
    running atomic.Bool
}

func (s *Service) Start() {
    // 步骤1:CAS 设置为 running
    if !s.running.CompareAndSwap(false, true) {
        return // 已经在运行
    }
    // 步骤2:执行启动逻辑
}

func (s *Service) Stop() {
    s.running.Store(false)
}

func (s *Service) IsRunning() bool {
    return s.running.Load()
}

// 场景2:并发安全计数器
type Counter struct {
    count atomic.Int64
}

func (c *Counter) Inc() int64  { return c.count.Add(1) }
func (c *Counter) Get() int64  { return c.count.Load() }

// 场景3:sync.Once 底层就是 atomic + Mutex(见第七章)

6、sync.WaitGroup

6.1 用生活类比先建立直觉

把 WaitGroup 想象成聚餐等人的计数器

  • Add(3) = 还有 3 个朋友没到
  • 每个 Done() = 一个朋友到了,打一个勾
  • Wait() = 站在门口等所有人到齐才开吃
graph TD
    Add["Add 3
counter = 3"] --> G1["启动 goroutine 1"] Add --> G2["启动 goroutine 2"] Add --> G3["启动 goroutine 3"] G1 --> D1["Done
counter = 2"] G2 --> D2["Done
counter = 1"] G3 --> D3["Done
counter = 0"] D1 --> Wait["Wait 阻塞中"] D2 --> Wait D3 -->|"counter == 0"| Release["释放信号量
Wait 返回"] Wait --> Release

桥接到工程:WaitGroup 的核心是"计数器 + 信号量”。Add 改计数,Done 减计数,Wait 在计数归零时被信号量唤醒。

6.2 工程要点

底层原理

WaitGroup 内部有三个关键字段:

字段作用操作方式
counter记录未完成的 goroutine 数Add(delta) 原子修改
waiter记录等待的 goroutine 数Wait() 时原子递增
sema信号量Wait 阻塞,counter 归零时释放

Add / Done / Wait 的使用

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    // 步骤1:Add 必须在 goroutine 外部调用
    wg.Add(3)

    // 步骤2:启动 3 个 goroutine
    for i := 1; i <= 3; i++ {
        go func(id int) {
            // 步骤3:defer Done 确保一定会执行
            defer wg.Done()
            fmt.Printf("goroutine %d 完成\n", id)
        }(i)
    }

    // 步骤4:Wait 阻塞直到 counter 归零
    wg.Wait()
    fmt.Println("所有 goroutine 完成")
}

常见坑

package main

import (
    "sync"
)

// 坑1:Add 在 goroutine 内部调用(竞争条件!)
func badExample() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        go func() {
            wg.Add(1) // 错误!可能在 Wait 之后才执行
            defer wg.Done()
        }()
    }
    wg.Wait() // 可能提前返回
}

// 正确写法
func goodExample() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1) // 正确!在启动 goroutine 前调用
        go func() {
            defer wg.Done()
        }()
    }
    wg.Wait()
}

// 坑2:Done 调用次数超过 Add 会导致 counter 为负
func negativeExample() {
    var wg sync.WaitGroup
    wg.Add(1)
    wg.Done()
    wg.Done() // panic: sync: negative WaitGroup counter
}

// 坑3:Wait 之后可以复用,但要确保之前的 Wait 已返回
func reuseExample() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
    }()

    // 步骤1:正确等待第一轮
    wg.Wait()

    // 步骤2:复用,开始第二轮
    wg.Add(1)
    go func() {
        defer wg.Done()
    }()
    wg.Wait()
}

⚠️ 新手必踩的坑: 最常见的错误就是在 goroutine 内部调用 wg.Add(1)。因为 goroutine 的调度顺序不确定,Wait() 可能在 Add(1) 执行前就发现 counter 为 0 而提前返回。记住铁律:Add 在外,Done 在内(用 defer)

底层实现详解

// Add 的简化逻辑(实际实现见 runtime/sema.go)
func (wg *WaitGroup) Add(delta int) {
    // 步骤1:用 atomic 原子更新 counter(高 32 位)
    state := atomic.AddUint64(&wg.state1, uint64(delta)<<32)
    v := int32(state >> 32) // counter
    w := uint32(state)       // waiter

    if v == 0 {
        // 步骤2:counter 归零,释放所有 waiter 的信号量
        for ; w != 0; w-- {
            runtime_Semrelease(&wg.sema, false, 0)
        }
    }
}

// Wait 的简化逻辑
func (wg *WaitGroup) Wait() {
    // 步骤1:原子递增 waiter 计数(低 32 位)
    state := atomic.AddUint64(&wg.state1, 1)
    v := int32(state >> 32) // counter

    if v > 0 {
        // 步骤2:counter > 0,阻塞在信号量上
        runtime_Semacquire(&wg.sema)
    }
}

// Done 的简化逻辑
func (wg *WaitGroup) Done() {
    // 步骤1:counter 减 1
    wg.Add(-1)
}

7、sync.Once 与 sync.Cond

7.1 用生活类比先建立直觉

sync.Once 就像公司的开业剪彩:不管多少人来,剪彩动作只发生一次,来晚了的人直接看到"已开业"状态,不会重复剪彩。

sync.Cond 就像餐厅叫号系统

  • Wait() = 拿号坐下等(先交出座位/锁,等叫号)
  • Signal() = 叫一个号
  • Broadcast() = 全部叫号(如"停电了,大家都走”)

桥接到工程:Once 保证初始化代码只执行一次;Cond 提供"等待-通知"机制,适合生产者-消费者场景。

7.2 工程要点

sync.Once

package main

import (
    "fmt"
    "sync"
)

type Singleton struct {
    name string
}

var (
    instance *Singleton
    once     sync.Once
)

// 步骤1:GetInstance 保证只初始化一次
func GetInstance() *Singleton {
    once.Do(func() {
        // 步骤2:这段代码只会执行一次
        fmt.Println("初始化 Singleton...")
        instance = &Singleton{name: "我是唯一的实例"}
    })
    return instance
}

func main() {
    var wg sync.WaitGroup

    // 步骤3:并发调用 GetInstance
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            s := GetInstance()
            fmt.Printf("goroutine %d: %s\n", id, s.name)
        }(i)
    }

    wg.Wait()
    // 输出中 "初始化 Singleton..." 只出现一次
}

sync.Once 底层实现(简化版,展示双检查锁逻辑):

// 简化的 Once 底层逻辑
type Once struct {
    done atomic.Uint32 // 标志位:0=未执行, 1=已执行
    m    sync.Mutex    // 互斥锁
}

func (o *Once) Do(f func()) {
    // 步骤1:快速路径——atomic 检查是否已执行(无锁)
    if o.done.Load() == 0 {
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    // 步骤2:双检查——防止多个 goroutine 同时通过第一次检查
    if o.done.Load() == 0 {
        // 步骤3:执行目标函数
        f()
        // 步骤4:标记为已执行
        o.done.Store(1)
    }
}

sync.Cond

package main

import (
    "fmt"
    "sync"
    "time"
)

type Queue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    items []int
}

func NewQueue() *Queue {
    q := &Queue{}
    // 步骤1:cond 必须关联一个 Mutex
    q.cond = sync.NewCond(&q.mu)
    return q
}

// 步骤2:消费者——等待数据
func (q *Queue) Consume() int {
    q.mu.Lock()
    defer q.mu.Unlock()

    // 步骤3:队列为空时等待(必须用 for,不能用 if)
    for len(q.items) == 0 {
        // Wait 内部会:释放锁 -> 阻塞 -> 被唤醒 -> 重新获取锁
        q.cond.Wait()
    }

    item := q.items[0]
    q.items = q.items[1:]
    return item
}

// 步骤4:生产者——添加数据并通知
func (q *Queue) Produce(item int) {
    q.mu.Lock()
    defer q.mu.Unlock()

    q.items = append(q.items, item)
    // 步骤5:唤醒一个等待的消费者
    q.cond.Signal()
    // 步骤6:如果要唤醒所有等待者,用 Broadcast()
    // q.cond.Broadcast()
}

func main() {
    q := NewQueue()

    // 步骤7:启动两个消费者
    var wg sync.WaitGroup
    for i := 1; i <= 2; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            val := q.Consume()
            fmt.Printf("消费者 %d 收到: %d\n", id, val)
        }(i)
    }

    // 步骤8:生产者往队列添加数据
    time.Sleep(500 * time.Millisecond)
    q.Produce(42)
    q.Produce(100)

    wg.Wait()
}

⚠️ 新手必踩的坑: cond.Wait() 必须在 for 循环中调用,不能在 if 中。因为被唤醒后,可能其他 goroutine 已经抢先消费了数据(虚假唤醒)。必须在循环中重新检查条件。这也是 Go 官方文档强调的。

sync.Cond vs channel

对比项sync.Condchannel
通信模型共享内存 + 等待通知消息传递
适用场景条件变量等待(如队列非空)数据传递、信号通知
复杂度较高(需要配合 Mutex)较低
Go 推荐优先用 channel首选方案

8、死锁

8.1 用生活类比先建立直觉

把死锁想象成十字路口的四辆车

  • 车A 等车B 走
  • 车B 等车C 走
  • 车C 等车D 走
  • 车D 等车A 走

没有一辆车能让步,所有人永远等下去——这就是循环等待

graph LR
    G1["goroutine 1
持有锁 A"] -->|"请求锁 B"| G2["goroutine 2
持有锁 B"] G2 -->|"请求锁 A"| G1 G1 -->|"永远等待"| Dead["死锁
程序挂起"] G2 -->|"永远等待"| Dead

桥接到工程:死锁的根源是"互相持有对方需要的资源"。只要打破四个必要条件中的任何一个,就能避免死锁。

8.2 工程要点

死锁的四个必要条件

条件含义打破方法
互斥资源同一时刻只能被一个 goroutine 使用无法打破(锁的本质)
持有等待持有资源的同时等待另一个资源一次性获取所有锁
不可剥夺不能强行夺走 goroutine 持有的锁使用带超时的锁
循环等待形成等待环固定加锁顺序

死锁示例

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var lockA, lockB sync.Mutex

    // 步骤1:goroutine 1 先锁 A 再锁 B
    go func() {
        lockA.Lock()
        fmt.Println("goroutine 1: 获得锁 A")
        time.Sleep(100 * time.Millisecond) // 制造时序

        lockB.Lock() // 等待 goroutine 2 释放锁 B
        fmt.Println("goroutine 1: 获得锁 B")
        lockB.Unlock()
        lockA.Unlock()
    }()

    // 步骤2:goroutine 2 先锁 B 再锁 A(顺序相反!)
    go func() {
        lockB.Lock()
        fmt.Println("goroutine 2: 获得锁 B")
        time.Sleep(100 * time.Millisecond)

        lockA.Lock() // 等待 goroutine 1 释放锁 A
        fmt.Println("goroutine 2: 获得锁 A")
        lockA.Unlock()
        lockB.Unlock()
    }()

    // 步骤3:程序死锁,永远无法结束
    // Go runtime 会检测到并 panic: "all goroutines are deadlock!"
    time.Sleep(2 * time.Second)
}

⚠️ 新手必踩的坑: 上面的程序运行后,Go runtime 会检测到所有 goroutine 都在等待,打印 fatal error: all goroutines are deadlock! 然后 crash。这是 Go 的内置死锁检测,但它只能检测所有 goroutine 都阻塞的情况。如果只有部分 goroutine 死锁,runtime 不会报错,程序会静默挂起。

如何避免死锁

package main

import (
    "sync"
    "time"
)

type Account struct {
    Balance int
}

// 方法1:固定加锁顺序(推荐)
func safeTransfer(from, to *Account, amount int, muA, muB *sync.Mutex) {
    // 步骤1:按地址排序,保证所有 goroutine 的加锁顺序一致
    first, second := muA, muB
    if &muA > &muB { // 用地址作为排序依据
        first, second = muB, muA
    }
    first.Lock()
    defer first.Unlock()
    second.Lock()
    defer second.Unlock()

    // 步骤2:安全操作
    from.Balance -= amount
    to.Balance += amount
}

// 方法2:使用带超时的锁(避免永久等待)
func tryLockWithTimeout(mu *sync.Mutex, timeout time.Duration) bool {
    done := make(chan struct{})
    go func() {
        mu.Lock()
        close(done)
    }()
    select {
    case <-done:
        return true
    case <-time.After(timeout):
        return false // 超时未获取到锁
    }
}

// 方法3:避免嵌套锁(减小锁粒度)
func noNesting(mu *sync.Mutex, data map[string]int) int {
    // 步骤1:只锁必要部分
    mu.Lock()
    val := data["key"]
    mu.Unlock()

    // 步骤2:不持锁的情况下做耗时操作
    result := process(val)

    // 步骤3:需要写入时再锁
    mu.Lock()
    data["result"] = result
    mu.Unlock()

    return result
}

func process(val int) int {
    return val * 2
}

如何识别死锁

识别方法说明适用场景
runtime 自动检测所有 goroutine 阻塞时 panic全局死锁
pprof goroutine导出 goroutine 调用栈部分死锁
NumGoroutine 监控goroutine 数持续增长疑似死锁
go-deadlock 库运行时检测锁顺序开发测试环境
package main

import (
    "fmt"
    "os"
    "runtime"
    "runtime/pprof"
    "time"
)

func detectDeadlock() {
    // 步骤1:监控 goroutine 数量
    ticker := time.NewTicker(time.Second)
    go func() {
        for {
            <-ticker.C
            fmt.Println("当前 goroutine 数:", runtime.NumGoroutine())
        }
    }()

    // 步骤2:导出 goroutine profile 供分析
    go func() {
        time.Sleep(5 * time.Second)
        f, _ := os.Create("/tmp/goroutine.prof")
        pprof.Lookup("goroutine").WriteTo(f, 2)
        f.Close()
        fmt.Println("profile 已导出到 /tmp/goroutine.prof")
    }()
}

9、map/slice 未初始化的 panic

9.1 用生活类比先建立直觉

把 nil 想象成一个还没装修的毛坯房

  • nil map = 毛坯房里没有柜子,你往墙上挂衣服 -> 墙塌了(panic)
  • nil slice = 毛坯房里没有柜子,但你搬了个新柜子进来放东西 -> 可以(append 分配底层数组)
  • nil channel = 一根两头都不通的管子,往里面倒水永远倒不进去,也流不出来(永久阻塞)

桥接到工程:Go 的 nil 不是"空值"那么简单,不同类型对 nil 的行为完全不同,这是面试常考的陷阱题。

9.2 工程要点

nil map 写操作 panic

package main

import "fmt"

func main() {
    // 步骤1:nil map 写入会 panic
    var m map[string]int // 声明但未初始化,m == nil
    // m["key"] = 1 // panic: assignment to entry in nil map

    // 步骤2:正确做法——先 make 初始化
    m2 := make(map[string]int)
    m2["key"] = 1 // 正常
    fmt.Println("初始化后写入:", m2)

    // 步骤3:nil map 的读取是安全的(返回零值)
    var m3 map[string]int // nil map
    val := m3["key"]      // 不 panic,返回 0
    fmt.Println("nil map 读取:", val)
}
操作nil map已初始化 map
写入 m[k]=vpanic正常
读取 m[k]返回零值正常
删除 delete(m,k)不 panic(无操作)正常
遍历 range m不 panic(0 次)正常
长度 len(m)0实际长度

nil slice 的 append

package main

import "fmt"

func main() {
    // 步骤1:nil slice 可以 append(会分配底层数组)
    var s []int // s == nil
    s = append(s, 1, 2, 3)
    fmt.Println("nil slice append:", s) // [1 2 3]

    // 步骤2:nil slice 的其他操作
    var s2 []int
    fmt.Println("len:", len(s2))    // 0
    fmt.Println("cap:", cap(s2))    // 0
    fmt.Println("nil?:", s2 == nil) // true

    // 步骤3:nil slice 遍历安全
    for _, v := range s2 {
        fmt.Println(v) // 不会执行
    }
}

⚠️ 新手必踩的坑: var s []ints := []int{} 是不同的。前者是 nil slice(底层指针为 nil),后者是空 slice(底层指针非 nil,长度为 0)。大多数场景两者行为一致,但在 JSON 序列化时:nil slice 序列化为 null,空 slice 序列化为 []。API 返回时要注意这个差异。

nil channel 永久阻塞

package main

import (
    "fmt"
    "time"
)

func main() {
    var ch chan int // ch == nil

    // 步骤1:nil channel 发送永久阻塞
    go func() {
        ch <- 42 // 永远阻塞在这里
    }()

    // 步骤2:nil channel 接收永久阻塞
    go func() {
        <-ch // 永远阻塞在这里
    }()

    // 步骤3:nil channel 在 select 中的妙用
    // 利用 nil channel 在 select 中"禁用"某个分支
    ch1 := make(chan int, 1)
    ch1 <- 1

    var ch2 chan int = nil // 故意设为 nil

    select {
    case val := <-ch1:
        fmt.Println("从 ch1 收到:", val)
    case val := <-ch2:
        // 步骤4:ch2 为 nil,这个分支永远不会被选中
        fmt.Println("从 ch2 收到:", val)
    }

    time.Sleep(100 * time.Millisecond)
}

各类型 nil 行为总结

类型nil 的行为是否安全
map写入 panic,读取返回零值写入不安全
sliceappend 安全(分配数组),读取返回零值安全
channel发送/接收永久阻塞不安全(但可利用)
pointer解引用 panic不安全
interface调用方法 panic不安全
function调用 panic不安全
// 最佳实践:统一用 make 初始化
func initCollections() {
    // 步骤1:map 用 make 初始化
    m := make(map[string]int)

    // 步骤2:slice 声明 nil 可以,需要时 append
    var s []int
    s = append(s, 1)

    // 步骤3:channel 用 make 初始化
    ch := make(chan int, 10)

    _ = m
    _ = s
    _ = ch
}

10、CSP 模型与共享变量通信

10.1 用生活类比先建立直觉

同一个办公室要维护一份"今日订单总数",有两种做法:

  • 共享变量派:墙上挂一块公共白板,谁要改数字,先去抢那支唯一的马克笔(锁),改完把笔放回去。改的人越多,抢笔的时间越长,而且总有人忘了放笔(忘 Unlock)、或者两个人各拿一支笔互相等对方(死锁)。
  • CSP 派:白板锁进一个人的办公室,只有他能改。其他人要加数就往门缝塞一张纸条(channel 发消息),要查数就塞一张"请把结果写在这张回执上"的纸条。数据从头到尾只被一个 goroutine 摸过,所以根本不需要笔,也就不存在抢笔问题。
graph TD
    subgraph SM["共享内存派:共享变量 + 锁"]
        A1["goroutine A"] -->|"抢锁后改"| W["公共白板 counter"]
        A2["goroutine B"] -->|"抢锁后改"| W
        A3["goroutine C"] -->|"排队等锁"| W
    end
    subgraph CSPG["CSP 派:消息传递"]
        B1["goroutine A"] -->|"发消息"| CH["channel 传送带"]
        B2["goroutine B"] -->|"发消息"| CH
        CH --> OWN["数据所有者 goroutine
counter 是它的局部变量"] end

这张图在讲:两派的分歧不在"用什么工具",而在数据的所有权归谁——是大家共有(需要锁来仲裁),还是独属于一个 goroutine(用消息排队,天然串行)。

桥接到工程:CSP 全称 Communicating Sequential Processes(Hoare, 1978),是一套并发理论模型——进程之间不共享内存,只通过消息通道通信。Go 把它落地成两个语言级设施:goroutine(顺序执行的进程)+ channel(通信通道)。所以那句 Go 谚语该这么读:“Do not communicate by sharing memory; instead, share memory by communicating”——别靠共享内存来通信,要靠通信来共享内存

10.2 工程要点

两种通信模型的本质区别

对比项共享变量通信(共享内存)CSP 通信(消息传递)
数据所有权多个 goroutine 共有同一时刻只归一个 goroutine
同步手段sync.Mutex / RWMutex / atomicchannel 的发送与接收
正确性依赖依赖程序员"每处访问都记得加锁"依赖"数据不逃出所有者"这一结构约束
典型故障数据竞争、忘解锁、死锁、锁粒度过大channel 泄露、死锁(无人收/无人发)、goroutine 泄露
可组合性差:多个锁组合就要考虑加锁顺序好:select 天然能组合多路事件 + 超时
关注点保护"临界区"编排"数据流动"
性能单变量高频更新更快(尤其 atomic)有调度与拷贝开销,但可控且易扩展
Go 中的定位底层基石(channel 内部也用它实现)上层推荐范式

同一需求的两种写法

先看共享变量派:

// 写法 A:共享变量 + Mutex(共享内存派)
type CounterMutex struct {
    mu sync.Mutex
    n  int
}

func (c *CounterMutex) Inc() {
    c.mu.Lock()   // 步骤1:抢"那支唯一的笔"
    c.n++         // 步骤2:改公共白板
    c.mu.Unlock() // 步骤3:把笔放回去(漏了这步,全程序卡死)
}

func (c *CounterMutex) Get() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n    // 步骤4:读也必须加锁,否则是数据竞争
}

再看 CSP 派——注意 n 变成了某个 goroutine 的局部变量,全程没有任何锁:

// 写法 B:CSP —— 数据只归一个 goroutine 所有,别人通过 channel 请求
type CounterCSP struct {
    incCh  chan struct{}  // 写请求通道
    readCh chan chan int  // 读请求通道:把"回执信封"一起递进去
}

func NewCounterCSP(ctx context.Context) *CounterCSP {
    c := &CounterCSP{
        incCh:  make(chan struct{}, 128), // 带缓冲,削峰
        readCh: make(chan chan int),
    }

    // 步骤1:唯一的"数据所有者" goroutine
    go func() {
        n := 0 // 步骤2:n 是局部变量,除它之外没人能碰 —— 天然无竞争
        for {
            select {
            case <-c.incCh:
                n++ // 步骤3:串行处理,不需要任何锁
            case reply := <-c.readCh:
                reply <- n // 步骤4:读也走消息,把快照回传给请求方
            case <-ctx.Done():
                return // 步骤5:明确的退出路径,避免所有者 goroutine 泄露
            }
        }
    }()
    return c
}

func (c *CounterCSP) Inc() { c.incCh <- struct{}{} }

func (c *CounterCSP) Get() int {
    reply := make(chan int) // 步骤6:每次请求自带回执 channel,结果不会串号
    c.readCh <- reply
    return <-reply
}

⚠️ 新手必踩的坑:CSP 不等于"channel 就是快的、安全的"。三个常见误解:

  1. channel 底层也是锁hchan 结构里有一把 lock,收发都要抢。所以单个 int 计数器用 atomic 比用 channel 快一个数量级,别为了"信仰 CSP"把计数器改成消息传递。
  2. channel 传指针 = 又回到共享内存ch <- ptr 之后如果发送方还继续读写 *ptr,数据竞争一分不少。CSP 的前提是所有权随消息转移,发出去就别再碰。
  3. CSP 也会死锁。无缓冲 channel 双方互等、select 里所有分支都不可能就绪,照样卡死;只是故障形态从"忘解锁"变成了"没人收/没人发"。

什么时候用哪个

Go 官方 FAQ 的态度并非"channel 万能",而是 “Use whichever is more expressive”(哪个表达力强用哪个)。落到实践上:

场景推荐原因
计数器、开关标志位、统计指标atomic单变量、临界区极短,无锁最快
缓存、配置表等"结构体状态 + 读多写少"RWMutex保护的是一坨字段,改成消息传递反而绕
任务分发、流水线、事件驱动、扇入扇出channel(CSP)关注点是数据流动与编排,select 可组合超时/取消
复杂状态机(如连接状态、会话状态)CSP:单一所有者 goroutine状态只被一个 goroutine 修改,逻辑天然串行、好推理
需要超时、取消、优先级channel + context锁没有超时语义,channel 有

一句面试可以直接说的总结:锁是"保护数据不被同时访问",CSP 是"让数据压根不被同时访问"。前者治标,后者改结构;Go 提供了两套,共享内存是地基,CSP 是推荐的门面。


11、消息处理协程池(Worker Pool)

11.1 用生活类比先建立直觉

一家外卖店突然涌进 10000 单,两种应对方式:

  • 来一单招一个厨师for range msgs { go handle(msg) }):厨房瞬间挤进 10000 个人,谁都动不了——对应到工程里就是 goroutine 数量失控,内存暴涨、调度器被打满、下游数据库连接被瞬间打爆。
  • 固定 3 个厨师 + 一条点单传送带(worker pool):订单排在传送带上(jobs channel),3 个厨师循环从传送带取单做菜,做完把餐盒放到出餐台(results channel)。传送带满了,前台就先接不了单——这就是天然的背压(back pressure)
graph LR
    P1["生产者 1
接单"] --> JQ["jobs channel
缓冲队列 = 背压阀门"] P2["生产者 2
接单"] --> JQ JQ --> W1["worker 1"] JQ --> W2["worker 2"] JQ --> W3["worker 3"] W1 --> RQ["results channel
出餐台"] W2 --> RQ W3 --> RQ RQ --> C["汇总 goroutine
写日志 / 落库"]

这张图在讲:worker pool 的三个要件——一条有界的任务队列数量固定的消费者一个独立的结果消费方。三者缺一都会出问题。

桥接到工程:协程池解决的不是"goroutine 太贵"(它很便宜),而是并发度必须有上限——下游的数据库连接数、第三方接口 QPS、本机内存都是有限资源。池子的 workers 数就是你对下游承诺的并发上限。

11.2 工程要点

完整可运行的消息处理协程池

package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

// Job 一条待处理消息
type Job struct {
    ID      int
    Payload string
}

// Result 处理结果(成功/失败都往回报,便于统计)
type Result struct {
    JobID  int
    Output string
    Err    error
}

type Pool struct {
    jobs    chan Job
    results chan Result
    wg      sync.WaitGroup
    workers int
}

// 步骤1:queueSize 决定缓冲深度 —— 有界队列才有背压,别用无界 slice 当队列
func NewPool(workers, queueSize int) *Pool {
    return &Pool{
        jobs:    make(chan Job, queueSize),
        results: make(chan Result, queueSize),
        workers: workers,
    }
}

// 步骤2:Start 一次性启动固定数量的 worker,goroutine 总数从此可控
func (p *Pool) Start(ctx context.Context) {
    for i := 1; i <= p.workers; i++ {
        p.wg.Add(1) // Add 必须在 go 之前(见第 6 章)
        go p.worker(ctx, i)
    }
}

func (p *Pool) worker(ctx context.Context, id int) {
    defer p.wg.Done()
    for {
        select {
        case job, ok := <-p.jobs:
            // 步骤3:ok == false 说明 jobs 已被生产者 close 且取空,正常退场
            if !ok {
                return
            }
            p.results <- p.handle(id, job)
        case <-ctx.Done():
            // 步骤4:外部超时/服务下线,立刻停手,不再取新活
            return
        }
    }
}

// 步骤5:单条消息的处理逻辑。必须用 defer recover 兜住业务 panic,
// 否则一条脏数据引发的 panic 会带崩整个进程
func (p *Pool) handle(workerID int, job Job) (r Result) {
    defer func() {
        if e := recover(); e != nil {
            r = Result{JobID: job.ID, Err: fmt.Errorf("panic: %v", e)}
        }
    }()

    time.Sleep(50 * time.Millisecond) // 模拟业务耗时
    return Result{
        JobID:  job.ID,
        Output: fmt.Sprintf("worker-%d 处理了 %s", workerID, job.Payload),
    }
}

// 步骤6:投递消息。队列满时这里会阻塞,压力自然回传给上游 —— 这是特性不是 bug
func (p *Pool) Submit(job Job) { p.jobs <- job }

// 步骤7:优雅关闭的固定套路:先 close(jobs) → 等 worker 全退 → 再 close(results)
// 顺序反了会 "send on closed channel" panic
func (p *Pool) Stop() {
    close(p.jobs)
    p.wg.Wait()
    close(p.results)
}

func (p *Pool) Results() <-chan Result { return p.results }

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    pool := NewPool(3, 16) // 3 个 worker,队列容量 16
    pool.Start(ctx)

    // 步骤8:结果消费必须与投递并发进行。
    // 如果先投完再来收,results 写满 16 条后 worker 会全部卡在发送上(死锁)
    var collect sync.WaitGroup
    collect.Add(1)
    go func() {
        defer collect.Done()
        for r := range pool.Results() { // close(results) 后循环自动结束
            if r.Err != nil {
                fmt.Printf("job %d 失败: %v\n", r.JobID, r.Err)
                continue
            }
            fmt.Println(r.Output)
        }
    }()

    // 步骤9:生产者投递消息
    for i := 1; i <= 10; i++ {
        pool.Submit(Job{ID: i, Payload: fmt.Sprintf("msg-%d", i)})
    }

    pool.Stop()      // 步骤10:投递方负责关闭 jobs
    collect.Wait()   // 步骤11:等结果收完再退出 main
    fmt.Println("全部消息处理完毕")
}

⚠️ 新手必踩的坑(四条铁律)

  1. close 只能由发送方做,绝不能让 worker 去 close(p.jobs)——多个 worker 重复 close 直接 panic: close of closed channel
  2. results 必须有人在并发地收。很多人写完 pool 后先 Submit 完再 for range results,结果队列写满、worker 全阻塞在 p.results <- ...,程序静默挂死。
  3. worker 里必须 recover。池里的 goroutine panic 不会被 main 的 recover 捕获,整个进程直接退出。
  4. 别忘了 ctx 分支。只监听 <-p.jobs 的 worker,在上游卡住时永远退不出去,就是第 3 章讲的 goroutine 泄露。

轻量替代:用 buffered channel 当信号量限并发

如果任务是一批一次性的(不需要常驻 worker),没必要建池子,用带缓冲 channel 做信号量更简单:

// 限制最大并发为 limit,跑完一批就结束
func runWithLimit(tasks []func(), limit int) {
    sem := make(chan struct{}, limit) // 步骤1:容量 = 并发上限,即"令牌总数"
    var wg sync.WaitGroup

    for _, task := range tasks {
        wg.Add(1)
        sem <- struct{}{} // 步骤2:拿一个令牌,令牌用光就在这里阻塞排队

        go func(f func()) {
            defer wg.Done()
            defer func() { <-sem }() // 步骤3:无论成功失败都要归还令牌
            defer func() {
                if e := recover(); e != nil { /* 步骤4:兜住 panic */ }
            }()
            f()
        }(task)
    }
    wg.Wait() // 步骤5:等这一批全部结束
}

两种方案怎么选

维度常驻 Worker PoolSemaphore 限并发
goroutine 生命周期长期常驻,复用每个任务一个,用完即弃
适用场景长期运行的消息消费(MQ 消费者、日志处理)一次性批量任务(批量拉取、批量导出)
队列语义有界队列 + 背压 + 可观测队列长度无显式队列,靠阻塞排队
优雅退出支持(close + WaitGroup + ctx)WaitGroup 自然结束
复杂度高,但可扩展(限流、重试、指标)低,二十行搞定

工程上还有两个进阶点值得知道:动态扩缩容(监控 len(p.jobs),队列持续积压时临时多起几个 worker,空闲一段时间后自动退出)和失败重试(Result 带 retryCount,未超限就重新 Submit,注意要防止重试风暴打满队列)。生产环境也可以直接用成熟库(如 ants),但面试要求手写这份骨架。


12、多服务并发读写同一份数据的正确性保障

12.1 用生活类比先建立直觉

同一个仓库要防止两个人同时搬走最后一箱货:

  • 同一间办公室(单进程多 goroutine):门上挂一把钥匙就够了——这就是 RWMutex。钥匙在内存里,大家看得见同一把。
  • 两栋不同的楼(多个服务实例 / 多个 Pod):A 楼的钥匙管不了 B 楼的人,因为进程内存不共享。这时必须去楼下物业前台领唯一的通行证——物业就是 Redis / 数据库 / etcd,谁领到证谁进仓库,这就是分布式锁。
  • 更聪明的办法:不领证,直接在出库单上写"我看到库存是 7,请在库存仍为 7 时扣减"。物业照单核对,对不上就退单让你重来——这就是乐观锁(版本号 CAS)
graph TD
    Q{"数据在哪个层面被并发访问?"} -->|"同一进程内的单个变量"| L1["atomic:CAS 无锁更新"]
    Q -->|"同一进程内的一坨状态"| L2["RWMutex / 分片锁
或 channel 交给单一所有者"] Q -->|"多进程 / 多服务实例"| L3["把裁决权交给共享存储"] L3 --> DB1["DB 乐观锁
version 字段 CAS"] L3 --> DB2["DB 悲观锁
SELECT FOR UPDATE"] L3 --> RD["Redis 分布式锁
SET NX PX + Lua 解锁"] L3 --> ET["etcd / ZooKeeper
强一致 + lease 自动续期"]

这张图在讲:先问清"并发发生在哪一层",再选工具。层次判断错了,方案必然错——单机用分布式锁是浪费,多实例用 Mutex 是纯粹的自欺欺人。

桥接到工程:面试被问"多个服务并发读写同一份数据怎么保证正确性",答题主线就是这张图:单进程内靠内存同步原语,跨进程靠外部存储做唯一裁决。锁的本质从来不是"锁",而是"所有竞争者都认同的同一个裁判"。

12.2 工程要点

层次一:单进程内 —— RWMutex 保护共享状态

读多写少的共享缓存,用 RWMutex 就是标准答案(完整代码见第 4 章 SafeCache)。竞争特别激烈时用分片锁降低粒度:

// 分片锁:把一把大锁拆成 N 把小锁,按 key 哈希打散,冲突概率降到 1/N
const shardCount = 32

type ShardedMap struct {
    shards [shardCount]struct {
        mu   sync.RWMutex
        data map[string]int
    }
}

func NewShardedMap() *ShardedMap {
    m := &ShardedMap{}
    for i := range m.shards {
        m.shards[i].data = make(map[string]int) // 步骤1:每片独立初始化
    }
    return m
}

// 步骤2:用 key 的哈希决定落在哪一片,不同片的读写完全互不阻塞
func (m *ShardedMap) shard(key string) int {
    h := fnv.New32a()
    h.Write([]byte(key))
    return int(h.Sum32()) % shardCount
}

func (m *ShardedMap) Set(key string, val int) {
    s := &m.shards[m.shard(key)]
    s.mu.Lock()   // 步骤3:只锁这一片
    defer s.mu.Unlock()
    s.data[key] = val
}

func (m *ShardedMap) Get(key string) (int, bool) {
    s := &m.shards[m.shard(key)]
    s.mu.RLock()  // 步骤4:读锁,同片内多读并发
    defer s.mu.RUnlock()
    v, ok := s.data[key]
    return v, ok
}

层次二:单进程内 —— 把数据交给唯一所有者(CSP 串行化)

如果操作逻辑复杂(读-改-写要跨多个字段、还要发通知),锁很容易漏。这时用第 10 章的思路:让一个 goroutine 独占数据,所有请求排队进来,天然串行、无需加锁,也不可能死锁。

层次三:跨进程 / 多服务 —— 数据库乐观锁与悲观锁

最容易被忽略的事实:如果数据本身在数据库里,数据库自己就是那个"唯一裁判",很多时候根本不需要额外的分布式锁

-- 方案 A:乐观锁(version 字段做 CAS)。冲突时受影响行数为 0,业务层重试
UPDATE stock
SET    count = count - 1, version = version + 1
WHERE  id = 1001 AND version = 7;

-- 方案 A+:更省事的写法 —— 把业务校验直接写进 WHERE,由数据库保证单条 UPDATE 的原子性
-- 这一条就能防超卖,不需要任何锁
UPDATE stock SET count = count - 1 WHERE id = 1001 AND count >= 1;

-- 方案 B:悲观锁。事务内先锁住行,适合"临界区里要做多次读写"的场景
BEGIN;
SELECT count FROM stock WHERE id = 1001 FOR UPDATE;  -- 锁行,其他事务在此阻塞
UPDATE stock SET count = count - 1 WHERE id = 1001;
COMMIT;                                               -- 提交即释放锁

Go 侧配合乐观锁的重试逻辑:

// 乐观锁重试:CAS 失败就重读重试,注意一定要有重试上限
func deductStock(ctx context.Context, db *sql.DB, id int) error {
    const maxRetry = 3
    for i := 0; i < maxRetry; i++ {
        // 步骤1:读出当前值和版本号
        var count, version int
        err := db.QueryRowContext(ctx,
            "SELECT count, version FROM stock WHERE id = ?", id).Scan(&count, &version)
        if err != nil {
            return err
        }
        if count <= 0 {
            return errors.New("库存不足")
        }

        // 步骤2:带版本号条件更新 —— 这就是数据库层面的 CompareAndSwap
        res, err := db.ExecContext(ctx,
            "UPDATE stock SET count = count - 1, version = version + 1 WHERE id = ? AND version = ?",
            id, version)
        if err != nil {
            return err
        }

        // 步骤3:影响行数为 0 说明版本变了(被别人抢先改过),退回重试
        if n, _ := res.RowsAffected(); n > 0 {
            return nil
        }
    }
    return errors.New("并发冲突,重试次数已用尽")
}

层次三:跨进程 / 多服务 —— Redis 分布式锁

数据不在单一数据库里(比如要保护"扣库存 + 发消息 + 写缓存"这一整套跨资源操作)时,才需要显式分布式锁:

package lock

import (
    "context"
    "errors"
    "time"

    "github.com/google/uuid"
    "github.com/redis/go-redis/v9"
)

// 步骤1:解锁脚本 —— "比对 value 再删除"必须是一个原子动作。
// 分成 GET + DEL 两条命令的话,中间锁过期被别人拿到,你的 DEL 就删了别人的锁
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
end
return 0
`)

type DistLock struct {
    rdb   *redis.Client
    key   string
    token string        // 步骤2:本次持锁的唯一凭证,防止误删他人锁
    ttl   time.Duration
}

func New(rdb *redis.Client, key string, ttl time.Duration) *DistLock {
    return &DistLock{rdb: rdb, key: key, token: uuid.NewString(), ttl: ttl}
}

func (l *DistLock) TryLock(ctx context.Context) (bool, error) {
    // 步骤3:SET key token NX PX ttl —— 一条命令同时完成"不存在才设"和"带过期时间"。
    // TTL 是保命符:持锁者崩溃了,锁也能自动释放,否则全局永久死锁
    return l.rdb.SetNX(ctx, l.key, l.token, l.ttl).Result()
}

// 步骤4:带重试的阻塞获取,必须响应 ctx 取消,否则调用方无法超时退出
func (l *DistLock) Lock(ctx context.Context, retryInterval time.Duration) error {
    for {
        ok, err := l.TryLock(ctx)
        if err != nil {
            return err
        }
        if ok {
            return nil
        }
        select {
        case <-ctx.Done():
            return errors.New("获取分布式锁超时: " + ctx.Err().Error())
        case <-time.After(retryInterval):
            // 继续下一轮重试
        }
    }
}

func (l *DistLock) Unlock(ctx context.Context) error {
    // 步骤5:只删自己的锁
    return unlockScript.Run(ctx, l.rdb, []string{l.key}, l.token).Err()
}

⚠️ 新手必踩的坑:分布式锁的四个致命细节

  1. 必须设过期时间,且要用 SET NX PX 一条命令完成。先 SETNXEXPIRE 是两步,中间宕机就留下一把永不释放的锁。
  2. value 必须唯一(UUID/请求ID)。用固定值时,A 的锁超时过期、B 拿到锁,A 执行完一个 DEL 就把 B 的锁删了,互斥彻底失效。
  3. 解锁必须用 LuaGET 判断和 DEL 之间存在时间窗,非原子就等于第 2 条的坑没堵上。
  4. 业务耗时可能超过 TTL。要么 TTL 给足余量,要么起一个"看门狗" goroutine 定期 EXPIRE 续期(redsyncgo-zero 都是这么做的),并在 Unlock 时停掉看门狗。另外,Redis 主从异步复制期间发生主从切换,锁可能同时被两个客户端持有——Redis 锁只保证"大概率互斥",钱相关的强一致场景请用 etcd/ZooKeeper 或数据库事务兜底

选型速查表

并发范围方案适用场景代价
单进程 · 单变量atomic计数器、标志位
单进程 · 一组状态Mutex / RWMutex缓存、配置、连接池锁竞争
单进程 · 热点激烈分片锁高 QPS 大 map内存翻倍、无法全局遍历
单进程 · 逻辑复杂channel 串行化(CSP)状态机、会话管理有调度开销、吞吐受单 goroutine 限制
多实例 · 数据在 DBDB 乐观锁 / WHERE 条件更新扣库存、改余额(冲突少)冲突需重试
多实例 · 临界区含多次读写DB 悲观锁 FOR UPDATE转账、对账(冲突多)持锁占事务,易放大慢查询
多实例 · 跨多个资源Redis 分布式锁定时任务防重跑、跨资源操作依赖 Redis 可用性,非强一致
多实例 · 要求强一致etcd / ZooKeeper选主、配置变更、金融级互斥部署与运维成本高

一条工程经验值得记住:能用一条带条件的 UPDATE 解决的,就别上分布式锁。锁是最后的手段,不是第一反应——每引入一个锁,就多引入一个死锁点和一个可用性依赖。


13、Mutex 内部状态位与自旋条件

15.1 用生活类比先建立直觉

把 Mutex 想象成厕所门上的三块指示灯,全部焊在一块小显示屏(一个 int32 变量)上:

  • locked 灯(mutexLocked):亮 = 有人正在用,新来者必须排队
  • woken 灯(mutexWoken):亮 = 已经有人在门口"叫醒"队首排队者了,别再去重复喊(避免惊群式重复唤醒)
  • starving 灯(mutexStarving):亮 = 已经有人等超过 1ms,进入饥饿模式,新来者直接去队尾排队、不再抢

这样设计的好处是:所有状态塞进一个 32 位整数,加锁解锁时用一次原子读写就能同时看到"是否被持有 + 是否饥饿 + 是否有等待者被唤醒 + 还有几个人在排队",不用额外字段、不用额外加锁。

graph LR
    S["state: int32
一个字段装下所有状态"] S --> L["bit0
mutexLocked
1=被持有"] S --> W["bit1
mutexWoken
1=已唤醒排队者"] S --> G["bit2
mutexStarving
1=饥饿模式"] S --> Q["高 29 位
waiterCount
等待者数量"]

桥接到工程:这就是第 4 章讲"正常模式/饥饿模式"的底层实现。模式切换、唤醒、排队计数,全部靠这几个位标志 + 位运算完成。

15.2 工程要点

Mutex 的三种状态位

// runtime 中 Mutex.state 的位布局(简化示意)
const (
    mutexLocked      = 1 << 0 // 第 0 位:是否被持有
    mutexWoken       = 1 << 1 // 第 1 位:是否有 waiter 已被唤醒
    mutexStarving    = 1 << 2 // 第 2 位:是否进入饥饿模式
    mutexWaiterShift = 3      // 第 3 位起:等待者计数
)

// locked/woken/starving 三个标志位,加上高 29 位的 waiter 数量,
// 全部打包在一个 int32 里,用 atomic 位运算读写
标志位含义谁设置 / 清除
mutexLocked锁当前是否被持有Lock 成功置 1,Unlock 清 0
mutexWoken已经有一个等待者被唤醒,别人别再发唤醒信号抢锁者/释放者设置,后续清除
mutexStarving已有等待者超过阈值,进入公平模式等待 >1ms 置 1,队首拿到锁且等待 <1ms 清 0
高 29 位当前在队列里等锁的 goroutine 数量入队 +1,出队 -1

Mutex 允许自旋的条件

第 4 章提到"新来的 goroutine 会先尝试自旋(最多 4 次)",但自旋不是随便就能转的。只有满足下面全部条件,才会进入自旋:

graph TD
    C1{"多核? GOMAXPROCS > 1"} -->|"否"| NoSpin["不自旋
直接排队"] C1 -->|"是"| C2{"当前 P 本地队列为空?"} C2 -->|"否"| NoSpin C2 -->|"是"| C3{"自旋次数 < 上限
主动自旋 4 次 / 激进 30 次?"} C3 -->|"超过"| NoSpin C3 -->|"未超"| C4{"还有别的 P 在跑且没闲着?"} C4 -->|"否"| NoSpin C4 -->|"是"| Spin["自旋
忙等一小会儿再试抢锁"]

具体条件(来自 sync_runtime_canSpin):

  1. 运行的 CPU 核数 > 1 且 GOMAXPROCS > 1——单核自旋纯浪费。
  2. 当前 P 的本地运行队列为空——否则该去调度别的 G,而不是空转等锁。
  3. 自旋次数未超上限:普通自旋最多 active_spin = 4 次,激进自旋最多 25 次(锁已被持有且持有者正在运行)。
  4. 至少有一个 P 正在运行且未空闲——说明"锁的持有者很可能马上就释放",值得等。

⚠️ 新手必踩的坑: 自旋是"用户态忙等",只发生在正常模式锁临界区很短的场景。如果临界区长,自旋的 goroutine 只是在烧 CPU,反而拖慢整体。饥饿模式下完全禁自旋——因为已经有人等太久,锁释放后必须直接交给队首。

考点总结: Mutex 用一个 int32 打包 locked/woken/starving + waiter 计数;自旋是正常模式下的优化,必须满足多核、本地队列空、次数未超、有 P 在跑四个条件,且只用于临界区极短的场景。


14、RWMutex 实现与注意事项

16.1 用生活类比先建立直觉

把 RWMutex 想象成图书馆阅览室

  • 读者(RLock):可以多人同时进来看书
  • 整理书架的管理员(写锁 Lock):必须等所有读者离场、且独占阅览室才能干活
  • 写锁饥饿:读者一波接一波地来,管理员永远等不到"全场清空"那一刻,一直进不去
  • 不能升级:你手里拿着"读者证"(已 RLock),不能当场变成"管理员证"(再 Lock)。必须先把读者证还了,重新排队申请管理员证——否则你会卡在门口等"所有读者离场",而你自己就是那个不走的人,死锁
graph TD
    R["读者 RLock x N"] -->|"readerCount > 0"| Block["写者 Lock 阻塞
等 readerCount 归零"] Block -->|"所有读者 RUnlock"| W["写者独占
writerSem 放行"] W -->|"Unlock"| Wake["唤醒等待的读者
readerSem 放行"] Upgrade["已持 RLock 又调 Lock"] -->|"自己也在 readerCount 里"| Dead["死锁
永远等不到归零"]

16.2 工程要点

RWMutex 的内部实现

RWMutex 不是"一把读锁 + 一把写锁"那么简单,内部有五个核心字段:

字段作用
w Mutex保护写者之间的互斥(多个写者借此串行)
writerSem写者等所有读者退场时,阻塞在这个信号量上
readerSem读者等写者释放时,阻塞在这个信号量上
readerCount int32当前持有读锁的读者数(负数表示该值减了 RWMutexMaxReaders,表示有写者等待)
readerWait int32写者等待离场的读者数量
// 简化逻辑:写锁 Lock
func (rw *RWMutex) Lock() {
    rw.w.Lock()                    // 步骤1:先和其他写者互斥
    r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) // 步骤2:把读者计数整体"扣掉",标记有写者等待
    if r != 0 {                    // 步骤3:还有读者没走
        atomic.Wait(&rw.writerSem) // 步骤4:写者睡在 writerSem 上,等读者全退场
    }
}

// 简化逻辑:读锁 RLock
func (rw *RWMutex) RLock() {
    if atomic.AddInt32(&rw.readerCount, 1) < 0 {
        // 步骤1:读者计数变负,说明有写者正在等待 -> 读者也要排队
        atomic.Wait(&rw.readerSem) // 步骤2:读者睡在 readerSem 上,等写者释放
    }
}

写锁饥饿

RWMutex 没有 Mutex 那种 1ms 饥饿切换机制。如果读者源源不断(每个 RLock 之间几乎无缝),readerCount 很难归零,写者会一直卡在 writerSem 上——这就是写锁饥饿。读多写少场景下这是预期行为;但若写操作有延迟敏感要求,要考虑用普通 Mutex 或分片降低单把锁的读竞争。

不能升级(RLock 无法直接变 Lock)

Go 的 RWMutex 不支持锁升级。如果一个 goroutine 先 RLock() 再调用 Lock()

func badUpgrade(rw *sync.RWMutex) {
    rw.RLock()
    defer rw.RUnlock()
    // 危险:在持有读锁时申请写锁
    rw.Lock()   // 写者会把 readerCount 整体扣掉,但本 goroutine 的 +1 还在里面
    defer rw.Unlock()
    // 结果:写者永远等不到 readerCount 归零(自己就是那个读者)-> 死锁
}

正确做法:先 RUnlock() 释放读锁,再按需 Lock()(注意释放后共享数据可能已被别人改动,需要重新读取校验)。

考点总结: RWMutex 用 readerCount(负数标记写者等待)+ 两个信号量(writerSem/readerSem)实现读写互斥;它没有公平性切换,写者可能饿死;并且禁止读锁升级为写锁,否则死锁。


15、Cond 的 Signal 与 Broadcast 区别

17.1 用生活类比先建立直觉

把 Cond 的唤醒想象成餐厅叫号系统

  • Signal() = 叫"下一个号":只放一个正在等待的顾客进门
  • Broadcast() = 广播"打烊清场 / 全场有座":把所有正在等待的顾客一次性全唤醒

差别的关键在于:Signal 适合"生产了一个资源,交给一个等待者即可";Broadcast 适合"状态发生了全局变化"(比如队列被清空、连接已关闭),所有等待者都需要重新检查条件。

17.2 工程要点

两者行为对比

方法唤醒数量典型场景
Signal()唤醒一个等待者往队列放入 1 个元素,只需 1 个消费者来取
Broadcast()唤醒所有等待者关闭队列 / 配置刷新 / 条件全局翻转
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var mu sync.Mutex
    cond := sync.NewCond(&mu)

    // 步骤1:启动 3 个等待者
    for i := 1; i <= 3; i++ {
        go func(id int) {
            mu.Lock()
            defer mu.Unlock()
            cond.Wait() // 进入等待(Wait 会先释放锁再阻塞)
            fmt.Printf("等待者 %d 被唤醒\n", id)
        }(i)
    }

    time.Sleep(300 * time.Millisecond)

    // 步骤2:Signal 只唤醒 1 个 -> 只有 1 个等待者打印
    mu.Lock()
    cond.Signal()
    mu.Unlock()
    time.Sleep(200 * time.Millisecond) // 此时只有 1 个打印

    // 步骤3:Broadcast 唤醒剩余全部 -> 剩下 2 个一起打印
    mu.Lock()
    cond.Broadcast()
    mu.Unlock()

    time.Sleep(300 * time.Millisecond)
}

⚠️ 新手必踩的坑: Wait() 必须在 for 循环中检查条件(见第 7 章)。Broadcast() 唤醒所有等待者后,它们会逐个重新抢锁、重新检查条件——这叫"惊群",但配合 for 循环检查能保证正确性。如果只用 if,虚假唤醒时会误以为条件满足了。

考点总结: Signal() 唤醒一个等待者,Broadcast() 唤醒全部;放入单个资源用 Signal,全局状态变化用 Broadcast;两者都必须配合 for 循环中的条件检查。


16、sync.Pool 临时对象复用

18.1 用生活类比先建立直觉

把 sync.Pool 想象成公司楼下的共享雨伞架

  • 下雨(高频创建临时对象)时,从伞架领一把(Get),用完还回去(Put)给下一个人用
  • 不用"每人买一把新伞"(每次都 new + 等 GC 回收),既省钱又减轻保洁阿姨(GC)的负担
  • GC 来大扫除时,伞架会被清空——Pool 里的对象不保证长期存活,下次 Get 可能拿到一把"新伞"(甚至 nil)
graph TD
    G["goroutine 需要临时对象"] -->|"pool.Get"| P["sync.Pool 伞架"]
    P -->|"有空闲"| Reuse["复用旧对象"]
    P -->|"为空/nil"| New["调用 New 新建"]
    G -->|"用完 pool.Put"| P
    GC["GC 触发"] -->|"清空伞架"| P

桥接到工程:Pool 的核心价值是降低分配压力和 GC 频率,不是"缓存"(因为对象随时可能被回收)。典型用途是 bytes.Bufferfmt 包、JSON 编解码里的临时 buffer 复用。

18.2 工程要点

用法

package main

import (
    "bytes"
    "fmt"
    "sync"
)

// 步骤1:定义一个 Pool,必须提供 New 函数
// New 在 Get 拿不到可复用对象时调用,保证不会返回 nil
var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer) // 返回一个新 buffer
    },
}

func process(data string) string {
    // 步骤2:Get —— 优先复用,没有才走 New
    buf := bufPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()              // 步骤3:归还前清空,避免脏数据
        bufPool.Put(buf)         // 步骤4:Put 还回去
    }()

    buf.WriteString("processed: ")
    buf.WriteString(data)
    return buf.String()
}

func main() {
    fmt.Println(process("hello"))
    fmt.Println(process("world"))
}

注意事项

注意点说明
对象随时被 GC 回收Pool 不阻止对象被回收,Get 可能拿到 nil(所以必须提供 New)
不能存需显式释放的资源别往里放 os.File*sql.DB 等需要 Close 的对象,GC 不会帮你 Close
Put 前必须 Reset否则下一个人拿到带脏数据的对象
适合"无状态临时对象"如 buffer、slice、临时 struct,不适合长期状态

⚠️ 新手必踩的坑: 有人把 Pool 当"全局缓存"用,存了业务关键状态——结果 GC 一跑对象没了,程序逻辑出错。记住:Pool 是性能优化手段,不是存储。另外 Get 返回的可能是别人 Put 的旧对象,必须在使用前 Reset 或重新初始化。

考点总结: sync.Pool 用于临时对象复用,降低分配与 GC 压力;对象可能被 GC 随时回收,不能当缓存;使用必须提供 New、Put 前 Reset。


17、GM 调度模型(Go 1.0 之前)

19.1 用生活类比先建立直觉

把调度模型的变化想象成厨房的进化:

  • GM 模型(早期) = 只有一块大公共黑板(全局队列),所有厨师(M)都挤在黑板前抢任务,而且黑板前还要排队领号(一把全局大锁)。
  • GMP 模型(现在) = 每个厨师配了一个自己的小工作台(P + 本地队列),优先在自己台面上干活,台面空了才去别处偷或去公共板拿。

GM 模型的问题就出在"一块黑板 + 一把大锁":人一多,大家全卡在抢锁上,黑板成了瓶颈。

graph TD
    subgraph GM["GM 模型(早期)"]
        GQ1["全局队列
单队列"] -->|"全局锁竞争"| M1["M1"] GQ1 -->|"全局锁竞争"| M2["M2"] GQ1 -->|"全局锁竞争"| M3["M3"] end subgraph GMP["GMP 模型(现在)"] P1["P1+本地队列"] --> MM1["M1"] P2["P2+本地队列"] --> MM2["M2"] GQ2["全局队列
溢出时才用"] -.-> P1 GQ2 -.-> P2 end

19.2 工程要点

GM 模型的问题

早期 Go(1.0 及之前,Goroutine 调度从 1.1 起改为 GMP)只有 G 和 M,没有 P 这个中间层

  1. 单一全局队列:所有 goroutine 都塞进一个全局队列
  2. 全局大锁:每次调度都要抢这把锁,多核下锁竞争极其激烈
  3. M 频繁创建销毁:系统调用阻塞时 M 被拖进内核,P 没有中间层缓存本地任务,恢复时又要重新调度
  4. Cache 不亲和:G 在不同 M 间飘,CPU cache 经常失效
  5. 负载不均:没有 work stealing,某些 M 忙死、某些闲死

为什么引入 P(GMP)

引入 P(Processor,逻辑处理器)后:

  • 每个 P 持有本地队列,绝大多数调度在用户态、无锁完成
  • 只有本地队列满/空才碰全局队列,全局锁竞争大幅减少
  • work stealing 让负载自动均衡
  • M 与 P 解绑后,P 可快速绑到另一个 M 上,减少线程抖动

⚠️ 新手必踩的坑: 面试被问"为什么要有 P"时,核心答案就是:P 把’任务队列’和’执行线程’解耦,用本地队列把全局锁竞争降下来,并让 work stealing 成为可能。没有 P,Go 的多核扩展性会非常差。

考点总结: GM 模型只有全局队列 + 全局锁,多核下锁竞争成为瓶颈;Go 1.1 引入 P,用本地队列 + work stealing 把调度下沉到用户态、化解全局锁竞争。


18、抢占式调度:协作式与基于信号

20.1 用生活类比先建立直觉

“抢占"就是调度器能不能强行把执行权从 goroutine 手里夺回来。两种手段:

  • 协作式(函数序言栈检测,Go < 1.14) = 每次进一个函数前,厨师先探头问一句"我是不是该下班了”。如果有个厨师写了个空 for 死循环(从不进任何函数),调度器永远没机会问这句话,就饿死整个 P——其他 goroutine 全卡住。
  • 基于信号(async preemption,Go 1.14+) = 调度器直接朝厨师背后扔个信号弹(SIGURG),厨师正在干任何事都会被中断,立刻把手头活放下去接受调度。这就治好了死循环饿死 P 的问题。
graph TD
    Coop["协作式抢占
函数序言检查"] -->|"函数调用间隙"| Check["检查是否需要调度"] Check -->|"需要"| Yield["让出 P"] Check -->|"无函数调用"| Stuck["死循环 for{}
永不检查 -> 饿死 P"] Sig["信号式抢占 1.14+"] -->|"sysmon 发 SIGURG"| Interrupt["中断任意执行点"] Interrupt --> Yield

20.2 工程要点

协作式抢占(函数序言栈检测)

Go 在 1.14 之前采用协作式抢占。编译器在每个函数的序言插入一段栈检查代码(原本是为栈扩容准备的 morestack 检查),借这个检查顺带判断"当前 G 是否该被抢占":

  • 优点:实现简单,依赖已有的栈增长机制
  • 缺点:必须发生函数调用才能触发检查。如果一个 G 执行 for {} 或长时间不调用函数,调度器无法插入,该 P 被独占,同 P 上的其他 G 全部饿死

基于信号的抢占(async preemption)

Go 1.14 引入:

  1. sysmon 监控线程发现某个 G 运行时间过长,在它的 preempt 标志位打标
  2. 向该 G 所在的 M 发送 SIGURG 信号
  3. M 被信号中断,陷入信号处理逻辑,调用 asyncPreempt
  4. 把当前寄存器状态保存为"被打断的现场",切换到 g0 栈,调用调度器把 G 放回队列
  5. G 之后被重新调度时,从保存的现场恢复,像什么都没发生过
// 伪代码:信号抢占的关键路径(概念示意)
// sysmon 中:
if 某G运行时间 > 10ms {
    g.preempt = true            // 步骤1:打抢占标记
    preemptM(g.m, SIGURG)       // 步骤2:发信号中断 M
}
// M 收到 SIGURG:
// 步骤3:保存寄存器 -> 切到 g0 -> schedule() -> G 回队列

⚠️ 新手必踩的坑: 基于信号的抢占解决了"死循环饿死 P"的问题,但汇编函数、某些不可中断的临界区仍可能延迟抢占。不过对普通 Go 业务代码而言,1.14+ 之后基本不会再因为 for {} 把整个 P 拖死。

考点总结: 协作式抢占靠函数序言检查,遇死循环会饿死 P;Go 1.14 引入基于 SIGURG 信号的异步抢占,可中断任意执行点,根治该问题。


19、GMP 中的阻塞类型与 sysmon 监控

21.1 用生活类比先建立直觉

把 G 在调度中被"叫停"想象成厨师干活时被打断的四种情形:

  1. 系统调用阻塞 = 厨师去仓库取货,卡在门口(陷入内核),灶台(P)得让给别人用
  2. 网络 IO 阻塞 = 厨师等外卖平台回执,Go 用 netpoll 异步等,不占灶台
  3. channel 阻塞 = 厨师等的食材没送来,他先去歇着(G 挂起),灶台换别的菜做
  4. 抢占 = 经理定时喊"换人",保证没人独占灶台太久

sysmon 就是那个不炒菜、只巡场的店长:盯着谁炒太久(该抢占)、谁卡系统调用太久(该把 P 抢回来)、GC 到点没、网络回执到了没。

graph TD
    B1["系统调用阻塞
M 陷内核, P 解绑"] --> Retake["sysmon retake
把 P 抢回"] B2["网络 IO
netpoll 异步等"] --> Net["sysmon 轮询 netpoll"] B3["channel 阻塞
G 挂起, 用户态切换"] --> Exec["P 换 G 执行"] B4["抢占
运行超 10ms"] --> Preempt["sysmon 打标 + 发信号"] Sys["sysmon 店长
监控一切"] --> Retake Sys --> Net Sys --> Preempt

21.2 工程要点

GMP 调度中的四类阻塞

阻塞类型是否解绑 P恢复方式
系统调用(文件 IO、阻塞 syscall)是,M 陷内核,P 找别的 M系统调用返回后尝试重新获取 P
网络 IO否,交给 netpoll 异步等待netpoll 就绪后 G 重新入队
channel 收发否,G 挂起对端发送/接收后唤醒 G
抢占(运行过久)否,G 被放回队列调度器切换到下一个 G

重点区分:只有系统调用阻塞才解绑 P;网络 IO 由 netpoll 接管,并不占用 M 在内核傻等,这是 Go 高并发网络服务的基石。

sysmon 的作用

sysmon 是一个不需要绑定 P 的特殊 M,在后台循环运行,职责包括:

  1. retake(抢占与夺回 P):标记运行超时的 G 需要抢占;把卡在系统调用里太久的 M 上的 P 强行夺回(hand off),让 P 去服务别的 G
  2. 触发 GC:到时间就启动垃圾回收
  3. netpoll 轮询:定期轮询网络轮询器,把就绪的网络 G 放回运行队列
  4. 回收 M:清理长时间阻塞、已无用的 M
// 伪代码:sysmon 主循环(概念示意)
func sysmon() {
    for {
        // 步骤1:检查运行过久的 G -> 打抢占标记
        retake()           // 抢回卡系统调用的 P + 标记超时 G
        // 步骤2:到时间就触发 GC
        if 该GC了 { gcStart() }
        // 步骤3:轮询网络
        netpoll(0)         // 把就绪的 G 放回队列
        // 步骤4:清理长时间阻塞的 M
        retake()           // 顺带回收
        usleep(一小段时间)
    }
}

⚠️ 新手必踩的坑: sysmon 不依赖 P,所以即使所有 P 都忙、所有 G 都在跑,sysmon 依然在后台运转——这正是异步抢占(第 18 章)能生效的前提:是 sysmon 负责发现"该抢占的 G"并发信号。

考点总结: GMP 中四类阻塞(系统调用解绑 P、网络走 netpoll、channel 用户态挂起、抢占回队列);sysmon 是不绑 P 的监控 M,负责 retake 夺回 P、触发 GC、轮询 netpoll、回收 M。


20、锁的常见误用:不可重入、不可复制、读也要加锁

22.1 用生活类比先建立直觉

锁就像更衣室的存衣柜钥匙

  • 不可重入:你拿着钥匙进了柜子,又想在柜子里再开一个子柜——但子柜锁和外面是同一把。你把自己锁在里面,钥匙还在自己兜里,外面的人进不来,你也出不去。
  • 不可复制:你把"柜子+钥匙"整体复印了一份,原件你拿、复印件给别人。结果两个人各开各的,锁的计数乱套,谁都以为自己独占。
  • 读也要加锁:你以为"我就看一眼柜子里有什么、不拿东西"不用锁门。但柜子另一头有人正往里塞东西(写),你看的瞬间正好他改到一半,看到半新半旧的数据,Go 运行时直接把程序 terminate 掉。

这三个错误都源于"把锁当成普通变量随手用"。

22.2 Mutex 不可重入(同一 goroutine 重复加锁自死锁)

Go 的 sync.Mutex非可重入锁:同一个 goroutine 已持有锁,再调一次 Lock() 会阻塞在"等自己释放锁"上——自己等自己,永远等不到,整个程序卡死。runtime 最终打印 fatal error: all goroutines are deadlock!

package main

import "sync"

var mu sync.Mutex
var chain string

func A() {
    mu.Lock()
    defer mu.Unlock()
    chain = chain + " --> A"
    B()
}

func B() {
    chain = chain + " --> B"
    C()
}

func C() {
    mu.Lock()        // 步骤1:同一 goroutine 再次加锁 -> 死锁!
    defer mu.Unlock()
    chain = chain + " --> C"
}

func main() {
    chain = "main"
    A()              // 永远卡在 C 的第二次 Lock
}
sequenceDiagram
    participant G as 同一 goroutine
    G->>mu: A() 中 Lock() 成功(持锁)
    G->>mu: B()/C() 中再次 Lock()
    Note over G,mu: C 阻塞等待锁释放
    Note over G: 但锁的持有者就是自己,
自己不可能再 Unlock -> 死锁

⚠️ 新手必踩的坑: Mutex 没有"递归锁"语义。如果业务逻辑天然需要嵌套加锁(如函数调用链每层都加锁),要么把锁提取到最外层只加一次,要么改成"先 Unlock 再调内部函数"或拆出不带锁的私有函数 _C

22.3 RWMutex 嵌套读锁 + 写者等待死锁

RWMutex 允许多个读并存,但有两个雷区:

  1. 读锁嵌套读锁:一个 goroutine 先 RLock(),调用链里又 RLock()。单独看没问题(读可并存),但一旦有写者在等,新读者会被"写者优先"机制挡住,于是本 goroutine 的第一次 RLock 永远等不到自己释放——死锁。
  2. 本质上和 22.2 同源:你"占着一个读名额"又在等"所有读者离场",而你自己就是那个不走的人。
package main

import (
    "fmt"
    "sync"
    "time"
)

var mu sync.RWMutex
var count int

func A() {
    mu.RLock()
    defer mu.RUnlock()
    B()
}

func B() {
    time.Sleep(5 * time.Second) // 模拟处理中
    C()
}

func C() {
    mu.RLock()        // 步骤1:此时 main 的写锁已在等待
    defer mu.RUnlock() // 新读者被"写者优先"挡住 -> 死锁(hang)
}

func main() {
    go A()
    time.Sleep(2 * time.Second)
    mu.Lock()         // 步骤2:写者加入等待,后续 RLock 会被阻塞
    defer mu.Unlock()
    count++
    fmt.Println(count)
}
graph TD
    A1["goroutine: A RLock 持有读名额"] --> A2["调用 B -> C"]
    A2 --> A3["C 再次 RLock"]
    M["main: mu.Lock 写锁等待
readerCount 归零"] M -->|"写者优先: 阻塞新 RLock"| A3 A3 -->|"永远等不到归零
(自己占着读名额)"| Dead["死锁 hang"]

⚠️ 新手必踩的坑: 这是第 14 章"读锁不能升级为写锁"之外第二个 RWMutex 死锁源。实践铁律:持有 RLock 时不要调用可能再次 RLock/Lock 的函数;需要递归读就只加一次锁,把内部逻辑做成"不重复加锁"的私有函数。

22.4 Mutex 不可值复制(复制破坏锁状态)

sync.Mutex 内部有状态(第 13 章讲过的 state + sema)。把它整体按值拷贝,两个副本是完全独立的锁;若拷贝时原锁正被持有,副本的"已持有"状态丢失,锁等于失效。

package main

import (
    "fmt"
    "sync"
)

type MyMutex struct {
    count int
    sync.Mutex
}

func main() {
    var mu MyMutex
    mu.Lock()
    var mu2 = mu          // 步骤1:值拷贝!mu2 是一把全新的锁
    mu.count++
    mu.Unlock()

    mu2.Lock()            // 步骤2:mu2 与原锁无关,可再次 Lock
    mu2.count++
    mu2.Unlock()
    fmt.Println(mu.count, mu2.count) // 各自独立计数,锁未保护共享
}

上面代码能跑通,但 mumu2 各自是一把独立锁——原本想用一把锁保护 count,结果锁被复制后保护失效。更严重的是,拷贝一把"已加锁"的 Mutex 会触发 go vet 的 copylocks 警告,且运行时行为未定义。

⚠️ 新手必踩的坑: Mutex/RWMutex 必须按指针或作为结构体的嵌入字段(通过指针接收者操作),绝不能按值传递或赋值。把锁放进 struct 时,struct 本身也要用指针传递(&MyMutex{}),否则一旦被值拷贝就悄无声息地失效。go vet 能静态抓出大多数 copylocks 问题,但别依赖它——写代码时就养成"锁只通过指针用"的习惯。

22.5 map 读操作也必须加锁

很多人的直觉是"读又不改,读 map 不用加锁"。错。sync.Map 之外,普通 map 的并发读 + 写会触发运行时 panic——注意是"读 + 写"同时发生就崩,不是"写 + 写"。

package main

import "sync"

type UserAges struct {
    ages map[string]int
    sync.Mutex
}

func (ua *UserAges) Add(name string, age int) {
    ua.Lock()
    defer ua.Unlock()
    ua.ages[name] = age          // 步骤1:写,已加锁
}

func (ua *UserAges) Get(name string) int {
    // 步骤2:BUG!读 map 没有加锁
    if age, ok := ua.ages[name]; ok {
        return age
    }
    return -1
}
// 并发调用 Add 与 Get -> fatal error: concurrent map read and map write
graph TD
    W["写 goroutine: Add 加锁写 map"] -->|"与读并发"| R["读 goroutine: Get 无锁读 map"]
    R -->|"map 内部检测到
读+写同时进行"| P["runtime panic
concurrent map read and map write"] P -->|"程序崩溃"| Crash["fatal error"]

正确做法:读也要在同一把锁保护下。读多写少用 RWMutexGetRLock/RUnlock(见第 4 章 SafeCache)。

⚠️ 新手必踩的坑: map 的"读"不是只读视图——Go 在 map 增长、扩容(哈希重排)时会改写内部指针,所以读操作同样会触碰这些指针。一旦扩容与并发读撞上,运行时主动 panic 而非给你错误数据。记住:普通 map 要么全程加锁(含读),要么用 sync.Map,没有"只读不用锁"的中间态

考点总结:sync.Mutex/RWMutex 都不是可重入锁,同一 goroutine 二次 Lock 必死锁;② RWMutex 持有读锁时再嵌套读锁,若已有写者等待会死锁;③ 锁是值类型语义敏感对象,按值拷贝 = 锁失效,必须用指针;④ 普通 map 的读和写都必须加锁,否则 concurrent map read and map write panic。


21、线程安全集合的 Iter:RWMutex 保护遍历 + 只读 channel 流式返回

23.1 用生活类比先建立直觉

遍历一个被多 goroutine 读写的集合,就像清点仍在营业的仓库库存

  • 你进仓库时先挂"盘点中"牌(RLock),保证清点期间没人往里搬进搬出(写被挡)
  • 你一边清点一边把每件货的名字念到对讲机里(往 channel 发),外面的人拿着耳机听(消费),不用闯进仓库
  • 清完把对讲机频道关掉(close(ch)),外面听到关闭声就知道到头了
  • 最关键:对讲机是单向的(返回 ←chan interface{}),外面的人只能听、不能往里塞,避免有人乱插话破坏清点

23.2 工程要点

返回只读 channel 的安全迭代器

这是"读多写少集合"对外暴露遍历的常见写法:在 goroutine 里加读锁、遍历、逐条发送、关闭 channel、释放读锁。调用方用 for v := range set.Iter() 消费,天然安全且不会持锁过久。

package main

import (
    "fmt"
    "sync"
)

type threadSafeSet struct {
    s  []interface{}
    mu sync.RWMutex
}

// Iter 返回一个只读 channel,调用方只能接收,不能发送
func (set *threadSafeSet) Iter() <-chan interface{} {
    ch := make(chan interface{}) // 步骤1:无缓冲,逐条推送
    go func() {
        set.RLock()              // 步骤2:加读锁,保证遍历期间无写
        defer set.RUnlock()      // 步骤4:遍历完释放读锁
        defer close(ch)          // 步骤5:关闭 channel,range 自然结束
        for _, elem := range set.s { // 步骤3:在锁保护下遍历
            ch <- elem
        }
    }()
    return ch
}

func main() {
    set := &threadSafeSet{s: []interface{}{"a", "b", "c"}}
    for v := range set.Iter() { // 只读消费,安全
        fmt.Println(v)
    }
}
graph TD
    Caller["调用方 for range Iter"] -->|"接收"| CH["只读 channel"]
    G["后台 goroutine"] -->|"RLock 保护"| Map["遍历 set.s"]
    Map -->|"逐条 ch <- elem"| CH
    Map -->|"遍历完 close(ch)"| CH
    CH -->|"range 结束"| Done["调用方退出循环"]

⚠️ 新手必踩的坑: 这个写法有两个隐藏约束:① channel 用无缓冲时,如果调用方不消费(没 range),后台 goroutine 会卡在 ch <- elem,读锁一直不释放,其他写者被永久挡住——这就是把"遍历"和"消费"绑死的风险。真实项目里通常给 channel 加缓冲,或在 goroutine 里 select 一个 done channel 以便提前取消。② RUnlock 必须放在 close(ch) 之后或 defer 中,保证遍历结束才放锁;顺序写反同样会出问题。

为什么返回只读 channel 而不是切片副本

方案优点缺点
返回 []T 副本调用方完全脱离锁大集合拷贝开销大,瞬时内存翻倍
返回 ←chan T流式、内存恒定、调用方只读需消费完,否则后台 goroutine 卡锁
持有锁 for 直接遍历最简单持锁时间长,阻塞写者

考点总结: 线程安全集合的遍历要在读锁保护下进行;通过返回只读 channel(←chan)把"遍历"与"消费"解耦,调用方只能接收、不能破坏集合;注意无缓冲 channel 下消费方不读取会导致后台 goroutine 持锁挂起。


22、WaitGroup 超时等待(WaitTimeout)

24.1 用生活类比先建立直觉

WaitGroup.Wait() 就像站在门口死等所有人到齐——不管等多久,人不齐就不走。但现实里你可能只想等 5 秒,超时就先走(“不等了,先开会”)。标准库 WaitGroup 没有内置超时,需要自己用 channel + time.AfterFunc 包一层。

24.2 工程要点

用 channel + 计时器实现 WaitTimeout

核心思路:起一个 goroutine 调 wg.Wait(),谁先完成谁先往 ch 写结果——要么 Wait() 返回(false,全部完成),要么 time.AfterFunc 到点(true,超时)。

package main

import (
    "fmt"
    "sync"
    "time"
)

func WaitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
    ch := make(chan bool, 1)
    // 步骤1:一个 goroutine 专门等 WaitGroup 完成
    go func() {
        wg.Wait()
        ch <- false // 步骤2:正常完成,信号 false
    }()
    // 步骤3:另一个计时器,到点发 true
    time.AfterFunc(timeout, func() {
        ch <- true // 步骤4:超时信号 true
    })
    // 步骤5:谁先到谁赢,取第一个结果
    return <-ch
}

func main() {
    wg := sync.WaitGroup{}
    c := make(chan struct{})
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(num int, close <-chan struct{}) {
            defer wg.Done()
            <-close          // 阻塞,直到被 close 唤醒
            fmt.Println(num)
        }(i, c)
    }

    if WaitTimeout(&wg, time.Second*5) {
        fmt.Println("timeout exit") // 5 秒内没唤醒 -> 超时
    } else {
        close(c)                   // 完成 -> 唤醒所有 goroutine
    }
    time.Sleep(time.Second * 10)
}
graph TD
    WG["wg.Wait goroutine"] -->|"全部 Done"| Ch["ch <- false"]
    T["time.AfterFunc 计时器"] -->|"到点"| Ch["ch <- true"]
    Ch -->|"return <-ch 取先到者"| R["返回 true=超时 / false=完成"]

⚠️ 新手必踩的坑: time.AfterFunc 注册的计时器即使没触发也会在到点时执行一次,所以它一定会往 ch 写一次 true。如果 Wait() 先完成并消费了 false,那个 true留在带缓冲(cap=1)的 channel 里——但这里 ch 只被 return <-ch 读一次,滞留的 true 会被 GC 回收,无副作用。注意 ch 必须带缓冲 make(chan bool, 1),否则无缓冲 channel 上 AfterFunc 的发送会永远阻塞在没人接收的状态(虽然不影响主流程,但 goroutine 泄露)。

考点总结: 标准 WaitGroup 无超时,用 go wg.Wait() + time.AfterFunc 两个信号竞速、取先到者实现 WaitTimeoutch 用容量为 1 的缓冲避免发送方 goroutine 泄露;返回 true 表示超时、false 表示全部完成。


23、runtime.Gosched 主动让出与 byte 溢出陷阱

25.1 用生活类比先建立直觉

runtime.Gosched() 就像你在跑步机上主动**按了一下"暂停让别人先跑"**的按钮:你让出当前 CPU 时间片,调度器去跑别的 goroutine,下一轮再轮到你。它和"系统调用阻塞"不同——你只是礼貌地让一下,马上还会回来。

byteuint8 的别名,取值范围 0~255。如果写 for i := byte(0); i <= 255; i++i 到 255 后 i++ 会回绕成 0,循环条件永远成立——这是个永不结束的死循环

25.2 工程要点

Gosched 的作用与死循环陷阱

下面这个 goroutine 想"打印完一轮就让出 CPU",但因为 ibytei <= 255 恒成立,循环根本停不下来:

package main

import (
    "fmt"
    "runtime"
)

func main() {
    go func() {
        var i byte
        for i = 0; i <= 255; i++ {
            fmt.Println("Dropping mic")
            runtime.Gosched() // 步骤1:每轮主动让出,给别的 goroutine 机会
            runtime.GC()      // 步骤2:顺便强制 GC(演示用)
        }
        fmt.Println("Done")   // 步骤3:永远不会执行
    }()

    // 主 goroutine 也主动让一下,让子 goroutine 有机会先跑
    runtime.Gosched()
}
graph TD
    Loop["for i byte=0; i<=255; i++"] -->|"i 到 255 后 i++ 回绕为 0"| Loop
    Loop -->|"每轮 runtime.Gosched()"| Yield["让出 P 给别的 G"]
    Yield --> Loop
    Note["i<=255 永远成立
循环永不退出"]

⚠️ 新手必踩的坑: 两个考点叠在一起:① byteuint8,自增到 255 会回绕,写 i <= 255 当终止条件 = 死循环。需要"0~255 遍历"时正确写法是用 int 循环,或明确 i != 0 配合回绕语义。② runtime.Gosched() 只是"建议让出",不是抢占、也不是退出。在 GOMAXPROCS=1 且有个死循环 goroutine 时,Gosched 能让同 P 上的其它 G 喘口气;但 Go 1.14+ 的异步抢占(见第 18 章)本就能打断这种循环,所以真实生产里死循环一般会被 sysmon 抢占,不会真饿死整个进程——但逻辑上的无限循环依然是 bug,必须修。

考点总结: runtime.Gosched() 主动让出当前 P、把执行权交还给调度器(用户态、不切换线程);byte/uint8 取值范围 0~255,i<=255 作循环终止条件会造成无限回绕死循环;两者结合是经典陷阱题。


24、无限递归与 goroutine 栈溢出上限

26.1 用生活类比先建立直觉

第 1 章说过 goroutine 初始栈只有 2KB,能拷贝式扩容到 1GB,所以"深递归"一般没事。但注意前提是递归会终止。如果递归永远不收敛,栈会一层层往下长,直到撞上 1GB 的硬上限——运行时直接 fatal error: stack overflow,整个程序崩掉。

最隐蔽的一种:在 String() 方法里用 %v 去格式化自己的指针,而 %v 又会回调 String(),于是 String()SprintfString()……无限套娃。

26.2 工程要点

fmt.String() 自引用导致的栈溢出

package main

import "fmt"

type ConfigOne struct {
    Daemon string
}

// 错误:用 %v 格式化 c,而 c 实现了 String(),%v 会再调用 String()
func (c *ConfigOne) String() string {
    return fmt.Sprintf("print: %v", c) // 步骤1:Sprintf 发现 c 有 String 方法
    // 步骤2:于是又调 c.String() -> 又 Sprintf -> 又 String() ... 无限递归
}

func main() {
    c := &ConfigOne{}
    c.String() // 步骤3:runtime: goroutine stack exceeds 1000000000-byte limit
}
graph TD
    S["c.String()"] -->|"Sprintf %v 发现 c 实现了 String"| S2["再次调用 c.String()"]
    S2 -->|"又 Sprintf %v"| S3["再调用 c.String() ..."]
    S3 -->|"栈帧层层累加"| OF["撞 1GB 上限
fatal error: stack overflow"]

⚠️ 新手必踩的坑: 修正方式是用 %+v(仅打印字段,不会回调 String())或 Printf("print: %s", c.Daemon) 直接拼字段,而不是把 c 整体交给 %v

func (c *ConfigOne) String() string {
    return fmt.Sprintf("print: %s", c.Daemon) // 只取字段,不再触发 String 回调
}

顺带纠正第 1 章的一个印象:goroutine 栈不会溢出"只适用于递归能终止"的情况。任何无限递归(不止 String() 自引用,还包括忘了终止条件的递归函数)最终都会撑爆 1GB 栈上限。写递归时第一件事就是确认"一定会在某层 return"。

考点总结: goroutine 栈可扩容到 1GB,但无限递归仍会撑爆上限触发 stack overflow;典型陷阱是 String() 方法内用 %v 格式化自身指针导致自引用无限递归,应改用 %+v 或直接拼接字段。


25、死锁的预防、检测与线上定位

27.1 用生活类比先建立直觉

类比:死锁就像四辆车在十字路口互相等对方先走——A 等 B、B 等 C、C 等 D、D 等 A,谁都不动,整条路堵死。在 Go 里,“车"是 goroutine,“路口"是锁(Mutex / channel)。只要四个条件同时满足,路就堵死了。

对应到工程:死锁不是"程序报错”,而是一群 goroutine 全部卡在等锁 / 等 channel,谁都动不了。最危险的是"部分死锁”——只有一小撮 goroutine 卡住,其余还在跑,程序表面正常,但那块功能悄悄失效,日志里半天看不出问题。

graph TD
    G1["goroutine 1
持有锁A 等锁B"] --> G2["goroutine 2
持有锁B 等锁A"] G2 --> G1 G1 --> D["死锁
双方永久阻塞"] G2 --> D Note["四条件: 互斥 / 持有等待 / 不可剥夺 / 循环等待"]

桥接:预防死锁 = 从根上破坏四个条件之一;检测死锁 = 在程序卡住时"看出谁在等谁";线上定位 = 用 pprof / 日志快速找到那几个阻塞的 goroutine。下面分别讲。

27.2 工程要点

死锁四条件与破坏策略

条件含义如何破坏(预防)
互斥资源同一时刻只能被一个 goroutine 用无法破坏(锁的本质)
持有等待持锁的同时等另一把锁一次性申请所有锁,或"拿不到就全释放重来"
不可剥夺不能强行抢走别人手里的锁用带超时的锁(try-lock),超时即放弃
循环等待形成等待环 A→B→A固定全局加锁顺序(最常用、最稳)
package main

import (
	"sync"
	"unsafe"
)

type Account struct{ Balance int }

// 步骤1:固定加锁顺序——永远按地址从小到大加锁,杜绝循环等待
func transfer(a, b *Account, muA, muB *sync.Mutex, amount int) {
	first, second := muA, muB
	// 步骤2:用地址排序,保证所有 goroutine 的加锁顺序一致
	if uintptr(unsafe.Pointer(muA)) > uintptr(unsafe.Pointer(muB)) {
		first, second = muB, muA
	}
	first.Lock()
	second.Lock() // 步骤3:顺序固定,不可能出现 A 等 B 同时 B 等 A
	a.Balance -= amount
	b.Balance += amount
	second.Unlock()
	first.Unlock()
}

⚠️ 新手必踩的坑: 破坏"循环等待"用固定顺序最可靠,但前提是所有加锁点都用同一套排序规则。只要有一处漏了(比如某个函数按相反顺序加锁),环就又形成了。大型项目里建议把"加锁顺序"收敛到一个工具函数里,别让业务代码各自为政。

Go 中检测死锁的方法

flowchart LR
    A["死锁发生"] --> B{"所有 goroutine
都阻塞?"} B -->|"是"| C["runtime 自动 panic
all goroutines are deadlocked"] B -->|"否(部分死锁)"| D["net/http/pprof
goroutine dump"] D --> E["看哪些 goroutine 卡在
sync.(*Mutex).Lock / chan recv"] E --> F["定位持有锁的代码"]
  1. runtime 自动检测(全局死锁):当所有 goroutine 都阻塞(典型如主 goroutine 在等一个永远不会发的 channel),Go runtime 会主动 panic:fatal error: all goroutines are deadlocked!。这是"免费"的检测,但只能发现全员卡死,部分死锁它不报错。

  2. pprof goroutine dump(部分死锁的主力):线上开启 net/http/pprof,死锁时抓取 goroutine 栈:

// 步骤1:在 main 里挂上 pprof(生产建议绑定内网端口)
import _ "net/http/pprof"

func init() {
	go func() { _ = http.ListenAndServe("localhost:6060", nil) }() // 步骤2:导出 /debug/pprof/goroutine
}
# 步骤3:抓取 goroutine 调用栈(?debug=2 看完整栈,含阻塞原因)
go tool pprof http://localhost:6060/debug/pprof/goroutine
# 交互界面: top 看数量最多的栈;traces 看完整调用链
curl 'http://localhost:6060/debug/pprof/goroutine?debug=2' > goroutine.prof
  1. 关于 GODEBUG=deadlock:⚠️ Go 并没有这个 GODEBUG 选项,网上流传的说法不可信。真正有用的是:

    • GODEBUG=schedtrace=1000:每 1 秒打印一次调度器概览(runqueue 长度、goroutine 总数),能看出 goroutine 数是否在疯涨;
    • GODEBUG=scheddetail=1(配合 schedtrace)打印每个 P 的本地队列详情。 它们不是"死锁检测器",而是辅助判断"是不是有 goroutine 堆积"的观测手段
  2. 日志 + 监控兜底:在加锁前打一行带 goroutine 标识的日志,死锁时这行日志"只报了上锁、没报解锁",就能反推出卡点。再配合 runtime.NumGoroutine() 定时上报,数量持续不降就告警。

线上死锁时 CPU 指标特点与快速定位步骤

CPU 指标特点:死锁时 goroutine 都在等锁 / 等 channel,处于阻塞态(Gwaiting),不消耗 CPU——所以你会观察到:

  • CPU 使用率反而骤降(卡住的 goroutine 不干活),和"高 CPU"的活锁 / 自旋完全不同;
  • 但对应的业务接口 QPS 跌零、延迟飙到超时
  • 进程内存可能缓慢上涨(阻塞的 goroutine 持有栈 + 对象不释放,若持续有新请求进来堆积)。

快速定位五步法

flowchart TD
    S1["1. 看监控: QPS跌零+CPU反降
怀疑死锁"] --> S2["2. 查 goroutine 数是否异常增长"] S2 --> S3["3. curl pprof goroutine?debug=2
抓全量栈"] S3 --> S4["4. 搜 sync.(*Mutex).Lock / chan receive
找长期阻塞的栈"] S4 --> S5["5. 顺栈找到持有锁的代码行
修复加锁顺序/加超时"]
  1. 看监控:业务 QPS 突然跌零、延迟全超时,但 CPU 不高 → 先怀疑死锁(而非 CPU 瓶颈)。
  2. 查 goroutine 数runtime.NumGoroutine() 是否远高于基线、且长时间不回落。
  3. 抓栈curl '.../debug/pprof/goroutine?debug=2' 拿全量栈。
  4. 找阻塞点:搜索 sync.(*Mutex).Locksemacquirechan receive 等关键帧,定位那些"停留时间异常长"的 goroutine。
  5. 看调用链:顺着阻塞 goroutine 的栈往上,找到它在等哪把锁,再找到现在持有那把锁的 goroutine(也卡在等另一把锁)——锁的等待环就显形了。
package main

import (
	"log"
	"runtime"
	"time"
)

// 步骤4:自动化兜底——goroutine 数异常时自动 dump 栈,方便事后排查
func watchDeadlock(alert func()) {
	ticker := time.NewTicker(10 * time.Second)
	var prev int
	for range ticker.C {
		n := runtime.NumGoroutine()
		// 步骤5:数量翻倍且偏高 → 疑似泄漏/死锁
		if prev != 0 && n > prev*2 && n > 500 {
			buf := make([]byte, 1<<20)
			stack := runtime.Stack(buf, true) // 步骤6:导出所有 goroutine 栈到日志
			log.Printf("疑似死锁, goroutines=%d:\n%s", n, buf[:stack])
			alert() // 步骤7:触发告警
		}
		prev = n
	}
}

⚠️ 新手必踩的坑: 死锁和活锁(livelock)指标相反——死锁 CPU 低(都睡着了),活锁 CPU 高(都在空转重试)。定位前先确认 CPU 特征,能少走很多弯路。另外 pprof 抓栈是瞬时快照,间歇性死锁要在复现时抓,最好配上面的"数量异常自动 dump"。


26、自测题与动手练习

自测题(5 道)

1. GMP 模型中,P 的本地队列容量是多少?满了之后新创建的 goroutine 去哪里?P 的本地队列空了之后怎么获取新的 G?

2. goroutine 发生 channel 阻塞时,M 和 P 会解绑吗?什么情况下才会解绑?解绑后 P 怎么办?

3. Mutex 的正常模式和饥饿模式有什么区别?什么条件触发模式切换?切回正常模式的条件是什么?

4. 下面代码会死锁吗?为什么?

var mu sync.Mutex
mu.Lock()
mu.Lock()

5. atomic.CompareAndSwapInt64atomic.AddInt64 有什么区别?CAS 在什么场景下比 Add 更合适?请举一个 CAS 适用但 Add 不适用的例子。

6. 死锁的四个必要条件是什么?其中哪几个"无法破坏"、哪几个"可以破坏"?用一句话说明"固定加锁顺序"为什么能预防死锁。

7. Go 程序线上出现了"业务 QPS 跌零、接口全部超时,但 CPU 使用率反而很低"的现象,你怀疑是死锁。请写出你的排查步骤,并说明为什么"GODEBUG=deadlock"不是一个真实可用的选项。

动手练习(3 个)

练习 1:实现一个带超时的并发任务执行器

要求:

  • 接收一组任务函数,并发执行
  • 支持整体超时(用 context.WithTimeout
  • 限制最大并发数(用 buffered channel 做 semaphore)
  • 返回所有成功结果和失败原因

提示:组合 context + WaitGroup + buffered channel

练习 2:制造并修复 goroutine 泄露

要求:

  • 写一个会泄露 50 个 goroutine 的程序
  • runtime.NumGoroutine() 验证泄露
  • pprof 导出 goroutine profile 定位泄露位置
  • 修复泄露(用 context 超时或 channel 关闭)
  • 验证修复后 goroutine 数量恢复正常

练习 3:实现线程安全的计数器,对比 Mutex 和 atomic 性能

要求:

  • sync.Mutex 实现一个计数器
  • sync/atomic 实现同样的计数器
  • 两者各启动 1000 个 goroutine,每个递增 1000 次
  • time.Now()time.Since() 测量耗时
  • 输出对比结果,分析性能差异

28、本章小结

本章从 Go 并发编程的核心知识出发,系统覆盖了面试高频考点:

调度层面:GMP 模型是 Go 并发的基石。G 是用户态协程,M 是操作系统线程,P 是逻辑处理器。P 持有 256 容量的本地队列,通过 work stealing 实现负载均衡。只有系统调用才会导致 M 和 P 解绑,channel 阻塞只是用户态的 goroutine 切换。GOMAXPROCS 控制 P 的数量,默认等于 CPU 核数。goroutine 初始栈 2KB,可拷贝式扩容到 1GB。

生命周期层面:每个 goroutine 都必须有明确的退出路径。用 channel 的 close 或 context.Cancel 通知退出。获取返回值用 channel 传回或 future 模式。goroutine 泄露是隐蔽的内存杀手——用 runtime.NumGoroutine() 和 pprof 定位,用 context 超时预防。

锁层面:Mutex 是悲观锁,有正常模式(自旋+排队)和饥饿模式(直接交接)两种模式,1ms 是切换阈值。RWMutex 适合读多写少。atomic 利用 CPU 原子指令实现无锁操作,五种操作(Add/Load/Store/Swap/CAS)覆盖了简单计数器场景。WaitGroup 底层是 counter + waiter + semaphore,Add 必须在 goroutine 外调用。

死锁层面:四个必要条件是互斥、持有等待、不可剥夺、循环等待。避免死锁的核心方法是固定加锁顺序、避免嵌套锁、使用超时。Go runtime 能检测全局死锁,部分死锁需要 pprof 定位。

类型安全层面:nil map 写入会 panic,nil slice 的 append 安全,nil channel 读写永久阻塞。牢记用 make 初始化 map 和 channel。

面试时能画出 GMP 模型图、讲清调度流程、区分 Mutex 两种模式、说出死锁四条件,就覆盖了 Go 并发面试的 80% 考点。

复习提示:
  • GMP 调度本质:Go runtime 是用户态线程调度器,G(协程)排队等 M(OS 线程),P(处理器)维护本地工作队列。work stealing 让空闲 P 从忙 P 那里"偷"一半任务,自动负载均衡。
  • Mutex 自旋优化:等待 < 10μs 时自旋(不进入内核),避免上下文切换开销;超过阈值转入睡眠队列,防止 CPU 空转。这是"延迟 vs 吞吐量"的经典权衡。
  • goroutine 泄露 = 内存泄漏:泄露的 goroutine 永远不会被 GC,必须通过 runtime.NumGoroutine() 监控和 pprof 定位。
  • 死锁四条件缺一不可:互斥、持有且等待、不可剥夺、循环等待——破坏任一即可避免。最实用的是固定加锁顺序。
面试官
为什么 channel 阻塞不会导致 M 和 P 解绑,而系统调用会?这有什么区别?
候选人
这是 Go 调度器设计中最精妙的区别:

系统调用(如文件 I/O):goroutine 陷入内核态,M(OS 线程)被阻塞,runtime 创建新 M 接替 P,原 M 和 P 解绑。
Channel 阻塞:发生在用户态,goroutine 被挂入 sendq/recvq 队列,P 继续执行其他 goroutine,无需创建新 M。

这意味着:channel 操作的代价远低于系统调用。一个 M 可以同时服务数百个 channel 上的 goroutine,但只能服务极少数 I/O 操作。

面试加分点:提到 Go 1.14+ 的 netpoller 把网络 I/O 也从内核态移到了用户态(epoll/kqueue),进一步扩大了 channel 阻塞 vs 系统调用的优势差距。
About Me

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

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

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

目标

学AI,加油!加油!