因为是面试真题,所有的题目都无法直观看到是什么内容,但尽量有一个大致要问的点。

以下两个函数谁的效率更高(cpu缓存)

  1. const matrixLength = 20000
  2. func foo() {
  3. matrixA := createMatrix(matrixLength)
  4. matrixB := createMatrix(matrixLength)
  5. for i := 0; i < matrixLength; i++ {
  6. for j := 0; j < matrixLength; j++ {
  7. matrixA[i][j] = matrixA[i][j] + matrixB[i][j]
  8. }
  9. }
  10. }
  11. func bar() {
  12. matrixA := createMatrix(matrixLength)
  13. matrixB := createMatrix(matrixLength)
  14. for i := 0; i < matrixLength; i++ {
  15. for j := 0; j < matrixLength; j++ {
  16. matrixA[i][j] = matrixA[i][j] + matrixB[j][i]
  17. }
  18. }
  19. }


这两个函数的区别就是for循环内的matrixB[i][j]和matrixB[j][i],是第一个函数效率更高一点,因为cpu缓存的原因,因为cpu每次会从内存中以固定的长度加载一个数据到cpu的cache,第一种会使用到cpu的加速,因为它读取的内存变量都是在内存内连续的。第二种这个二维数组每循环一次它读取的变量都是不连续的,就会涉及到从内存读取到cpu cache,还会因为如果cpu的cache不充足时,会涉及到cache的失效和修改,就会导致cpu的运行变得更慢,没有使用到cpu加速
:多核cpu的cache如何保持不冲突和一致
:MESI。因为现代的处理器都是多核处理器,并且每个核心都带有多个缓存(指令缓存和数据缓存,见下图)。因为cpu访问内存的速度比较慢,所以需要多级缓存,是为了提高访问速度。既然每个核都有缓存,那么假设两个核或者多个核心同时访问一个变量时,这些缓存是如何进行同步的呢?就有了MESI协议

缓存行的四个状态,分别是M(modified)、E(exclusive)、S(shared)、I(invalid)。

  • M:代表该缓存行中的内容被修改了,并且该缓存行只被缓存在该cpu中。这个状态的缓存行中的数据和内存中的不一样,在未来的某个时刻它会被写入到内存中
  • E:代表了该缓存行对应内存中的内容只被该cpu缓存,其他cpu没有缓存该缓存对应内存行中的内容。这个状态的缓存行中的内容与内存中的内容一致。该缓存可以在任何其他cpu读取该缓存对应内存中的内容时变成S状态。或者本地处理器写该缓存就会变为M状态
  • S:该状态意味着数据不止存在本地cpu中,还存在别的cpu的缓存中。这个状态的数据和内存中的数据是一致的。当有一个cpu修改该缓存行对应的内存的内存时会变成I状态
  • I:代表该缓存行中的内容是无效的。

这段程序的结果是什么(uint相减)?

func main() {
    var a uint = 1
    var b uint = 2
    fmt.Print(a-b)
}

答:
uint这个类型的最大值,如果这个操作系统是32位的,就是2^32-1,如果是64位的,就是2^64-1
:可以解释一下为什么吗,是uint不能为负数吗?
答:
首先go是一个强类型语言,uint-uint的结果也是uint,uint 1 - 2 可以转换为 0 - 1,而计算机的计算都是以加法计算,就可以转换为 0 + (-1),而负数的计算都会转成补码,-1的补码就是所有的位数都是1,最后的结果就是所有位数都是1的一个二进制数,以一个无符号类型去识别的话,就是一个当前位数的最大值。

你了解过rune类型吗?

答:在官方文档中有一句话,rune是int32的别名,几乎在所有方面都等同于int32,它的主要区别是用来区分字符值和整数值。比如计算一个中英文结合的string中字符串的长度(不是计算底层字节的长度),在golang中string的底层是byte数组实现的。中文字符在unicode下占2个字节,在utf8下占3个字节,golang默认编码是utf8。所以计算字符串的长度就可以通过字符串转rune数组来输出length

