简介
陆续总结一些面试常常会问到的问题,对知识体系做一个梳理 源码地址https://gitee.com/secondtonone1/go-interview-questions
面试题
1 简述go协程调度原理
go协程调度模型图 https://llfc.club/articlepage?id=21XXEPY1IoqquP7phaftDjf9Rt4 简述原理: go的协程是通过MPG模型调用的,M为内核态线程,G为用户态协程,P为处理器,系统会通过调度器从全局队列找到G分配给空闲的M,P会选择一个M来运行,M和G的数量不等,P会有一个本地队列表示M未处理的G,M本身有一个正在处理的G,M每次处理完一个G就从本地队列里取一个G,并且更新P的schedtick字段,如果本地队列没有G,则从全局队列一次性取走G/P个数的G,如果全局队列里也没有,就从其他的P的本地队列取走一半。go1.2之前goroutine是轮询式调度,之后改为抢占式,弱抢占式。 弱抢占式: 如果某个P的schedtick一直没有递增,说明这个P一直在执行一个G任务,如果超过一定时间就会为G增加标记,并且该G执行非内联函数时中断自己并把自己加到队尾。
2 struct能否比较
结构体比较时 1 如果两个结构体内部成员类型不同,则不能比较 2 如果两个结构体内部成员类型相同,但是顺序不同,则不能比较 3 如果两个结构体内不含有无法比较类型,则无法比较 4 如果两个结构体类型,顺序相同,且不含有无法比较类型(slice, map, )
3 defer关键字
一个函数定义了多个defer函数,defer的调用顺序和栈一样,先进后出,最先调用的是最后写的defer。函数将返回值入栈,然后执行析构,在析构之前要执行defer的操作。defer使用的注意事项
3.1 defer常用来释放变量
我们实现一个文件copy函数,将src路径下的文件copy到dst路径下
func CopyFile(dst, src string) (written int64, err error){
srcF, err := os.Open(src)
if err != nil{
return
}
defer srcF.Close()
dstF, err := os.Create(dst)
if err != nil{
return
}
defer dstF.Close()
return io.Copy(dstF, srcF)
}
注意,如果Open失败或者Create失败,千万不要调用src.Close,因为src为nil。
3.2 defer被声明时,其参数是实时解析和捕获
func DeferParam() {
i := 0
defer func(m int) {
log.Println(m)
}(i)
i++
}
程序输出0,因为defer声明时捕获i的值为0,传入函数后输出也是0,不管以后i变成什么值。 如果defer是无参函数,内部引用了外部变量,就不同了,会记录i的引用
func DeferNoParam() {
i := 0
defer func() {
log.Println(i)
}()
i++
}
最后i变为什么值,defer就输出什么值。此时输出值为1
3.3 defer调用顺序为栈式调用
func DeferOrder() {
for i := 0; i < 5; i++ {
defer func(m int) {
log.Println(m)
}(i)
}
}
输出结果为4,3,2,1,0
3.4 defer可以捕获函数返回值
因为defer可以捕获函数内变量,所以可以捕获函数的返回值
func DeferReturn() (res int) {
defer func() {
res++
log.Println(res)
}()
return 0
}
defer输出为1,因为defer捕获到返回值为0,+1就输出1
4 select有什么作用
select是用来控制并发访问的技术,当有多个逻辑要监控时,可以写入select的不同的case,同时也能捕获外界通知,也可以控制协程退出和并发等。select的case表达式必须为一个channel类型,所有case都会被求值,自上而下,从左而又。 如果有多个case满足条件,则随机选择一个执行,如果所有case都不满足条件,且实现了defautl逻辑,则走入default逻辑。如果没有实现default逻辑,则阻塞等待,知道某个case条件满足为止。 break可以跳出select执行,所以要注意配合for循环的select,如果想要跳出循环,请使用return或者break+标签的方式跳转到某个位置。 我们实现一个逻辑,不断的从channel中读取数据 1 要求监听外界退出的通知,收到通知后退出并返回 2 要求从channel中读取数据,超过5s未读出则打印超时,然后继续等待读取,这期间有退出信号要及时退出 3 保证channel中有数据则优先读取
func NoneBlockRead(ctx context.Context, chread chan interface{}, wt *sync.WaitGroup) {
for {
select {
case <-ctx.Done():
wt.Done()
log.Println("receive main exit signal")
return
case data := <-chread:
log.Println("receive ", data)
default:
select {
case <-time.After(time.Second * 5):
log.Println("time out")
case data := <-chread:
log.Println("receive ", data)
case <-ctx.Done():
log.Println("receive main exit signal")
wt.Done()
return
}
}
}
}
NoneBlockRead优先捕获主线程的ctx的退出信号和chread读取数据,如果上述条件都不满足,则进入default逻辑启动定时器检测超时,并监控退出信号和chread的数据读取。 我们实现主函数
func main(){
var wg = &sync.WaitGroup{}
chread := make(chan interface{})
wg.Add(1)
ctx, _ := context.WithTimeout(context.TODO(), time.Second*10)
go NoneBlockRead(ctx, chread, wg)
select {
case <-time.After(time.Second * 7):
chread <- 1
}
wg.Wait()
}
主函数中设置定时器7秒后才像chread写入数据,所以NoneBlockRead会先打印超时,然后读出数据,接着等待3秒后自动退出,因为ctx十秒会触发cancel逻辑。 程序输出
2021/12/06 11:28:03 time out
2021/12/06 11:28:05 receive 1
2021/12/06 11:28:08 receive main exit signal
5 context的作用
context在多个goroutine中运行是安全的,用于信息传递,协程管理等。我们再实现一个从channel读取数据的函数
func NoneBlockRead2(ctx context.Context, chread chan interface{}, wt *sync.WaitGroup) {
defer func() {
wt.Done()
}()
select {
case <-ctx.Done():
log.Println("receive main exit signal")
return
case data := <-chread:
log.Println("receive data is ", data)
return
}
}
这个函数就是通过传递一个ctx达到控制协程的目的
func main(){
var wg = &sync.WaitGroup{}
chread := make(chan interface{})
wg.Add(1)
ctx2, _ := context.WithTimeout(context.TODO(), time.Second*3)
go NoneBlockRead2(ctx2, chread, wg)
wg.Wait()
}
6 主协程如何等待其余协程退出
有很多种方式,为每个协程传递一个channel,当每个协程退出时向该channel写入数据或者关闭该channel,主协程读取channel捕获退出信号。或者采用waitgroup也可以。
func main(){
var wg = &sync.WaitGroup{}
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
wg.Done()
}()
}
wg.Wait()
}
或者
func main(){
waitch := make(chan int, 5)
for i := 0; i < 5; i++ {
go func() {
waitch <- 1
}()
}
for i := 0; i < 5; i++ {
<-waitch
}
}
7 slice的扩容原理
slice底层的结构为一个结构体,包含data数据域和实际的长度len,以及容量cap,每次append都要判断当前长度是否达到len,如果达到就将cap扩充为len的2倍,然后移动数据到新的内存空间,所以append会返回新的slice,内部包含了数组的地址,使用slice要用make初始化
a := make([]int, 1,2)
开辟一个slice,长度len为1,cap为2
8 go的map是有序的吗?
go的map底层是hash table,也就是hmap类型,对key值进行hash运算,将低八位用来确定value存在那个bucket中,高八位与bucket的tophash进行比较,确定key是否存在。出现hash碰撞后,会将bucket的overflow指向一个新的bucket,形成一个单向链表 map排序可以通过取出key值构造slice再排序
data_m := make(map[int]int)
data_m[1] = 111
data_m[2] = 222
data_m[3] = 333
keys := make([]int, 0, len(data_m))
for k := range data_m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
return keys[i] < keys[j]
})
for _, val := range keys {
log.Println(data_m[val])
}
9 实现set
利用map,建立value为ture,key为对应键值即可。要实现安全的set,可以用sync.map
data_set := make(map[int]bool)
data_set[1] = true
data_set[2] = true
if val, ok := data_set[3]; !ok {
log.Println("key not exist")
return
} else {
log.Println("val is ", val)
}
如何实现生产者消费者
用sync.map存储topic,主线程用waitGroup等待退出,生产者和消费者之间分别用两个chan通信,详见 https://llfc.club/category?catid=20RbopkFO8nsJafpgCwwxXoCWAs#!aid/21Xe63IHvSfz8AEOulnUP4wrHZs
10 GC原理
go采用三色标记法回收内存,程序开始创建的对象全部为白色,gc扫描后将可到达的对象标记为灰色,再从灰色对象中找到其引用的其他对象,将其标记为灰色,将自身标记为黑色,重复上述步骤,直到找不到灰色对象为止。最后对所有白色对象清除。gc采用标记清除(mark and sweep)算法的STW(STOP THE WORLD)的操作,标记阶段runtime把所有线程全部冻结掉,所有线程全部冻结意味着用户逻辑也是暂停的,这样所有对象都不会被修改,在清除阶段用户逻辑和清除操作时可并行的,因为白色对象意味着用户不再使用。go引入写屏障机制,在写操作之间和之后内存的修改被系统感知,然后重新标记,这时也会有短暂的stw,所以新生成的对象一律都是灰色的。如果一个黑色对象引用了曾经标记的白色对象,则写屏蔽机制被触发,向gc发送信号,gc重新扫描,将其着色为灰色。
11 go的堆栈原理
栈: 由操作系统自动分配和释放,存放在函数的参数值,局部变量的值等。 堆: 程序员自己分配并释放,如果不释放,可能由os回收,也可能不会(C++需要手动释放),go对于堆空间的回收有垃圾回收算法,不一定是成为孤儿对象就立即回收,所以堆的回收较栈缓慢,栈则是从存储空间开辟,调用完立即释放。 go 的内存分配遵循内存分配逃逸原则,所谓逃逸分析(escape analysis)是指由编译器决定内存分配的位置,不需要程序员指定。在函数中申请一个对象,如果分配在栈中,则函数结束自动回收,如果分配在堆中,则gc回收,以下场景会造成内存逃逸 1 申请并开辟指针对象,指针对象在堆空间 2 闭包,内部函数引用了外部函数的变量,则变量变为堆开辟 3 栈空间不足,在堆上开辟 逃逸分析减低gc压力,go开发不见得使用指针就是更高效,指针为四字节作为数据传递可以减少开销,但是回收要由gc管理,所以在传递频繁的场景尽量使用指针。