go-随笔2

一、go的并发模型(GMP):

  •  G(goroutine)gorountinego语言的开发核心,可以理解是一个轻量级协程,栈初始较小,由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:

image

在这个例子中,say("hello")在一个新的 Goroutine 中运行,而say("nice to meet you")在当前的 Goroutine 中运行。

 

Channels:

Channels是go的一种类型,可以用于在goroutine中传递数据,一个channel是一个通道,可以通过它接收和发送数据

image

在以上例子中,我们通过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              // 容量
}

 

扩容规则:

  1. 当前容量cap=0的时候(初始一个空数组),第一次分配,新cap=1或根据元素大小调整,int的话cap=1
  2. 期望容量>当前容量*2,容量=期望容量
  3. 如果当前容量( cap) < 256:新 cap = 旧 cap × 2
  4. 如果当前 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 int1// 缓冲为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是负担  

 

posted @ 2026-04-09 10:42  阿陌i  阅读(3)  评论(0)    收藏  举报