s := "hello 你好"
fmt.Println(len(s)) // 12
fmt.Println([]rune(s)) // 8

golang中还有一个byte类型与rune相似,他们都是用来标识字符类型的变量,区别在于

  • byte等同于int8,常用来处理ascii字符
  • rune等同于int32,常用来处理unicode或utf8字符

三个goroutine函数按顺序输出字符

编写三个函数,每个函数都使用goroutine,每个函数按顺序输出cat、dog、fish,总共输出一百次。这题看着简单,写起来坑真是多。

func main() {

    var wg sync.WaitGroup
    wg.Add(3)

    dogCh := make(chan struct{})
    fishCh := make(chan struct{})
    catCh := make(chan struct{})

    go dog(&wg, dogCh, fishCh)
    go fish(&wg, fishCh, catCh)
    go cat(&wg, catCh, dogCh)

    dogCh <- struct{}{}

    wg.Wait()
}

func dog(wg *sync.WaitGroup, dogCh, fishCh chan struct{}) {

    for i := 0; i < 5; i++ {
        <-dogCh
        fmt.Println("dog")
        fishCh <- struct{}{}
    }

    <-dogCh // 如果没有这一步直接close,就可能遇到cat想要往一个closed的channel中传值,会panic
    close(dogCh)
    wg.Done()
}

func fish(wg *sync.WaitGroup, fishCh, catCh chan struct{}) {
    for i := 0; i < 5; i++ {
        <-fishCh
        fmt.Println("fish")
        catCh <- struct{}{}
    }

    close(fishCh)
    wg.Done()
}

func cat(wg *sync.WaitGroup, catCh, dogCh chan struct{}) {
    for i := 0; i < 5; i++ {
        <-catCh
        fmt.Println("cat")
        dogCh <- struct{}{}
    }

    close(catCh)
    wg.Done()
}

:可以讲一下channel吗,你知道的都可以说

go中的channel大部分都和goroutine一起用,有一句经典的话叫不要通过共享内存来通信,而应该通过通信来共享内存。

  1. 给一个nil channel发送数据,造成永久阻塞
  2. 从一个nil channel接收数据,造成永久阻塞
  3. 给一个已经关闭的channel发送数据,引起panic
  4. 从一个已经关闭的channel接收数据,如果缓冲区为空,则返回一个零值,如果缓冲区不为空,则接收缓冲区内容
  5. 无缓冲的channel是同步的,有缓冲的channel是非同步的
  6. 关闭一个nil channel会panic

channel的实现是:

  • buf: 环形缓冲区
  • sendx:用于记录buf中发送的index
  • recvx:用于记录buf中接受的index
  • lock:互斥锁
  • recvq:接收者协程队列
  • sendq:发送者协程队列

接收或者发送的过程为:

  • 加锁
  • 把数据从goroutine中copy到队列,或从队列copy到goroutine中
  • 释放锁

hchan_channl.gif
比如有G1、G2两个goroutine,当G1想要向一个缓冲区满了的channel进行send操作时,会主动调用GO的调度器,让G1等待,让出M,让其他的G去运行。通过G1会被抽象成含有G1指针和send元素的sudog结构体保存到hchan的sendq中等待被唤醒。这时当G2进行recv操作时,G2从缓存队列中取出元素,channel会将等待队列中的G1推出,将G1当时send的数据推到缓存中,然后调用G1的scheduler,唤醒G1,并把G1放到可运行的goroutine队列中。
相反如果想要在空队列中recv时,逻辑一样。
:你认为channel是线程安全的还是不安全的?

