go-随笔2
一、go的并发模型(GMP):
- G(goroutine):gorountine是go语言的开发核心,可以理解是一个轻量级协程,栈初始较小,由GO运行时调动。
- M(machine):机器,操作系统线程,实际执行G的载体,数量由系统决定
- P(processor):逻辑处理器,持有本地队列LRQ,并决定了并行数量
关键核心机制:
工作窃取(work stealing):当一个本地逻辑处理器(P)为空的时候,会尝试从其他P的队列或全局队列”偷取”一半的G来执行
hand off(交接):当M因系统调用阻塞时,P会与M解绑,寻找空闲的M或新建M来接管P,这样P不会浪费,继续执行其他的G
总结:
GMP模型是Go运行时实现高并发的核心。G代表协程,初始栈只有2KB;M是操作系统线程;P是逻辑处理器,数量等于GOMAXPROCS。
关键机制有两个:一是工作窃取——当某个P的本地队列空了,它会从其他P那里偷一半任务过来,避免空闲;二是交接机制——当M阻塞在系统调用时,P会立刻与M解绑,交给另一个M继续工作。
这种设计的核心优势是把调度从内核态转移到了用户态,不依赖操作系统线程调度,所以创建和切换成本极低,可以轻松支撑百万级并发。
1.Goroutine的调度时机(什么时候发生调度的)?
①主动调度:runtime.gosched()--主动让出CPU,主动触发调度
②阻塞调度(等待资源):
- 系统调用阻塞:系统调用阻塞,M与P解绑,其他goroutine可以在这个P上执行,被动触发调度
- channel读写阻塞,被动触发调度
- 锁竞争:锁被占用,阻塞,触发调度,被动触发调度
③抢占调度:一个goroutine长时间运行(>10ms)的函数(如死循环)会被强制抢占。Go1.14之后引入了基于信号的异步抢占调度。被动触发调度
④创建调度:go func()创建一个新的goroutine,被动触发调度
⑤GC调度:垃圾回收的STW,被动触发调度
⑥返回调度:goroutine执行完毕,主动触发调度
二、Channel的底层实现与使用陷阱:
1. channel的底层数据结构是什么?无缓冲和有缓冲的区别在哪里?
无缓冲:同步通信。发送者必须等待接收者准备好,反之亦然(ch:=make(chan int))
有缓冲:异步通信,满则阻塞发送,空则阻塞接收,(ch:=make(chan int,10))
底层数据结构:环形队列(qcount,dataqsize),包含recvx(接收索引)和sendx(发送索引)。关闭通道时所有等待的goroutine会被唤醒
// 底层数据结构
// 源码位置:runtime/chan.go type hchan struct { qcount uint // 队列中的元素个数 dataqsiz uint // 环形队列大小(缓冲区长度) buf unsafe.Pointer // 指向环形队列的指针 elemsize uint16 // 元素大小 closed uint32 // 是否已关闭 elemtype *_type // 元素类型 sendx uint // 发送索引 recvx uint // 接收索引 recvq waitq // 等待接收的goroutine队列(双向链表) sendq waitq // 等待发送的goroutine队列 lock mutex // 互斥锁 }
2.使用陷阱:
陷阱1:向已关闭的channel发送数据会panic
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
// 正确做法:使用select + ok模式或sync.Once
var once sync.Once
once.Do(func() { close(ch) })
陷阱2:从已关闭的channel接收不会panic,会返回0值
ch := make(chan int)
close(ch)
v := <-ch // v = 0(int的零值)
// 需要判断channel是否关闭
v, ok := <-ch
if !ok {
fmt.Println("channel closed")
}
陷阱3:nil channel永远阻塞
var ch chan int // nil channel <-ch // 永久阻塞 ch <- 1 // 永久阻塞
3.Goroutine和channels?
Goroutine:

在这个例子中,say("hello")在一个新的 Goroutine 中运行,而say("nice to meet you")在当前的 Goroutine 中运行。
Channels:
Channels是go的一种类型,可以用于在goroutine中传递数据,一个channel是一个通道,可以通过它接收和发送数据

