Go常见面试题分享,涵盖了常见的Go面试八股文
Go语言特点
Go语言是一种开源的编程语言,由Google开发,主要用于编写高效、可维护的系统软件。以下是Go语言的一些特性:
- 并发编程:Go语言天生支持并发编程,内置goroutine(协程)和channel(通道)机制,简化了并发编程的实现
- 内存管理:Go语言自带的垃圾回收机制可以自动管理内存,减少了程序员的负担,提高了代码的健壮性和可靠性
- 语法简洁:Go语言的语法简洁明了,支持面向对象和函数式编程风格,使得代码易读易写
- 高效编译:Go语言编译速度快,可生成可执行文件,可以在不同的平台上轻松地交叉编译
- 强类型语言:Go语言是一种强类型语言,可以在编译时检查类型错误,避免了一些常见的程序错误
- 标准库丰富:Go语言的标准库提供了丰富的API,包括网络编程、文件操作、加密、并发等方面,使得开发者可以更快地实现功能。
- 可靠性:Go语言的设计注重可靠性和健壮性,可以有效地避免空指针和数组越界等常见的编程错误
总的来说,Go语言具有简洁、高效、并发、可靠等特性,适合用于编写高性能的服务器端、分布式系统和网络应用等
1. 无缓冲Chan的发送和接收是否同步
1 | ch := make(chan int) 无缓冲的channel由于没有缓冲发送和接收需要同步 |
- channel无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据
- channel有缓冲时,当缓冲满时发送阻塞,当缓冲空时接收阻塞
2. 什么是channel,为什么它可以做到线程安全
- Channel是Go中的一个核心类型,可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯(communication),Channel也可以理解是一个先进先出的队列,通过管道进行通信
- Golang的Channel,发送一个数据到Channel和从Channel接收一个数据都是原子性的
- Go的设计思想就是,不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel
- 设计Channel的主要目的就是在多任务间传递数据的,本身就是安全的
3. Goroutine和Channel的作用分别是什么
进程是内存资源管理和cpu调度的执行单元。为了有效利用多核处理器的优势,将进程进一步细分,允许一个进程里存在多个线程,这多个线程还是共享同一片内存空间,但cpu调度的最小单元变成了线程
协程,可以看作是轻量级的线程。但与线程不同的是,线程的切换是由操作系统控制的,而协程的切换则是由用户控制的
Go中的goroutinue就是协程,可以实现并行,多个协程可以在多个处理器同时跑。而协程同一时刻只能在一个处理器上跑(可以把宿主语言想象成单线程的就好了)
多个goroutine之间的通信是通过channel,在Golang中channel则是goroutinues之间进行通信的渠道。可以把channel形象比喻为工厂里的传送带,一头的生产者goroutine往传输带放东西,另一头的消费者goroutinue则从输送带取东西。channel实际上是一个有类型的消息队列,遵循先进先出的特点
4. Channel是同步的还是异步的
Channel是异步进行的,channel存在3种状态:
- nil,未初始化的状态,只进行了声明,或者手动赋值为nil
- active,正常的channel,可读或者可写
- closed,已关闭,千万不要误认为关闭channel后,channel的值是nil
| 操作 | 一个零值nil通道 | 一个非零值但已关闭的通道 | 一个非零值且尚未关闭的通道 |
|---|---|---|---|
| 关闭 | 产生恐慌 | 产生恐慌 | 成功关闭 |
| 发送数据 | 永久阻塞 | 产生恐慌 | 阻塞或者成功发送 |
| 接收数据 | 永久阻塞 | 永不阻塞 | 阻塞或者成功接收 |
5. Go中的channel的实现
在Go中最常见的就是通信顺序进程(Communicating sequential processes,CSP)的并发模型,通过共享通信,来实现共享内存,这里就提到了channel。Goroutine 和 Channel 分别对应 CSP 中的实体和传递信息的媒介,Go 语言中的 Goroutine 会通过 Channel 传递数据