channel是线程安全的,底层用了互斥锁。不论是收还是发,channel都会加锁,在同一时刻只有一个goroutine可以操作channel。
:看你提到了mutex,那你知道mutex是乐观锁还是悲观锁吗?

  • 悲观锁:就是数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。
    • 悲观锁的实现:
      • 传统的关系型数据库使用这种锁机制,比如行锁、表锁、读锁、写锁等,都是在操作之前上锁。
      • golang中的mutex
    • 悲观锁主要分为共享锁和排他锁
      • 共享锁(shared locks)又称为读锁,简称S锁。顾名思义,共享锁就是多个事务对于同一个数据可以共享一把锁,都能访问到数据,但是只能读不能修改
      • 排他锁(exclusive locks)又称为写锁,简称X锁。顾名思义,拍他所就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行进行读取和修改。
  • 乐观锁:乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何进行下一步。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量。乐观锁采取了更加宽松的加锁机制。也是为了避免数据库幻读、业务处理时间过长等原因引起的数据处理错误的一种机制,但乐观锁不会可以使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。
    • 乐观锁的实现:
      • CAS实现:golang中atomic.CompareAndSwap比较并交换的方式实现了类似乐观锁的功能,只有原来的值和传入的old值一样才会去修改。

:你知道mutex有几种模式吗?
答:
两种模式,正常模式和饥饿模式

  • 正常模式的mutex是个非公平锁,所有等待锁的goroutine按照FIFO顺序等待。唤醒的goroutine不会直接拥有锁,而是会和其他请求锁的goroutine竞争。这时新进入的goroutine因为正在cpu执行,就有更大的可能获取锁。这时被唤醒的goroutine会进入到等待队列中。如果一个等待的goroutine超过1ms没有获取到锁,那这个mutex就会进入饥饿模式
  • 饥饿模式的mutex是公平锁,为了解决等待G队列的长尾问题,饥饿模式下,会直接由unlock把锁交给等待队列中排在第一位的G,同时,饥饿模式下新进来的G不会参与抢锁也不会进入自旋模式,会直接进入等待队列的尾部,这样很好的解决了老的G一直抢不到锁的情况。
  • 在以下情况会切换会正常模式

    • 当前等待队列里只有最后一个goroutine
    • 当前goroutine等待时间小于1ms

      总结:

      对于两种模式,正常模式下性能是最好的,goroutine可以连续多次获取锁,饥饿模式下解决了锁公平的问题,但是性能会下降,其实是性能和公平的一个平衡模式。
      :你觉得mutex可以作为自旋锁吗?

      :你了解过RWMutex锁吗
      :比mutex锁更细粒度的锁,读与读之间不互斥,读写、写写会互斥,相对于mutex的读读都互斥的并发性能更好。
      :你还了解过其他的共享内存的方式吗?
      :channel
      :你的工作中使用过channel吗
      :(这个看自己的工作场景),我自己的话在磁盘写入速度不够的时候且内存有限的时候,会用有缓存channel作阻塞
      :讲一下你对goroutine的了解
      :goroutine是一个与其他goroutines并行运行在同一地址空间的go函数或者方法。一个程序由一个或多个goroutine组成。然后与goroutine有关的就是GMP了,GMP是一个调度模型,由
  • G(goroutine)

  • M(machine)
  • P(processor)

