随着云计算时代愈演愈烈,Go 语言的应用也越来越广泛,已然成为首选编程语言。而且,薪资也水涨船高,并且都是急聘。为啥?因为现在熟练掌握 Go 语言的人才少,看到趋势的人才太少,这个赛道还没有十分拥挤,机会也在日益增多。
1. make 与 new的区别
知识点
主要在对于go的使用层面的理解new与make的功能
回答
都是是内建函数
new: 的作用是初始化一个指向类型的指针(*T),使用 new 函数来分配空间。传递给 new 函数的是一个类型,不是一个值。返回值是指向这个新分配的零值的指针。
make:的作用是为 slice,map 或 chan 初始化并返回引用 (T),第一个参数是一个类型,第二个参数是长度;返回值是一个类型。
make(T, args) 函数的目的与 new(T) 不同。它仅仅用于创建 Slice, Map 和 Channel,并且返回类型是 T(不是T*)的一个初始化的(不是零值)的实例
2. main 与 init的区别
知识点
对main方法与init方法的执行,细节的考虑;也需要考虑包的引用
回答
1. 数量对比:对于当前项目包来说只能存在1个main、而init可以存在多个;
2. 编译上:程序在编译的时候会先加载相关依赖包的init函数,再执行main中的init函数;
3. 作用:main用于启动整个程序,init用于程序执行前做包的初始化函数;
4. 注意细节:一个包可以出线多个 init() 函数,一个源文件也可以包含多个 init() 函数,但是同一个包中多个 init()函数的执行顺序没有明确定义,但是不同包的init函数是根据包导入的依赖关系决定的;
5. 使用上:init() 函数在代码中不能被显示调用、不能被引用(赋值给函数变量),否则出现编译错误;
6. 一个包被引用多次,如 A import B,C import B,A import C,B 被引用多次,但 B 包只会初始化一次;
7. 引入包,不可出现死循坏。即 A import B,B import A,这种情况编译失败;
8. 在项目开发中,对于整个项目需要尽量避免使用init因为会与其他依赖阐述;
3. 如何控制并发
知识点
对go的运用,其中包含协程的运行和结束,以及使用协程的注意细节;其中也包含channel、sync、协程的运用和理解;
回答
1. 可以构建协程池,利用协程池解决问题
2. 利用clannel、sync、原子包
4. go defer(for defer)的执行
先进后出,后进先出
5. select可以用于什么?
知识点
考察对select的运用以及理解
回答
常用于gorotine的完美退出 golang 的 select 就是监听 IO 操作,当 IO 操作发生时,触发相应的动作每个case语句里必须是一个IO操作,确切的说,应该是一个面向channel的IO操作。
6. context包的用途
知识点
考察对Context的运用以及理解。
回答
Context通常被译作上下文,它是一个比较抽象的概念,其本质,是【上下上下】存在上下层的传递,上会把内容传递给下。在Go语言中,程序单元也就指的是Goroutine
而它的设计除了上下层的信息传递之外,还可以用与控制协程的退出,结合select
7. map如何顺序读取
知识点
这是一个干扰题,在实际中很少运用,考察map与切片的理解
回答
map不能顺序读取,是因为他是无序的,想要有序读取,首先的解决的问题就是,把key变为有序,所以可以把key放入切片,对切片进行排序,遍历切片,通过key取值。
8. Slice与数组区别,Slice底层结构
知识点
考察切片与数组的理解
回答
切片是基于数组实现的,它的底层是数组,它自己本身非常小,可以理解为对底层数组的抽象。因为基于数组实现,所以它的底层的内存是连续分配的,效率非常高,还可以通过索引获得数据,可以迭代以及垃圾回收优化。
切片本身并不是动态数组或者数组指针。它内部实现的数据结构通过指针引用底层数组,设定相关属性将数据读写操作限定在指定的区域内。切片本身是一个只读对象,其工作机制类似数组指针的一种封装。
切片对象非常小,是因为它是只有3个字段的数据结构:
- 指向底层数组的指针
- 切片的长度
- 切片的容量
这3个字段,就是Go语言操作底层数组的元数据。
9. Golang Slice的扩容机制,有什么注意点
Go 中切片扩容的策略是这样的:
首先判断,如果新申请容量大于 2 倍的旧容量,最终容量就是新申请的容量。
否则判断,如果旧切片的长度小于 1024,则最终容量就是旧容量的两倍。
否则判断,如果旧切片长度大于等于 1024,则最终容量从旧容量开始循环增加原来的 1/4 , 直到最终容量大于等于新申请的容量。
如果最终容量计算值溢出,则最终容量就是新申请容量。
情况一:
原数组还有容量可以扩容(实际容量没有填充完),这种情况下,扩容以后的数组还是指向原来的数组,对一个切片的操作可能影响多个指针指向相同地址的Slice。
情况二:
原来数组的容量已经达到了最大值,再想扩容, Go 默认会先开一片内存区域,把原来的值拷贝过来,然后再执行append() 操作。这种情况丝毫不影响原数组。
要复制一个Slice,最好使用Copy函数。
10. 退出程序时怎么防止channel没有消费完
知识点
考察对channel的使用情况
回答
这个问题可以通过参数记录channel的任务量,根据获取channel的返回结果减一,归零的时候则完成;或者可以利用sync.waitGroup
11. sync.Pool用过吗,为什么使用
这是对象池子,我们可以通过它提供的New方法指定我们需要创建的对象,然后再返回
12. go 中除了加 Mutex 锁以外还有哪些方式安全读写共享变量?
Go 中 Goroutine 可以通过 Channel 进行安全读写共享变量。
13. Go中对nil的Slice和空Slice的处理是一致的吗?
首先Go的JSON 标准库对 nil slice 和 空 slice 的处理是不一致。
- slice := make([]int,0):slice不为nil,但是slice没有值,slice的底层的空间是空的。
- slice := []int{} :slice的值是nil,可用于需要返回slice的函数,当函数出现异常的时候,保证函数依然会有nil的返回值
14. goroutine泄漏有没有处理
知识点
主要是观察你是否对协程的使用有细节考虑
回答
首先需分析:泄露的原因主要存在的问题在于协程没有释放,也就是协程没有运行结束
解决:在协程内部可以通过context上下文控制创建的协程,也可以通过timeout结合select处理
15. 进程,线程,协程的区别
进程: 进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
线程: 线程是进程的一个实体,线程是内核态,而且是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
协程: 协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
16. 垃圾回收
垃圾回收就是对程序中不再使用的内存资源进行自动回收的操作。
常见的垃圾回收算法:
1、引用计数:每个对象维护一个引用计数,当被引用对象被创建或被赋值给其他对象时引用计数自动加 +1;如果这个对象被销毁,则计数 -1 ,当计数为 0 时,回收该对象。
优点:对象可以很快被回收,不会出现内存耗尽或到达阀值才回收。
缺点:不能很好的处理循环引用
2、标记-清除:从根变量开始遍历所有引用的对象,引用的对象标记“被引用”,没有被标记的则进行回收。
优点:解决了引用计数的缺点。
缺点:需要 STW(stop the world),暂时停止程序运行。
3、分代收集:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代有不同的回收算法和回收频率。
优点:回收性能好
缺点:算法复杂
4、三色标记法
白色:可以清除的元素,视为垃圾
灰色:可能是活跃元素,最终会转化为黑色,先是白色转为灰色
黑色:则就是活跃元素
规则
- 初始状态下所有对象都是白色的。
- 从根节点开始遍历所有对象,把遍历到的对象变成灰色对象
- 遍历灰色对象,将灰色对象引用的对象也变成灰色对象,然后将遍历过的灰色对象变成黑色对象。
- 循环步骤3,直到灰色对象全部变黑色。
- 通过写屏障(write-barrier)检测对象有变化,重复以上操作
- 收集所有白色对象(垃圾)。
STW(Stop The World)
为了避免在 GC 的过程中,对象之间的引用关系发生新的变更,使得GC的结果发生错误(如GC过程中新增了一个引用,但是由于未扫描到该引用导致将被引用的对象清除了),停止所有正在运行的协程。
STW对性能有一些影响,Golang目前已经可以做到1ms以下的STW。
写屏障(Write Barrier)
为了避免GC的过程中新修改的引用关系到GC的结果发生错误,我们需要进行STW。但是STW会影响程序的性能,所以我们要通过写屏障技术尽可能地缩短STW的时间。
造成引用对象丢失的条件:
一个黑色的节点A新增了指向白色节点C的引用,并且白色节点C没有除了A之外的其他灰色节点的引用,或者存在但是在GC过程中被删除了。以上两个条件需要同时满足:满足条件1时说明节点A已扫描完毕,A指向C的引用无法再被扫描到;满足条件2时说明白色节点C无其他灰色节点的引用了,即扫描结束后会被忽略 。
写屏障破坏两个条件其一即可
破坏条件1:Dijistra写屏障
满足强三色不变性:黑色节点不允许引用白色节点 当黑色节点新增了白色节点的引用时,将对应的白色节点改为灰色
破坏条件2:Yuasa写屏障
满足弱三色不变性:黑色节点允许引用白色节点,但是该白色节点有其他灰色节点间接的引用(确保不会被遗漏) 当白色节点被删除了一个引用时,悲观地认为它一定会被一个黑色节点新增引用,所以将它置为灰色
17、GPM 调度 和 CSP 模型
GPM 分别是什么、分别有多少数量:
G(Goroutine):即Go协程,每个go关键字都会创建一个协程。
M(Machine):工作线程,在Go中称为Machine,数量对应真实的CPU数(真正干活的对象)。
P(Processor):处理器(Go中定义的一个摡念,非CPU),包含运行Go代码的必要资源,用来调度 G 和 M 之间的关联关系,其数量可通过 GOMAXPROCS() 来设置,默认为核心数。
M必须拥有P才可以执行G中的代码,P含有一个包含多个G的队列,P可以调度G交由M执行。
Goroutine调度策略:
队列轮转:P 会周期性的将G调度到M中执行,执行一段时间后,保存上下文,将G放到队列尾部,然后从队列中再取出一个G进行调度。除此之外,P还会周期性的查看全局队列是否有G等待调度到M中执行。
系统调用:当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。M1的来源有可能是M的缓存池,也可能是新建的。
当G0系统调用结束后,如果有空闲的P,则获取一个P,继续执行G0。如果没有,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠。
CSP 模型:
CSP 模型是“以通信的方式来共享内存”,不同于传统的多线程通过共享内存来通信。用于描述两个独立的并发实体通过共享的通讯 channel (管道)进行通信的并发模型。
18. 竞态、内存逃逸
竞态
资源竞争,就是在程序中,同一块内存同时被多个 goroutine 访问。我们使用 go build、go run、go test 命令时,添加-race 标识可以检查代码中是否存在资源竞争。
解决这个问题,我们可以给资源进行加锁,让其在同一时刻只能被一个协程来操作。
sync.Mutex
sync.RWMutex
内存逃逸
「逃逸分析」就是程序运行时内存的分配位置(栈或堆),是由编译器来确定的。堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。
逃逸场景:
- 指针逃逸
- 栈空间不足逃逸
- 动态类型逃逸
- 闭包引用对象逃逸
19. Go中对nil的Slice和空Slice的处理是一致的吗?
首先Go的JSON 标准库对 nil slice 和 空 slice 的处理是不一致。
slice := make([]int,0):slice不为nil,但是slice没有值,slice的底层的空间是空的。
slice := []int{} :slice的值是nil,可用于需要返回slice的函数,当函数出现异常的时候,保证函数依然会有nil的返回值。
20. Golang的内存模型中为什么小对象多了会造成GC压力?
通常小对象过多会导致GC三色法消耗过多的CPU。优化思路是,减少对象分配。
21. channel 为什么它可以做到线程安全?
Channel 可以理解是一个先进先出的队列,通过管道进行通信,发送一个数据到Channel和从Channel接收一个数据都是原子性的。不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。设计Channel的主要目的就是在多任务间传递数据的,本身就是安全的。
22. GC 的触发条件?
主动触发(手动触发),通过调用 runtime.GC 来触发GC,此调用阻塞式地等待当前GC运行完毕。
被动触发,分为两种方式:
1. 使用步调(Pacing)算法,其核心思想是控制内存增长的比例,每次内存分配时检查当前内存分配量是否已达到阈值(环境变量GOGC):默认100%,即当内存扩大一倍时启用GC
2. 使用系统监控,当超过两分钟没有产生任何GC时,强制触发 GC。
23. 怎么查看Goroutine的数量?怎么限制Goroutine的数量?
在Golang中,GOMAXPROCS中控制的是未被阻塞的所有Goroutine,可以被 Multiplex 到多少个线程上运行,通过GOMAXPROCS可以查看Goroutine的数量。
使用通道。每次执行的go之前向通道写入值,直到通道满的时候就阻塞了。
24. Channel是同步的还是异步的?
Channel是异步进行的, channel存在3种状态:
1、nil,未初始化的状态,只进行了声明,或者手动赋值为nil
2、active,正常的channel,可读或者可写
3、closed,已关闭,千万不要误认为关闭channel后,channel的值是nil
操作 | 一个零值nil通道 | 一个非零值但已关闭的通道 | 一个非零值且尚未关闭的通道 |
关闭 | 产生异常 | 产生异常 | 成功关闭 |
发送数据 | 永久阻塞 | 产生异常 | 阻塞或者成功发送 |
接收数据 | 永久阻塞 | 永不阻塞 | 阻塞或者成功接收 |
25. Go的Slice如何扩容?
在使用 append 向 slice 追加元素时,若 slice 空间不足则会发生扩容,扩容会重新分配一块更大的内存,将原 slice 拷贝到新 slice ,然后返回新 slice。扩容后再将数据追加进去。
扩容操作只对容量,扩容后的 slice 长度不变,容量变化规则如下:
若 slice 容量小于1024个元素,那么扩容的时候slice的cap就翻番;一旦元素个数超过1024个元素,增长因子就变成1.25,即每次增加原来容量的四分之一。
若 slice 容量够用,则将新元素追加进去,slice.len++,返回原 slice
若 slice 容量不够用,将 slice 先扩容,扩容得到新 slice,将新元素追加进新 slice,slice.len++,返回新 slice。
26. Go值接收者和指针接收者的区别?
方法的接收者:
1、值类型,既可以调用值接收者的方法,也可以调用指针接收者的方法;
2、指针类型,既可以调用指针接收者的方法,也可以调用值接收者的方法。
但是接口的实现,值类型接收者和指针类型接收者不一样:
1、以值类型接收者实现接口,类型本身和该类型的指针类型,都实现了该接口;
2、以指针类型接收者实现接口,只有对应的指针类型才被认为实现了接口。
通常我们使用指针作为方法的接收者的理由:
1、使用指针方法能够修改接收者指向的值。
2、可以避免在每次调用方法时复制该值,在值的类型为大型结构体时,这样做会更加高效。
27. 在Go函数中为什么会发生内存泄露?
Goroutine 需要维护执行用户代码的上下文信息,在运行过程中需要消耗一定的内存来保存这类信息,如果一个程序持续不断地产生新的 goroutine,且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象。
28. Goroutine发生了泄漏如何检测?
可以通过Go自带的工具pprof或者使用Gops去检测诊断当前在系统上运行的Go进程的占用的资源。
29. Go中两个Nil可能不相等吗?
Go中两个Nil可能不相等。
接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。
两个接口值比较时,会先比较 T,再比较 V。接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。
func main() { var p *int = nil var i interface{} = p fmt.Println(i == p) // true fmt.Println(p == nil) // true fmt.Println(i == nil) // false }
例子中,将一个 nil 非接口值 p 赋值给接口 i,此时,i 的内部字段为(T=*int, V=nil),i与p作比较时,将 p 转换为接口后再比较,因此 i == p,p 与 nil 比较,直接比较值,所以 p == nil。
但是当 i 与 nil 比较时,会将nil转换为接口 (T=nil, V=nil),与 i(T=*int, V=nil) 不相等,因此 i != nil。因此 V 为 nil ,
但 T 不为 nil 的接口不等于 nil。
30. Go语言中的内存对齐了解吗?
CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如 32 位的 CPU ,字长为 4 字节,那么 CPU 访问内存的单位也是 4 字节。
CPU 始终以字长访问内存,如果不进行内存对齐,很可能增加 CPU 访问内存的次数,例如:
变量 a、b 各占据 3 字节的空间,内存对齐后,a、b 占据 4 字节空间,CPU 读取 b 变量的值只需要进行一次内存访问。如果不进行内存对齐,CPU 读取 b 变量的值需要进行 2 次内存访问。第一次访问得到 b 变量的第 1 个字节,第二次访问得到 b 变量的后两个字节。
也可以看到,内存对齐对实现变量的原子性操作也是有好处的,每次内存访问是原子的,如果变量的大小不超过字长,那么内存对齐后,对该变量的访问就是原子的,这个特性在并发场景下至关重要。
简言之:合理的内存对齐可以提高内存读写的性能,并且便于实现变量操作的原子性。
31. channel 实现原理,底层实现结构,怎么优雅的关闭一个 channel。channel 有哪些应用,什么情况下 channel 会造成内存泄露?( buf 是环形链表的数据结构)
chan实现原理
结构体:
type hchan struct { qcount uint // 队列中的总元素个数 dataqsiz uint // 环形队列大小,即可存放元素的个数 elemsize uint16 //每个元素的大小 closed uint32 //标识关闭状态 elemtype *_type // 元素类型 buf unsafe.Pointer // 环形队列指针 - sendx uint // 发送索引,元素写入时存放到队列中的位置 recvx uint // 接收索引,元素从队列的该位置读出 recvq waitq // 等待读消息的goroutine队列 sendq waitq // 等待写消息的goroutine队列 lock mutex //互斥锁,chan不允许并发读写 }
读写流程
向 channel 写数据:
1. 若等待接收队列 recvq 不为空,则缓冲区中无数据或无缓冲区,将直接从 recvq 取出 G ,并把数据写入,最后把该 G 唤醒,结束发送过程。
2. 若缓冲区中有空余位置,则将数据写入缓冲区,结束发送过程。
3. 若缓冲区中没有空余位置,则将发送数据写入 G,将当前 G 加入 sendq ,进入睡眠,等待被读 goroutine 唤醒。
从 channel 读数据:
1. 若等待发送队列 sendq 不为空,且没有缓冲区,直接从 sendq 中取出 G ,把 G 中数据读出,最后把 G 唤醒,结束读取过程。
2. 如果等待发送队列 sendq 不为空,说明缓冲区已满,从缓冲区中首部读出数据,把 G 中数据写入缓冲区尾部,把 G 唤醒,结束读取过程。
3. 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程。
4. 将当前 goroutine 加入 recvq ,进入睡眠,等待被写 goroutine 唤醒。
关闭 channel:
1. 关闭 channel 时会将 recvq 中的 G 全部唤醒,本该写入 G 的数据位置为 nil。将 sendq 中的 G 全部唤醒,但是这些 G 会 panic。
panic 出现的场景还有:
关闭值为 nil 的 channel
关闭已经关闭的 channel
向已经关闭的 channel 中写数据
32. channel 关闭了接着 send 数据会发生什么,关闭一个已经关闭的 channel 会发生什么?
读已经关闭的 chan 能一直读到东西,但是读到的内容根据通道内 关闭前 是否有元素而不同。
如果 chan 关闭前, buffer 内有元素还未读 , 会正确读到 chan 内的值,且返回的第二个 bool 值(是否读成功)为 true 。
如果 chan 关闭前, buffer 内有元素已经被读完, chan 内无值,接下来所有接收的值都会非阻塞直接成功,返回 channel 元素的零值,但是第二个 bool 值一直为 false 。
写已经关闭的 chan 会 panic
33. map 的底层实现,什么情况下会扩容,怎么扩容?是线程安全的吗?那 sync 包中的 map 是怎么实现线程安全的?
map底层实现:
map由结构体hmap构成
type hmap struct { // Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go. // Make sure this stays in sync with the compiler's definition. count int // 哈希表中元素个数,即len(map)的返回值 flags uint8 B uint8 // 线性表中桶个数的的对数log_2(哈希表元素数量最大可达到装载因子*2^B) noverflow uint16 // 溢出桶的大概数字;详情见incrnoverflow hash0 uint32 // 哈希种子 buckets unsafe.Pointer // 指向线性表的指针,数组大小为2^B,如果元素个数为0,它为nil. oldbuckets unsafe.Pointer // 指向扩容后的老线性表地址 nevacuate uintptr // 表示扩容进度 extra *mapextra // 垃圾回收用 }
bmap结构体
// A bucket for a Go map. type bmap struct { // tophash包含此桶中每个键的哈希值最高字节(高8位)信息(也就是前面所述的high-order bits)。 // 如果tophash[0] < minTopHash,tophash[0]则代表桶的搬迁(evacuation)状态。 tophash [bucketCnt]uint8 }
在顺序遍历时会用随机种子产生一个随机数,表示开始遍历的桶位置,因为随机数每次产生的数字可能都是不同的,
所以每次for range得到的结果也是不同的。以下为初始化哈希迭代器的方法源码:
map扩容方式
有俩种方式会导致map的扩容,另一种是由于桶后面跟的链表太长所导致的扩容。
载荷因子引起:当元素个数 >= 桶(bucket)总数 * 6.5,这时说明大部分桶都被占满了如果再来元素,大概率会发生哈希冲突。因此需要扩容,扩容方式为将 B + 1,新建一个buckets数组,新的buckets大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。该方法我们称之为增量扩容。如下图所示,插入25时会经历大量哈希冲突,再插入元素时6/8=0.67就需要扩容了。
判断溢出桶是否太多,当桶总数 < 2 ^ 15 时,如果溢出桶总数 >= 桶总数,则认为溢出桶过多。当桶总数 >= 2 ^ 15 时,直接与 2 ^ 15 比较,当溢出桶总数 >= 2 ^ 15 时,即认为溢出桶太多了。buckets数量维持不变,将长度过长的溢出桶搬运到[]bmap的其他桶上,该方法我们称之为等量扩容。如下图所示,每一个溢出桶可以存八个元素,为了画图方便这里老虎就只当做只能存一个元素处理了,当插入元素41时,链表长度已经达到了5,如果哈希冲突过多,那么会最终演变为遍历访问链表,时间复杂度为O(n)的算法了!
考点:sync.Map
go1.9版本之后的 sync.Map , sync.map 通过原子操作的方式实现了并发安全,并采用读写分离的方式,提高了并发的性能。
34. context 结构原理
context 用途:
Context(上下文)是Golang应用开发常用的并发控制技术 ,它可以控制一组呈树状结构的goroutine,每个goroutine拥有相同的上下文。Context 是并发安全的,主要是用于控制多个协程之间的协作、取消操作。
数据结构
Context 只定义了接口,凡是实现该接口的类都可称为是一种 context。
type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key interface{}) interface{} }
「Deadline」 方法:可以获取设置的截止时间,返回值 deadline 是截止时间,到了这个时间,Context 会自动发起取消请求,返回值 ok 表示是否设置了截止时间。
「Done」 方法:返回一个只读的 channel ,类型为 struct{}。如果这个 chan 可以读取,说明已经发出了取消信号,可以做清理操作,然后退出协程,释放资源。
「Err」 方法:返回Context 被取消的原因。
「Value」 方法:获取 Context 上绑定的值,是一个键值对,通过 key 来获取对应的值。
35. Go中的map如何实现顺序读取?
Go中map如果要实现顺序读取的话,可以先把map中的key,通过sort包排序。
36. select+channel
select 可以实现多路复用,即同时监听多个 channel。
发现哪个 channel 有数据产生,就执行相应的 case 分支
如果同时有多个 case 分支可以执行,则会随机选择一个
如果一个 case 分支都不可执行,则 select 会一直等待
select 实现原理:
select 是 GO 语言中用来提供 IO 复用的机制,它可以检测多个 chan 是否 ready(可读/可写)。
Go 实现 select 时,定义了一个数据结构表示每个 case 语句(包含defaut),select 执行过程可以类比成一个函数,函数输入 case 数组,输出选中的 case,然后程序流程转到选中的 case块。
源码包 src/runtime/select.go 定义了表示case语句的数据结构:
c : 表示当前 case 语句所操作的 channel 指针
// Select case descriptor. // Known to compiler. // Changes here must also be made in src/cmd/internal/gc/select.go's scasetype. type scase struct { c *hchan // chan elem unsafe.Pointer // data element }
elem :表示缓冲区地址
总结
- select 语句中除 default 外,每个 case 操作一个channel,要么读要么写
- select语句中除 default 外,各 case 执行顺序是随机的
- select 语句中如果没有 default 语句,则会阻塞等待任一 case
- select 语句中读操作要判断是否成功读取,关闭的 channel 也可以读取
37. Goroutine和线程的区别?
一个线程可以有多个协程
线程、进程都是同步机制,而协程是异步
协程可以保留上一次调用时的状态,当过程重入时,相当于进入了上一次的调用状态
协程是需要线程来承载运行的,所以协程并不能取代线程,「线程是被分割的CPU资源,协程是组织好的代码流程」
38. 知道golang的内存逃逸吗?什么情况下会发生内存逃逸?
能引起变量逃逸到堆上的典型情况:
栈空间不足也会发生逃逸
- 在方法内把局部变量指针返回 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此 其生命周期大于栈,则溢出。
- 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。 所以编译器没法知道变量什么时候才会被释放。
- * 在一个切片上存储指针或带指针的值。** 一个典型的例子就是 [] string 。这会导致切片的内容逃逸。尽管其后 面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
- slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知 道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。 在 interface 类型上调用方法。
- 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知 道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上 分配。
举例
通过一个例子加深理解,接下来尝试下怎么通过 go build -gcflags=-m 查看逃逸的情况。
/package main import "fmt" type A struct { s string } // 这是上面提到的 "在方法内把局部变量指针返回" 的情况 func foo(s string) *A { a := new(A) a.s = s return a //返回局部变量a,在C语言中妥妥野指针,但在go则ok,但a会逃逸到堆 } func main() { a := foo("hello") b := a.s + " world" c := b + "!" fmt.Println(c) }
39. 拷贝大切片一定比小切片代价大吗?
并不是,所有切片的大小相同;三个字段(一个 uintptr,两个int)。切片中的第一个字是指向切片底层数组的指 针,这是切片的存储空间,第二个字段是切片的长度,第三个字段是容量。将一个 slice 变量分配给另一个变量只会 复制三个机器字。所以 拷贝大切片跟小切片的代价应该是一样的。
SliceHeader 是切片在go的底层结构。
type SliceHeader struct { Data uintptr Len int Cap int }
大切片跟小切片的区别无非就是 Len 和 Cap的值比小切片的这两个值大一些,如果发生拷贝,本质上就是拷贝上面的三个字段。
40. 拷贝大切片一定比小切片代价大吗?
字符串转成切片,会产生拷贝。严格来说,只要是发生类型强转都会发生内存拷贝。那么问题来了。频繁的内存拷贝 操作听起来对性能不大友好。有没有什么办法可以在字符串转成切片的时候不用发生拷贝呢?
package main import ( "fmt" "reflect" "unsafe" ) func main() { a :="aaa" ssh := *(*reflect.StringHeader)(unsafe.Pointer(&a)) b := *(*[]byte)(unsafe.Pointer(&ssh)) fmt.Printf("%v",b) }
41. 这个代码会造成死循环吗?
问题:
package main import "fmt" func main() { s := []int{1,2,3,4,5} for _, v:=range s { s =append(s, v) fmt.Printf("len(s)=%vn",len(s)) } }
回答
不会死循环,for range其实是golang的语法糖,在循环开始前会获取切片的长度 len(切片),然后再执行len(切片)次数 的循环。
func main() { s := []int{1,2,3,4,5} for_temp := s len_temp := len(for_temp) for index_temp := 0; index_temp < len_temp; index_temp++ { value_temp := for_temp[index_temp] _ = index_temp value := value_temp // 以下是 original body s =append(s, value) fmt.Printf("len(s)=%vn",len(s)) } }
42. for循环select时,如果通道已经关闭会怎么样?
如果select中的case只有一个, 又会怎么样? for循环select时,如果其中一个case通道已经关闭,则每次都会执行到这个case。 如果select里边只有一个case,而这个case被关闭了,则会出现死循环。
43. 对已经关闭的的chan进行读写,会怎么样?为什么?
读已经关闭的chan能一直读到东西,但是读到的内容根据通道内关闭前是否有元素而不同。
- 如果chan关闭前,buffer内有元素还未读,会正确读到chan内的值,且返回的第二个bool值(是否读成功)为true。
- 如果chan关闭前,buffer内有元素已经被读完,chan内无值,接下来所有接收的值都会非阻塞直接成功,返回 channel 元素的零值,但是第二个bool值一直为false。
写已经关闭的chan会panic
1.为什么写已经关闭的chan就会panic呢?
因为在底层中对chansend的时候;当c.closed !=0则为通道关闭,此时执行写,源码提示直接panic,输出的内容就是上面提到的”send on closed channel”
2. 为什么读已关闭的chan会一直能读到值?
c.closed != 0 && c.qcount == 0指通道已经关闭,且缓存为空的情况下(已经读完了之前写到通道里的值)
如果接收值的地址ep不为空
- 那接收值将获得是一个该类型的零值
- typedmemclr 会根据类型清理相应地址的内存
- 这就解释了上面代码为什么关闭的chan会返回对应类型的零值
44. 对未初始化的的chan进行读写,会怎么样?为什么?
读写未初始化的chan都会阻塞。
45. 死锁产生的原因
主要是主协程因为channel而被阻塞,就会报dead lock。
死锁的情况有哪些
1. 第一种:由于无缓冲channel必须同时写和读才能执行,所以当单个gorountine顺序执行的时候,channel的此性质会造成死锁
2. 第二种:2个 以上的go程中, 使用同一个 channel 通信。 写入无缓冲channel先顺序执行的时候
3. 第三种:2个以上的go程中,使用多个 channel 通信。 A go 程 获取channel 1 的同时,尝试使用channel 2, 同一时刻,B go 程 获取channel 2 的同时,尝试使用channel 1
4. 第四种: 在go语言中, channel 和 读写锁、互斥锁 尽量避免交叉混用。——“隐形死锁”。如果必须使用。推荐借助“条件变量”
死锁如何检查
1. 利用pprof检查golang代码中的死锁
2. 可能在应用中产生的因素在于rpc的调度,或者http调度,没有设置超时,导致远程交叉
3. 可能是读写数据库,因为大量数据或者SQL写的有问题导致与数据库之间的连接出现问题(尽量设置好超时时间,或者考虑开辟一个新的协程去处理)
4. 养成单元测试的习惯,一般简单死锁都可以通过测试解决
46. channel的应用
1. 通道同步
2. 任务通道
3. 生产者/消费者
4. 并发控制
5. 停止任务
本站部分资源来源于网络,仅限用于学习和研究目的,请勿用于其他用途。
如有侵权请发送邮件至1943759704@qq.com删除
码农资源网 » 精选常见go语言面试题及答案解析