一、并发
1 GMP模型——goroutine调度——scheduler调度器
1.1 GMP优点
GMP模型可以在用户空间实现任务的切换,上下文切换成本更小,可以达到使用较少的线程数量实现较大并发的能力
1.2 GMP含义
G是goroutine,是golang协程,是用户态轻量级线程。
M是machine,是内核级线程,是实质上实现业务逻辑的载体。
P是processor本地队列
1.3 M必须拥有P,才能执行G中的代码,P负责G的调度。
1.4 如何新增G
M1新增G会被保存在M1所绑定的本地队列P1中,P1队列超过256个满了以后,会把新增的G放进全局队列中
1.5 M如何从P中获取G
从本地队列P中获取G(无锁)
如果本地队列为空,从全局队列中获取G(加锁)
如果全局队列也为空,再去另一个本地队列P中偷一半的G
1.6 当M1在执行G1的时候被阻塞了,如何继续执行
当M1在执行G1的时候阻塞了,M1与绑定的本地队列P1解绑,接着M2绑定P1,然后执行P1的下一个协程G2。
1.7 G的生命周期
- 创建G:go func()可以创建协程G
- 保存G:创建的G优先保存到本地队列P,如果本地队列P满了,则会放到全局队列P中
- M获取G:M1首先从本地队列P1获取G,如果P1为空,则从全局队列中获取G,如果全局队列也为空,则从另一个本地队列偷一半的G
- M调度和执行G:M1调用G.func()函数执行协程G,如果M1在执行G的过程被阻塞了,则本地队列P1与M1解绑。其他M绑定P1,继续执行P1的其他G。
1.8 GM和GMP的区别
GMP能减少大量的全局队列锁竞争。
M 绑定本地队列 P 后,直接在 P 中获取、添加和执行 G,是无锁操作。
1.9 M和P的数量
- P的个数在程序启动时决定,默认情况下等同于CPU的核数。
- M的数量一般大于P的数量
- M创建的条件
没有足够的M来绑定P。
比如所有的M此时都阻塞了,但是 P 中还有很多就绪任务,就要去寻找空闲 M,没找到空闲的 M 就会去创建新的 M。
2 Golang协程切换时机
- 主动调用
- 协程执行完成
- 管道读写阻塞
- 垃圾回收之后
- time系列定时操作
- 会阻塞的系统调用,比如文件io,网络io
3 如何在Golang中对性能优化
- 使用sync包中的锁时,优先考虑用读写锁
- 使用 goroutine 和 channel 实现并发
- 使用原子操作
4 Golang中线程同步的方法
- channel:用于在goroutine之间传递数据和同步操作。
- WaitGroup:用于等待一组 goroutine 执行完成后再进行下一步操作。
- Mutex 和 RWMutex:用于保护共享资源,避免多个 goroutine 同时访问。
- Atomic:用于对共享资源进行原子操作。
5 为什么goroutine支持高并发
- 协程比线程更小,只有2k,内存里可以有更多的协程处理并发任务
- 协程的上下文切换不用从用户态切换到内核态,而且保存的寄存器更少,所以切换开销更小,效率更高
- go内置了协程调度器,通过gmp模型高效的调度goroutine的运行
二、内存
1 GC垃圾回收:三色标记法和混合写屏障
1.1 三色标记法
- 在最开始,所有对象的颜色设置成白色
- 从根节点开始遍历所有对象,将遍历到的对象放进灰色集合
- 遍历灰色集合,将灰色对象引用的对象变为灰色,遍历之后将本对象标记为黑色
- 重复第三步,直到灰色中无任何对象
- 回收所有白色对象。
1.2 混合写屏障
- GC开始将栈上的对象全部扫描并标记为黑色,之后不再进行第二次重复扫描
- GC期间,任何在栈上创建的新对象均为黑色
- 被删除的对象标记为灰色
- 被添加的对象标记为灰色
1.8版本后为了不造成stop the world,提高回收精度混合写屏障满足弱三色不变式,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,不需要STW。因为栈在扫描后始终是黑色的,也不需要进行re-scan操作。
强三色不变式
不存在黑色对象引用到白色对象的指针。
弱三色不变式
黑色对象可以引用白色对象,但前提是白色对象存在其他灰色对象对它的引用,或链路上游存在灰色对象。
1.3 GC触发时机
- 主动触发
调用runtime.GC,阻塞式地等待当前 GC 运行完毕 - 被动触发
- 距上一次GC的最长时间,默认两分钟
- 分配的堆大小达到阈值
2 golang是如何做内存管理的
- Golang的内存分配主要基于TC Malloc机制,会缓存一些可分配内存,避免多个线程同时分配和释放锁。
- 垃圾回收机制是:并发标记清除算法,可以在不阻塞线程的的情况下进行垃圾回收
- 通过逃逸分析,编译器在编译时,分析变量是分配在栈上还是堆上,尽量避免不必要的堆分配,优化性能。
- 较大或复杂对象分配在堆上,由垃圾回收器管理生命周期
- 局部变量和短生命周期对象分配在栈上,分配和回收效率高。
- 用了内存池技术:提供sync.Pool来复用对象,减少频繁分配和垃圾回收的开销。
- 支持超过1MB的大对象
三、数据结构和关键字
1 mutex
mutex很像操作系统中的PV操作,通过信号量来处理线程中同步与互斥的问题。
S代表剩余资源数。
P代表申请资源,S原子减一,如果减一后S小于0则将自己阻塞起来。
V表示释放资源,S原子++,如果S++后S<=0,表示等待队列上有等待线程,需要将第一个等待的线程唤醒。
- mutex是一个结构体,提供lock()和unlock()方法。
- state代表互斥锁的状态,例如是否被锁定。内部实现分成四部分
- sema表示信号量,等待信号量的协程会阻塞,解锁的协程释放信号量从而唤醒等待信号量的协程
2 channel
- channel是一个用于通信的管道,遵循先进先出。
- 需要用make来初始化channel,可以选择是否有缓冲,无缓冲是同步的。
channel的底层实现
- 缓冲区是个循环队列,保存队列当前的大小,和队列最大大小
- 发送者队列和接收者队列,用于存储等待写入或读取数据的 goroutine 的信息;
- 互斥锁
如果往一个关闭的channel读、写会怎么样
- 如果写的话,会直接panic。所以永远不要在读端关闭channel,多个写端可以通过context来解决。
- 如果里边有数据,会拿到数据
- 如果里边没有数据,会读到零值,可以通过判断ok为false来解决。
使用channel的注意事项
- channel关闭后的读写问题
- 无缓冲的channel是同步的,避免阻塞
- 有缓冲的channel的容量如果满了,发送方会阻塞
3 对map的理解,map有哪些注意事项
- map是引用类型,将map赋值、传递的时候,用的是引用,而不是副本(内容),多个变量指向的是同一个底层数据结构,对其中一个变量的修改会影响其他变量。
- map不是线程安全的,可以用互斥锁或者sync.Map
- map的底层实现使用哈希表来存储元素,迭代顺序不一定
- 当bucket中平均存储的键值对数量超过6.5(默认是8),或者溢出桶过多。会触发扩容,容量会翻倍。创建新哈希表进行计算、拷贝等,释放旧哈希表内存。
- 需要使用ok来判断
向map中存储一个kv
通过 k 的 hash 值与 buckets 长度取余,hash值的低八位定位到key在哪一个bucket(桶)中,
hash 值的高8位存储在 bucket 的 tophash[i] 中,用来快速判断key是否存在。
当一个 bucket 满时,通过 overflow 指针链接到下一个 bucket。
sync.Map比加锁的方案好在哪里,底层数据结构是什么样的
- sync.Map比互斥锁快的原理是用空间换时间
- 底层有两个map,read map并发读安全,读操作优先read map,所有写操作直接在dirty map中,read map和dirty map在需要时会进行数据同步。
- 通过读写分离,降低锁时间来提高效率,适用于读多写少的场景
4 对slice的理解
slice和数组的区别
- 数组(array)的长度是固定的,切片(slice)是可变的
- 数组是值类型,切片是引用类型。拷贝数组会复制整个数组的内容,复制切片只能复制指针
- 对slice切片会指向原内存空间,可能出现并发或者内存泄漏问题。通过申请新的内存空间做拷贝来解决
slice扩容规则是什么
- 如果当前切片长度小于1024,新容量为原来的2倍
- 当前切片长度大于等于1024,新容量为原来的1.25倍
- 如果一次扩容后仍无法容纳新增元素,会继续扩容(加法和对齐)
- 扩容时会将原来的元素复制到新的内存空间,旧的被释放。
5 context
1 context的作用
- 在gorountine之间传递上下文信息。一般用来:超时控制,并发控制。
- context的树形结构可以在不同层级的goroutine之间有效的传递信号。
2 context树的结构
- 树的根是一个空的context(context.Background()或者context.TODO())
- 需要某个节点是子操作,只需要在声明ctx的时候把父ctx传进去
- 每个节点代表一个新创建的context,可能包含值、取消信号或截止时间
- 树的边代表从父context到子context的继承关系
3 取消操作
- 当需要取消一个操作和所有子操作的时候,通过调用树中某个节点的cancel()函数实现。
- 触发沿树向下传递的取消信号,使所有子节点都能收到取消信号。
4 context树的构建
- context.WithValue,包含键值对,在整个请求范围内传递数据
- context.WithCancle、context.WithDeadline、context.WithTimeout,附加取消信号或截止时间,信号用于在请求被取消或超时时通知子goroutine
6 new和make的区别
- new用于基本数据类型和结构体类型的内存分配,置为初始值
- make适用于引用类型的内存分配,如切片、map和channel等
其他
1 如何在Golang中对性能优化
- 使用 Golang 的内置工具进行性能分析
- 减少内存分配。尽可能地重用变量和对象。
- 减少垃圾收集,减少内存分配也有助于减少垃圾收集。
- 使用并发处理。
- 避免使用过多的锁,可以使用读写锁来减少锁的使用
- 使用适当的数据结构
- 使用编译器优化。
2 golang中常见的引发panic的情况
- 往被关闭的channel写数据
- 关闭已经被关闭的channel
- 类型断言失败,可以通过判断是否断言成功来避免
- 数组下标越界
- 空指针调用
- 除数是0
3 go defer
- go defer用于注册延迟调用,直到return前才会被执行,
- 主要用来做资源清理、等候全部子协程退出的并发同步控制、释放锁
- 如果有多个defer,按照先进后出的方式执行
4 golang引包为什么用_
如果引入一个包,但是不使用包内部的变量或函数的时候,go编译器会报错,下划线可以避免这种报错。
一般用于实现了init函数的包,或者隐式的调用包的init函数
5 init函数的作用和特点
- init函数用于在启动时做一些初始化操作,没有参数和返回值,可以用来初始化所需的变量、配置或资源,确保程序能够正常运行。
- 特点:
- 自动调用:程序启动时候会自动调用所有导入包的init函数,不需要显式调用
- 顺序执行:按照导入包的顺序执行init
- 可有可无:如果某个包没有init,不会做任何初始化操作
- 不能被显式调用:只会在程序启动时自动执行
- 不能有参数和返回值:只执行一些初始化操作