Home Go 常见面试题
Post
Cancel

Go 常见面试题

Go - nil

nil 只能赋值给指针、chanfuncinterfacemapslice 类型的变量

channel

Go 语言中,不要通过共享内存来通信,而要通过通信来实现内存共享。Go 的 CSP(Communicating Sequential Process)并发模型,中文叫做通信顺序进程,是通过 goroutine 和 channel 来实现的。

channel 收发遵循先进先出 FIFO,分为有缓存和无缓存,channel 中大致有 buffer(当缓冲区大小部位 0 时,是个 ring buffer)、sendxrecvx 收发的位置(ring buffer 记录实现)、sendqrecvq 当前 channel 因为缓冲区不足而阻塞的队列、使用双向链表存储、还有一个 mutex 锁控制并发、其他原属等。

向通道发送数据

1
2
3
4
5
6
7
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    lock(&c.lock)

    if c.closed != 0 {
        unlock(&c.lock)
        panic(plainError("send on closed channel"))
    }

在发送数据的逻辑执行之前会先为当前 Channel 加锁,防止多个线程并发修改数据。如果 Channel 已经关闭,那么向该 Channel 发送数据时会报 “send on closed channel” 错误并中止程序

执行过程分成以下的三个部分:

  • 当存在等待的接收者时,通过 runtime.send 直接将数据发送给阻塞的接收者;
  • 当缓冲区存在空余空间时,将发送的数据写入 Channel 的缓冲区;
  • 当不存在缓冲区或者缓冲区已满时,等待其他 Goroutine 从 Channel 接收数据;

发送数据

  • 如果当前 Channel 的 recvq 上存在已经被阻塞的 Goroutine,那么会直接将数据发送给当前 Goroutine 并将其设置成下一个运行的 Goroutine,也就是将接收方的 Goroutine 放到了处理器的 runnext 中,程序没有立刻执行该 Goroutine

    向一个非缓冲型的 channel 发送数据、从一个无元素的(非缓冲型或缓冲型但空)的 channel 接收数据,都会导致一个 goroutine 直接操作另一个 goroutine 的栈, 由于 GC 假设对栈的写操作只能发生在 goroutine 正在运行中并且由当前 goroutine 来写, 所以这里实际上违反了这个假设。可能会造成一些问题,所以需要用到写屏障来规避

  • 如果 Channel 存在缓冲区并且其中还有空闲的容量,我们会直接将数据存储到缓冲区 sendx 所在的位置上;
  • 如果不满足上面的两种情况,会创建一个 runtime.sudog 结构并将其加入 Channel 的 sendq 队列中,当前 Goroutine 也会陷入阻塞等待其他的协程从 Channel 接收数据;

接收数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
    if c == nil {
        if !block {
            return
        }
        gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
        throw("unreachable")
    }

    lock(&c.lock)

    if c.closed != 0 && c.qcount == 0 {
        unlock(&c.lock)
        if ep != nil {
            typedmemclr(c.elemtype, ep)
        }
        return true, false
    }

当我们从一个空 Channel 接收数据时会直接调用 runtime.gopark 让出处理器的使用权。

使用 runtime.chanrecv 从 Channel 接收数据时还包含以下三种不同情况:

  1. 当存在等待的发送者时,通过 runtime.recv 从阻塞的发送者或者缓冲区中获取数据;
  2. 当缓冲区存在数据时,从 Channel 的缓冲区中接收数据;
  3. 当缓冲区中不存在数据时,等待其他 Goroutine 向 Channel 发送数据;

直接接收数据时根据缓冲区的大小分别处理不同的情况:

  • 如果 Channel 不存在缓冲区
    1. 调用 runtime.recvDirect 将 Channel 发送队列中 Goroutine 存储的 elem 数据拷贝到目标内存地址中;
  • 如果 Channel 存在缓冲区
    1. 将队列中的数据拷贝到接收方的内存地址;
    2. 将发送队列头的数据拷贝到缓冲区中,释放一个阻塞的发送方;

无论发生哪种情况,运行时都会调用 runtime.goready 将当前处理器的 runnext 设置成发送数据的 Goroutine,在调度器下一次调度时将阻塞的发送方唤醒。