我先讲一下golang 1.0之前的GM调度模型嘛,就是调度器把G都分配给M,不同的G在不同的M并发运行时,都需要向系统申请资源,比如堆栈内存等,因为资源是全局的,就会因为资源竞争造成很多性能损耗。为了解决这一个问题,golang从1.1版本引入P对象,让P去管理G,M要想运行G,就必须绑定一个P,才能运行P所管理的G。然后是

  • GMP的调度流程
    • 每个P都有一个局部队列,局部队列里有待执行的G,当P的局部队列满了以后,新来的G会进入全局队列。
    • 每个P和一个M绑定,M是真正执行P中G的实体,M从绑定的P中的局部队列来获取G执行
    • 当M绑定的P的局部队列为空时,会从全局队列获取到本地队列来执行G或从其他P的局部队列偷取一半的G来执行,这种从其他P中偷G的行为称为work stealing
    • 当G因为syscall(系统调用)阻塞时会阻塞M,此时P会与M解绑,即hand off,并寻找休眠的M队列中新的M,如果没有休眠的M,就会新建一个M
    • 当G因为channel或者network IO阻塞时,不会阻塞M,M会寻找其他可运行的G;当阻塞的G恢复后会编程runnable重新进入P队列等待执行
  • GMP中work stealing机制
    • 线程想运行任务就要获得P,P从本地队列获取G,P队列为空时,M会尝试从全局队列拿一批G放到P的局部队列,或者从其他的P的局部队列中偷一半放到自己的P的局部队列
  • GMP中hand off机制
    • 当本线程M因为G进行的syscall阻塞时,线程会释放绑定的P,将P转移给其他空闲的M实行。当发生上下文切换时,要进行现场保护,以便下次调用时恢复。要将Go调度器M的栈保存在G对象上,只需要将M所需要的寄存器SP、PC等保存到G对象上就可以实现。当这些寄存器数据被保存起来,就可以做上下文切换了。如果此时G任务还没有执行完,M可以将任务重新丢到P的任务队列,等待下一次被调度执行。
  • 协作式的抢占式调度:
    • 在golang1.14之前,golang程序只能依靠goroutine主动让出CPU资源才能触发调度,会有这几个问题:
      • 某些goroutine可能长时间占用线程,造成其他goroutine饥饿
      • 垃圾回收程序需要STW,最长可能几分钟时间,导致整个程序无法工作。
  • 在1.14,golang使用了基于信号的抢占式调度,它的流程是:
    • M注册一个SIGURG信号的处理函数:sighandler
    • sysmon线程检测执行时间过长的G或者需要GC STW的时候,会向相应的M发送SIGURG信号
    • M收到信号之后,内核执行sighandler函数,通过pushCall插入asyncPreempt函数调用
    • 回到当前G执行asyncPreempt函数
    • 将当前goroutine插入查到全局可运行队列,M则继续寻找其他G来执行
    • 被抢占的G再次调度执行时,会继续原来的执行流。

:GMP调度过程中存在哪些阻塞:

  • IO
  • syscall
  • channel
  • 等待锁
  • runtime.Gosched

:抢占式调度用到的信号是什么,了解过这个信号吗
答:用的是SIGURG信号,是用来处理带外数据的,带外数据就是用来迅速告知对方本端发生的重要事件,比带内数据拥有更高的优先级,不论发送缓冲区中是否有排队等待发送的数据都是立即发送。
:信号抢占的机制是什么,还是随便就让G被抢占了
:sysmon会做周期性检查,向运行时间超过10ms的g进行retake,还有就是收回因syscall长时间阻塞的P
sysmon的作用:

  - 释放闲置超过5分钟的span物理内存
  - 如果2分钟没有进行GC就会强制执行
  - 将长时间未处理的netpool结果添加到任务队列
  - 向运行时间超过10ms的g进行retake
  - 还有就是收回因syscall长时间阻塞的P

