失业有几个月了,刷了一些面经自认为准备的还算充分了,所以开始四处投简历,前几天被蜻蜓FM捞起简历,心里有点开心。记一下面试前后的经历。
第一步是自我介绍。这里略过。直接进入正文
1.有用过slice吗?slice的扩容机制是什么?
答:有的。背了一段面经原文
1.如果当前传入的cap比原有切片的cap的2倍还要大,那么按照当前传入的cap来作为新切片的容量。
2.否则检验原有切片的容量是否小于1024
2.1如果小于1024,按照原有切片容量的2倍扩容;
2.2如果大于1024,按照原有切片容量的1.25倍扩容。 最后在进行字节对齐
1.1追问。我从未在扩容时主动传入cap,你刚刚讲的cap是怎么传入的?什么时候用2倍?什么时候用1.25倍?为什么不一直用2倍呢?
我当时的回答:
①有点慌了,开始直支支吾吾,因为没准备过。开始扯底层的逻辑。
②我刚刚提到接收的cap容量是原有容量的2倍以上时,或者原有切片容量小于1024。
③原有的切片容量大于1024时按照1.25倍。
④任何一个大于1的基数都不能一直按照幂次递增,一直重复,会递增到一个天文数字。
正确回答:
刚刚第一问的描述有欠缺,我重新回答。 “当原有切片的元素小于1024个时,按照2倍容量扩容,否则按照1.25倍扩容。”
以上回复在go1.17以及之前的版本成立,而到了go 1.18之后,slice扩容又有新的方式。
首先回复期望的cap是怎么来的,假设初始化时slice的cap为3,那么当我append第四个元素时,期望的cap值就是4。空slice的cap为0,append几个元素,cap就加几,如果我要一次性append2个元素,那么cap=2。
1.slice空间递增的函数为growslice,它主要接收两个参数old和cap,它们的值都是int类型。
2.如果cap>2×old,新切片的空间为cap。
3.否则检验旧切片的cap是否小于256,如果小于256,按照old的2倍容量扩容。
4.如果old大于256,
4.1 进入一个for循环,条件为old>0 && old < cap,并在循环中递增新切片容量newcap+=(old+3×256)/4。
4.2 如果计算出来的新切片容量newcap<=0,就以cap作为切片的容量。
4.2 最后进行字节对齐。
为防止意外,贴上代码
// 只关心扩容规则的简化版growslice
func growslice(old, cap int) int {
newcap := old
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
const threshold = 256 // 不同点1
if old < threshold {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
newcap += (newcap + 3*threshold) / 4 // 不同点2
}
if newcap <= 0 {
newcap = cap
}
}
}
return newcap
}
2.map线程安全吗?为什么?
答:
map非线程安全,对map的的频繁读写会修改bucket中的k-v键值对的地址,有可能导致桶迁移。如果一定使用线程安全的map,有两个建议,任意一个都可以实现效果。
1.对map进行读写时加锁,读和写的时候都加。
2.新版本的go推出了线程安全的sync.Map。
以上回答自认为是正确的,但可以扩展一下,比如说说什么情况会导致桶迁移,map的相同容量扩容和2倍容量扩容的差别,以及map+锁和sync.Map效率上的差别。有兴趣的同学可以了解下,这里不再赘述,只是总结。
3.既然你提到sync.Map,那聊聊它的实现原理。
答:
最关键的原理是以空间换时间,最主要有4个关键的组成部分,
1.仅读map
2.仅写map
3.击穿次数
4.锁
读写时,操作的是不同的map。如果读时在读map中取不到值,那么会去写map中获取值,此时记为一次击穿,击穿次数累计到一定值触发一次同步操作。
4.有没有用过sync.WaitGroup,在什么场景下用的,能否举例.
答:
有的,在并发使用使用协程时。开启多协程时,首先在父协程中定义一个var wg sync.WaitGroup的变量,然后每起一个协程都调用一次wg.Add(1),最后在子协程的的第一句定义一个defer wg.Done()。不论协程是否出错都会主动关闭父协程。
5.有没有用过context,它是用来做什么的?
答:
有用过,有两个主要功能,
1.用来传递元数据
2.控制协程的生命周期。
刚刚提到的并发协程控制同样可以用context来控制,可以在主协程或自定义方法中定义一个
ctx, cancel := context.WithCancel(context.Background())
并且在子协程中监听<-ctx.Done(),之后无论主协程或者子协程都可以在适当的时机调用cancel()主动关闭协程。
而且context还支持设置deadline,可以设置生命周期,即WithTimeout。
6.有用过sync.Cond吗?在什么场景下使用它。
当时回答:
没有(因为确实没有,这个函数相当少用,它的功能基本可以用channel替代,面试中问到,一般都是考察你有没有看过相关资料)
正确回答:
sync.Cond是用来做并发协程控制的一种方法,与channel相比,它有一个优势就是可以多次通知,并且可以重复使用。
至于如何使用,分三步走
1.用cond := sync.NewCond(&mu)定义一个cond。
2.定义一个全局锁var mu = sync.Mutex{},在开启的协程中cond.Wait()必须要与锁配合使用
3.单个协程用cond.Signal()通知解除阻塞,多个协程用cond.Broadcast()通知。以上两个方法允许重复调用。
详情参看文章
juejin.cn/post/722875…
7.除了以上方法还有没有可以控制子协程的生命周期?
答:
有的,但是在实际生产中很少使用
比如:开启协程前设置一个channel,每个子协程都监听它,一旦子协程从这个channel中接收到值,那么立即结束自己。这种方式有侵入性,一般不推荐使用
8.没有用过channel,channel是用来做什么的?
答:
channle是通道,主要功能是用来实现进程间通信,go语言的特性是用通信的方式实现共享内存。
9.有缓冲的channel和无缓冲的channel有什么差别?
当时回答:
最大的区别就在于缓冲区。无缓冲区的属于同步通信,有缓冲区的是异步通信。
9.1追问如果我在一个协程中向无缓冲的channel发送一个数据,有另一个协程是读取这个数据的,这时会发生什么?
答:
一旦发送,会被立即读取到。
追问2:那使用有缓冲的通信呢?
答:
此时我意识到第一个问题答错了,但是米已成粥,改口说第一个问题回答错误,应该是协程二先读取,解除了阻塞,协程一才能写入。使用有缓冲的通信时,当缓冲区有值时就会被读取到
正确回答:
最关键的一点就是缓冲区,无缓冲的channel必须即时读写也即是同一时间只允许至多一个数据存在于通道中,也即是说如果有程序向channel中传入一个值,那么必须有另一个程序来接收它,这是一种同步通信。而有缓冲的通信,因为缓冲区的存在,在缓冲区没满之前,channel不会阻塞。
用一个直白点的描述辅助理解,
无缓冲的channel:送信员送信到你家,你不在家,他就一直等,等你回家接了信,他才走。 有缓冲的channel:送信员送信到你家,只要你家的邮箱没满,他丢下信就走。如果满了,他就等你取出一封信他才走。
追问1的正确回答:
先读取,再写入。读取时解除了阻塞,写入的goroutine才能执行。 附上代码
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan struct{})
fmt.Println(time.Now().Unix())
go func() {
ch <- struct{}{}
fmt.Println(time.Now().Unix(), "写入成功")
}()
//time.Sleep(2 * time.Second)
go func() {
<-ch
fmt.Println(time.Now().Unix(), "读取成功")
}()
time.Sleep(1 * time.Second)
}
执行结果如下:
追问2的正确回答是:
先写再读。
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan struct{}, 1)
fmt.Println(time.Now().Unix())
go func() {
<-ch
fmt.Println(time.Now().Unix(), "读取成功")
}()
//time.Sleep(2 * time.Second)
go func() {
ch <- struct{}{}
fmt.Println(time.Now().Unix(), "写入成功")
}()
time.Sleep(1 * time.Second)
}
打印结果:
10.我出两道题,你告诉我结果?
10.1 在main函数中定义一个无缓冲的channel,并开启两个协程,第一个用来向channel中输入数据,第二个用来向channel中输出数据。此时会发生什么?
当时回答:
goroutine的执行是不以代码中的先后顺序为参考的,每个goroutine获取内核的时间点并不固定,所以有两种情况,
第一种情况,先调用输入goroutine,后调用输出goroutine。
此时打印出通道中输入的结果第二种情况,先调用输出goroutine,后调用输入goroutine
打印不出任何东西
正确回答:
如果没有在main函数和输出的goroutine中定义休眠时间,打印不出任何东西,直接结束。
如果定义了休眠时间那么可以打印出channel中的值。
正确的代码如下:
package main
import (
"fmt"
)
func main() {
ch1 := make(chan int)
go suck(ch1)
go pump(ch1)
//time.Sleep(2e9)
}
func pump(ch chan int) {
//fmt.Println("我来输入了")
ch <- 1
}
func suck(ch chan int) {
//time.Sleep(1e9)
//fmt.Println("我来输出了")
fmt.Println(<-ch)
}
不加休眠,输出结果如下:
加休眠,输出结果如下:
10.2 还是在main函数中定义一个无缓冲的channel,并开启一个协程,main函数用来向channel中输入数据,协程用于输出数据并打印,这时候会发生什么?
我反问,协程中对于channel有用for循环和select配合做监听吗?
当时回答:没有问题,正常打印。
正确回答:不论是否监听,是否加休眠都会死锁
附上代码和打印
package main
func main() {
ch := make(chan int)
ch <- 1
go suck(ch)
}
func suck(ch chan int) {
for {
select {
case kk := <-ch:
println(kk)
}
}
}
打印:
11.有没有用过锁,go中有哪几种锁?读写锁和互斥锁的区别是什么?
答:
有,用过互斥锁,主要的锁是互斥锁和读写锁,即sync.Mutex和sync.RWMutex,读写锁可以在读操作上重复加锁,但是读写和写写互斥。至于区别,这个没比较过。
正确答案:
其实刚刚那个回答已经很接近了,只差临门一脚,两者的区别在于以下两点
1.sync.Mutex中每一对锁都必须成对存在,不能对加锁的数据重复加锁,一旦重复加锁会报panic。
2.互斥锁能够保证同一时刻只有一个goroutine访问共享资源,读写锁中的只有读写和写写互斥,读读不互斥,也就是说读读可以重复加锁,但是要等所有锁都解开了,才能进行写操作。
12.GMP模型是什么?各自有什么含义
答:这个就直接背面经,
G:代表协程,协程是用户级的线程,数量不受限制,但受内存影响。
M:代表线程,系统级线程,数量不能超过1W。
P:代表虚拟内核,数量在GOMAXPROC中有定义,一般为内核数。
13.聊聊go的生命周期?go协程的从创建到销毁。
当时回答:
由用户创建,在整个生命周期内受用户控制,创建后会被分配给等待执行的队列中,按照一定的算法分配给P,并且在M上执行,如果执行完毕,不会立即销毁,会放在一边,如果由新的goroutine要创建,就会重新调用这个goroutine的内存空间。
正确回答:
goroutine的完整的生命周期涉及到三块;
1.管理员g0
2.gopark(goroutine的休眠)
3.goready(goroutine的唤醒)
这里主要说说goreday,1和2在第14和15问回答。
goready函数的作用就是唤醒waiting状态的goroutine。它通过systemstack切到g0栈,在g0栈上发起调度
1.获取goroutine的状态
2.将waiting状态的goroutine切换到runable状态
3.尝试唤起一个p来执行当前goroutine。
14.G0这个协程是用来干什么的?
当时回答:
G0没听过。(一脸懵逼)
正确回答:
g0 的主要作用是提供一个比一般 goroutine 要大的多的栈(64K)供 runtime 代码执行。
g0 作为一个特殊的 goroutine,为 scheduler 执行调度循环提供了场地(栈)。对于一个线程来说,g0 总是它第一个创建的 goroutine。之后,它会不断地寻找其他普通的 goroutine 来执行,直到进程退出。
g0 其他的一些“职责”有:
- 1.创建 goroutine
- 2.deferproc 函数里新建 _defer
- 3.垃圾回收相关的工作
- 3.1 stw
- 3.2扫描 goroutine 的执行栈
- 3.3 一些标识清扫的工作
- 3.4 栈增长
- 3.5 其他
15.聊聊休眠,Go协程什么时候会进入休眠状态?
当时回答:
当时愣了一下,反问说是休眠还是阻塞,因为我第一时间的反应是不是将goroutine变成waiting状态。我知道goroutine有9种状态,其中就有runable和waiting。他说休眠我没反应过来,唉,还是基础不牢吧。
正确回答:
说到休眠不得不提goroutine的切换。goroutine的切换涉及到一个很重要的函数gopark。
gopark的作用有三个:
- 将running状态的goroutine设置为waiting。
- 解除goroutine和当前工作线程M的关系。
- 获取一个新goroutine来运行。
gopark函数的关键就是mcall函数调用的park_m。
park_m的作用:
- gopark通过mcall将当前线程的堆栈切换到g0的堆栈
- 保存当前goroutine的上下文(pc、sp寄存器->g.sched)
- 在g0栈上,调用park_m
- 将当前的g从running状态设置成waiting状态
- 通过
dropg来解除m和g的关系- 最后通过schedule来发起新一轮的调度
schedule()->execute()->gogo(),gogo尝试从gobuf中恢复出协程执行状态并跳转到上一次指令处继续执行。
最后三个问题其实是相互关联的,详见这2位博主的文章
juejin.cn/post/691837…
baijiahao.baidu.com/s?id=170439…
综上,暴露出3个问题,
1.对slice的掌握停留在八股文阶段,并且没有获取到最新资讯,
2.对channel的应用熟练度不够
3.goroutine的生命周期没具体了解过。
回答完后,面试官很直接,说你不适合我们公司。你有什么想问的吗?
1.G0是用来干什么的?
答:
这个是一个系统级的goroutine,主要工作是goroutine的调度、垃圾回收等
2.什么情况下会进入休眠?
答:
这个问题和go语言无关,任何进程都会涉及休眠问题。具体你可以查一下相关文章。
3.你们公司想招个什么样的人,承担哪些职责?
答:
就是搞业务的,T3到T4级别。
4.似乎只问了Go相关的问题,没问过其他问题。
答:
对的,以你的工作经历,我相信Mysql和Redis那些常规问题,有相关经验和准备。
结论就是说我go语言基础还有所欠缺,有点失落,但也在情理之中,因为最近一直背面经,实操少。
总结:
1.背面经管用,但不能止步于此,要自己模仿面试官问自己问题,看看回答得是否满意。主要是费曼学习法,要能假想自己教会别人。
2.对于一些特性不能止步于面经,必须实操,彻底了解,不要妄想面试时能蒙混过关。
3.每个公司每个公司侧重点不一样,虽然现在是寒冬,但是还是有机会的,必须抓紧并且把握住。
最后感谢一下蜻蜓FM的Y先生,面试过后给了一些建议。他算是近来面试中唯一一位给技术反馈的。如果你读到这篇文章,我想额外给一些技术以外的建议。
1.如果你的目标是某些大厂,可以先找一些小厂练练手,找找感觉。
2.小厂的面试很有一些野路子的感觉,有时候问你是否用过某个框架,就不接着问了,如果你遇到这种面试官,无视就好了。正常的面试官都关注基础,基础牢固,框架就只是工具。
3.如果你遇到非常装逼的面试官,放弃吧,他只想鄙视你而不是想录用你。
4.祝你早日找到合适的工作哟。
我在广州某森科技就遇到了非常装逼的一个面试官,不吐不快,他自身的表达能力相当有问题,一直扯人工智能,AI绘图,WebGL,说AI负责人是MIT硕,那感觉余有荣焉。因为我并没有见到那位AI负责人所以无法给予评价,但就面试官而言,应该给予候选人足够的尊重,而且自身的表达和考察能力也是很重要的,否则容易翻车。领导的才能是他自身的,作为下属可以崇拜、可以学习、也可以追赶,但最好不要当作偶像崇拜,世上除了最顶尖的那一小嘬天才,比如:马斯克、特斯拉、爱因斯坦等值得被当作偶像,其他人最好还是平常心一点。
好了,谢谢你在这里听我的废话,如果对你有帮助,请让我知道。文章中有疏漏之处也请指正,我们下篇文章见。