从通道接收数据

  1. 如果 Channel 为空,那么会直接调用 runtime.gopark 挂起当前 Goroutine;
  2. 如果 Channel 已经关闭并且缓冲区没有任何数据,runtime.chanrecv 会直接返回;
  3. 如果 Channel 的 sendq 队列中存在挂起的 Goroutine,会将 recvx 索引所在的数据拷贝到接收变量所在的内存空间上并将 sendq 队列中 Goroutine 的数据拷贝到缓冲区;
  4. 如果 Channel 的缓冲区中包含数据,那么直接读取 recvx 索引对应的数据;
  5. 在默认情况下会挂起当前的 Goroutine,将 runtime.sudog 结构加入 recvq 队列并陷入休眠等待调度器的唤醒;

触发调度时机

  1. 发送数据时
    1. 发送数据时发现 Channel 上存在等待接收数据的 Goroutine,立刻设置处理器的 runnext 属性,但是并不会立刻触发调度;
    2. 发送数据时并没有找到接收方并且缓冲区已经满了,这时会将自己加入 Channel 的 sendq 队列并调用 runtime.goparkunlock 触发 Goroutine 的调度让出处理器的使用权;
  2. 从 Channel 接收数据时,会触发 Goroutine 调度的两个时机

    1. 当 Channel 为空时;
    2. 当缓冲区中不存在数据并且也不存在数据的发送者时;

channel 最佳实践

  • 读已经关闭的 chan 能⼀直读到东⻄,但是读到的内容根据通道内关闭前是否有元素⽽不同。
    1. 如果 chan 关闭前,buffer 内有元素还未读, 会正确读到 chan 内的值,且返回的第⼆个 bool 值(是否读成功)为 true
    2. 如果 chan 关闭前,buffer 内有元素已经被读完,chan 内⽆值,接下来所有接收的值都会⾮阻塞直接成功,返回 channel 元素的零值,但是第⼆个 bool 值⼀直为 false
  • 触发 panic 的情况
    1. 向已经关闭的 chan 发送数据会 panic
    2. 关闭一个 nil 的 channel;
    3. 重复关闭一个 channel。
  • nil 的通道发送或接收数据会调用 gopark 挂起,并陷入永久阻塞
  • channel 泄漏 泄漏的原因是 goroutine 操作 channel 后,处于发送或接收阻塞状态,而 channel 处于满或空的状态,一直得不到改变。同时,垃圾回收器也不会回收此类资源,进而导致 gouroutine 会一直处于等待队列中,不见天日。

runtime 包中的常用方法

  1. NumCPU: 返回当前系统的 CPU 核数量
  2. GOMAXPROCS: 设置最大的可同时使用的 CPU 核数

    通过 runtime.GOMAXPROCS 函数,应用程序何以在运行期间设置运行时系统中得 P 最大数量。但这会引起 “Stop the World”。所以,应在应用程序最早的调用。并且最好是在运行Go程序之前设置好操作程序的环境变量 GOMAXPROCS,而不是在程序中调用 runtime.GOMAXPROCS 函数。

    无论我们传递给函数的整数值是什么值,运行时系统的P最大值总会在1~256之间。

  3. Gosched:让当前线程让出 cpu 以让其它线程运行,它不会挂起当前线程,因此当前线程未来会继续执行
    这个函数的作用是让当前 goroutine 让出 CPU,当一个 goroutine 发生阻塞,Go 会自动地把与该 goroutine 处于同一系统线程的其他 goroutine 转移到另一个系统线程上去,以使这些 goroutine 不阻塞。
  4. Goexit: 退出当前 goroutine(但是defer语句会照常执行)
  5. NumGoroutine: 返回正在执行和排队的任务总数
    runtime.NumGoroutine 函数在被调用后,会返回系统中的处于特定状态的 Goroutine 的数量。这里的特指是指 Grunnable\Gruning\Gsyscall\Gwaition。处于这些状态的 Groutine 即被看做是活跃的或者说正在被调度。 注意:垃圾回收所在 Groutine 的状态也处于这个范围内的话,也会被纳入该计数器。
  6. GOOS: 目标操作系统
  7. GC: 会让运行时系统进行一次强制性的垃圾收集 1.强制的垃圾回收:不管怎样,都要进行的垃圾回收。2.非强制的垃圾回收:只会在一定条件下进行的垃圾回收(即运行时,系统自上次垃圾回收之后新申请的堆内存的单元(也成为单元增量)达到指定的数值)。
  8. GOROOT: 获取 goroot 目录
  9. GOOS: 查看目标操作系统