:GC(STW)了解过吗?
:golang的GC发展有几个阶段

  • v1.1 mark sweep STW
  • v1.3 Mark STW Sweep 并行
  • v1.5 三色标记
  • v1.8 混合写屏障
  • 经典的GC算法有三种:引用计数(reference counting)、标记清扫(mark&sweep)、复制收集(copy and collection),golang的GC主要是基于mark&sweep算法,并在这个基础上进行了改进。
    • mark&sweep
      • 主要有两个步骤
        • mark 找出不可达对象,然后做上标记
        • sweep 回收标记好的对象
      • 操作非常简单,就是mark&sweep算法执行的时候需要stw
      • 这个算法存在的问题:
        • stw让程序暂停,出现卡顿
        • 标记需要扫描整个heap
        • 清除数据会产生heap碎片
        • 这里面最重要的问题就是需要stw
    • mark、sweep、stw并行
      • 1.3版本golang分离了mark和sweep操作,开始和以前一样,暂停所有任务开始mark,但是mark完马上就会启动被暂停的任务,让sweep任务和普通协程任务一样并行执行。如果运行在多核处理器上,go会试图将gc放在单独核心上尽量不影响业务代码运行。go team自己的说法是减少了50%-70%的暂停时间
    • 三色标记法
      • 1.4埋下了引入写屏障的伏笔,在1.5版本更新了非分代的、非移动的、并发的、三色的标记清除垃圾收集器。分为四个阶段
      • 首先:程序创建的对象都标记为白色
        • gc开始stw暂停程序
        • 启动标记工作协程,用于第二阶段
        • 启动写屏障
        • 扫描所有可达到的对象,标记为灰色
        • 取消stw
        • 从灰色对象中找到其引用对象标记为灰色,把灰色对象本身标记为黑色
        • 监视对象中的内存更改,如果程序新创建或者修改了对象,就会触发写屏障,将对象放入单独的marking队列,也标记为灰色
        • 持续上一步操作,直到没有灰色标记的对象
        • 扫描完队列里的对象,就会进入第三阶段
      • 三:
        • stw
        • 将marking阶段触发写屏障的队列内的对象取出,标记为黑色
        • 然后检测是否指向了另一个对象,如果有,就将另一个对象放入标记阶段
        • 扫描完marking队列里的对象,就停止stw,进入第四阶段
      • 四:
        • 清除所有的白色对象
    • 混合写屏障
      • 1.8引入混合写屏障,因为1.5的插入写屏障的栈对象无法hook,导致扫描完还需要一次STW重新扫描栈。所以引入了混合写屏障
      • 继承了插入写屏障的优点,开始的时候不需要STW,直接并发扫描
      • 继承了删除写屏障的优点,扫描一次就不需要再扫描了,这样就消除了插入写屏障最后还需要一次STW
      • 混合写屏障精度继承了删除写屏障,比插入写屏障精度低,但是带来的好处是GC全程没有STW
      • 虽然说没有全局STW,但是对于扫描到具体的栈时,还是需要STW来暂停goroutine赋值器的工作,针对一个goroutine栈来说,是暂停扫的,要么全灰,要么全黑,原子状态。

:你知道GC触发的几种情况吗?

gc可以主动触发,也可以被动触发。主动触发就是调用runtime.GC强制开启GC。被动触发有几种情况,一种是超过一段时间没有GC就会启动GC,系统监控sysmon判断runtime.forceGcPeriod变量,一般是2分钟。还有一种是默认内存扩大一倍,就启动GC。
:你还了解其他的GC方式吗
分代收集:将heap划分为两个或多个代(generation)的空间。新创建的对象放在成为新生代(young generation)中,随着垃圾回收的重复执行,生命周期较长的对象会被提升(promotion)到老年代中。新生代垃圾回收和老年代有两种不同的垃圾回收方式。新生代的垃圾回收速度快。
:go的内存管理方式你了解过吗
:gc触发的内存,比如第一次在5m的时候触发,下一次触发就会在10m。还有就是如果你的内存使用一直很少,就会在每forcegcperiod时触发,默认2min。
:go的内存分配你了解过吗
:go在程序启动的时候,会先向操作系统申请一块内存(这时还只是一段虚拟的地址空间,并不会真正的分配内存),切成小块后自己进行管理。
image.png
申请到的内存被分配了三个区域,在X64上分别是

  • 512M的spans
    • spans区域存放mspan(也就是arena分割的页组合起来的内存基本管理单元)的指针,每个指针对应一个页,所以spans的区域的大小就是512GB/8KB*8B=512MB,除以8KB是计算arena区域的页数,而最后乘以8是计算spans区域所有指针的大小。创建mspans的时候,按页填充对应的spans区域,在回收Object时,根据地址很容易就能找到它所属于的mspan
  • 16GB的bitmap
    • 标识arena区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、gc的标记信息。bitmap中一个byte大小的内存对应了arena区中4个指针大小(指针大小为8B)的内存,所以bitmap区域的大小是512GB/(4*8B)=16GB
  • 512GB的arena
    • 就是我们所谓的堆区,go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,这些页组合起来称为mspan
    • image.png
    • 这图里还能看到bitmap的高地址区域指向arena的低地址区域,也就是说bitmap的地址是由高地址向低地址增长的

