字节跳动的Go语⾔⾯试会问哪些问题?
众所周知,字节跳动内部的后端开发⼤多数都是使⽤ go 语⾔的,那么⼀般 go 语⾔的⾯试会问哪些问题?
这个⼀般分为两个层次,初中级开发(1-1、1-2)和⾼级开发(2-1、2-2),不同级别的⾯试⼀般要求是不⼀样的。对于初中级开发,⼀般会问⼀些语⾔层⾯的东西,⼀些常⽤的基础原理和⼀些算法,但是⾼级开发就没那么简单了。下⾯我为读者分享⼀段⾯试的经历。
⾯试官:你平常使⽤什么编程语⾔⽐较多?
⾯试者:go。
⾯试官:好的,那我们聊⼀下⼀些 go 相关的问题吧。
⾯试者:好的。
⾯试官:⽤过 fallthrough 关键字吗?这个关键字的作⽤是什么?
⾯试者:其他语⾔中,switch-case 结构中⼀般都需要在每个 case 分⽀结束处显式的调⽤ break 语句以
防⽌ 前⼀个 case 分⽀被贯穿后调⽤下⼀个 case 分⽀的逻辑,go 编译器从语法层⾯上消除了这种重复的⼯作,让开发者更轻松;但有时候我们的场景就是需要贯穿多个 case,但是编译器默认是不贯穿的,这个时候 fallthrough 就起作⽤了,让某个 case 分⽀再次贯穿到下⼀个 case 分⽀。
⾯试官:go 中除了加 Mutex 锁以外还有哪些⽅式安全读写共享变量?
⾯试者:go 中 Goroutine 可以通过 Channel 进⾏安全读写共享变量。
⾯试官:⽆缓冲 Chan 的发送和接收是否同步?
⾯试者:举两个例⼦:
// ⽆缓冲的channel由于没有缓冲发送和接收需要同步.
ch := make(chan int)
//有缓冲channel不要求发送和接收操作同步.
ch := make(chan int, 2)
因此 channel ⽆缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据;channel有缓冲时,当缓
冲满时发送阻塞,当缓冲空时接收阻塞。
⾯试官:请谈⼀谈 go 语⾔的并发机制以及它所使⽤的CSP并发模型。
⾯试者:CSP 模型是上个世纪七⼗年代提出的,不同于传统的多线程通过共享内存来通信,CSP 讲究的是“以通信的⽅式来共享内存”。⽤于描述两个独⽴的并发实体通过共享的通讯 channel (管道)进⾏通信的并发模型。CSP 中 channel 是第⼀类对象,它不关注发送消息的实体,⽽关注与发送消息时使⽤的 channel。
go 中 channel 是被单独创建并且可以在进程之间传递,它的通信模式类似于 boss-worker 模式的,⼀个实体通过将消息发送到channel 中,然后⼜监听这个 channel 的实体处理,两个实体之间是匿名的,这个就实现实体中间的解耦,其中 channel 是同步的⼀个消息被发送到 channel 中,最终是⼀定要被另外的实体消费掉的,在实现原理上其实类似⼀个阻塞的消息队列。
Goroutine 是 go 实际并发执⾏的实体,它底层是使⽤协程(coroutine)实现并发,coroutine 是⼀种运⾏在⽤户态的⽤户线程,类似于 greenthread,go 底层选择使⽤ coroutine 的出发点是因为,它具有以下特点:
⽤户空间 避免了内核态和⽤户态的切换导致的成本。
可以由语⾔和框架层进⾏调度。
更⼩的栈空间允许创建⼤量的实例。
go 中的 Goroutine 的特性:
Golang 内部有三个对象:P 对象(processor) 代表上下⽂(或者可以认为是 CPU),M(work thread) 代表⼯作线程,G 对象(goroutine)。
正常情况下⼀个 CPU 对象启⼀个⼯作线程对象,线程去检查并执⾏ goroutine 对象。碰到 goroutine 对象阻塞的时候,会启动⼀个新的⼯作线程,以充分利⽤cpu资源。所有有时候线程对象会⽐处理器对象多很多。
G(Goroutine):我们所说的协程,为⽤户级的轻量级线程,每个Goroutine对象中的sched保存着其上下⽂信息.
M(Machine):对内核级线程的封装,数量对应真实的CPU数(真正⼲活的对象).
P(Processor):即为G和M的调度对象,⽤来调度G和M之间的关联关系,其数量可通过 GOMAXPROCS() 来设置,默认为核⼼数.
在单核情况下,所有Goroutine运⾏在同⼀个线程(M0)中,每⼀个线程维护⼀个上下⽂(P),任何时刻,⼀个上下⽂中只有⼀个Goroutine,其他Goroutine在runqueue中等待。
⼀个 Goroutine 运⾏完⾃⼰的时间⽚后,让出上下⽂,⾃⼰回到 runqueue中。
当正在运⾏的G0阻塞的时候(可以需要IO),会再创建⼀个线程(M1),P转到新的线程中去运⾏。
当 M0 返回时,它会尝试从其他线程中“偷”⼀个上下⽂过来,如果没有偷到,会把 Goroutine 放到 Global runqueue 中去,然后把⾃⼰放⼊线程缓存中。上下⽂会定时检查Global runqueue。
go 的 CSP 并发模型,是通过 Goroutine 和 Channel 来实现的。Goroutine 是 go 语⾔中并发的执⾏单位。有点抽象,其实就是和传统概念上的”线程“类似,可以理解为”线程“。Channel 是 go 语⾔中各个并发结构体(Goroutine)之前的通信机制。通常Channel,是各个 Goroutine 之间通信的”管道“,有点类似于Linux中的管道。通信机制channel也很⽅便,传数据⽤channel <-data,取数据⽤<-channel。在通信过程中,传数据channel <- data和取数据<-channel必然会成对出现,因为这边传,那边取,两个goroutine之间才会实现通信。⽽且不管传还是取,必阻塞,直到另外的goroutine传或者取为⽌。
⾯试官:嗯,不错,了解的很深⼊。那 go 中有哪些常⽤的并发模型?
⾯试者:Golang 中常⽤的并发模型有三种:
通过channel通知实现并发控制
⽆缓冲的通道指的是通道的⼤⼩为0,也就是说,这种类型的通道在接收前没有能⼒保存任何值,它要求发送 goroutine 和接收goroutine 同时准备好,才可以完成发送和接收操作。
从上⾯⽆缓冲的通道定义来看,发送 goroutine 和接收 gouroutine 必须是同步的,同时准备后,如果没有同时准备好的话,先执⾏的操作就会阻塞等待,直到另⼀个相对应的操作准备好为⽌。这种⽆缓冲的通道我们也称之为同步通道。
func main() {
ch := make(chan struct{})
go func() {
fmt.Println(start working)
time.Sleep(time.Second * 1)
ch <- struct{}{}
}()
<-ch
fmt.Println(finished)
}
func main(){
var wg sync.WaitGroup
var urls = []string{
/,
le/,
}
for _, url := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
http.Get(url)
}(url)
}
wg.Wait()
}
func main(){
wg := sync.WaitGroup{}
for i := 0; i < 5; i++ {
wg.Add(1)
go func(wg sync.WaitGroup, i int) {
fmt.Printf(i:%d, i)
wg.Done()
}(wg, i)
}
wg.Wait()
fmt.Println(exit)
}
i:1i:3i:2i:0i:4fatal error: all goroutines are asleep - deadlock!
goroutine 1 [semacquire]:
sync.runtime_Semacquire(0xc000094018)
/home/keke/soft/go/src/:56 +0x39
sync.(*WaitGroup).Wait(0xc000094010)
/home/keke/soft/go/src/:130 +0x64
main.main()
/home/keke/go/:17 +0xab
exit status 2
// A Context carries a deadline, cancelation signal, and request-scoped values // across API boundaries. Its methods are safe for simultaneous use by multiple // goroutines.
type Context interface {
// Done returns a channel that is closed when this `Context` is canceled
// Done returns a channel that is closed when this `Context` is canceled
// or times out.
Done() <-chan struct{}
// Err indicates why this Context was canceled, after the Done channel
// is closed.
Err() error
// Deadline returns the time when this Context will be canceled, if any.
Deadline() (deadline time.Time, ok bool)
// Value returns the value associated with key or nil if none.
Value(key interface{}) interface{}
}
⼀个 Context 不能拥有 Cancel ⽅法,同时我们也只能 Done channel 接收数据。其中的原因是⼀致的:
接收取消信号的函数和发送信号的函数通常不是⼀个。典型的场景是:⽗操作为⼦操作操作启动 goroutine,⼦操作也就不能取消⽗操作。
Context 对象是线程安全的,你可以把⼀个 Context 对象传递给任意个数的 gorotuine,对它执⾏ 取消 操作时,所有 goroutine 都会接收到取消信号。
Value() ⽅法允许 Context 对象携带request作⽤域的数据,该数据必须是线程安全的。
Deadline() 设置该context cancel的时间点
Err() 在Done() 之后,返回context 取消的原因。
Done() 返回⼀个只能接受数据的channel类型,当该context关闭或者超时时间到了的时候,该channel就会有⼀个取消信号
context 包的核⼼是 struct Context,接⼝声明如下:
context 包主要是⽤来处理多个 goroutine 之间共享数据,及多个 goroutine 的管理。
通常,在⼀些简单场景下使⽤ channel 和 WaitGroup 已经⾜够了,但是当⾯临⼀些复杂多变的⽹络并
发场景下 channel 和WaitGroup 显得有些⼒不从⼼了。⽐如⼀个⽹络请求 Request,每个 Request 都需要开启⼀个 goroutine 做⼀些事情,这些goroutine ⼜可能会开启其他的 goroutine,⽐如数据库和RPC服务。所以我们需要⼀种可以跟踪 goroutine 的⽅案,才可以达到控制他们的⽬的,这就是Go语⾔为我们提供的 Context,称之为上下⽂⾮常贴切,它就是goroutine 的上下⽂。它是包括⼀个程序的运⾏环境、现场和快照等。每个程序要运⾏时,都需要知道当前程序的运⾏状态,通常Go 将这些封装在⼀个 Context ⾥,再将它传给要执⾏的 goroutine 。
在Go 1.7 以后引进的强⼤的Context上下⽂,实现并发控制
这个第⼀个修改⽅式:将匿名函数中 wg 的传⼊类型改为 *sync.WaitGrou,这样就能引⽤到正确的WaitGroup了。这个第⼆个修改⽅式:将匿名函数中的 wg 的传⼊参数去掉,因为Go⽀持闭包类型,在匿名函数中可以直接使⽤外⾯的 wg 变量
因此 Wait 就死锁了。
它提⽰所有的 goroutine 都已经睡眠了,出现了死锁。这是因为 wg 给拷贝传递到了 goroutine 中,导致只有 Add 操作,其实Done操作是在 wg 的副本执⾏的。
在Golang官⽹中对于WaitGroup介绍是A WaitGroup must not be copied after first use,在 WaitGroup 第⼀次使⽤后,不能被拷贝
在主 goroutine 中 Add(delta int) 索要等待goroutine 的数量。在每⼀个 goroutine 完成后 Done() 表⽰这⼀个goroutine 已经完成,当所有的 goroutine 都完成后,在主 goroutine 中 WaitGroup 返回返回。
Add, 可以添加或减少 goroutine的数量.
Done, 相当于Add(-1).
Wait, 执⾏后会堵塞主线程,直到WaitGroup ⾥的值减⾄0.
Goroutine是异步执⾏的,有的时候为了防⽌在结束mian函数的时候结束掉Goroutine,所以需要同步等待,这个时候就需要⽤WaitGroup了,在 sync 包中,提供了 WaitGroup ,它会等待它收集的所有 goroutine 任务全部完成。在WaitGroup⾥主要有三个⽅法:
通过sync包中的WaitGroup实现并发控制
当主 goroutine 运⾏到 <-ch 接受 channel 的值的时候,如果该 channel 中没有数据,就会⼀直阻塞等待,直到有值。这样就可以简单实现并发控制
⾯试官:Golang GC 有了解吗?GC 时会发⽣什么?
⾯试者:内存管理是程序员开发应⽤的⼀⼤难题。传统的系统级编程语⾔(主要指C/C++)中,程序开发者必须对内存⼩⼼的进⾏管理操作,控制内存的申请及释放。因为稍有不慎,就可能产⽣内存泄露问题,这种问题不易发现并且难以定位,⼀直成为困扰程序开发者的噩梦。如何解决这个头疼的问题呢?
过去⼀般采⽤两种办法:
内存泄露检测⼯具。这种⼯具的原理⼀般是静态代码扫描,通过扫描程序检测可能出现内存泄露的代码段。然⽽检测⼯具难免有疏漏和不⾜,只能起到辅助作⽤。
智能指针。这是 c++ 中引⼊的⾃动内存管理⽅法,通过拥有⾃动内存管理功能的指针对象来引⽤对象,程序员不⽤太关注内存的释放,⽽达到内存⾃动释放的⽬的。这种⽅法是采⽤最⼴泛的做法,但是对程序开发者有⼀定的学习成本(并⾮语⾔层⾯的原⽣⽀持),⽽且⼀旦有忘记使⽤的场景依然⽆法避免内存泄露。
为了解决这个问题,后来开发出来的⼏乎所有新语⾔(java,python,php等等)都引⼊了语⾔层⾯的⾃动内存管理 – 也就是语⾔的使⽤者只⽤关注内存的申请⽽不必关⼼内存的释放,内存释放由虚拟机(virtual machine)或运⾏时(runtime)来⾃动进⾏管理。⽽这种对不再使⽤的内存资源进⾏⾃动回收的⾏为就被称为垃圾回收。
常⽤的垃圾回收的⽅法:
引⽤计数(reference counting)
go和java后端开发劣势这是最简单的⼀种垃圾回收算法,和之前提到的智能指针异曲同⼯。对每个对象维护⼀个引⽤计数,当引⽤该对象的对象被销毁或更新时被引⽤对象的引⽤计数⾃动减⼀,当被引⽤对象被创建或被赋值给其他对象时引⽤计数⾃动加⼀。当引⽤计数为0时则⽴即回收对象。
这种⽅法的优点是实现简单,并且内存的回收很及时。这种算法在内存⽐较紧张和实时性⽐较⾼的系统中使⽤的⽐较⼴泛,如ios cocoa框架,php,python等。
但是简单引⽤计数算法也有明显的缺点:
1. 频繁更新引⽤计数降低了性能。
⼀种简单的解决⽅法就是编译器将相邻的引⽤计数更新操作合并到⼀次更新;还有⼀种⽅法是针对频繁发⽣的临时变量引⽤不进⾏计数,⽽是在引⽤达到0时通过扫描堆栈确认是否还有临时对象引⽤⽽决定是否释放,等等还有很多其他⽅法。
2. 循环引⽤。
当对象间发⽣循环引⽤时引⽤链中的对象都⽆法得到释放。最明显的解决办法是避免产⽣循环引⽤,如cocoa引⼊了strong指针和weak指针两种指针类型。或者系统检测循环引⽤并主动打破循环链。当然这也增加了垃圾回收的复杂度。
标记-清除(mark and sweep)
标记-清除(mark and sweep)分为两步,标记从根变量开始迭代到遍历所有被引⽤的对象,对能够通过应⽤遍历访问到的对象都进⾏标记为“被引⽤”;标记完成后进⾏清除操作,对没有标记过的内存进⾏回收(回收同时可能伴有碎⽚整理操作)。这种⽅法解决了引⽤计数的不⾜,但是也有⽐较明显的问题:每次启动垃圾回收都会暂停当前所有的正常代码执⾏,回收使系统响应能⼒⼤⼤降低!当然后续也出现了很多mark&sweep算法的变种(如三⾊标记法)优化了这个问题。
分代搜集(generation)
java的jvm 就使⽤的分代回收的思路。在⾯向对象编程语⾔中,绝⼤多数对象的⽣命周期都⾮常短。分代收集的基本思想是,将堆划分为两个或多个称为代(generation)的空间。新创建的对象存放在称为新⽣代(young generation)中(⼀般来说,新⽣代的⼤⼩会⽐ ⽼年代⼩很多),随着垃圾回收的重复执⾏,⽣命周期较长的对象会被提升(promotion)到⽼年代中(这⾥⽤到了⼀个分类的思路,这个是也是科学思考的⼀个基本思路)。
因此,新⽣代垃圾回收和⽼年代垃圾回收两种不同的垃圾回收⽅式应运⽽⽣,分别⽤于对各⾃空间中的对象执⾏垃圾回收。新⽣代垃
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论