垃圾回收

追踪式标记清除

GC 的根对象是什么?

  1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
  2. 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
  3. 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

三色标记

三色抽象只是一种描述追踪式回收器的方法,在实践中并没有实际含义,它的重要作用在于从逻辑上严密推导标记清理这种垃圾回收方法的正确性。

垃圾回收器的视角来看,三色抽象规定了三种不同类型的对象,并用不同的颜色相称:

  • 白色对象(可能死亡):未被回收器访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。
  • 灰色对象(波面):已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。
  • 黑色对象(确定存活):已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。

这样三种不变性所定义的回收过程其实是一个波面不断前进的过程,这个波面同时也是黑色对象和白色对象的边界,灰色对象就是这个波面。

悬挂指针,即指针没有指向特定类型的合法对象,影响了内存的安全性,想要并发或者增量地标记对象还是需要使用屏障技术。

屏障技术

  • 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象
  • 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径

插入写屏障

1
2
3
writePointer(slot, ptr):
    shade(ptr)
    *slot = ptr

Dijkstra 的插入写屏障是一种相对保守的屏障技术,它会将有存活可能的对象都标记成灰色以满足强三色不变性

垃圾收集和用户程序交替运行时将黑色对象 A 指向白色对象 B,会将对象 B 标记成灰色,完成插入写屏障也就满足了强三色不变性。

插入式的 Dijkstra 写屏障虽然实现非常简单并且也能保证强三色不变性,但是它也有明显的缺点。因为栈上的对象在垃圾收集中也会被认为是根对象,所以为了保证内存的安全,Dijkstra 必须为栈上的对象增加写屏障或者在标记阶段完成重新对栈上的对象进行扫描,这两种方法各有各的缺点,前者会大幅度增加写入指针的额外开销,后者重新扫描栈对象时需要暂停程序,垃圾收集算法的设计者需要在这两者之间做出权衡。

删除写屏障

会在老对象的引用被删除时,将白色的老对象涂成灰色,这样删除写屏障就可以保证弱三色不变性,老对象引用的下游对象一定可以被灰色对象引用。

垃圾收集和用户程序交替运行时黑色对象 A 指向灰色对象 B,B 指向白色对象 C,此时 A 指向 C,因为灰色对象 B 指向白色对象 C,此时不触发删除写屏障,如果将灰色对象 B 原本指向白色对象 C 的指针删除,会将对象 C 标记成灰色,完成删除写屏障也就满足了强/弱三色不变性。

混合写屏障

在 Go 语言 v1.7 版本之前,运行时会使用 Dijkstra 插入写屏障保证强三色不变性,但是运行时并没有在所有的垃圾收集根对象上开启插入写屏障。因为应用程序可能包含成百上千的 Goroutine,而垃圾收集的根对象一般包括全局变量和栈对象,如果运行时需要在几百个 Goroutine 的栈上都开启写屏障,会带来巨大的额外开销,所以 Go 团队在实现上选择了在标记阶段完成时暂停程序、将所有栈对象标记为灰色并重新扫描,在活跃 Goroutine 非常多的程序中,重新扫描的过程需要占用 10 ~ 100ms 的时间

会将被覆盖的对象标记成灰色并在当前栈没有扫描时将新对象也标记成灰色

为了移除栈的重扫描过程,除了引入混合写屏障之外,在垃圾收集的标记阶段,我们还需要将创建的所有新对象都标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收,因为栈内存在标记阶段最终都会变为黑色,所以不再需要重新扫描栈空间。

