Jquery中文网 www.jquerycn.cn
Jquery中文网 >  后端编程  >  Go语言  >  正文 字节跳动的 Go 语言面试会问哪些问题?

字节跳动的 Go 语言面试会问哪些问题?

发布时间:2021-04-16   编辑:www.jquerycn.cn
jquery中文网为您提供字节跳动的 Go 语言面试会问哪些问题?等资源,欢迎您收藏本站,我们将为您提供最新的字节跳动的 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{
         http://www.golang.org/,
         http://www.google.com/,
     }
     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/runtime/sema.go:56  0x39
 sync.(*WaitGroup).Wait(0xc000094010)
         /home/keke/soft/go/src/sync/waitgroup.go:130  0x64
 main.main()
         /home/keke/go/Test/wait.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
     // 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)

这是最简单的一种垃圾回收算法,和之前提到的智能指针异曲同工。对每个对象维护一个引用计数,当引用该对象的对象被销毁或更新时被引用对象的引用计数自动减一,当被引用对象被创建或被赋值给其他对象时引用计数自动加一。当引用计数为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)到老年代中(这里用到了一个分类的思路,这个是也是科学思考的一个基本思路)。

因此,新生代垃圾回收和老年代垃圾回收两种不同的垃圾回收方式应运而生,分别用于对各自空间中的对象执行垃圾回收。新生代垃圾回收的速度非常快,比老年代快几个数量级,即使新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是因为大多数对象的生命周期都很短,根本无需提升到老年代。

Golang 1.5后,采取的是“非分代的、非移动的、并发的、三色的”标记清除垃圾回收算法。

golang 中的 gc 基本上是标记清除的过程:

gc的过程一共分为四个阶段:

  1. 栈扫描(开始时STW)

  2. 第一次标记(并发)

  3. 第二次标记(STW)

  4. 清除(并发)

整个进程空间里申请每个对象占据的内存可以视为一个图,初始状态下每个内存对象都是白色标记。

  1. 先STW,做一些准备工作,比如 enable write barrier。然后取消STW,将扫描任务作为多个并发的goroutine立即入队给调度器,进而被CPU处理

  2. 第一轮先扫描root对象,包括全局指针和 goroutine 栈上的指针,标记为灰色放入队列

  3. 第二轮将第一步队列中的对象引用的对象置为灰色加入队列,一个对象引用的所有对象都置灰并加入队列后,这个对象才能置为黑色并从队列之中取出。循环往复,最后队列为空时,整个图剩下的白色内存空间即不可到达的对象,即没有被引用的对象;

  4. 第三轮再次STW,将第二轮过程中新增对象申请的内存进行标记(灰色),这里使用了write barrier(写屏障)去记录

Golang gc 优化的核心就是尽量使得 STW(Stop The World) 的时间越来越短。

面试官:问个小细节, JSON 标准库对 nil slice 和 空 slice 的处理是一致的吗?

面试者:首先 JSON 标准库对 nil slice 和 空 slice 的处理是不一致的。

通常错误的用法,会报数组越界的错误,因为只是声明了slice,却没有给实例化的对象。

 var slice []int
 slice[1] = 0
 slice := make([]int,0)
 slice := []int{}

总之,nil slice 和 empty slice是不同的东西,需要我们加以区分的。

当我们查询或者处理一个空的列表的时候,这非常有用,它会告诉我们返回的是一个列表,但是列表内没有任何值。

empty slice 是指slice不为nil,但是slice没有值,slice的底层的空间是空的,此时的定义如下:

此时slice的值是nil,这种情况可以用于需要返回slice的函数,当函数出现异常的时候,保证函数依然会有nil的返回值。

面试官:了解过选项模式吗?能否写一段代码实现一个函数选项模式?

面试者:

 var defaultStuffClientOptions = StuffClientOptions{
     Retries: 3,
     Timeout: 2,
 }
 type StuffClientOption func(*StuffClientOptions)
 type StuffClientOptions struct {
     Retries int //number of times to retry the request before giving up
     Timeout int //connection timeout in seconds
 }
 func WithRetries(r int) StuffClientOption {
     return func(o *StuffClientOptions) {
         o.Retries = r
     }
 }
 func WithTimeout(t int) StuffClientOption {
     return func(o *StuffClientOptions) {
         o.Timeout = t
     }
 }
 type StuffClient interface {
     DoStuff() error
 }
 type stuffClient struct {
     conn    Connection
     timeout int
     retries int
 }
 type Connection struct {}
 func NewStuffClient(conn Connection, opts ...StuffClientOption) StuffClient {
     options := defaultStuffClientOptions
     for _, o := range opts {
         o(&options)
     }
         return &stuffClient{
             conn:    conn,
             timeout: options.Timeout,
             retries: options.Retries,
         }
 }
 func (c stuffClient) DoStuff() error {
     return nil
 }

面试官:写的不错,请说说选项者模式的优点。

面试者:选项模式是 go 语法所特有的,也是 go 语言的创始人所推崇的,可以做到灵活的给接口提供参数,且参数的数量可以自定义,同时屏蔽了一些不需要对接口使用者的细节。

后记:

不吹不捧,也不想挑起语言争论的说,如果你是一名后端工程师,那我建议你一定要深入学习下 Go 语言。因为,在未来几年内,Go 语言的市场份额会越来越大,它的前途无可限量。

到此这篇关于“字节跳动的 Go 语言面试会问哪些问题?”的文章就介绍到这了,更多文章或继续浏览下面的相关文章,希望大家以后多多支持JQ教程网!

您可能感兴趣的文章:
go 函数末尾缺少返回值_王垠:Go语言野心勃勃,实际情况又如何
go语言核心编程_Go语言核心编程李文塔
Go 语言到底适合干什么?
Go 语言十年而立,Go2 蓄势待发
Go 开发关键技术指南 | 为什么你要选择 Go?(内含超全知识大图)
Go语言学习3----Go语言特色
Go语言发展历史、核心、特性及学习路线
golang学习系列——1. go语言的特点
字节跳动的 Go 语言面试会问哪些问题?
go html提取纯文本_Go 语言高性能编程

[关闭]