Golang八股文
协程是什么
协程是一种轻量级用户态线程,不由操作系统的内核管理,协程的创建和调度完全由 Go 调度器管理
GMP 模型
GMP 指 Go 运行时系统的三个组件:Goroutine、Machine、Processor
- Goroutine:go 实现的协程,goroutine 最终要放在 M 上执行
- Machine:操作系统的线程,M 必须和 P 绑定后才能执行 goroutine
- Processor:goroutine 的调度上下文,管理着一组 goroutine 队列,会对队列做一些调度:比如把占用 CPU 时间过长的 goroutine 暂停,运行后续 goroutine;如果自己的队列消费完了就去全局队列里拿;如果全局队列也消费完了就去其他 P 的队列里抢任务。P 反映了最大并行数,所以默认值为 CPU 的核心数
- 当创建一个 goroutine 时,go 的调度器会把这个 goroutine 放到当前 P 的本地队列。如果当前 P 的本地队列满了,Go 会把这个新 G 连同本地队列的一半 G 一起搬到全局队列里
- 之后 M 需要和一个 P 绑定才能工作,他会优先从绑定的 P 的本地队列中取出一个 G 来工作。如果本地队列空了,M 会去全局队列里取;如果本地和全局队列都空了,M 会去从其他的 P 那里取一半的 G 放入到当前 P 的本地队列
- 当 M 成功拿到 G,开始在 CPU 上运行。如果运行完毕,M 会把它放到 P 的空闲列表,清空 G 的资源供下次复用。如果 G 在执行某些阻塞操作,为了不影响 P 本地队列中后面 G 的运行,M 会主动释放 P,P 会与新的 M 绑定,当 M 执行完任务回来之后,他会去绑定新的 P,如果绑定不到的话,就把刚才运行完的 G 丢进全局队列
跟其他语言相比,其他语言的线程是由 OS 内核调度的。goroutine 则是由 Go 运行时调度的,调度器采用 m : n 的技术(调度 m 个 goroutine 到内核线程 / M)。其一大特点是 goroutine 的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多
defer 执行顺序
- 栈式执行,后进先出,也就是逆序执行
- 参数在
defer声明时就完成求值 - 返回值先赋值,再执行
defer,最后真正返回 - 命名返回值可以被
defer修改(匿名返回值不行,返回值先赋值是拷贝的副本)
1 |
|
slice 切片底层与扩容
1 | type slice struct { |
slice底层结构体由三部分组成,当len要超过cap时会触发扩容机制,创建一个更大新的底层数组并拷贝原数据- 小容量切片扩容以大约2倍增长,大容量切片扩容随容量增大扩容倍率下降
- 扩容后新旧切片不再共享底层数组
map 底层与扩容
map底层基于哈希桶和溢出桶实现,每个桶存放8个键值对,存放项时,先根据key计算哈希值,哈希值的低位决定该键值对落在哪个桶内,哈希值的高8位用于快速比对,决定键值对落在该桶的哪个位置。如果桶满了,会通过链表跳转到溢出桶map原生是并发不安全的,并发读写未加保护可能会panicmap当键值对过多时(每个桶的平均元素 > 6.5)时会触发翻倍扩容;当溢出桶过多时会触发等量扩容(整理)- 翻倍扩容:开辟新内存,桶的数量翻一倍,并记录指向旧桶的指针
- 等量扩容:开辟新内存,桶的数量和原来相同(整理),减少了溢出桶的数量,并记录指向旧桶的指针
- 扩容期间同时存在对新桶和旧桶的引用
- 数据渐进迁移,后续读写时顺带搬迁旧桶的数据,减少一次性抖动
sync.Map 底层和使用场景
sync.Map 是并发安全的
1 | type Map struct { |
- 读取时先从只读层读取,只读层读取是原子读取操作,性能快,无需锁。如果只读层找不到 key,需要加锁去脏数据层读取,并且计数器要 +1
- 写入数据时需要加锁写入脏数据层
- 当
计数器 >= len(dirty)时,认为只读层与脏数据层差别太大,需要把脏数据层的数据拷贝到只读层
综上,选择使用场景如下:
sync.Map适合读多写少,键值对长期稳定的情况。否则更常用map + mutexsync.Map键值对类型是any,如果需要强类型声明需要选择map
CSP
CSP 是 Communicating Sequential Processes,强调通过通信共享内存,而不是通过共享内存通信。CSP 认为并发系统应该由一组独立、顺序运行的实体组成,实体之间通过发送信息来共享数据
在 go 中,实体就是 goroutine,共享数据的通道就是 channel
- goroutine 负责执行,用 channel 负责 goroutine 之间的通信
- 用 channel 通信代替锁,可以减少锁竞争和共享可变状态,减少死锁和数据竞争
- CSP 是 go 语言的一种并发设计思想,不是“完全不用锁”
Channel
channel 底层维护了三个数据结构:
- 环形数组(有缓冲区),维护
sendx和recvx表示下一个发送的元素和下一个接收的接收元素在数组中的索引 - 发送者队列,由双向链表实现,如果缓冲区已满,发送者会进入发送者队列
- 接收者队列,由双向链表实现,如果缓冲区为空,接收者会进入接收者队列
- 往 channel 写数据时,如果接收者队列中有
receiver,说明缓冲区为空,数据直接发送给receiver。如果接收者队列为空,会尝试把数据写入缓冲区。如果缓冲区未满,直接把数据写入缓冲区。如果缓冲区已满,把该sender加入发送者队列。 - 从 channel 读数据时,如果发送者队列中有
sender,说明缓冲区满了,尝试从缓冲区读取数据。如果缓冲区为空(缓冲区大小为0),就从sender获取数据。如果发送者队列中没有sender,先尝试从缓冲区读取数据,如果缓冲区没有数据没有就把该receiver加入接收者队列。
操作特点:
- 对
nil channel收发会阻塞,关闭会panic - 对
closed channel发送和重复关闭会panic,但可以继续读取剩余数据 for range遍历会读取closed channel剩余数据,读取完自动退出循环;如果channel没关闭会一直阻塞读取
switch 和 select
switch是条件分支,按case的顺序匹配第一个满足条件的case分支执行select是channel的多路复用机制,会判断哪些channel操作已经ready,如果多个case同时ready,会随机选择一个执行;如果所有的case都没有ready,有default分支就走default分支,否则会阻塞等待
panic 和 recover
panic
panic会立刻中断当前协程的执行,并开始回溯调用栈,依次执行已经注册的defer语句,如果没有recover捕获,panic会向上传播,程序最终会崩溃退出- 当发生
panic时,当前函数的后续代码都不会再执行,但该函数中已注册的defer仍然会执行
recover
recover用于捕获同一个协程中的panic,它只能在defer函数中生效- 一旦
recover捕获到panic,panic的传播会停止,执行完调用栈中的defer语句后程序恢复正常执行 recover的返回值也就是panic()中的参数
1 | func safeFunc() { |
使用场景:例如后端服务,注册一个Recovery中间件,利用defer + recover来捕获panic,当panic发生时程序不会崩溃,而是返回给前端500状态码
interface
Go 的接口由两部分组成:动态类型 + 动态值
如果要比较两个接口,只有当动态类型和动态值都相等时,两个接口才相等(如果动态类型属于不能比较的类型,比如切片、map、函数,两个接口比较时会直接 panic)
当接口的动态类型和动态值都为 nil 的时候,interface == nil才会返回true
例如:
1 | // 标准库 |
更底层表示:go 的 interface 在运行时由两种结构体表示:eface(空接口)和 iface(带方法的接口)。
eface — 空接口(interface{} / any)
1 | // runtime/runtime2.go |
iface — 带方法的接口
会多维护一个itab结构体,其中含fun数组,保存了具体类型所实现的接口方法的地址,当通过接口变量调用方法时,可以利用该数组找到正确的方法执行
反射
核心反射包:reflect
反射可以在程序运行时获取变量的类型,动态地获取和修改对象的值、调用对象的方法
核心类型:Type和Value
反射三定律
- 根据接口获取反射对象
1 | t := reflect.TypeOf(x) |
- 根据反射对象获取接口
1 | i := v.Interface() |
- 如果要动态修改变量的值,必须传递指针
1 | func test(x interface{}) { |
使用场景
- JSON 序列化和反序列化,通过反射获取结构体的字段类型和值,读取 json tag
- Gorm 也是通过反射来解析结构体
- Gin 框架,参数绑定
ShouldBind()通过反射解析结构体
缺点
- 性能差:正常类型在编译期就确定了,反射需要动态类型检查
- 类型不安全:字段名写错,解析不到会
panic - 可读性差
闭包
闭包 = 函数 + 它引用的外部变量
闭包捕获的是变量的引用,不是值的拷贝。
循环变量捕获(经典陷阱)
Go 1.22 之前,循环变量在整个循环期间是同一个变量(每次迭代只更新值),导致闭包捕获到的永远是最后一次循环的值:
1 | // Go 1.22 之前:有 Bug |
Go 1.22 起:for 循环变量每次迭代都是新变量,上述问题已自动修复。但如果面试被问到,以上两种修复方式仍要能写出来
常见面试考点
- 闭包捕获的是变量引用而非值拷贝,所以多个闭包可能共享同一个变量
- 循环中启动 goroutine 用闭包捕获循环变量(Go 1.22 前),需要传参或用局部变量遮蔽
- 闭包中修改被捕获的变量,外部也会看到变化
Context
context 用于在调用链之间传递取消信号、超时控制、键值对等;树状结构,父context取消后,派生出的子context都会取消
1 | type Context interface { |
创建 Context
context.Background():根 context,一般作为最顶层的父 contextcontext.TODO():当不确定用哪个 context 时的占位符context.WithCancel(parent):返回子 context 和一个 cancel 函数,调用 cancel 会取消该子树context.WithDeadline(parent, time.Time):到达指定时间点自动取消context.WithTimeout(parent, time.Duration):经过指定时长后自动取消(底层调用WithDeadline)context.WithValue(parent, key, value):携带键值对的 context
通常配合select使用,例如消费者消费消息:
1 | func consumer(ctx context.Context, msgs <-chan string) { |
如果不调用cancel会有什么问题?
当我们通过 WithTimeout 或 WithCancel 创建子 Context 时,父 Context 会在内部维护一个子节点的关联关系。如果不调用 cancel() 且父 Context 一直存活,子 Context 就会一直挂在父节点上无法被垃圾回收,资源泄漏。同时,对于依赖ctx.Done()退出的 goroutine,他们可能会一直等待而无法退出,导致 goroutine 泄漏
垃圾回收(三色标记法)
Go 的 GC 采用并发三色标记-清除算法,核心思路是:在程序正常运行的同时,GC 线程并发地找出所有”可达”的对象,不可达的即为垃圾,回收之
三色标记法
将对象分为三种颜色:
- 白色:尚未被 GC 访问过。标记开始时所有对象默认白色,标记结束后仍为白色的就是垃圾,可以被回收
- 灰色:对象自身已被访问,但它引用的子对象还没全部扫描完
- 黑色:对象自身及它引用的所有子对象都已被扫描完毕
STW(Stop The World)
STW 指暂停主程序,让 GC 独占运行
写屏障
标记阶段与用户代码并发执行,用户代码可能会修改变量的引用关系:
新增了:黑色对象 引用 -> 白色对象(如果白色对象不被重复扫描,可能会被错误回收)
可能会导致程序引用了已经被回收的内存
解决方案:写屏障
如果 GC 线程正在标记,当用户代码更改变量的引用关系时,编译器会在变更操作之前插入一段逻辑,用于通知 GC 线程,这样在刚才的例子中,被引用的白色对象会被 GC 重新标记
新版本的 Go 使用混合写屏障,例如上面的例子中,黑色对象除了新增对白色对象的引用,还要删除对原本旧灰色对象的引用,混合写屏障还会通知 GC 线程,让旧的灰色对象也被 GC 重新标记
GC 流程
最开始,所有对象都是白色
- 标记准备:触发 STW,开启写屏障,扫描根对象,把可达对象标记为灰色
- 并发标记:与用户线程并发执行,从根对象开始遍历引用的子对象,把可达的子对象标记为灰色,根对象标记为黑色,然后再从子对象开始递归这个过程,直到所有可达对象都被标记为黑色,此时灰色集合为空,剩余的白色对象就是不可达的对象
- 标记终止:触发 STW,关闭写屏障,完成标记收尾(并发期间用户线程可能修改对象引用,补全标记,防止漏标)
- 清除:与用户线程并发执行,回收白色对象
GC 触发时机
- 自动触发:堆内存分配量达到阈值,自动触发(如果距离上次 GC 时间超过了2min,也会自动触发)
- 手动触发:调用
runtime.GC,阻塞运行
内存模型与逃逸分析
前置概念
- 内存溢出(OOM, Out of Memory):程序申请的内存超过了系统可用内存,导致程序崩溃或系统 OOM Killer 杀进程。Go 中常见原因:大量创建 goroutine 导致栈内存膨胀、持有大对象不释放、GOGC 设置过高导致 GC 跟不上分配速度
- 内存泄漏(Memory Leak):程序不再使用的内存无法被回收,占着不用。有 GC 的语言不代表不会泄漏——只要有”预期之外的引用”让 GC 认为对象还活着,就不会回收。Go 常见泄漏:goroutine 阻塞不退、channel 未关闭、map 只增不减、time.Ticker 不 Stop、slice 截取后底层大数组被小切片引用无法释放
Go 内存分配:栈与堆
- 栈:函数局部变量,随函数返回自动回收,分配极快(一个 SP 偏移指令)
- 堆:生命周期超出函数作用域的变量,由 GC 负责回收。分配慢,回收有 GC 开销
Go 编译器逃逸分析决定一个变量分配在栈还是堆
逃逸分析
逃逸分析是编译器在编译期做的静态分析:判断一个变量是否”逃逸”出了当前函数的栈帧。逃逸的条件:变量在函数返回后仍然可能被引用
常见逃逸场景:
- 返回局部变量的指针:变量必须放到堆上,否则函数返回后栈帧已销毁,指针变成悬空指针
1 | // 逃逸:返回了局部变量的指针 |
- interface 装箱:具体值赋给
interface{}时,编译器无法确定类型和生命周期,可能逃逸到堆
1 | fmt.Println(x) |
- 闭包捕获了局部变量
1 | func counter() func() int { |
- 变量过大:栈放不下,优先放到堆上
用 go build -gcflags="-m" 可以查看逃逸分析结果。
逃逸对性能的影响
- 栈分配几乎零开销,堆分配需要找空闲内存、记录 GC 元信息
- 堆变量增多 → GC 扫描更多对象 → STW 频率和时间上升
- 优化方向:避免不必要的指针、减少 interface 装箱
多线程轮流打印 A B C
1 | package main |
说些什么吧!