增量和并发

  • 增量垃圾收集 — 增量地标记和清除垃圾,降低应用程序暂停的最长时间

    增量式(Incremental)的垃圾收集是减少程序最长暂停时间的一种方案,它可以将原本时间较长的暂停时间切分成多个更小的 GC 时间片,虽然从垃圾收集开始到结束的时间更长了,但是这也减少了应用程序暂停的最大时间;

    为了保证垃圾收集的正确性,我们需要在垃圾收集开始前打开写屏障,这样用户程序修改内存都会先经过写屏障的处理,保证了堆内存中对象关系的强三色不变性或者弱三色不变性。虽然增量式的垃圾收集能够减少最大的程序暂停时间,但是增量式收集也会增加一次 GC 循环的总时间,在垃圾收集期间,因为写屏障的影响用户程序也需要承担额外的计算开销,所以增量式的垃圾收集也不是只带来好处的,但是总体来说还是利大于弊。

  • 并发垃圾收集 — 利用多核的计算资源,在用户程序执行时并发标记和清除垃圾

    并发(Concurrent)的垃圾收集不仅能够减少程序的最长暂停时间,还能减少整个垃圾收集阶段的时间,通过开启读写屏障、利用多核优势与用户程序并行执行,并发垃圾收集器确实能够减少垃圾收集对应用程序的影响

    虽然并发收集器能够与用户程序一起运行,但是并不是所有阶段都可以与用户程序一起运行,部分阶段还是需要暂停用户程序的,不过与传统的算法相比,并发的垃圾收集可以将能够并发执行的工作尽量并发执行;当然,因为读写屏障的引入,并发的垃圾收集器也一定会带来额外开销,不仅会增加垃圾收集的总时间,还会影响用户程序,这是我们在设计垃圾收集策略时必须要注意的。

垃圾收集的阶段

  1. 清理终止阶段 STW
    1. 暂停程序,所有的处理器在这时会进入安全点(Safe point);
    2. 如果当前垃圾收集循环是强制触发的,我们还需要处理还未被清理的内存管理单元;
  2. 标记阶段
    1. 将状态切换至 _GCmark、开启写屏障、用户程序协助(Mutator Assists)并将根对象入队;
    2. 恢复执行程序,标记进程和用于协助的用户程序会开始并发标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会被直接标记成黑色
    3. 开始扫描根对象,包括所有 Goroutine 的栈、全局对象以及不在堆中的运行时数据结构,扫描 Goroutine 栈期间会暂停当前处理器;
    4. 依次处理灰色队列中的对象,将对象标记成黑色并将它们指向的对象标记成灰色;
    5. 使用分布式的终止算法检查剩余的工作,发现标记阶段完成后进入标记终止阶段;
  3. 标记终止阶段 STW
    1. 暂停程序、将状态切换至 _GCmarktermination 并关闭辅助标记的用户程序;
    2. 清理处理器上的线程缓存;
  4. 清理阶段
    1. 将状态切换至 _GCoff 开始清理阶段,初始化清理状态并关闭写屏障;
    2. 恢复用户程序,所有新创建的对象会标记成白色;
    3. 后台并发清理所有的内存管理单元,当 Goroutine 申请新的内存管理单元时就会触发清理

runtime.work 变量:该结构体中包含大量垃圾收集的相关字段,例如:表示完成的垃圾收集循环的次数、当前循环时间和 CPU 的利用率、垃圾收集的模式等等,我们会在后面的小节中见到该结构体中的更多字段。

GC 触发时机

Go 语言中对 GC 的触发时机存在两种形式:

  1. 主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。

  2. 被动触发,分为两种方式:

    • 使用系统监控,如果一定时间内没有触发,就会触发新的循环,该触发条件由 runtime.forcegcperiod 变量控制,默认为 2 分钟;
    • 使用步调(Pacing)算法,其核心思想是控制内存增长的比例。
  3. 申请内存

    最后一个可能会触发垃圾收集的就是 runtime.mallocgc 了,我们在上一节内存分配器中曾经介绍过运行时会将堆上的对象按大小分成微对象、小对象和大对象三类,这三类对象的创建都可能会触发新的垃圾收集循环:

    1. 当前线程的内存管理单元中不存在空闲空间时,创建微对象和小对象需要调用 runtime.mcache.nextFree 从中心缓存或者页堆中获取新的管理单元,在这时就可能触发垃圾收集;
    2. 当用户程序申请分配 32KB 以上的大对象时,一定会构建 runtime.gcTrigger 结构体尝试触发垃圾收集;

    通过堆内存触发垃圾收集需要比较 runtime.mstats 中的两个字段 — 表示垃圾收集中存活对象字节数的 heap_live 和表示触发标记的堆内存大小的 gc_trigger;当内存中存活的对象字节数大于触发垃圾收集的堆大小时,新一轮的垃圾收集就会开始。在这里,我们将分别介绍这两个值的计算过程:

    • heap_live — 为了减少锁竞争,运行时只会在中心缓存分配或者释放内存管理单元以及在堆上分配大对象时才会更新;
    • gc_trigger — 在标记终止阶段调用 runtime.gcSetTriggerRatio 更新触发下一次垃圾收集的堆大小;