Goroutine通过使用channel传递数据,一个会向 Channel 中发送数据,另一个会从 Channel 中接收数据,它们两者能够独立运行并不存在直接关联,但是能通过 Channel 间接完成通信
Channel 收发操作均遵循了先入先出(FIFO)的设计,具体规则如下:
- 先从 Channel 读取数据的 Goroutine 会先接收到数据
- 先向 Channel 发送数据的 Goroutine 会得到先发送数据的权利
Channel 通常会有以下三种类型:
- 同步 Channel — 不需要缓冲区,发送方会直接将数据交给(Handoff)接收方
- 异步 Channel:基于环形缓存的传统生产者消费者模型
chan struct{}类型的异步Channel的struct{}类型不占用内存空间,不需要实现缓冲区和直接发送(Handoff)的语义
Channel 在运行时使用 runtime.hchan 结构体表示:
1 | type hchan struct { |
其中hchan结构体中有五个字段是构建底层的循环队列:
1 | qcount:Channel中的元素个数 |
6. Golang并发机制以及它所使用的CSP并发模型
在计算机科学中,通信顺序过程(communicating sequential processes,CSP)是一种描述并发系统中交互模式的正式语言,它是并发数学理论家族中的一个成员,被称为过程算法(process algebras),或者说过程计算(process calculate),是基于消息的通道传递的数学理论
Golang中的Goroutine的特性:
Golang内部有三个对象: P对象(processor) 代表上下文(或者可以认为是cpu),M(work thread)代表工作线程,G对象(goroutine)
正常情况下一个cpu对象启一个工作线程对象,线程去检查并执行goroutine对象。碰到goroutine对象阻塞的时候,会启动一个新的工作线程,以充分利用cpu资源。所以有时候线程对象会比处理器对象多很多
- G(Goroutine) :我们所说的协程,为用户级的轻量级线程,每个Goroutine对象中的sched保存着其上下文信息
- M(Machine) :对Os内核级线程的封装,数量对应真实的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
Golang的CSP并发模型,是通过Goroutine和Channel来实现的
因此,GPM的简要概括即为:事件循环,线程池,工作队列
7. Go的GPM如何调度
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置
线程和进程的操作是由程序触发系统接口,最后的执行者是系统;协程的操作执行者则是用户自身程序,goroutine也是协程
groutine能拥有强大的并发实现是通过GPM调度模型实现
Go的调度器内部有四个重要的结构:M,G,P,Sched:
- M:M代表内核级线程,一个M就是一个线程,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息
- G:代表一个goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度
- P:P全称是Processor,逻辑处理器,它的主要用途就是用来执行goroutine的,所以它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine
- Sched:代表调度器,它维护有存储M和G的队列以及调度器的一些状态信息等
- 新创建的Goroutine会先存放在Global全局队列中,等待Go调度器进行调度,随后Goroutine被分配给其中的一个逻辑处理器P,并放到这个逻辑处理器对应的Local本地运行队列中,最终等待被逻辑处理器P执行即可
- 在M与P绑定后,M会不断从P的Local队列中无锁地取出G,并切换到G的堆栈执行,当P的Local队列中没有G时,再从Global队列中获取一个G,当Global队列中也没有待运行的G时,则尝试从其它的P窃取部分G来执行相当于P之间的负载均衡
- 每一个M都拥有一个处理器P,每一个也都有一个正在运行的goroutine。P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行
- 对于没有运行的goroutine,而是出于ready的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue),Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个goroutine,在下一个调度点,就从runqueue中取出(如何决定取哪个goroutine?)一个goroutine执行
- 当一个OS线程M0陷入阻塞时,P转而在运行M1,图中的M1可能是正被创建,或者从线程缓存中取出
- 当MO返回时,它必须尝试取得一个P来运行goroutine,一般情况下,它会从其他的OS线程那里拿一个P过来,如果没有拿到的话,它就把goroutine放在一个
global runqueue里,然后自己睡眠(放入线程缓存里)。所有的P也会周期性的检查global runqueue并运行其中的goroutine,否则global runqueue上的goroutine永远无法执行 - 另一种情况是P所分配的任务G很快就执行完了(分配不均),这就导致了这个处理器P处于空闲的状态,但是此时其他的P还有任务,此时如果global runqueue没有任务G了,那么这个P就会从其他的P里偷取一些G来执行。通常来说,如果P从其他的P那里要拿任务的话,一般就拿
run queue的一半,这就确保了每个OS线程都能充分的使用
8. Golang中常用的并发模型
Golang中常用的并发模型有三种:
- 通过channel通知实现并发控制
- 通过sync包中的WaitGroup实现并发控制
- 在Go1.7以后引进的强大的Context上下文,实现并发控制
通过channel通知实现并发控制
无缓冲的通道指的是通道的大小为0,也就是说,这种类型的通道在接收前没有能力保存任何值,它要求发送 goroutine 和接收 goroutine 同时准备好,才可以完成发送和接收操作
发送 goroutine 和接收 gouroutine 必须是同步的,同时准备后,如果没有同时准备好的话,先执行的操作就会阻塞等待,直到另一个相对应的操作准备好为止
1
2
3
4
5
6
7
8
9
10func main() {
ch := make(chan struct{})
go func() {
fmt.Println("start working")
time.Sleep(time.Second * 1)
ch <- struct{}{}
}()
<-ch
fmt.Println("finished")
}当主 goroutine 运行到
<-ch接受 channel 的值的时候,如果该 channel 中没有数据,就会一直阻塞等待,直到有值。 这样就可以简单实现并发控制
通过sync包中的WaitGroup实现并发控制
Goroutine是异步执行的,有的时候为了防止在结束main函数的时候结束掉Goroutine,所以需要同步等待,这个时候就需要用 WaitGroup了,在sync包中,提供了WaitGroup,它会等待它收集的所有Goroutine任务全部完成。在WaitGroup里主要有三个方法:
- Add,可以添加或减少Goroutine的数量
- Done,相当于Add(-1)
- Wait,执行后会堵塞主线程,直到WaitGroup里的值减至0
在主 goroutine 中 Add(delta int) 索要等待goroutine 的数量。在每一个 goroutine 完成后 Done() 表示这一个goroutine 已经完成,当所有的 goroutine 都完成后,在主 goroutine 中 WaitGroup 返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15func 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()
}在Golang官网中对于WaitGroup介绍是
A WaitGroup must not be copied after first use,在 WaitGroup第一次使用后,不能被拷贝1
2
3
4
5
6
7
8
9
10
11
12func 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")
}运行之后会报错:
1
2
3
4
5
6
7
8
9
10i: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它提示所有的
goroutine都已经睡眠了,出现了死锁。这是因为wg给拷贝传递到了goroutine中,导致只有Add操作,其实Done操作是在wg的副本执行的,因此 Wait 就会死锁这个第一个修改方式:将匿名函数中wg的传入类型改为
*sync.WaitGroup,这样就能引用到正确的WaitGroup了这个第二个修改方式:将匿名函数中的wg的传入参数去掉,因为Go支持闭包类型,在匿名函数中可以直接使用外面的wg变量
Context上下文实现并发控制
通常,在一些简单场景下使用channel和WaitGroup已经足够了,但是当面临一些复杂多变的网络并发场景下channel和WaitGroup显得有些力不从心了,比如一个网络请求Request,每个Request都需要开启一个goroutine做一些事情,这些goroutine又可能会开启其他的goroutine,比如数据库和RPC服务
所以我们需要一种可以跟踪goroutine的方案,才可以达到控制他们的目的,这就是Go语言为我们提供的Context,称之为上下文非常贴切,它就是goroutine的上下文,它是包括一个程序的运行环境、现场和快照等。每个程序要运行时,都需要知道当前程序的运行状态,通常Go将这些封装在一个 Context里,再将它传给要执行的goroutine ,context包主要是用来处理多个goroutine之间共享数据,及多个goroutine的管理
context包的核心是struct Context,接口声明如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// 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() 返回一个只能接受数据的channel类型,当该context关闭或者超时时间到了的时候,该channel就会有一个取消信号
Done() <-chan struct{}
// Err indicates why this Context was canceled, after the Done channel
// is closed.
// Err() 在Done() 之后,返回context 取消的原因。
Err() error
// Deadline returns the time when this Context will be canceled, if any.
// Deadline() 设置该context cancel的时间点
Deadline() (deadline time.Time, ok bool)
// Value returns the value associated with key or nil if none.
// Value() 方法允许 Context 对象携带request作用域的数据,该数据必须是线程安全的。
Value(key interface{}) interface{}
}Context对象是线程安全的,你可以把一个Context对象传递给任意个数的gorotuine,对它执行 取消 操作时,所有goroutine都会接收到取消信号。一个Context不能拥有Cancel方法,同时我们也只能Done channel接收数据。其中的原因是一致的:接收取消信号的函数和发送信号的函数通常不是一个。典型的场景是:父操作为子操作操作启动goroutine,子操作也就不能取消父操作
9. 并发编程概念是什么
- 并行是指两个或者多个事件在同一时刻发生;并发是指两个或多个事件在同一时间间隔发生
- 并行是在不同实体上的多个事件,并发是在同一实体上的多个事件。在一台处理器上“同时”处理多个任务,在多台处理器上同时处理多个任务。如hadoop分布式集群
- 并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的“同时执行”
- 并发编程是指在一台处理器上“同时”处理多个任务。并发是在同一实体上的多个事件。多个事件在同一时间间隔发生。并发编程的目标是充分的利用处理器的每一个核,以达到最高的处理性能
10. 怎么查看Goroutine的数量
在Golang中,GOMAXPROCS中控制的是未被阻塞的所有Goroutine,可以被 Multiplex 到多少个线程上运行,通过GOMAXPROCS可以查看Goroutine的数量
11. 怎么限制Goroutine的数量
在Golang中,Goroutine虽然很好,但是数量太多了,往往会带来很多麻烦,比如耗尽系统资源导致程序崩溃,或者CPU使用率过高导致系统忙不过来。所以我们可以限制下Goroutine的数量,这样就需要在每一次执行go之前判断goroutine的数量,如果数量超了,就要阻塞go的执行
- 使用通道,每次执行的go之前向通道写入值,直到通道满的时候就阻塞了
- 但是新的问题于是就出现了,因为并不是所有的goroutine都执行完了,在main函数退出之后,还有一些goroutine没有执行完就被强制结束了。这个时候我们就需要用到sync.WaitGroup。使用WaitGroup等待所有的goroutine退出
- Go的GOMAXPROCS默认值已经设置为CPU的核数, 这里允许我们的Go程序充分使用机器的每一个CPU,最大程度的提高我们程序的并发性能。
runtime.NumGoroutine函数在被调用后,会返回系统中的处于特定状态的Goroutine的数量。这里的特指是指Grunnable\Gruning\Gsyscall\Gwaition。处于这些状态的Groutine即被看做是活跃的或者说正在被调度 - 注意: 垃圾回收所在Groutine的状态也处于这个范围内的话,也会被纳入该计数器
12. Goroutine和线程的区别
从调度上看,goroutine的调度开销远远小于线程调度开销
- 线程:OS的线程由OS内核调度,每隔几毫秒,一个硬件时钟中断发到CPU,CPU调用一个调度器内核函数。这个函数暂停当前正在运行的线程,把他的寄存器信息保存到内存中,查看线程列表并决定接下来运行哪一个线程,再从内存中恢复线程的注册表信息,最后继续执行选中的线程。这种线程切换需要一个完整的上下文切换:即保存一个线程的状态到内存,再恢复另外一个线程的状态,最后更新调度器的数据结构。某种意义上,这种操作还是很慢的
- goroutine:Go运行的时候包涵一个自己的调度器,这个调度器使用一个称为一个M:N调度技术,m个goroutine到n个os线程(可以用GOMAXPROCS来控制n的数量),Go的调度器不是由硬件时钟来定期触发的,而是由特定的go语言结构来触发的,他不需要切换到内核语境,所以调度一个goroutine比调度一个线程的成本低很多
从栈空间上,goroutine的栈空间更加动态灵活
每个OS的线程都有一个固定大小的栈内存,通常是2MB,栈内存用于保存在其他函数调用期间哪些正在执行或者临时暂停的函数的局部变量。这个固定的栈大小,如果对于goroutine来说,可能是一种巨大的浪费。作为对比goroutine在生命周期开始只有一个很小的栈,典型情况是2KB, 在go程序中,一次创建十万左右的goroutine也不罕见(2KB*100,000=200MB)。而且goroutine的栈不是固定大小,它可以按需增大和缩小,最大限制可以到1GB
goroutine没有一个特定的标识
在大部分支持多线程的操作系统和编程语言中,线程有一个独特的标识,通常是一个整数或者指针,这个特性可以让我们构建一个线程的局部存储,本质是一个全局的map,以线程的标识作为键,这样每个线程可以独立使用这个map存储和获取值,不受其他线程干扰
goroutine中没有可供程序员访问的标识,原因是一种纯函数的理念,不希望滥用线程局部存储导致一个不健康的超距作用,即函数的行为不仅取决于它的参数,还取决于运行它的线程标识
13. 在Go函数中为什么会发生内存泄露,Goroutine发生了泄漏如何检测
内存泄漏,指的是能够预期的能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收
- 预期能被快速释放的内存因被根对象引用而没有得到迅速释放;当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放
- Goroutine泄漏;Goroutine作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的goroutine并复用这部分内存,就会造成内存泄漏的现象
- 检测方法:通过Go自带的工具pprof或者使用Gops去检测诊断当前在系统上运行的Go进程的占用的资源
13.1 PProf是什么
PProf是一个Go程序性能分析工具,可以分析CPU,内存等性能Go在语言层面上集成了profile采样工具,只需要在代码中简单的引入runtime/ppro或者net/http/pprof包即可获取程序的profile文件,并通过该文件来进行性能分析runtime/pprof还可以为控制台程序或者测试程序产生pprof数据- 其实
net/http/pprof中只是使用runtime/pprof包来进行封装了一下,并在HTTP端口上暴露出来
13.2 PProf使用
在gin的路由中注册pprof的路由
1 | // pprof router |
通过go tool pprof http://127.0.0.1/debug/pprof/profile可以获取profile采集信息并分析
也可以直接在浏览器访问http://localhost:8080/debug/pprof来查看当前API服务的状态,包括CPU占用情况和内存使用情况等
通过topN的输出可以分析出哪些函数占用CPU时间片最多,这些函数可能存在性能问题。此命名用于显示profile文件中的最靠前的N个样本(默认前10个sample),它的输出格式字段的含义依次是:
- 采样点落在该函数中的总时间
- 采样点落在该函数中的百分比
- 上一项的累积百分比
- 采样点落在该函数,以及被它调用的函数中的总时间
- 采样点落在该函数,以及被它调用的函数中的总次数百分比
- 函数名
如果觉得不直观,可以直接再生成函数调用图,通过调用图来判断哪些函数耗时最久,在pprof交互界面,执行svg生成svg文件
但是需要确保系统已经安装graphviz命令(Mac OS:brew install graphviz; CentOS: yum -y install graphviz.x86_64)
14. Goroutine和内核线程(KernelThread)之间是什么关系
进程、线程、协程的关系和区别:
- 进程拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度
- 线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程亦由操作系统调度(标准线程是的)
- 协程和线程一样共享堆,不共享栈,协程由程序开发者在协程的代码里显示调度
为什么协程比线程轻量?
- go协程调用跟切换比线程效率高
- 线程并发执行流程: 线程是内核对外提供的服务,应用程序可以通过系统调用让内核启动线程,由内核来负责线程调度和切换。线程在等待IO操作时线程变为unrunnable状态会触发上下文切换。现代操作系统一般都采用抢占式调度,上下文切换一般发生在时钟中断和系统调用返回前,调度器计算当前线程的时间片,如果需要切换就从运行队列中选出一个目标线程,保存当前线程的环境,并且恢复目标线程的运行环境,最典型的就是切换ESP指向目标线程内核堆栈,将EIP指向目标线程上次被调度出时的指令地址
- go协程并发执行流程:不依赖操作系统和其提供的线程,golang自己实现的CSP并发模型实现:M, P, G .go协程也叫用户态线程,协程之间的切换发生在用户态。在用户态没有时钟中断,系统调用等机制,因此效率高
- go协程占用内存少
- 执行go协程只需要极少的栈内存(大概是4~5KB),默认情况下,线程栈的大小为1MB。goroutine就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈。所以它非常廉价,我们可以很轻松的创建上万个goroutine,但它们并不是被操作系统所调度执行
- 因此协程和线程一样共享堆,不共享栈,协程由用户态下面的轻量级线程
15. 协程和线程和进程的区别
- 进程:是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。
每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全 - 线程:是进程的一个实体,线程是内核态,而且是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据
- 协程:是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快
16. G0的作用
- 在Go中g0作为一个特殊的goroutine,为scheduler执行调度循环提供了场地(栈)。对于一个线程来说,g0总是它第一个创建的 goroutine。之后,它会不断地寻找其他普通的goroutine来执行,直到进程退出
- 当需要执行一些任务,且不想扩栈时,就可以用到 g0了,因为g0的栈比较大
- g0 其他的一些“职责”有:创建
goroutine、deferproc函数里新建_defer、垃圾回收相关的工作(例如stw、扫描goroutine的执行栈、一些标识清扫的工作、栈增长)等等
17. Golang的内存模型中为什么小对象多了会造成GC压力
通常小对象过多会导致GC三色法消耗过多的CPU。优化思路:减少对象分配
18. Go的对象在内存中是怎样分配的
Go中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分
大对象指大小大于32kb,小对象是在mcache中分配的,而大对象是直接从mheap分配的,从小对象的内存分配看起
Go的内存分配原则:
- Go在程序启动的时候,会先向操作系统申请一块内存(注意这时还只是一段虚拟的地址空间,并不会真正地分配内存),切成小块后自己进行管理
- 申请到的内存块被分配了三个区域,在X64上分别是512MB,16GB,512GB大小
- arena区域就是我们所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来称为mspan
- bitmap区域标识arena区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。bitmap中一个byte大小的内存对应arena区域中4个指针大小(指针大小为 8B )的内存,所以bitmap区域的大小是
512GB/(4*8B)=16GB - bitmap的高地址部分指向arena区域的低地址部分,这里bitmap的地址是由高地址向低地址增长的
- spans区域存放mspan(是一些arena分割的页组合起来的内存管理基本单元,后文会再讲)的指针,每个指针对应一页,所以spans区域的大小就是
512GB/8KB*8B=512MB - 除以8KB是计算arena区域的页数,而最后乘以8是计算spans区域所有指针的大小。创建mspan的时候,按页填充对应的spans区域,在回收object时,根据地址很容易就能找到它所属的mspan
19. 栈的内存是怎么分配的
栈和堆只是虚拟内存上2块不同功能的内存区域:
- 栈在高地址,从高地址向低地址增长
- 堆在低地址,从低地址向高地址增长
栈和堆相比优势:
- 栈的内存管理简单,分配比堆上快。
- 栈的内存不需要回收,而堆需要,无论是主动free,还是被动的垃圾回收,这都需要花费额外的CPU。
- 栈上的内存有更好的局部性,堆上内存访问就不那么友好了,CPU访问的2块数据可能在不同的页上,CPU访问数据的时间可能就上去了
20. 堆内存管理怎么分配的
- 通常在Golang中,当我们谈论内存管理的时候,主要是指堆内存的管理,因为栈的内存管理不需要程序去操心
- 堆内存管理中主要是三部分, 1.分配内存块,2.回收内存块, 3.组织内存块
- 一个内存块包含了3类信息:元数据、用户数据和对齐字段,内存对齐是为了提高访问效率。申请内存的时候,就需要进行内存对齐
- 释放内存实质是把使用的内存块从链表中取出来,然后标记为未使用,当分配内存块的时候,可以从未使用内存块中有先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存
- 因为随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将2个连续的未使用的内存块合并,减少碎片
21. 在Go函数中为什么会发生内存泄露
同上13
22. Go语言的栈空间管理是怎么样的
Go语言的运行环境(runtime)会在goroutine需要的时候动态地分配栈空间,而不是给每个goroutine分配固定大小的内存空间。这样就避免了需要程序员来决定栈的大小
分块式的栈是最初Go语言组织栈的方式。当创建一个goroutine的时候,它会分配一个8KB的内存空间来给goroutine的栈使用。我们可能会考虑当这8KB的栈空间被用完的时候该怎么办?
为了处理这种情况,每个Go函数的开头都有一小段检测代码。这段代码会检查我们是否已经用完了分配的栈空间。如果是的话,它会调用morestack函数。morestack函数分配一块新的内存作为栈空间,并且在这块栈空间的底部填入各种信息(包括之前的那块栈地址)。在分配了这块新的栈空间之后,它会重试刚才造成栈空间不足的函数。这个过程叫做栈分裂(stack split)
在新分配的栈底部,还插入了一个叫做lessstack的函数指针。这个函数还没有被调用。这样设置是为了从刚才造成栈空间不足的那个函数返回时做准备的。当我们从那个函数返回时,它会跳转到lessstack。lessstack函数会查看在栈底部存放的数据结构里的信息,然后调整栈指针(stack pointer)。这样就完成了从新的栈块到老的栈块的跳转。接下来,新分配的这个块栈空间就可以被释放掉了
23. Go中的逃逸分析是什么
在Go中逃逸分析是一种确定指针动态范围的方法,可以分析在程序的哪些地方可以访问到指针。它涉及到指针分析和形状分析
当一个变量(或对象)在子程序中被分配时,一个指向变量的指针可能逃逸到其它执行线程中,或者去调用子程序。如果使用尾递归优化(通常在函数编程语言中是需要的),对象也可能逃逸到被调用的子程序中。 如果一个子程序分配一个对象并返回一个该对象的指针,该对象可能在程序中的任何一个地方被访问到——这样指针就成功“逃逸”了
如果指针存储在全局变量或者其它数据结构中,它们也可能发生逃逸,这种情况是当前程序中的指针逃逸。 逃逸分析需要确定指针所有可以存储的地方,保证指针的生命周期只在当前进程或线程中
导致内存逃逸的情况比较多,有些可能还是官方未能够实现精确的分析逃逸情况的 bug,通常来讲就是如果变量的作用域不会扩大并且其行为或者大小能够在编译的时候确定,一般情况下都是分配到栈上,否则就可能发生内存逃逸分配到堆上
内存逃逸的五种情况:
- 发送指针的指针或值包含了指针到
channel中,由于在编译阶段无法确定其作用域与传递的路径,所以一般都会逃逸到堆上分配 - slices 中的值是指针的指针或包含指针字段。一个例子是类似
[]*string的类型。这总是导致 slice 的逃逸。即使切片的底层存储数组仍可能位于堆栈上,数据的引用也会转移到堆中。 - slice 由于 append 操作超出其容量,因此会导致 slice 重新分配。这种情况下,由于在编译时 slice 的初始大小的已知情况下,将会在栈上分配。如果 slice 的底层存储必须基于仅在运行时数据进行扩展,则它将分配在堆上
- 调用接口类型的方法。接口类型的方法调用是动态调度,实际使用的具体实现只能在运行时确定。考虑一个接口类型为 io.Reader 的变量 r。对 r.Read(b) 的调用将导致 r 的值和字节片b的后续转义并因此分配到堆上
- 尽管能够符合分配到栈的场景,但是其大小不能够在在编译时候确定的情况,也会分配到堆上
有效的避免上述的五种逃逸的情况,就可以避免内存逃逸
24. Go函数返回局部变量的指针是否安全
在Go中是安全的,Go编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上
25. Golang中除了加Mutex锁以外还有哪些方式安全读写共享变量
Golang中Goroutine可以通过Channel进行安全读写共享变量,还可以通过原子性操作进行
26. Go中的锁有哪些
Go中的三种锁包括:
- 互斥锁
- 读写锁
- sync.Map安全锁
互斥锁
Go并发程序对共享资源进行访问控制的主要手段,由标准库代码包中sync中的Mutex结构体表示,sync.Mutex包中的类型只有两个公开的指针方法Lock和Unlock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// Locker表示可以锁定和解锁的对象。
type Locker interface {
Lock()
Unlock()
}
// 锁定当前的互斥量
// 如果锁已被使用,则调用goroutine
// 阻塞直到互斥锁可用。
func (m *Mutex) Lock()
// 对当前互斥量进行解锁
// 如果在进入解锁时未锁定m,则为运行时错误。
// 锁定的互斥锁与特定的goroutine无关。
// 允许一个goroutine锁定Mutex然后安排另一个goroutine来解锁它。
func (m *Mutex) Unlock()如果对一个已经上锁的对象再次上锁,那么就会导致该锁定操作被阻塞,直到该互斥锁回到被解锁状态。互斥锁锁定操作的逆操作并不会导致协程阻塞,但是有可能导致引发一个无法恢复的运行时的panic,比如对一个未锁定的互斥锁进行解锁时就会发生panic。避免这种情况的最有效方式就是使用defer
读写锁
读写锁是针对读写操作的互斥锁,可以分别针对读操作与写操作进行锁定和解锁操作 。读写锁的访问控制规则如下:
- 多个写操作之间是互斥的
- 写操作与读操作之间也是互斥的
- 多个读操作之间不是互斥的
在这样的控制规则下,读写锁可以大大降低性能损耗。sync中的RWMutex有以下几种方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14//对读操作的锁定
func (rw *RWMutex) RLock()
//对读操作的解锁
func (rw *RWMutex) RUnlock()
//对写操作的锁定
func (rw *RWMutex) Lock()
//对写操作的解锁
func (rw *RWMutex) Unlock()
//返回一个实现了sync.Locker接口类型的值,实际上是回调rw.RLock and rw.RUnlock.
func (rw *RWMutex) RLocker() Locker- 若对一个未被写锁定的读写锁进行写解锁,就会引发一个不可恢复的panic,同理对一个未被读锁定的读写锁进行读写锁也会如此。
- 由于读写锁控制下的多个读操作之间不是互斥的,因此对于读解锁更容易被忽视。对于同一个读写锁,添加多少个读锁定,就必要有等量的读解锁,这样才能其他协程有机会进行操作
- 因此Go中读写锁,在多个读线程可以同时访问共享数据,写线程必须等待所有读线程都释放锁以后,才能取得锁
- 同样的,读线程必须等待写线程释放锁后,才能取得锁,也就是说读写锁要确保的是如下互斥关系,可以同时读,但是读-写,写-写都是互斥的
sync.Map安全锁
golang中的sync.Map是并发安全的,其实也就是sync包中golang自定义的一个名叫Map的结构体
- Delete 方法
- Store 方法
- Load方法
sync.Map是通过冗余的两个数据结构(read、dirty),实现性能的提升。为了提升性能,load、delete、store等操作尽量使用只读的read;为了提高read的key击中概率,采用动态调整,将dirty数据提升为read;对于数据的删除,采用延迟标记删除法,只有在提升dirty的时候才删除
27. Go中的锁如何实现
锁是一种同步机制,用于在多任务环境中限制资源的访问,以满足互斥需求
go源码sync包中经常用于同步操作的方式:
- 原子操作
- 互斥锁
- 读写锁
- waitgroup
互斥锁的数据结构:
1 | // A Mutex is a mutual exclusion lock. |
state和sema两个加起来只占 8 字节空间的结构体表示了 Go 语言中的互斥锁
在默认情况下,互斥锁的所有状态位都是 0,int32 中的不同位分别表示了不同的状态:
- mutexLocked 表示互斥锁的锁定状态;
- mutexWoken 表示从正常模式被从唤醒;
- mutexStarving 当前的互斥锁进入饥饿状态
- waitersCount 当前互斥锁上等待的 Goroutine 个数
sync.Mutex 有两种模式,正常模式和饥饿模式:
- 正常模式:锁的等待者会按照先进先出的顺序获取锁。但是刚被唤起的
Goroutine与新创建的Goroutine竞争时,大概率会获取不到锁,为了减少这种情况的出现,一旦 Goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式,防止部分 Goroutine 被饿死 - 饥饿模式:饥饿模式是在 Go 语言 1.9 版本引入的优化的,引入的目的是保证互斥锁的公平性(Fairness)。在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。如果一个 Goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms,那么当前的互斥锁就会被切换回正常模式
相比于饥饿模式,正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时
28. Go中数据竞争问题怎么解决
- 数据竞争问题可以使用互斥锁sync.Mutex,或者也可以通过CAS无锁并发解决
- 其中使用同步访问共享数据或者CAS无锁并发是处理数据竞争的一种有效的方法
- golang在1.1之后引入了竞争检测机制,可以使用
go run -race或者go build -race来进行静态检测。其在内部的实现是,开启多个协程执行同一个命令, 并且记录下每个变量的状态
1 | go test -race mypkg // 测试包 |
29. Go中CAS是怎么回事
CAS算法(Compare And Swap),是原子操作的一种,CAS算法是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题
该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值
Go中的CAS操作是借用了CPU提供的原子性指令来实现。CAS操作修改共享变量时候不需要对共享变量加锁,而是通过类似乐观锁的方式进行检查,本质还是不断的占用CPU 资源换取加锁带来的开销(比如上下文切换开销)
Go中CAS操作可以有效的减少使用锁所带来的开销,但是需要注意在高并发下这是使用cpu资源做交换的
30. Go主协程如何等其余协程完再操作
- Go提供了更简单的方法——使用
sync.WaitGroup。WaitGroup,就是用来等待一组操作完成的。WaitGroup内部实现了一个计数器,用来记录未完成的操作个数 - 它提供了三个方法,
Add()用来添加计数。Done()用来在操作结束时调用,使计数减一。Wait()用来等待所有的操作结束,即计数变为0,该函数会在计数不为0时等待,在计数为0时立即返回
31. Context包的用途是什么
在 Go http包的Server中,每一个请求在都有一个对应的 goroutine 去处理。请求处理函数通常会启动额外的 goroutine 用来访问后端服务,比如数据库和RPC服务。用来处理一个请求的 goroutine 通常需要访问一些与请求特定的数据,比如终端用户的身份认证信息、验证相关的token、请求的截止时间。 当一个请求被取消或超时时,所有用来处理该请求的 goroutine 都应该迅速退出,然后系统才能释放这些 goroutine 占用的资源
在Google 内部,我们开发了 Context 包,专门用来简化 对于处理单个请求的多个 goroutine 之间与请求域的数据、取消信号、截止时间等相关操作,这些操作可能涉及多个 API 调用
Context中的方法:
- Done会返回一个channel,当该context被取消的时候,该channel会被关闭,同时对应的使用该context的routine也应该结束并返回
- Context中的方法是协程安全的,这也就代表了在父routine中创建的context,可以传递给任意数量的routine并让他们同时访问
- Deadline会返回一个超时时间,routine获得了超时时间后,可以对某些io操作设定超时时间
- Value可以让routine共享一些数据,当然获得数据是协程安全的
这里需要注意一点的是在goroutine中使用context包的时候,通常我们需要在goroutine中新创建一个上下文的context,原因是:如果直接传递外部context到协层中,一个请求可能在主函数中已经结束,在goroutine中如果还没有结束的话,会直接导致goroutine中的运行的被取消
1 | go func() { |
context.Background函数的返回值是一个空的context,经常作为树的根结点,它一般由接收请求的第一个routine创建,不能被取消、没有值、也没有过期时间
Context上下文数据的存储就像一个树,每个结点只存储一个key/value对。WithValue()保存一个key/value对,它将父context嵌入到新的子context,并在节点中保存了key/value数据。Value()查询key对应的value数据,会从当前context中查询,如果查不到,会递归查询父context中的数据
值得注意的是,context中的上下文数据并不是全局的,它只查询本节点及父节点们的数据,不能查询兄弟节点的数据
Context 使用原则:
- 不要把Context放在结构体中,要以参数的方式传递
- 以Context作为参数的函数方法,应该把Context作为第一个参数,放在第一位
- 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO
- Context的Value相关方法应该传递必须的数据,不要什么数据都使用这个传递
- Context是线程安全的,可以放心的在多个goroutine中传递
32. Go中对nil的Slice和空Slice的处理是一致的吗
在Go中,nil slice和空 slice是有区别的
nil slice是一个指向nil的指针,它没有分配任何内存空间空 slice是一个长度为0的切片,它指向一块已经分配的内存空间
在对 nil slice执行任何操作之前,必须将其初始化为空切片,否则会出现空指针异常。而对于空 slice,可以直接进行操作而不会出现异常
Go的JSON 标准库对 nil slice 和 空 slice 的处理是不一致
通常错误的用法,会报数组越界的错误,因为只是声明了slice,却没有给实例化的对象。nil的slice定义如下:
1 | var slice []int |
此时slice的值是nil,这种情况可以用于需要返回slice的函数,当函数出现异常的时候,保证函数依然会有nil的返回值
空的slice是指slice不为nil,但是slice没有值,slice的底层的空间是空的,空的slice定义如下:
1 | slice := make([]int,0) |
当我们查询或者处理一个空的列表的时候,这非常有用,它会告诉我们返回的是一个列表,但是列表内没有任何值
总结: nil的slice和空的slice是不同的东西,需要我们加以区分
33. Go的Slice如何扩容
33.1 数组Array和切片Slice
- 数组和切片都是用来存储相同数据类型的数据结构
- 数组在初始化或者是定义的时候就必须指明长度,或者初始化的时候使用…自动推导其数组的长度,数组初始化后其长度就是固定不变的;切片在初始化或定义的时候无需声明其长度,可以往切片里追加元素,追加元素可能导致切片扩容
- 数组和切片都可以通过内置函数
len求元素个数,都可以通过下标索引获取相对应的元素值,都可以通过for range进行遍历 - 函数参数:数组作为函数参数时,必须指定参数数组的大小,且传入的数组大小必须与指定的大小一致,数组为按值传递的,函数内对数组的值的改变不影响初始数组;切片作为函数参数传递时为按引用传递的,函数内对切片内元素的修改将导致函数外的值也发生改变,不过由于传入函数的是一个指针的副本,所以对该指针的修改不会导致原来的指针的变化(例如append函数不会改变原来的切片的值)
- slice是Go中的一种基本的数据结构,使用这种结构可以用来管理数据集合。但是slice本身并不是动态数据或者数组指针。slice常见的操作有 reslice、append、copy
- slice自身并不是动态数组或者数组指针。它内部实现的数据结构通过指针引用底层数组,设定相关属性将数据读写操作限定在指定的区域内。slice本身是一个只读对象,其工作机制类似数组指针的一种封装
- slice是对数组一个连续片段的引用,所以切片是一个引用类型。这个片段可以是整个数组,或者是由起始和终止索引标识的一些项的子集
- slice的结构体由3部分构成,Pointer 是指向一个数组的指针,len 代表当前切片的长度,cap 是当前切片的容量。cap 总是大于等于 len 的
通常我们在对slice进行append等操作时,可能会造成slice的自动扩容
- 1.18版本之前: 当原slice容量小于1024的时候,新slice容量变成原来的2倍;原slice容量超过1024,新slice容量变成原来的1.25倍
- 1.18版本之后: 当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍;原slice容量超过256,新slice容量newcap = oldcap+(oldcap+3*256)/4
- 最后: 扩容后的容量并不一定就是按照以上公式计算的结果,容量可能会存在小的差异,主要原因是扩容后会有内存对齐,导致容量不是百分百按照公式计算的结果。如果扩容之后,还没有触及原数组的容量,那么,切片中的指针指向的位置,就还是原数组,如果扩容之后,超过了原数组的容量,那么,Go就会开辟一块新的内存,把原来的值拷贝过来,这种情况丝毫不会影响到原数组
34. Go的Struct能不能比较
- 相同struct类型的可以比较
- 不同struct类型的不可以比较,编译都不过,类型不匹配
35. Go中的map的实现
Go中Map是一个KV对集合。底层使用hash table,用链表来解决冲突 ,出现冲突时,不是每一个Key都申请一个结构通过链表串起来,而是以bmap为最小粒度挂载,一个bmap可以放8个kv
在哈希函数的选择上,会在程序启动时,检测 cpu 是否支持 aes,如果支持,则使用aes hash,否则使用memhash
每个map的底层结构是hmap,是有若干个结构为bmap的bucket组成的数组。每个bucket底层都采用链表结构
1 | type hmap struct { |
bmap 就是我们常说的“桶”,桶里面会最多装 8 个 key,这些 key之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的,关于key的定位我们在map的查询和赋值中详细说明
在桶内,又会根据key计算出来的hash值的高8位来决定 key到底落入桶内的哪个位置(一个桶内最多有8个位置)
当map的key和value都不是指针,并且 size都小于128字节的情况下,会把bmap标记为不含指针,这样可以避免gc时扫描整个hmap
bmap其实有一个overflow的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把overflow移动到 hmap的extra 字段来
这样随着哈希表存储的数据逐渐增多,我们会扩容哈希表或者使用额外的桶存储溢出的数据,不会让单个桶中的数据超过 8 个,不过溢出桶只是临时的解决方案,创建过多的溢出桶最终也会导致哈希的扩容
36. Go中的map如何实现顺序读取
Go中map如果要实现顺序读取的话,先把map中的key添加到数组(array)或切片(slice)中,然后通过sort包对数组或切片进行排序,最后在遍历数组或切片的时候将map中key以及对应的value取出来
37. Go中new和make的区别
- 值类型:int,float,bool,string,struct和array。变量直接存储值,分配栈区的内存空间,这些变量所占据的空间在函数被调用完后会自动释放
- 引用类型:slice,map,chan和值类型对应的指针。变量存储的是一个地址(或者理解为指针),指针指向内存中真正存储数据的首地址。内存通常在堆上分配,通过GC回收
- 注意: 对于引用类型的变量,我们不仅要声明变量,更重要的是,我们得手动为它分配空间
- new该方法的参数要求传入一个类型,而不是一个值,它会申请一个该类型大小的内存空间,并会初始化为对应的零值,返回指向该内存空间的一个指针。new只用于分配内存,返回一个指向地址的指针。它为每个新类型分配一片内存,初始化为0且返回类型*T的内存地址,它相当于&T{}
- make也是用于内存分配,但是和new不同,只用来引用对象slice、map和channel的内存创建,它返回的类型就是类型本身,而不是它们的指针类型。make只可用于slice,map,channel的初始化,返回的是引用
38. Go的defer原理是什么
defer意为延迟,在 golang 中用于延迟执行一个函数。它可以帮助我们处理容易忽略的问题,如资源释放、连接关闭等。但在实际使用过程中,有一些需要注意的地方:
- 若函数中有多个 defer,其执行顺序为 先进后出,可以理解为栈
- return 会做什么?Go 的函数返回值是通过堆栈返回的,return 语句不是原子操作,而是被拆成了两步
- 给返回值赋值 (rval)
- 调用 defer 表达式
- 返回给调用函数(ret)
- 若 defer 表达式有返回值,将会被丢弃。在实际开发中,defer 的使用经常伴随着闭包与匿名函数的使用
39. Go的select可以用于什么
Golang 的 select 机制可以理解为是在语言层面实现了和 select, poll, epoll 相似的功能:监听多个描述符的读/写等事件,一旦某个描述符就绪(一般是读或者写事件发生了),就能够将发生的事件通知给关心的应用程序去处理该事件。 golang 的 select 机制是,监听多个channel,每一个 case 是一个事件,可以是读事件也可以是写事件,随机选择一个执行,可以设置default,它的作用是:当监听的多个事件都阻塞住会执行default的逻辑
使用select case ,ok实现goroutine的优雅退出
- 使用for-range退出
- 使用select case ,ok退出
- 使用退出通道退出
40. Go值接收者和指针接收者的区别
Go中的方法能给用户自定义的类型添加新的行为。它和函数的区别在于方法有一个接收者,给一个函数添加一个接收者,那么它就变成了方法。接收者可以是值接收者,也可以是指针接收者
在调用方法的时候,值类型既可以调用值接收者的方法,也可以调用指针接收者的方法;指针类型既可以调用指针接收者的方法,也可以调用值接收者的方法。也就是说,不管方法的接收者是什么类型,该类型的值和指针都可以调用,不必严格符合接收者的类型
| 函数和方法 | 值接收者 | 指针接收者 |
|---|---|---|
| 值类型调用者 | 方法会使用调用者的一个副本,类似于“传值” | 使用值的引用来调用方法,上例中,p1.GetAge() 实际上是 (&p1).GetAge() |
| 指针类型调用者 | 指针被解引用为值,上例中,p2.GetAge()实际上是 (*p1).GetAge() | 实际上也是“传值”,方法里的操作会影响到调用者,类似于指针传参,拷贝了一份指针 |
如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法
如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身
通常我们使用指针作为方法的接收者的理由:
- 使用指针方法能够修改接收者指向的值。
- 可以避免在每次调用方法时复制该值,在值的类型为大型结构体时,这样做会更加高效
因而,我们是使用值接收者还是指针接收者,不是由该方法是否修改了调用者(也就是接收者)来决定,而是应该基于该类型的本质
41. Go中两个Nil可能不相等吗
Go中两个Nil可能不相等
接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。两个接口值比较时,会先比较 T,再比较 V。接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较
42. Golang垃圾回收算法
Golang1.5版本后,采取的是“非分代的、非移动的、并发的、三色的”标记清除垃圾回收算法
垃圾回收算法分类:
- 引用计数
- 标记-清除
- 分代回收
golang的垃圾回收是基于标记清扫算法,这种算法需要进行 STW(stop the world),这个过程就会导致程序是卡顿的,频繁的 GC 会严重影响程序性能,golang在此基础上进行了改进,通过三色标记清扫法与写屏障来减少STW的时间
gc的过程一共分为四个阶段:
- 栈扫描(开始时STW) 所有对象最开始都是白色
- 从 root 开始找到所有可达对象,标记为灰色,放入待处理队列
- 遍历灰色对象队列,将其引用对象标记为灰色放入待处理队列,自身标记为黑色
- 清除(并发) 循环步骤3直到灰色队列为空为止,此时所有引用对象都被标记为黑色,所有不可达的对象依然为白色,白色的就是需要进行回收的对象
三色标记法相对于普通标记清扫,减少了STW时间. 这主要得益于标记过程是 “on-the-fly” 的,在标记过程中是不需要STW的,它与程序是并发执行的,这就大大缩短了STW的时间
写屏障:
- 当标记和程序是并发执行的,这就会造成一个问题. 在标记过程中,有新的引用产生,可能会导致误清扫
- 清扫开始前,标记为黑色的对象引用了一个新申请的对象,它肯定是白色的,而黑色对象不会被再次扫描,那么这个白色对象无法被扫描变成灰色、黑色,它就会最终被清扫,而实际它不应该被清扫
- golang采用了写屏障,其作用就是为了避免这类误清扫问题. 写屏障即在内存写操作前,维护一个约束,从而确保清扫开始前,黑色的对象不能引用白色对象
43. GC的触发条件
- 主动触发(手动触发),通过调用
runtime.GC来触发GC,此调用阻塞式地等待当前GC运行完毕 - 被动触发,分为两种方式:
- 使用系统监控,当超过两分钟没有产生任何
GC时,强制触发GC - 使用步调(Pacing)算法,其核心思想是控制内存增长的比例,当前内存分配达到一定比例则触发
- 使用系统监控,当超过两分钟没有产生任何
44. Go中的http包的实现原理
Golang中http包中处理 HTTP 请求主要跟两个东西相关:ServeMux 和 Handler
ServeMux 本质上是一个 HTTP 请求路由器(或者叫多路复用器,Multiplexor)。它把收到的请求与一组预先定义的 URL 路径列表做对比,然后在匹配到路径的时候调用关联的处理器(Handler)
处理器(Handler)负责输出HTTP响应的头和正文。任何满足了http.Handler接口的对象都可作为一个处理器。通俗的说,对象只要有个如下签名的ServeHTTP方法即可
Go 语言的 HTTP 包自带了几个函数用作常用处理器,比如FileServer,NotFoundHandler 和 RedirectHandler
45. Go中的defer函数使用下面的两种情况下结果是什么
无
1. Go项目性能测试、压测
- 单元测试,以
Test开头,在需要测试的包路径内执行go test对该包进行测试 - 性能测试,以
Benchmark开头,go test默认不会执行压力测试函数,需要通过指定参数-test.bench来运行压力测试函数,-test.bench后跟正则表达式,如:go -test.bench=".*"表示执行所有的压力测试函数 - 测试覆盖率,
cover工具,go test -coverprofile=cover.out,在测试文件目录下运行并统计测试覆盖率 - 性能分析,
pprof进行性能分析 - 使用
Jmeter做Web性能测试
API性能测试指标,主要有3个:
- 并发数(
Concurrent),是指某个时间范围内,同时正在使用系统的用户数 - 每秒查询数(
QPS),是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准,QPS=并发数/平均请求响应时间,在说QPS时需要指明多少并发数下的QPS,因为不同并发数下QPS是不同的 - 请求响应时间(
TTLB),是从客户端发出请求到得到响应的整个时间
2. 下面2个函数分别输出什么结果
1 | package main |

3. Go面向对象
面向对象的三大基本特性:封装、继承、多态
- 封装
- 面向对象中的 “封装” 指的是可以隐藏对象的内部属性和实现细节,仅对外提供公开接口调用,这样子用户就不需要关注你内部是怎么实现的
- 在 Go 语言中的属性访问权限,通过首字母大小写来控制:
- 首字母大写,代表是公共的,可被外部访问的
- 首字母小写,代表是私有的,不可以被外部访问
- 继承
- 面向对象中的 “继承” 指的是子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为
- 继承是编译时特征,在
struct内加入所需要继承的类即可
- 多态
- 面向对象中的 “多态” 指的同一个行为具有多种不同表现形式或形态的能力,具体是指一个类实例(对象)的相同方法在不同情形有不同表现形式
- 多态也使得不同内部结构的对象可以共享相同的外部接口,也就是都是一套外部模板,内部实际是什么,只要符合规格就可以
- 多态是运行时特征,在 Go 语言中,多态是通过接口
interface来实现的,类型和接口是松耦合的,某个类型的实例可以赋给它所实现的任意接口类型的变量
4. Go编写HTTP服务器
1 | package main |
运行代码之后,在浏览器中打开localhost:8000就可以看到Hello World。这段代码先利用http.HandleFunc在根路由/上注册了一个HelloHandler,然后利用http.ListenAndServe启动服务器并监听本地的 8000 端口。当有请求过来时,则根据路由执行对应的handler函数
参考链接:点击跳转
5. Gin示例
1 | package main |
6. TX代码分析
1 | package main |
这段代码创建了一个结构体类型 student,包含两个字段 name 和 age。然后在 main 函数中,创建了一个空的映射 m,并且创建了一个切片 stus,其中包含了三个 student 类型的值
接下来的循环将 stus 中的每个元素的指针存储到映射 m 的键值对中,键是 student 的 name 字段,值是 student 的指针
然而,由于 Go 语言中的循环变量是重用的,因此每次迭代时,变量 stu 的地址都是相同的,也就是指向同一个 student 对象的地址。因此,无论循环的键值对如何分配,最终映射 m 中的每个键值对都将包含指向 stus 切片中最后一个元素的指针
最后,代码打印出了映射 m 的内容,并遍历了映射 m 的每个键值对,并输出每个键和对应值的 name 字段。由于每个键值对的值都是指向 stus 切片中最后一个元素的指针,因此在输出时,它们都将显示为最后一个元素的 name 字段。因此,输出将为:
1 | 张三 => 王五 |
为了修复这个问题,可以在循环中使用 stu 的副本来解决。修改后的代码如下所示:
1 | package main |
输出为:
1 | 张三 => 张三 |
7. goroutine+chan交替打印ping/pong
题目:用两goroutine+chan实现ping/pong 的交替输出。一个 goroutine print ping,一个goroutine print Pong
1 | package main |
创建了两个通道 pingChan 和 pongChan。然后我们创建了两个匿名的goroutine,一个用于打印”ping”,另一个用于打印”pong”。这两个goroutine都使用无限循环,不断等待在通道上的信号。当接收到信号后,它们会打印相应的字符串,然后通过另一个通道发送信号,使另一个goroutine开始工作。最后,我们通过向 pingChan 发送一个初始信号来启动整个游戏。由于这是一个无限循环,我们使用 select{} 来阻止程序退出
8. 多协程统计10000内的素数
1 | package main |
代码输出:
1 | Total prime numbers: 1229 |
在这个例子中,我们使用一个有缓冲的通道 ch 来存储要检查的数字。我们向这个通道发送2到10000之间的所有整数。然后,我们使用10个goroutine并行读取通道中的数字,并使用 isPrime 函数检查它们是否是素数。如果是素数,我们就将计数器 cnt 加1。每个goroutine都在处理完通道中的数字后,将计数器 cnt 的值发送到 res 通道中
最后,我们在主goroutine中读取 res 通道中的所有值,将它们相加,并打印总素数的数量
注意:这个例子中使用了一个固定的数字10来指定使用的goroutine数量,这不一定是最优的值,实际的最优值可能因系统配置和其它因素而异。如果要优化性能,可以尝试不同的goroutine数量,并找到最快的方式来计算素数
知乎Golang面试题总结链接:点击跳转
总结
Go面试复习应该有所侧重,关注切片,通道,异常处理,Goroutine,GMP模型,字符串高效拼接,指针,反射,接口,sync。对于比较难懂的部分,GMP模型和GC和内存管理,应该主动去看源码,然后慢慢理解。业务代码写多了,自然就有理解了
本文链接:
https://huajun-chen.github.io/2022/10/28/Golang面试题/