*golang 的堆和栈的分配还有薄弱
:channel是分配在堆上的还是栈上的,golang中哪些对象分配在堆上哪些分配在栈上。

channel分配在堆上,一般全局的对象分配在堆上,局部变量分配在栈上。
(这里答的不好,查一下答案)
:大对象小对象你了解过吗?
:golang中将长度小于16bytes的对象称为小对象,最常见的就是小字符串,一般会将微小对象组合起来,用单块内存存储,这样可以有效的减少内存浪费。当微小对象需要分配空间span,首先缓存部件会按照指定的规格(tiny size class)取出一块内存,如果容量不足就重新提取一块;前面也会提到将微小对象进行组合,而这些组合的微小对象是不能包含指针的,因为垃圾回收的原因,一般都是将当前存储单元里所有的微小对象都不可达时再将这块内存进行回收。go的内存分配器在分配对象时,根据对象的大小,分成三类:小对象 (小于等于16B)、一般对象(大于16B,小于等于32KB)、大对象(大于32KB),大体的分配流程:

  • <=16B的对象使用mcache的tiny分配器分配
  • (16B,32KB]的对象首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配
    • 如果mcache没有相应规格大小的mspan,就向mcentral申请
    • 如果mcentral没有相应规格大小的mspan,就向mheap申请
    • 如果mheap也没有,就向操作系统申请
  • 大于32KB的对象,直接从mheap上分配

:那你们在项目中遇到过内存溢出的情况吗?
:(这是我本人写的)这一段可以自己来操作一下,select + time.After会有坑,然后用go tool pprof来检查出来,这样也有了go tool的经验
:那你在go的实际项目中遇到过什么坑没有
:(本人回复)查一下数据竞争
:那你工作中有没有遇到过什么挑战,你是如何解决掉的
:这个根据个人情况不同想出一个回复,但一定要设计一些具体的知识点来让考官问下去,不能说一个很宏大的困难,靠时间去完成。
:你提到了指令在cpu中是乱序执行的, 那你知道如何强制cpu按顺序执行指令吗(内存屏障)

Nginx为什么快

  • nginx采用多进程+异步非阻塞的方式(IO多路复用epoll)
  • 一个nginx有一个master进程和多个worker进程,master进程负责管理worker进程,同时为了避免进程数过多,在nginx.conf中可以配置核心数
  • worker进程竞争新的连接,获胜方通过三次握手建立socket连接

选一个比较熟悉的项目讲讲
筛选日志的时候,日志格式是不一样的,你们是如何处理的?
处理日志的时候如果日志量比较大会堆积吗?怎么处理的?
日志落盘到机器上,是如何采集的?
采集服务有问题的话可能会影响报警的及时性吗?
处理日志的时候如果发现突然量变大,该如何扩容让以前堆积的日志可以消耗掉?
调研的正则库内部是怎么实现的?
go里面比较成熟的日志框架了解过没有
redis发布订阅怎么实现的?你自己要怎么实现?
redis高可用怎么做?
了解redis主从复制机制吗?
分布式锁有哪些实现方式
redis的setnx底层怎么实现的?
go协程线程进程区别
详细讲下gmp模型
了解的gc算法有哪些?
go的gc原理了解吗?
go的init函数是什么时候执行的?
多个init函数执行顺序能保证吗?
gin框架的路由是怎么处理的?
mysql索引结构
B+树和B树有什么区别
快排