Big Picture

年底了,面试/被面试的需求比较多,正好有需要让我整理一份面试题,干脆写一份,权当对自己的复习/整理了

  1. go语言切片和数组区别,go切片的原理:
  • 数组[array]:

初始化后长度是固定的,无法修改其长度

初始化: array := [5]int{1,2,3,4,5}/ array := […]int{1,2,3,4,5}

Go中的数组是值类型

  • 切片[slices]: s :=[]int{1,2,3} / s := make([]int,len,cap)

长度可变的"数组",可以追加元素,一是len长度,二是cap容量,长度是指已经被赋过值的最大下标+1,可通过内置函数len()获得

初始化:
切片是引用类型, 数据结构其实如下:

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

其中,Data是uintptr, 就是个指针类型,表示Data指向的一块连续的内存空间,所以切片可以理解为一片连续的内存空间加上长度与容量标识,扩容后,如果切片的容量小于1024个元素,那么扩容的时候slice的cap就翻番,乘以2;一旦元素个数超过1024个元素,增长因子就变成1.25,即每次增加原来容量的四分之一,如果扩容之后,还没有触及原数组的容量,那么,切片中的指针指向的位置,就还是原数组,如果扩容之后,超过了原数组的容量,那么,Go就会开辟一块新的内存,把原来的值拷贝过来,这种情况丝毫不会影响到原数组。

  1. go并发模型有哪些?现在需要启动30个协程来处理一个文件,怎么写
for i:=0; i < 30; i++ {
    go func{
        // do something
    }()
}
  1. go的channel介绍一下

    channel用于goroutines之间的通信,让它们之间可以进行数据交换。像管道一样,一个goroutine_A向channel_A中放数据,另一个goroutine_B从channel_A取数据。

初始化: ch := make(chan int)

读写,ch <- VALUE(给ch赋值), res := <- ch(将channel数据send出去)

非缓存 channel, 同步操作,channel 里的数据没有send出去则一直阻塞

缓存 channel, 异步操作,channel 里的数据没有满之前,可以继续写入,如果写满了,且没有send出去则一直阻塞,缓存channel 有长度和容量2个概念,和切片类似。

  1. go的死锁

    简单来说就是channel 阻塞,比如协程A 生产消息到channel ch,然后协程B 消费 channel ch的消息,然后在同一时间,channel ch的消息无法被协程B消费,且由于channel ch存在数据导致了协程A 被阻塞,从而产生死锁,最常见的死锁代码:
package main 

import (
	"fmt"
)

func main (){
	counter := make(chan int)
	counter <- 32
	fmt.Println(<-counter)
}

上面的代码会报fatal error: all goroutines are asleep - deadlock!,原因是main函数也是个协程,协程Main 里的channel <- 32导致了协程Main被阻塞,导致了后续的<-counter无法消费channel的数据,从而导致了死锁,解决方案可以再启动一个协程

package main 

import (
	"fmt"
)

func main (){
	counter := make(chan int)
    go func() {
	    counter <- 32
    }()
    
    fmt.Println(<-counter)
}
  1. go为什么这么快
  • golang 的协程是非常轻量级的用户态线程,不像线程那样需要内核-用户态的上下文切换
  • 线程栈空间通常是 2M,Goroutine 栈空间最小 2K,可以动态扩容,最大64位机器是1G,32位是256MB
  • go的G-P-M模型
  • GC 还会周期性地将不再使用的内存回收,收缩栈空间。
  1. 什么的go的G-P-M 模型

    goroutine 并非传统意义上的协程,现在主流的线程模型分三种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型),传统的协程库属于用户级线程模型,而 goroutine 和它的 Go Scheduler 在底层实现上其实是属于两级线程模型。
  • 内核级线程模型: 用户线程绑定一个内核线程,应用程序对线程的调度完全交给内核来控制。
  • 用户级线程模型: 多个用户线程绑定一个内核线程,用户线程的控制都可以在用户态由程序控制,但问题在于假如用户线程里的一个线程被阻塞了,会导致其他线程都会阻塞。
  • 混合型线程模型: 多个用户进程和多个内核线程绑定,这样当某个用户线程被阻塞了,也只会影响同样绑定到对于内核线程的用户线程。
    GPM的含义:
  • G: 表示 Goroutine,每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。
  • P: Processor,表示逻辑处理器, 对 G 来说,P 相当于 CPU 核,G 只有绑定到 P(在 P 的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量),P 的数量由用户设置的 GOMAXPROCS 决定,但是不论 GOMAXPROCS 设置为多大,P 的数量最大为 256。
  • M: Machine,OS 线程抽象,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础,M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。
    调度过程:

    当通过 go 关键字创建一个新的 goroutine 的时候,它会优先被放入 P 的本地队列。为了运行 goroutine,M 需要持有(绑定)一个 P,接着 M 会启动一个 OS 线程,循环从 P 的本地队列里取出一个 goroutine 并执行。执行调度算法:当 M 执行完了当前 P 的 Local 队列里的所有 G 后,P 也不会就这么在那划水啥都不干,它会先尝试从 Global 队列寻找 G 来执行,如果 Global 队列为空,它会随机挑选另外一个 P,从它的队列里中拿走一半的 G 到自己的队列中执行。
  1. 函数参数是值传递还是引用传递

    值传递
  2. go的继承和多态是如何实现的
  • 继承: 基于结构体继承:
