Week 1 · Chapter 1 · Go语言基础

复习难度:⭐⭐⭐ | 预计时长:2.5小时 | 重点程度:高


1.1 GMP 调度模型

原理

Go 的运行时(runtime)采用 GMP 模型:

                    ┌─────────────────────────────────────┐
                                      M                   
                      ┌─────────────────────────────┐    
                            P (runnable G queue)       
                        [G1] [G2] [G3] [G4] ...        
                      └─────────────────────────────┘    
                    └─────────────────────────────────────┘

调度流程: 1. M 从 P 的本地队列取 G;若本地队列空,从全局队列或偷其他 P 的队列 2. G 阻塞在 channel/sync 操作时,M 解绑 P 去执行其他 G 3. G 重新就绪后,放入某个 P 的队列等待调度

常见面试题

Q1:GPM 模型中 P 的数量默认是多少?可以动态调整吗?

答:默认等于 CPU 核数(runtime.GOMAXPROCS 初始值)。可以动态调整,runtime.GOMAXPROCS(n) 可以在运行时设置,但不会改变 M 的总数。

Q2:Go 1.14 引入的抢占式调度解决了什么问题?

答:解决了"死循环 G 占满 P 导致其他 G 无法运行"的问题。通过编译器插入抢占标记 + 信号抢占,使长耗时 G 能被强制挂起。


1.2 Channel

原理

Channel 是 Go 实现 CSP 模型的核心,提供阻塞式的 goroutine 通信。

// 无缓冲 channel:发送和接收同步阻塞
ch := make(chan int)

// 有缓冲 channel:非满不满时非阻塞
ch := make(chan int, 10)

// 单向 channel(用于函数签名)
func producer(ch chan<- int)  // 只能发
func consumer(ch <-chan int)  // 只能收

底层结构(简化):

type hchan struct {
    qcount   uint           // 元素数量
    dataqsiz uint           // 环形队列大小
    buf      unsafe.Pointer // 环形队列指针
    elemsize uint16         // 元素大小
    closed   uint32         // 是否关闭
    recvq    waitq           // 阻塞接收的 G 队列(sudog)
    sendq    waitq           // 阻塞发送的 G 队列(sudog)
}

关键机制: - 发送:buf 未满则直接入队;buf 满则当前 G 置入 sendq 阻塞,等待 receiver 唤醒 - 接收:buf 有元素则直接出队;buf 空则当前 G 置入 recvq 阻塞,等待 sender 唤醒 - 关闭:close(ch) 后,接收方收到零值 + ok=false

常见面试题

Q1:channel 关闭后还能读取吗?还能写入吗?

答:关闭后可以继续读取,返回零值+false;写入会 panic。因此关闭操作应在 sender 端,且确保不再写入。

Q2:如何判断 channel 是否已关闭且无数据?

