底层实现
Go的GMP模型
- G(Goroutine): 即Go协程,每个go关键字都会创建一个协程。
- M(Machine):工作线程,在Go中称为Machine,数量对应真实的CPU数(真正干活的对象)。
- P(Processor): 处理器(Go中定义的一个摡念,非CPU),包含运行Go代码的必要资源,用来调度 G 和 M 之间的关联关系,其数量可通过 GOMAXPROCS() 来设置,默认为核心数。
M必须拥有P才可以执行G中的代码,P含有一个包含多个G的队列,P可以调度G交由M执行。
Go的垃圾回收机制
从根变量开始遍历所有引用的对象,引用的对象标记“被引用”,没有被标记的则进行回收
- 初始状态下所有对象都是白色的。
- 从根节点开始遍历所有对象,把遍历到的对象变成灰色对象
- 遍历灰色对象,将灰色对象引用的对象也变成灰色对象,然后将遍历过的灰色对象变成黑色对象。
- 循环步骤3,直到灰色对象全部变黑色。
- 通过写屏障(write-barrier)检测对象有变化,重复以上操作
- 收集所有白色对象(垃圾)。
写屏障(Write Barrier):通过写屏障技术尽可能地缩短STW的时间
golang的内存逃逸,什么情况下会发生内存逃逸?
逃逸分析是编译器用来确定由程序创建的值所处位置的过程。具体来说,编译器执行静态代码分析,以确定是否可以将值放在构造函数的栈(帧)上,或者该值是否必须逃逸到堆上。
golang程序中是在编译阶段确定逃逸的,而非运行时,因此我们可以使用
- 1.通过编译工具查看详细的逃逸分析过程(go build -gcflags '-m -l' main.go)
- 2.通过反编译命令查看go tool compile -S main.go
怎么避免内存逃逸?
- 底层分配到堆,还是栈。实际上对你来说是透明的,不需要过度关心
- 每个 Go 版本的逃逸分析都会有所不同(会改变,会优化)
- 直接通过go build -gcflags '-m -l' 就可以看到逃逸分析的过程和结果
- 到处都用指针传递并不一定是最好的,要用对
- map & slice 初始化时,预估容量,避免由扩展导致的内存分配。但是如果太大(10000)也会逃逸,因为栈的空间是有限的
Go的内存是怎么分配的
Golang安全读写共享变量
Go 中 Goroutine 可以通过 Channel 进行安全读写共享变量
mutex加锁
Go主协程如何等其余协程完再操作?
GC的触发条件都有哪些?
- 主动触发(手动触发),通过调用 runtime.GC 来触发GC,此调用阻塞式地等待当前GC运行完毕。
- 被动触发,分为两种方式:
-
- 使用系统监控,当超过两分钟没有产生任何GC时,强制触发 GC。
- 使用步调(Pacing)算法,其核心思想是控制内存增长的比例,每次内存分配时检查当前内存分配量是否已达到阈值(环境变量GOGC):默认100%,即当内存扩大一倍时启用GC。
并发问题
协程之间传递变得更加便捷,也可以把控一组协程的退出时机
- 从请求上下文中获取用户信息
- 从请求上下文中获取请求的唯一标识(traceId,常用于分布式日志追踪)
- 控制请求超时时间
WithCancel创建一个支持主动取消的上下文
WithDeadline带截止时间的上下文
WithTimeout带超时时间的上下文
WithValue带值传播的上下文
goroutine 泄漏如何处理
- Goroutine 内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会被一直阻塞。
- Goroutine 内的业务逻辑进入死循环,资源一直无法释放。
- Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待。
解决:1. runtime.NumGoroutine() 获取当前运行中的 goroutine 数量,进行前后对比业务服务的运行场景中,Goroutine 内导致的泄露,更多的是使用 PProf
2. 多个goroutine抢占用排他锁mutex加锁,解锁的时候 defer mutext.Unlock()
go 锁 (Mutex)
- 互斥锁 Mutex
当拿不到锁的时候,会阻塞等待,会睡眠,等待锁释放后被唤醒;
互斥锁在设计上主要有两种模式: 正常模式和饥饿模式。
正常模式下,所有阻塞在等待队列中的goroutine会按顺序进行锁获取,当唤醒一个等待队列中的goroutine时,此goroutine并不会直接获取到锁,而是会和新请求锁的goroutine竞争。 通常新请求锁的goroutine更容易获取锁,这是因为新请求锁的goroutine正在占用cpu片执行,大概率可以直接执行到获取到锁的逻辑。
饥饿模式下, 新请求锁的goroutine不会进行锁获取,而是加入到队列尾部阻塞等待获取锁。
饥饿模式的触发条件:
-
- 当一个goroutine等待锁的时间超过1ms时,互斥锁会切换到饥饿模式
饥饿模式的取消条件:
-
- 当获取到锁的这个goroutine是等待锁队列中的最后一个goroutine,互斥锁会切换到正常模式
- 当获取到锁的这个goroutine的等待时间在1ms之内,互斥锁会切换到正常模式
- RWMutext
RLock(): 申请读锁,每次执行此函数后,会对readerCount++,此时当有写操作执行Lock()时会判断readerCount>0,就会阻塞。
RUnLock(): 解除读锁,执行readerCount--,释放信号量唤醒等待写操作的goroutine。
写操作不会被饿死的原因: 写操作到来时,RWMutex.readerCount值拷贝到RWMutex.readerWait中,readerWait值变为0时唤醒写操作
对已经关闭的的 chan 进行读写,会怎么样?为什么?
- 写已经关闭的 chan 会 panic
- 读已经关闭的 chan 能一直读到东西,但是读到的内容根据通道内关闭前是否有元素而不同。
-
- 如果 chan 关闭前,buffer 内有元素还未读 , 会正确读到 chan 内的值,且返回的第二个 bool 值(是否读成功)为 true。
- 如果 chan 关闭前,buffer 内有元素已经被读完,chan 内无值,接下来所有接收的值都会非阻塞直接成功,返回 channel 元素的零值,但是第二个 bool 值一直为 false。
对未初始化的的 chan 进行读写,会怎么样?为什么?
读写未初始化的 都会阻塞
Go的defer原理是什么?
特点:后进先出、延时处理
Go 语言中延迟函数 defer 充当着 try...catch 的重任;多个 defer 的执行顺序为“后进先出/先进后出”;
defer、return、返回值三者的执行顺序应该是:return最先给返回值赋值;接着 defer 开始执行一些收尾工作;最后 RET 指令携带返回值退出函数。
Channel是同步的还是异步的?
Channel是异步进行的, channel存在3种状态:
- nil,未初始化的状态,只进行了声明,或者手动赋值为nil
- active,正常的channel,可读或者可写
- closed,已关闭,千万不要误认为关闭channel后,channel的值是nil
操作 | 一个零值nil通道 | 一个非零值但已关闭的通道 | 一个非零值且尚未关闭的通道 |
关闭 | 产生恐慌 | 产生恐慌 | 成功关闭 |
发送数据 | 永久阻塞 | 产生恐慌 | 阻塞或者成功发送 |
接收数据 | 永久阻塞 | 永不阻塞 | 阻塞或者成功接收 |
实践问题
new 和 make 的区别
- new、make 二者都是用来做初始化(内存分配)。引用类型的变量需要声明且初始化,不然不能赋值
- make (make(map[string]int, 10)) 只用于slice、map、channel的初始化,返回的还是这三个引用类型
- new (int) 用于类型的内存分配,内存对应的值为零值(false,0,nil),返回的是指向类型的指针
nil 切片和空切片的区别
切片、函数、指针变量的默认为nil
- nil切片和空切片指向的地址不一样。nil切片引用数组指针地址为0(无指向任何实际地址)
- 空切片的引用数组指针地址是有的,且固定为一个值
string和[]byte的转换
s := genString(10000) bs := []byte(s) 这种直接转换会重新分配内存
unsafe;unsafe.Pointer(&s) 复用原来的内存
type StringHeader struct { Data uintptr Len int } type SliceHeader struct { Data uintptr Len int Cap int }
两者类型基本一样,Slice多了一个Cap,其实这也决定了[]byte可以直接使用指针强转成string,但是反过来却不行
两个interface{} 能不能比较?
可以,在golang中可比较的类型有int,string,bool,pointer,channel,interface,array 不可比较的类型有slice,map,func
for循环select时,如果通道已经关闭会怎么样?如果select中的case只有一个,又会怎么样?
- for循环select时,如果其中一个case通道已经关闭,则每次都会执行到这个case。
- 如果select里边只有一个case,而这个case被关闭了,则会出现死循环。
一个为nil的切片可以追加数据吗?
可以对nil切片进行append操作。在切片容量未知的前提下,建议优先声明为nil切片,而不用担心容量问题。因为它的每次重分配容量都是倍增的
map扩容的时机,满足什么条件时扩容?
hmap和bmap;hmap是来表示map的结构体。bmap就是桶,hmap的buckets指向的就是bmap数组。
当元素数量变多时,会导致碰撞变多,那么bmap里的值就更多,查找效率就会变低,所以到一定程度时就需要扩容。
需要一个指标衡量,就是负载因子。元素数量除以桶的数量,默认为6.5)
-
- 正常元素过多造成的扩容:这种情况只需要最简单的扩容,即把B加1,使桶扩容两倍的大小。但Go并没有直接把桶的元素转移,而是采取了类似于redis的渐进式扩容,这也就解释了hmap里oldbucket的作用。
扩容后会先将olbucket指向数组,每次插入修改删除时都会调用growwork()方法,尝试搬迁,搬迁后 序号不变。
map 并不是一个线程安全的数据结构。同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。
map的iterator是否安全?能不能一边delete一边遍历?
map 并不是一个线程安全的数据结构。同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。
普通map如何不用锁解决协程安全问题?
go 语言中有一个其他的工具 sync.WaitGroup 计数器。WaitGroup对象内部有个计数器, 最初从0 开始, 他有3个方法 Add() , Done(), Wait()用来控制计数器的数量。 Add(n) 把计数器设置成n, Done() 每次把计数器-1, wait() 会阻塞代码的运行, 直到计数器的值减为0
Go 1.9 以后官方给出了sync.Map 同步map
Go值接收者和指针接收者的区别?
Go中两个Nil可能不相等吗?
可能不相等
slice怎么实现,如何扩容
- 正常情况就是双倍扩容
- cap是老数组的容量+新加元素数量,即至少扩容值
- 如果两倍扩容达不到这个cap,新数组的容量就为这个cap
- 如果两倍扩容达到了这个最小值,就根据老数组元素数量是否小于1024来决定扩容容量
- 如果小于1024,就正常扩容两倍。
- 如果大于等于1024,就循环扩容1.25倍,直到达到或者超过cap
go 打印时 %v %+v %#v 的区别
- %v 只输出所有的值;
- %+v 先输出字段名字,再输出该字段的值;
- %#v 先输出结构体名字值,再输出结构体(字段名字+字段的值);