type Father struct {
  name string
  age int
}
type Son struct {
  Father
  agent int
}
student := Son{Father{"jake",46},12}
  • 多态: 基于interface实现:
package main

import "fmt"

type Human interface {
    speak(language string)
}

type Chinese struct {

}

type American struct {

}

func (ch Chinese) speak(language string ) {
  fmt.Printf("Chinese speck %s\n",language)
}

func (am American ) speak(language string ) {
  fmt.Printf("America speck %s\n",language)
}

func main() {
    var ch Human
    var am Human

    ch = Chinese{}
    am = American{}

    ch.speak("Chinese")
    am.speak("English")
}
  1. 进程,线程,协程
  • 进程:进程是系统进行资源分配的基本单位,有独立的内存空间。

  • 线程:线程是 CPU 调度和分派的基本单位,线程依附于进程存在,每个线程会共享父进程的资源。

  • 协程:协程是一种用户态的轻量级线程,协程的调度完全由用户控制,协程间切换只需要保存任务的上下文,没有内核的开销。

  1. sync.WaitGroup啥时候用,channel 啥时候用?
    先看下sync.WaitGroup作用,主进程中启动了多个协程,为了避免主进程结束了,而协程没有结束,所以一般会sleep 对主进程进行阻塞,但sleep多少秒是为止的,所以通过sync.waitgroup 进行协程的阻塞,确保协程执行完以后,才到主进程, 好处在于处理多个协程很方便,但如果多个协程需要channel 进行通信,则不合适。
package main
import (
    "sync"
    "time"
    "fmt"
)

func main() {
	var wg sync.WaitGroup
    wg.Add(1)
    go func(wg *sync.WaitGroup) {
        fmt.Println("Wait worker sleep")
        time.Sleep(5 * time.Second)
        wg.Done()
    }(&wg)
    wg.Wait()
}

channel 一样可以实现类似的效果,通过channel 阻塞的特性进行实现,好处在于可以利用channel 进行多个进程的通信,但是需要创建很多个channel,影响性能

package main
 
import (
	"fmt"
)
 
func main() {
	a := 1
    b := make(chan int)
	go func() {
		a = (a + 1) * 100
		b <- 0
	}()
	<-b
    fmt.Println(a)
}
  1. go编译之后文件太大优化处理

    go build -ldflags -w test.go //会去除 DWARF调试信息、符号信息

  2. go的并发安全性
    说白了为了避免2个进程/协程同时写同一段内存,需要在操作这段内存(变量)前加锁

package main 
import (
    "sync"
    "fmt"
)
type TestData struct {
    lock  sync.RWMutex
    data  string
}

func (t *TestData) write(wg *sync.WaitGroup) {
    for i := 0; i < 10; i++ {
        t.lock.Lock()
        t.data = "a"
        t.lock.Unlock()
    }
    wg.Done()
}

func (t *TestData) read(wg *sync.WaitGroup) {
    for i := 0; i < 10; i++ {
        t.lock.Lock()
        fmt.Println(t.data)
        t.lock.Unlock()
    }
    
    wg.Done()
}

func main() {
	var wg sync.WaitGroup
    var t TestData
    wg.Add(1)
    go t.write(&wg)

    wg.Add(1)
    go t.read(&wg)
    wg.Wait()   
}

同样也可以通过channel保证线程安全

package main 
import (
    "fmt"
)

func write(data string, lch chan bool) {
    for i := 0; i < 10; i++ {
        data = "a"
    }
    lch <- true
}

func read(data string, lch chan bool) {
    for i := 0; i < 10; i++ {
        fmt.Println(data)
    }
    lch <- true

}

func main() {
    lch := make(chan bool)
    var data string 
    go write(data, lch)
    go read(data, lch)

    <- lch
    fmt.Println("finished")
}