答: go v, ok := <-ch if !ok { // channel 已关闭 }

Q3:用 channel 实现一个互斥锁。

答: go func NewMutex() chan struct{} { ch := make(chan struct{}, 1) ch <- struct{}{} // 持有锁 return ch } // Lock: <-ch (阻塞获取) // Unlock: ch <- struct{}{} (释放)


1.3 Map

原理

Go 的 map 是哈希表,实现为数组+桶(bucket)+链表/红黑树(Go 1.18 起引入)。

type hmap struct {
    count      int           // 元素数量
    B          uint8         // 桶数量的 log2(如 B=3 则有 8 个桶)
    buckets    unsafe.Pointer // 桶数组
    oldbuckets unsafe.Pointer // 扩容时保存旧桶
    extra      *mapextra     // overflow 桶
}

type bmap struct {
    tophash [8]uint8   // 8 个槽位的高 8 位哈希值
    keys    [8]keytype
    values  [8]valuetype
    overflow *bmap     // overflow 桶指针(哈希冲突时连接)
}

核心特点: - 哈希冲突:用开放寻址法的变体(tophash + 链式 overflow) - 渐进式扩容:负载因子 > 6.5 时触发 2 倍扩容;等量扩容时重建 bucket 减少 overflow - 不是线程安全:多个 goroutine 并发读写会 panic;需用 sync.RWMutexsync.Map

常见面试题

Q1:map 的扩容因子为什么是 6.5?

答:6.5 是通过大量实验得出的平衡值。(loadFactor > 6.5 时平均每桶超过 6.5 个元素,链表会变长,查找性能下降)

Q2:遍历 map 的顺序是固定的吗?

答:不固定,每次遍历顺序不同。因为遍历的是桶的顺序,而哈希到桶的顺序由哈希函数决定,map 故意对遍历顺序做了随机化防止用户依赖顺序。

Q3:sync.Map 的适用场景?

答:适合读多写少的场景(读不加锁)。内部用两个 map(read 和 dirty)实现,写操作多的场景反而更慢因为每次写都要加锁。


1.4 Interface

原理

Go 的接口是隐式实现(无需 implements 关键字),核心是接口表中方法指针的调度

// 空接口:所有类型都实现
var i interface{} = "hello"

// 非空接口:需要实现所有方法
type Writer interface {
    Write([]byte) (int, error)
}

iface 底层结构:

type iface struct {
    tab  *itab           // 接口类型 + 实际类型的调度表
    data unsafe.Pointer // 指向实际值的指针
}

type itab struct {
    inter *interfacetype // 接口类型元数据
    _type *_type          // 实际类型
    fun   [1]uintptr     // 方法指针数组(可变长)
}

nil receiver 问题:

var p *Person // p == nil
var i PersonInterface = p  // i != nil(iface 的 data 为 nil,但 tab 不为 nil)
// 调用 i.Method() 不会 panic,但行为取决于方法是否检查 receiver == nil

常见面试题

Q1:interface 何时会触发 GC?

答:当接口持有的对象不再被任何变量引用时,会被 GC 回收。空接口 interface{} 持有对象时,对象的引用计数至少为 1。

Q2:如何判断 interface{} 是 nil?

答: go if v, ok := i.(type); ok { // i 不是 nil } 直接 i == nil 判断的是 iface 的两个字段都为 nil 的情况,但赋值后 data 可能为 nil 而 tab 不为 nil。


1.5 内存模型

原理

Go 内存模型定义了 goroutine 之间 happens-before 关系(与 Java JMM 类似但更简洁)。

核心规则: 1. 初始化:包级变量的初始化 var x = ax = b 满足 happens-before 2. goroutine 创建go f()f() 的执行满足 happens-before 3. channel 通信:send → 对应 receive 满足 happens-before 4. sync 包sync.Mutex / sync.RWMutex 的 Lock/Unlock、Once 的 do 等

内存分配策略: - 栈(stack):每个 goroutine 独立栈,初始 2KB,最大 1GB;函数返回后栈帧自动释放 - 堆(heap):GC 管理,逃逸分析决定分配在堆还是栈

常见面试题

Q1:什么是 happens-before?举一个例子。

答:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。例子:goroutine A 向 channel 发送数据,B 从 channel 接收 → A 的 send happens-before B 的 receive → B 一定能收到 A 发送的数据。

Q2:为什么不要在 defer 中做重型计算?

答:defer 在函数返回前(panic 流程也会)按 LIFO 顺序执行。如果 defer 中有锁释放、文件关闭等资源清理操作没问题,但如果做复杂计算会影响性能。Go 1.14 后 defer 性能已大幅提升(基于栈的延迟帧)。


📝 本章高频问题速记

问题 核心答案一句话
GMP 中 P 默认数量 CPU 核数
channel 关闭后能否写入 不能,写入 panic
map 遍历顺序是否固定 不固定,故意随机化
sync.Map 适用场景 读多写少
interface nil 判断 i == nil 要求 tab 和 data 都为 nil
happens-before 定义 A 结果对 B 可见,且 A 在 B 之前执行