在以上例子中,我们通过make(chan int)创建了一个新的channel通道,然后在两个goroutine中计算结果并将结果发送到c上,在主goroutine中我们收到c并且打印
Goroutine和channels一起使用时,可以编写出强大高效和简洁的代码。这是go语言的特性之一。
三、slice扩容机制与内存陷阱:
1.slice底层结构是什么?扩容策略是什么?为什么q=q[1:]后内存没有被释放?
底层结构:
len:长度,slice当前存储的元素个数
cap:容量,slice底层数组总共可以存储元素个数;cap决定了append时是否需要重新分配内存
// 源码位置:runtime/slice.go
type slice struct {
array unsafe.Pointer // 指向底层数组的指针 可以简称ptr代替
len int // 长度
cap int // 容量
}
扩容规则:
- 当前容量cap=0的时候(初始一个空数组),第一次分配,新cap=1或根据元素大小调整,int的话cap=1
- 期望容量>当前容量*2,容量=期望容量
- 如果当前容量(
cap) < 256:新cap = 旧 cap × 2 - 如果当前
cap ≥ 256:新cap = 旧 cap × 1.25(大约)
如果期望容量>当前容量的2倍,直接扩容到期望容量。否则:如果当前容量<256,扩容到2倍。如果当前容量>=256,扩容因子=(当前容量:newcap +3*256)/4≈1.25倍。直到满足需求
同时进行内存对齐,防止频繁扩容
注意:扩容时会生成新的底层数组,函数内append不改变原切片。
扩容代码示例:
func testSliceCap() {
s := make([]int, 0)
for i := 5; i <= 10; i++ {
s = append(s, i)
fmt.Printf("len=%d, cap=%d\n", len(s), cap(s))
}
}
// 输出:
// len=1, cap=1 内容:[5] len>cap(初始为0),第一次分配,所以cap=1
// len=2, cap=2 内容:[5,6] len<=cap ← cap翻倍(上一个cap*2)=(1*2)
// len=3, cap=4 内容[5,6,7] len> cap ← cap翻倍(上一个cap*2)=(2*2)
// len=4, cap=4 内容:[5,6,7,8] len<=cap
// len=5, cap=8 内容:[5,6,7,8,9] len>cap ← cap翻倍(上一个cap*2)=(4*2)
// len=6, cap=8 内容:[5,6,7,8,9,10] len<cap
为什么q=q[1:]后内存没有被释放?
首先我们要知道[start:end]中的传参意思 也可以说核心规则是什么? 第一个参数为起始位置,包含这个索引对应的元素。
第二个参数为结束位置,不包含这个索引对应的位置。
结果长度=结束位置-开始位置。 比如data[1:4]代表,从第一个位置开始,到第4位结束,长度=4-1。
切片只是视图,不复制底层数据。比如:
original := []int{10, 20, 30, 40, 50} // 底层数组有5个位置
sub := original[1:4] // sub = [20, 30, 40]
//original的指针指向10
//底层数组(5个int):
//┌────┬────┬────┬────┬────┐
//│ 10 │ 20 │ 30 │ 40 │ 50 │
//└────┴────┴────┴────┴────┘
// ↑ ↑
// │ └──结束位置
// └── 开始位置
//sub := original[1:4] 之后:
//- sub的指针 指向 20 的位置
//- sub.len = 3(20,30,40)
//- sub.cap = 4(从20开始到数组末尾,共4个位置:20,30,40,50)
具上所属q[1:]代表,把q的指针向后挪一位。等于从第1个索引开始到末尾的数据,q=q[1:]底层数据依然是q的长度,只是再也看不到q的第0个索引(第一个元素)的值,但是整个数组仍然存在内存中,因为q的指针仍指向数组内部
2.s2:=s1[1:4]后,修改s2为什么s1也会更改?
因为指针指向的是同一个底层数组。比如:
s1 := []int{10, 20, 30, 40, 50}
s2 := s1[1:4] // s2 = [20, 30, 40]
s2[0] = 999 //修改s2的第一个元素
fmt.Println(s1) // [10, 999, 30, 40, 50] ← 20 变成了 999
fmt.Println(s2) // [999, 30, 40]
//为什么20会变成999?
//因为 s1 和 s2 的 指针 指向的是同一个底层数组!
//s1.ptr ──────┐
// ↓
// ┌────┬─────┬────┬────┬────┐
// │ 10 │ 999 │ 30 │ 40 │ 50 │
// └────┴─────┴────┴────┴────┘
// ↑
//s2.ptr ──────┘ (指向索引1的位置)
//s2.len = 3
//s2.cap = 4
3.核心陷阱:切片截取导致内存泄露?
错误代码:
func test() []int {
a := make([]int, 1000000) // 分配1M个int
// ... 填充数据 ...
return a[:2] // 只返回前2个元素
}
func main() {
b:= test() // 你以为只用了2个int的内存
// 但实际上整1M的int的数组都无法被GC回收
}
图解:
test() 返回后,内存中的情况:
堆内存(1M个int):
┌────┬────┬────┬────┬────────────────────────┐
│ 0 │ 1 │ 2 │ 3 │ ... ... 999999 │
└────┴────┴────┴────┴────────────────────────┘
↑
└── b指针指向这里
b.len = 2
b.cap = 1M
关键:
GC判断一个对象能否回收,是看有没有"引用"指向它。
这里整个1M数组都被small.ptr"引用"着(虽然small只用了前2个位置),
所以整个数组都"可达",GC不会回收!
正确更改:
func test() []int {
a:= make([]int, 1000000)
// ... 填充数据 ...
// 方法1:复制需要的部分
result := make([]int, 2)
copy(result, a) //a数组没有被外部引用,可以被GC回收
return result
// 方法2:直接返回新的切片字面量
// return []int{a[0], a[1]}
}
四、defer底层原理与返回值陷阱:
1.defer的执行顺序是什么?为什么defer可以修改命名返回值?
顺序:先进后出。
底层原理:defer在编译时会被转化为runtime.deferproc,每个defer对应一个_defer结构体:
// 源码位置:runtime/runtime2.go
type _defer struct {
siz int32 // 参数大小
started bool // 是否已开始执行
heap bool // 是否在堆上分配
sp uintptr // 调用者的栈指针
pc uintptr // 调用者的程序计数器
fn *funcval // defer的函数
link *_defer // 链表,指向下一个defer
}
defer会被组成一个链表,先进后出原则
func deferOrder() {
defer fmt.Println("1") // 第三个执行
defer fmt.Println("2") // 第二个执行
defer fmt.Println("3") // 第一个执行
}
// 输出:3 2 1f
2. 返回值陷阱:
非命名返回值:defer无法修改
返回机制:
//匿名返回值 func test1() int { var i int defer func() { i++ }() return i } // 等价于: func test1() int { var i int __return_value := i // ① 把i的值复制到返回值变量 func() { i++ }() // ② 执行defer(修改的是i,不是返回值) return __return_value // ③ 返回副本 } // 结果:0 //命名返回值 func test2() (i int) { defer func() { i++ }() return 0 } // 等价于: func test2() (i int) { i = 0 // ① return语句直接赋值给i func() { i++ }() // ② 执行defer(修改的就是返回值i) return // ③ 返回i } // 结果:1
3. defer与panic/recover
panic会沿着调用栈向上传播,直到被recover捕获或程序崩溃
recover只能在defer函数中生效
核心:panic在defer外面使用,defer会在函数退出前执行(包括panic时)。同时如果触发了panic,会被defer捕获到panic的值。recover只能在defer中生效。
func test(a, b int) (result int, err error) {
// 这个defer会在函数退出前执行(包括panic时)
defer func() {
if r := recover(); r != nil {
// recover() 捕获了panic的值
err = fmt.Errorf("panic: %v", r)
}
}()
if b == 0 {
panic("test by zero") // 触发panic,但会被上面的defer捕获
}
return a / b, nil
}
执行流程:
test(10, 0)
↓
发现 b == 0,执行 panic("test by zero")
↓
正常函数执行中断,开始执行defer链
↓
进入defer函数,recover() 捕获到panic的值
↓
设置 err = "panic: test by zero"
↓
defer函数返回,panic被"吃掉",程序继续正常执行
↓
test返回 (0, err)
4.性能优化:
热路径(循环内)使用defer仍有开销:
for i := 0; i < 1000000; i++ {
mu.Lock()
defer mu.Unlock() // 每次循环都会创建_defer结构体
doSomething()
}
建议改为:手动释放或者使用匿名函数包装
//手动函数释放
for i := 0; i < 1000000; i++ {
mu.Lock()
doSomething()
mu.Unlock()
}
// 或者使用匿名函数包装
for i := 0; i < 1000000; i++ {
func() {
mu.Lock()
defer mu.Unlock()
doSomething()
}() // defer只在匿名函数内部,每次循环结束后释放
}
五、GC机制与性能优化:
1.go的GC算法是怎样的?如何减少GC对服务吞吐量的影响?
GC算法的机制是:并发三色标记+混合写屏障
三色标记法:
白(未扫描/待回收)、灰(已标记子集未处理/待扫描)、黑(已标记且子集已处理/已扫描)
三色标记流程:
初始状态:所有对象白色
├── 标记开始:根对象标灰,加入工作队列(结果:根对象变灰)
├── 标记阶段:
│ ├── 从队列取出灰色对象/扫描灰色对象
│ ├── 将其引用的所有对象标灰/将引用对象标记为灰色
│ └── 当前对象标黑/自身变为黑色(标记阶段结束后的结果:部分变黑,新引用变灰)
├── 重复以上步骤,直到没有灰色对象(结果:黑色存活,白色回收)
├── 标记结束:只有黑白对象,白色为垃圾
└── 清扫阶段:回收白色对象
屏障机制:
插入写屏障(v1.5~1.8):在A引用B的时候,若A为黑色则将B标灰,需要STW(STOP THE WORLD)重新扫描
混合写屏障(V1.8+):插入写屏障+删除写屏障,核心是:GC开始时,栈上的对象全标黑,无需STW重新扫描栈,极大缩短了STW的扫描时间
2. GC阶段与STW(STOP THE WORLD)
①.STW:暂停所有正在运行中的goroutine
②.为什么需要STW:垃圾回收需要保证内存状态的一致性;如果一边回收一边有goroutine在修改内存指针,就会乱套。
③.GC只有两个短暂的STW阶段:
标记准备:开启写屏障,需要STW确保所有的P都准备好了
标记终止:关闭写屏障,重新扫描被扫过的对象
时间 →
────────────────────────────►
[标记准备] [并发标记] [标记终止] [并发清扫]
↓ ↓ ↓ ↓
STW 并发 STW 并发
(10-50μs) (最长) (20-100μs) (后台执行)
3.如何调优GC:
使用sync.Pool减少内存分配;控制对象大小(小对象分配更频繁);调成goGC环境变量;
六.逃逸分析:
1.什么是内存逃逸?如何查看?为什么栈分配比堆分配好?
内存逃逸:编译器决定变量分配是在栈上还是堆上(变量本来应该分配在栈上,但编译器决定把它分到堆上。)
栈VS堆/区别/为什么栈分配更好:
栈:分配速度极快,函数执行时自动分配,函数返回时自动释放,不需要GC
堆:分配速度较慢,手动分配(new/make),由GC负责回收,会有GC压力
逃逸场景:1.返回局部变量的指针;2.将指针存入interface{}(动态类型);3.闭包引用外部变量;4.切片append导致容量不足;5.变量过大(栈空间不足)。样例:
// 场景1:返回局部变量指针
func escape1() *int {
a := 42
return &a // 逃逸
}
// 场景2:将指针存入interface{}
func escape2() {
a := 42
var y interface{} = a // 可能逃逸(取决于编译器)
}
// 场景3:闭包引用外部变量
func escape3() func() {
a := 42
return func() {
fmt.Println(a) // a逃逸
}
}
// 场景4:切片append导致容量不足
func escape4() {
s := make([]int, 0, 10)
for i := 0; i < 100; i++ {
s = append(s, i) // 扩容时新数组在堆上
}
}
// 场景5:变量过大(栈空间不足)
func escape5() {
a := [1000000]int{} // 大于栈空间限制,逃逸到堆
}
优化:通过go build -gcflags=”-m” 查看逃逸分析结果。栈分配比堆分配快的多,且无需GC扫描
如何查看逃逸分析:
# 查看逃逸分析结果 go build -gcflags="-m" main.go # 更详细的信息 go build -gcflags="-m -m" main.go # 输出示例: # ./main.go:5:2: moved to heap: x # ./main.go:6:9: &x escapes to heap
七.context的原理与goroutine泄露防治
1.context的作用是什么?
context是一个信号传递工具,主要用途:
①传递截止时间[超时\截至(WithTimeout/Withdeadline)]:ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) // "这个任务必须在 10 分钟内完成"
②传递取消信号:cancel函数用于主动终止下游协程 ctx, cancel := context.WithCancel(ctx) // 可以随时取消任务,cancel()所有任务停止
③传递请求域的值(withvalue):慎用
典型场景:HTTP请求超时控制,数据库连接取消,多协程联动退出
超时控制代码样例:
// 场景:调用一个可能很慢的 API
func test(ctx context.Context, userID string) (string, error) {
// 给这个操作设置 2 秒超时
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() // 重要!函数结束时要清理
// 模拟一个慢请求
select {
case <-time.After(3 * time.Second): // 模拟耗时 3 秒
return "user data", nil
case <-ctx.Done(): // 2 秒后这里会收到信号
return "", ctx.Err() // 返回 "context deadline exceeded"
}
}
func main() {
ctx := context.Background()//创建一个空白的context
result, err := test(ctx, "123")
if err != nil {
fmt.Println("错误:", err) // 输出: 错误: context deadline exceeded
return
}
fmt.Println("结果:", result)
}
2.goroutine泄露怎么防止?
核心思路:给goroutine一个“退出通道”。比如
func safeFunction(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case v := <-ch:
fmt.Println(v)
case <-ctx.Done(): // ← 监听Context的取消信号
fmt.Println("被取消了,退出")
return // 主动退出
}
}()
}
func main() {
// 创建一个3秒后自动取消的Context
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() // 重要:确保最终释放资源
safeFunction(ctx)
}
八、核心数据结构底层原理:
Slice(切片):
结构:type slice struct{array unsafe.pointer;len int;cap int}
扩容机制:
如果期望容量>当前容量的2倍,直接按期望容量扩容。
否则,当前容量<256则扩容为2倍。
否则循环扩容,(newcap +3*256)/4(约1.25倍)。只到满足需求
注意:扩容时会生成新的底层数组,函数内append不改变原切片。
Map:
底层结构:hash表,hmap包含b(桶数量对数),buckets指针,每个桶(bmap)通常存在8个键值对
扩容:
翻倍扩容:负载因子>6.5(装载的键值对太多)
等量扩容:溢出桶过多,此时B不变,目的是整理碎片,把溢出桶数据挪到正常桶
遍历:随机起始点+扩容期间遍历(会遍历新旧桶,保证不遗漏key)
并发:非并发安全。并发场景用sync.map或者mutex
Defer:
机制:FIFO(后进先出)
参数预算:defer注册时,参数(包括receiver)就已经被计算并复制了
返回值陷阱:
命名返回值:defer可以修改返回值
匿名返回值:defer无法修改返回值(除非通过指针)
九 如何保证高并发下的数据一致性?
- 互斥锁sync.Mutex:适用于读写都频繁的场景--最常用
- 读写锁sync.RWMutex:适用于读多写少场景
- 原子操作:适用于简单数值操作场景
- channel:适用于数据流或传递消息场景
- 单线程:适用于状态集中管理
- Sync.Once:确保初始化只执行一次
十 接口的底层结构:
- 空接口interface{}:eface,包含_type(类型信息)和data(数据指针)
// runtime/runtime2.go
type eface struct {
_type *_type // 类型信息
data unsafe.Pointer // 数据指针
}
type _type struct {
size uintptr // 类型大小
ptrdata uintptr // 指针数据大小
hash uint32 // 类型哈希
tflag tflag // 类型标志
align uint8 // 对齐
fieldAlign uint8 // 字段对齐
kind uint8 // 类型种类
equal func(unsafe.Pointer, unsafe.Pointer) bool // 相等函数
gcdata *byte // GC 数据
str nameOff // 类型名字偏移
ptrToThis typeOff // 指向自身的指针偏移
}
- 非空接口/有方法的接口:iface,包含itab(接口表:存类型信息,方法集)和data
// runtime/runtime2.go
type iface struct {
tab *itab // 接口表(类型信息+方法集)
data unsafe.Pointer // 数据指针
}
type itab struct {
inter *interfacetype // 接口类型
_type *_type // 具体类型
hash uint32 // 类型哈希(用于快速判断)
_ [4]byte
fun [1]uintptr // 方法列表(变长)
}
十一 协程泄露的场景和防止泄露的方法:
场景:
- Channel未关闭导致阻塞:向无缓冲且没有接收者的channel发送;无缓冲且没有发送者的channel接收;同nil channel发送数据。
- 无线循环:死循环
- 锁未释放:忘记unlock,下次lock会永远阻塞
- Waitgroup计数未归零
- For-range未关闭的channel
防止泄露的方式:
- 使用context控制生命周期:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
- 使用带缓冲的channle
ch := make(chan int, 1) // 缓冲为1
排查工具/监测goroutine泄露:
go pprof查看goroutine堆栈,或用goleak做单元测试
十二 运行时(Runtime):runtime包的作用和使用场景?Go程序启动流程是怎样的?
作用:runtime包含了内存分配,垃圾回收(GC),goroutine调度,channel,map,反射等核心组件的底层实现
场景:常用于性能调优和调试例如
- 调度控制:runtime.Gosched()主动让出CPU,runtime.Goexit()立即终止当前goroutine,runtime.GOMAXPROCS()设置逻辑处理器数量
- 监控与调试:runtime.NumGoroutine()获取goroutine数量,runtime.ReadMemstarts()获取内存统计信息,runtime.Stack()获取堆栈信息
启动流程:go程序的入口在runtime包中,不同平台的入口文件不同。它会进行内存分配,调度器,GC等运行时环境的初始化,最后才调用main.main()执行用户代码
十三 CGO:CGO的使用场景和限制是什么?
场景:让go可以无缝的用C语言库,主要用于复用C生态,进行底层系统操作,以及对极致性能的核心模块进行优化
限制:
- 性能损耗:go和C的切换有较大开销
- GC管理限制:C代码分配的内存不会被go的GC管理,需手动处理,易引发内存泄露
- 交叉编译复杂:CGO的交叉编译非常复杂,需要特定平台的C编译器,典型的可以设置CGO_ENABLED=0进行纯go编译
十四 代码生成:go generate的用途和原理?
原理:go generate会扫描go源文件中的//go:generate指令,并按顺序执行指令后的命令,不属于go build 的一部分,需要手动运行
用途:常用于自动化代码生成任务,提高开发效率(从.proto生成.prt.go代码,生成string()方法,生成mock)
十六 嵌入式开发:Go在嵌入式开发中的优势和挑战?
优势:
- 开发效率高:语法简介,内置并发模型和垃圾回收,开发速度快
- 并发能力强: goroutine和channel简化了多任务处理
挑战:go运行时带来的开销,尤其是GC,对资源受限的MCU是负担


浙公网安备 33010602011771号