个人学习笔记,资源来自网上各位大佬
一、协程
1、coroutine
- M:1,一个协程阻塞,从属的协程也会阻塞
2、goroutine![]()
- 有调度器,实现协程和线程的动态绑定和灵活调度
- 栈空间动态伸缩(默认2KB)
- M:N,实现真正的并行
二、GMP
1.G0调度器代码:
为线程m寻找空闲的p,绑定
在本地队列(lrp)取出来goroutine,执行代码
2.线程M阻塞发生了什么
- 当前g将当前p放回空闲p队列,取出当前g和他一同阻塞
- 线程m终于读取到文件后
- 找一个p进行绑定(分配内存的第一路径是通过p的本地内存缓存申请,没有p,m无法申请执行g的内存)
- 阻塞完了,先当前g寻找原先的p进行绑定,再去g0在空闲p队列寻找p
- 失败
- g0将当前g释放到全局队列,当前线程m休眠,等待被唤醒去绑定空闲p
3.GMP调度流程
- G=Goroutine(Go 协程,用户态线程)
- M=Machine(操作系统线程)
- P=Processor(逻辑处理器,代表 CPU 核心)
它解决了传统线程模型中“线程切换开销大、资源浪费”问题
- 启动:Go 启动时,创建
GOMAXPROCS个 P(默认 CPU 核心数)。- 创建 G:
go func()→ G 被加入当前 P 的本地队列。- 调度:M 绑定 P → 从本地队列取 G 执行。(M中的G0进行的逻辑调度)
- 阻塞处理:
- M 解绑P(P 会被新 M 绑定)。
- G 被挂起,等待阻塞操作完成。(只有系统调用m才阻塞,否则挂起)
- 恢复:阻塞完成 → G 重新加入 P 的LRQ。
- 工作窃取:P 的队列为空时,M 会从其他 P 的队列“偷”任务(避免空闲)。
顺序:本地队列->->全局P队列其他P->(从tail开始,偷一半)
4.P设计的好处
P的存在实现了无锁的本地调度。每个P维护独立的本地队列,M绑定P后可以直接从本地队列取G执行,大部分情况下都不需要全局锁。只有本地队列空了才去偷取,这大大减少了锁竞争。
5.介绍一下GMP模型
GMP模型是goroutine调度模型,让成千上万的goroutine高效地运行在少量os线程上
- G(Goroutine):用户态协程,初始栈2kb,可动态伸缩
- M(Machine):OS 线程(内核线程)
- P(Processor):逻辑处理器,连接 G 和 M 的核心
- go func(),创建一个g
- g 进入当前 p 的本地队列LRQ,满了就进入全局队列 GRQ
- m 绑定 p 在本地队列取 g 运行,如果无 g 就 work stealing 机制
- 从其他 p 本地队列尾部开始偷取一半,没有得偷就去全局队列 GRQ 偷
- 如果 g 阻塞,m 解绑 p(避免卡住cpu),然后挂起,等待g阻塞完毕
- p会被新 m 绑定执行 g
通过本地队列,work stealing,抢占调度,syscall解绑来让海量goroutine高效运行
一、G 阻塞的两种核心场景(及 M-P 解绑逻辑)
场景 1:G 进入系统调用(如文件 IO、sleep、网络阻塞早期)
- G 执行到
os.Open()/time.Sleep()等系统调用 → 触发 M 进入内核态阻塞;- 关键动作:M 立刻释放绑定的 P,P 会被 runtime 重新放入 “空闲 P 列表”;
- 空闲 M(或新建 M)会从空闲列表中抢占这个 P → P 继续调度本地队列的其他 G 执行;
- 当原 G 的系统调用结束 → 这个 G 会被放入可运行队列(本地 / 全局),而原 M 则:
- 若能抢到空闲 P → 继续执行这个 G;
- 若抢不到 → M 会被休眠(减少系统线程开销),等待后续唤醒。
场景 2:G 因网络 IO 阻塞(如
net.Dial()/http.Get())这种场景更高效:Go 的网络库会把 IO 事件交给NetPoller(网络轮询器)处理,而非让 M 阻塞:
- G 触发网络 IO → runtime 先把 G 挂到 NetPoller 的等待队列,直接让 M 释放 G;
- M 无需阻塞,继续用 P 调度下一个 G(M-P 不解绑);
- 当 IO 事件完成(如连接建立 / 数据到达)→ NetPoller 把 G 重新放回可运行队列,等待 M 调度。
二、为什么要这么设计?(核心价值)
你可以把 P 理解为 “CPU 核心的使用权”(GOMAXPROCS 本质是限制 P 的数量 = CPU 核心数):
- 如果 M 阻塞时不释放 P → 这个 P 就被 “锁死” 在阻塞的 M 上,对应 CPU 核心空转,其他 G 只能排队;
- 如果 M 阻塞时释放 P → P 能立刻被其他 M 复用,CPU 核心始终有 G 在执行,利用率拉满。
举个直观例子:假设你的机器是 4 核(GOMAXPROCS=4),有 4 个 P。如果一个 G 因 sleep (10s) 阻塞,若 M 不释放 P → 这 10s 内只有 3 个 P 在干活,1 个 CPU 核心闲置;而释放 P 后 → 4 个 P 全程都在调度 G,CPU 利用率保持 100%。
三、
1. go程序启动发生了什么
深入原理
- 命令行参数复制:读取命令行参数,复制到 argc 和 argv。
- 初始化 g0 栈:g0 是运行时系统的一个特殊的 goroutine,它在程序启动时被创建,用于执行系统调用和协程调度。
- runtime.check 运行时检查:
- 类型长度,指针操作,结构体字段偏移量.......
- runtime.args 参数初始化:将 argc 和 argv 的参数赋值到 Go 的变量中。
- runtime.osinit 初始化操作系统特点的设置:主要是判断系统字长和 CPU 核数。
- runtime.schedinit 初始化调度器:
- 锁初始化,内存分配器初始化,GC初始化,调度器初始......
- runtime.newproc 创建主协程 g0 并将其放入队列中等待执行。
- runtime. mstart 启动调度器:初始化 m0,并调度 g0 去执行
2. Slice切片原理
type slice struct { array unsafe.Pointer // 指向底层数组的指针 len int // 切片的有效长度(当前可访问的元素个数) cap int // 切片的容量(底层数组从指针位置开始的总元素个数) }
- 赋值、传参、截取,都是操作array本身
- 自动扩容(1.8以前)
- cap < 1024,翻倍
- cap >= 1024,新容量=原容量 X 1.25
- 深拷贝,向上取整 到 内存对齐的整数倍
- 自动扩容(1.8及以后)
- 需要容量 > 2倍: 直接使用需要的容量
- cap < 256,翻倍(2x)
- cap>=256,
newcap = oldcap + (oldcap + 3 * 256 ) / 4,直到容量够- 深拷贝,向上取整 到 内存对齐的整数倍
3. Map映射原理
type hmap struct { count int // map中实际存储的键值对数量 (len(map)返回的值) flags uint8 // 状态标识(是否正在扩容、是否并发写等) B uint8 // 哈希桶的数量 = 2^B 个,核心扩容相关字段 noverflow uint16 // 溢出桶的数量,越多代表哈希冲突越严重 hash0 uint32 // 哈希种子,随机值,保证哈希结果的均匀性 buckets unsafe.Pointer // 指向「哈希桶数组」的指针,核心存储区域 oldbuckets unsafe.Pointer // 扩容时用,指向「旧哈希桶数组」的指针 nevacuate uintptr // 扩容时的搬迁进度标记 extra *mapextra // 存储溢出桶的内存地址 } type bmap struct { tophash [8]uint8 // 存储每个键的哈希值高8位,用于快速查找匹配 // 后续是8个key、8个value,内存地址连续存储,编译期自动填充 // 最后是一个溢出桶指针,指向当前桶存满后,挂载的下一个bmap }
- Go 的
map是引用类型(和切片一样,不是值类型),赋值 / 传参时只拷贝底层结构体指针,修改会互相影响;- Go
map的底层实现是「拉链法」哈希表,核心解决哈希冲突的问题;- Go
map无序,遍历结果的元素顺序不固定,每次遍历都可能不一样;- Go
map非并发安全,多个 goroutine 同时读写会 panic;map的键key必须满足可比较性(支持==/!=),切片、map、函数不能作为 key。
- 插入和查找(hash后共64位)
- key:低B位
- value:高8位
- 自动扩容
- 装载因子 > 6.5,桶数量翻倍
- 溢出桶太多,等量扩容,桶的数量不变,只是将键值对重新排列,减少溢出桶的使用。
- bucket_cnt < 2^15,如果溢出桶数量超过桶数量,就会触发扩容;
- bucket_cnt >= 2^15时,如果溢出桶数量超过2^15,就会触发扩容
- 渐进式扩容,每次写 操作迁移 1-2 个桶
4. Channel原理
type hchan struct { qcount uint // 当前队列元素个数 dataqsiz uint // 缓冲区长度(由 make 的 N 决定) buf unsafe.Pointer // 指向环形缓冲区,元素大小 = sizeof(T) elemsize uint16 closed uint32 // 关闭标志,CAS 置 1 elemtype *_type // 元素类型信息,用于 GC 扫描 sendx uint // 发送索引 recvx uint // 接收索引 //无缓冲channel只用到下面字段 recvq waitq // 等待接收的 sudog 双链表 sendq waitq // 等待发送的 sudog 双链表 lock mutex // 一把全局锁,保护 hchan 内部字段 } type waitq struct { first, last *sudog }发送chansend
- lock
- 判断是否关闭,是则panic
- 判断recvq有无,有则直接发(完整拷贝一个过去),goready唤醒,unlock
- 判断是否有缓冲区qcount<dataqsiz,有则发送到缓冲区,unlock
- 无则初始化sudog放入sendq,gopark
接受chanrecv
- lock
- 判断是否关闭,是则return
- 判断sendq有无,有则直接接受(完整拷贝一个过来),goready唤醒,unlock
- 判断缓冲区是否已经有数据qcount>0,是则接受到缓冲区,unlock
- 无则初始化sudog放入recvq,gopark
为什么选用chanel来进行协会通信?
- 首先是重点,go推崇“不要通过共享内存来通信,而应通过通信来共享内存”。
天然避免了数据竞争,传统锁机制需要手动管理,容易死锁遗漏,channal通过数据所有权的转移,保证同一时刻只有一个goroutine来操作数据
简化流程,数据传递和同步合二为一,发送/接受数据本身包含阻塞等待,不需要额外的信号量或者condition,简单易读
PS
- ⽆缓冲的 channel 是同步的,⽽有缓冲的 channel 是⾮同步的
- 从⼀个 nil channel 接收数据,造成永远阻塞
- 给⼀个 nil channel 发送数据,造成永远阻塞
- 从⼀个已经关闭的 channel 接收数据,如果缓冲区中为空,则返回⼀个零值
- 给⼀个已经关闭的 channel 发送数据,引起 panic
- 关闭⼀个 nil channel 将会发⽣ panic
5、defer
defer 是 Go 语言中用于延迟执行函数调用的关键字。它的主要作用是:
确保资源释放:如文件关闭、锁释放
异常恢复:配合 recover 处理 panic
执行顺序:后进先出(LIFO)
参数求值:参数在 defer 声明时立即求值
返回值修改:可以修改命名返回值
实现原理上,defer 在编译时会被转换成
_defer结构体,形成一个链表。函数返回时按链表顺序执行 defer。Go 1.13+ 对 defer 进行了性能优化,支持栈分配和开放编码。"
//① 先计算返回值 → ② 再按 LIFO 执行 defer → ③ 最终才真正返回给调用方。 func f() (r int) { // 命名返回值 defer func() { r++ }() // ② 在RET之前执行,成功改值 return 1 // ① 先完成 r = 1 } // ③ 真正返回 26、for range
// 源代码 slice := []int{1, 2, 3} for i, v := range slice { fmt.Println(i, v) } // 编译器大致转换为 for i := 0; i < len(slice); i++ { v := slice[i] // 关键:值拷贝! fmt.Println(i, v) } //所以如果需要用到指针,要用i索引 slice := []int{1, 2, 3} for i, v := range slice { func(&slice[i]) }7、内存逃逸
内存逃逸:原本应该在栈上分配的内存被分配到了堆上
// 1、返回局部变量指针 → 生命周期>栈帧,必逃 func foo() *int { x := 42; return &x } // &x escapes to heap // 2、把指针扔进 channel → 编译期不知接收方,只能堆上 func bar() { ch := make(chan *int) v := 1 ch <- &v // &v escapes to heap } // 3、切片里存指针 → 切片本体可栈,但指向的元素必逃 func baz() []*string { s := "hi"; return []*string{&s} } // &s escapes to heap // 4、append 导致重新分配 → 运行时才能决定新数组地址,逃 func qux() []int { s := make([]int, 0, 2) // 初始栈上 s = append(s, 1, 2, 3) // 超出 cap,新数组 escapes to heap return s } // 5、interface 方法调用 → 动态派发,接收器与实参都逃 type animal interface { run() } type dog struct {} func (a dog) run() {} func main() { //编译时就知道类型,栈 var a animal = dog{} a.run() //不知道类型,堆 var a1 animal a1=dog{} a1.run() } // 6. 大块对象 > 64 KB func bigHeap() { b := make([]byte, 1<<20) // 1 MB b[0] = 1 fmt.Printf("big slice len = %d\n", len(b)) }简而言之
- 局部变量的生命周期超出了所在函数的生命周期
- 变量类型不确定
- 变量大小不确定或者太大
为什么要避免内存逃逸?
大量的逃逸会影响应用程序性能
8、CSP模型
核心思想:不要通过共享内存来通信,而应通过通信来共享内存。
- 避免竞态条件
- 简化同步,不需要显式上锁解锁,通道自动同步
| 特性 | CSP(通道通信) | 共享变量通信 |
| 通信方式 | 通过通道发送和接受消息 | 通过共享内存读写数据 |
| 数据共享 | 数据通过消息传递,不直接共享内存 | 数据存储在共享内存 |
| 同步机制 | 通道的发送和接受操作自动同步 | 需要显示使用锁,信号量等同步机制 |
| 编程复杂度 | 更简单,避免竞态条件 | 更复杂,手动管理 |
| 典型语言 | Go | C、C++、Java |
| 性能 | 通道通信存在一定开销 | 直接访问内存,性能较高 |
| 使用场景 | 高并发、任务间通信频繁的场景 | 需要精细控制内存访问的场景 |
9、GO GC垃圾回收
三色标记法
1、开始全部对象都在白色集合
2、然后把根节点移到灰色集合
3、循环遍历灰色集合,把其引用对象不断加入灰色集合,然后自己去黑色集合
4、直到灰色集合为空
完整的 GC 周期分为 4 个阶段,其中仅 2 个阶段有极短的 STW:
- STW - 初始标记(Initial Mark):暂停所有 goroutine,标记根对象(栈、全局变量),耗时极短(百微秒级);
- 并发标记(Concurrent Mark):恢复 goroutine,GC 后台线程并发遍历堆中对象,标记存活对象;
- 此阶段若程序修改对象引用(如指针赋值),写屏障会记录修改,确保标记不遗漏;
- 若程序内存分配速率过快,会触发 “辅助 GC”:让分配内存的 goroutine 协助做标记工作,避免 GC 滞后。
- STW - 最终标记(Final Mark):再次短暂暂停 goroutine,处理剩余未标记的少量对象(如写屏障记录的修改);
- 并发清理(Concurrent Sweep):恢复 goroutine,GC 后台线程并发清理未标记的死亡对象,将内存归还给堆(或操作系统)。
请你介绍一下go的gc垃圾回收?
混合屏障:只要堆上指针修改,同时标记新旧对象
Go的gc采用的是三色回收法,主要分4个阶段
- STW-初始标记,暂停goroutine,开启写屏障,标记根节点,加入灰色集合
- 并发标记,恢复goroutine,并发循环遍历灰色集合,把引用的对象加入灰色集合,自己进入黑色集合(有写屏障,会处理并发对象)
- STW-最终标记,暂停goroutine,处理剩下未标记的对象(写屏障的修改)
- 并发清理,恢复goroutine,并发清理未标记的对象
为什么混合屏障?
- 跳过栈扫描,采用栈全黑,提高性能
- 无需快照,减少内存开销
10、go的优势
1、极致的并发性能
go的并发模型csp:goroutine(初始栈2KB,一台机器就能百万级goroutine)+channel
2、语法简洁
只有25个关键字,快速上手
3、高性能
编译性语言,执行效率高
gc垃圾回收stw停顿时间短,微秒级
4、标准库非常丰富