GC 如何调优

  • 通过 go tool pprofgo tool trace 等工具
  • 控制内存分配的速度,限制 goroutine 的数量,从而提高赋值器对 CPU 的利用率。
  • 减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例如提前分配足够的内存来降低多余的拷贝。
  • 需要时,增大 GOGC 的值,降低 GC 的运行频率。

GC 跟不上分配的速度

目前的 Go 实现中,当 GC 触发后,会首先进入并发标记的阶段。并发标记会设置一个标志,并在 mallocgc 调用时进行检查。当存在新的内存分配时,会暂停分配内存过快的那些 goroutine,并将其转去执行一些辅助标记(Mark Assist)的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。

内存分配

内存逃逸

逃逸分析最基本的原则: 编译器会分析代码的特征和代码生命周期,Go 中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上。

引起变量逃逸到堆上的典型情况

  • 在⽅法内把局部变量指针返回, 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引⽤,因此其⽣命周期⼤于栈,则溢出。
  • 发送指针或带有指针的值到 channel。在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
  • 在⼀个切⽚上存储指针或带指针的值。⼀个典型的例⼦就是 []*string。这会导致切⽚的内容逃逸。尽管其后⾯的数组可能是在栈上分配的,但其引⽤的值⼀定是在堆上。
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量(cap)slice 初始化的地⽅在编译时是可以知道的,它最开始会在栈上分配。如果切⽚背后的存储要基于运⾏时的数据进⾏扩充,就会在堆上分配。
  • interface 类型上调⽤⽅法。在 interface 类型上调⽤⽅法都是动态调度的⽅法的真正实现只能在运⾏时知道。想像⼀个 io.Reader 类型的变量 r , 调⽤ r.Read(b) 会使得 r 的值和切⽚ b 的背后存储都逃逸掉,所以会在堆上分配。

  • 闭包的捕获变量也会分配到堆上,还有就是大对象 大于 32KB

new 一个对象最后在堆上还是栈上就根据上面的逃逸分析进行回答

defer

defer 关键字的实现跟 go 关键字很类似,不同的是它调⽤的是 runtime.deferproc ⽽不是 runtime.newproc。 在 defer 出现的地⽅,插⼊了指令 call runtime.deferproc,然后在函数返回之前的地⽅,插⼊指令 call runtime.deferreturn。 goroutine 的控制结构中,有⼀张表记录 defer,调⽤ runtime.deferproc 时会将需要 defer 的表达式记录在表中,⽽在调⽤ runtime.deferreturn 的时候,则会依次从 defer 表中出栈并执⾏。 因此,题⽬最后输出顺序应该是 defer 定义顺序的倒序。 panic 错误并不能终⽌ defer 的执⾏。

继承与多态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type People interface {
    Speak(string) string
}

type Stduent struct{}

func (stu *Stduent) Speak(think string) (talk string) {
    if think == "love" {
        talk = "You are a good boy"
    } else {
        talk = "hi"
    }
    return
}

func main() {
    var peo People = Stduent{}
    think := "love"
    fmt.Println(peo.Speak(think))
}

在 golang 中对多态的特点体现从语法上并不是很明显。

我们知道发生多态的几个要素:

  1. interface 接口,并且有接口定义的方法。
  2. 有子类去重写 interface 的接口。
  3. 有父类指针指向子类的具体对象

那么,满足上述 3 个条件,就可以产生多态效果,就是,父类指针可以调用子类的具体方法。

所以上述代码报错的地方在 var peo People = Stduent{} 这条语句, Student{} 已经重写了父类 People{} 中的 Speak(string) string 方法,那么只需要用父类指针指向子类对象即可。(Go 中不叫父类,这里是为了好理解)

所以应该改成 var peo People = &Student{} 即可编译通过。(People 为 interface 类型,就是指针类型)

This post is licensed under CC BY 4.0 by the author.

Redis 常见面试题

Slice