Go语言面试题合集(2022)

如有需要,请关注我的github账号:https://github.com/jeremyke

基础语法

Go 支持默认参数或可选参数吗?

不支持。但是可以利用结构体参数,或者…传入参数切片数组。

// 这个函数可以传入任意数量的整型参数
func sum(nums ...int) {
    total := 0
    for _, num := range nums {
        total += num
    }
    fmt.Println(total)
}

Go 语言 tag 的用处?

tag可以为结构体成员提供属性。常见的:

  1. json序列化或反序列化时字段的名称
  2. db: sqlx模块中对应的数据库字段名
  3. form: gin框架中对应的前端的数据字段名
  4. binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端

结构体打印时,%v 和 %+v 的区别

%v输出结构体各成员的值;
%+v输出结构体各成员的名称
%#v输出结构体名称和结构体各成员的名称和值

package main

import "fmt"

type T struct{
	a int 
	b float64
}
func test(){
	t:=T{
		a:1,
		b:2.0,
	}
	fmt.Printf("%v\n",t)
	fmt.Printf("%+v\n",t)
	fmt.Printf("%#v\n",t)
}
func main(){
	test()
}

输出:

> {1 2}
> {a:1 b:2}
> main.T{a:1, b:2}

空 struct{} 的用途

struct{}本身不占任何空间

  1. 用map模拟一个set,那么就要把值置为struct{},可以避免任何多余的内存分配。
  2. 有时候给通道发送一个空结构体,channel<-struct{}{},也是节省了空间。
  3. 仅有方法的结构体

init() 函数是什么时候执行的?

简答: 在main函数之前执行。
详细:init()函数是go初始化的一部分,由runtime初始化每个导入的包,初始化不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。
每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的init()函数。同一个包,甚至是同一个源文件可以有多个init()函数。init()函数没有入参和返回值,不能被其他函数调用,同一个包内多个init()函数的执行顺序不作保证。
执行顺序:import –> const –> var –>init()–>main()
一个文件可以有多个init()函数!

init()函数的特殊点?

init函数非常特殊:

  • 初始化不能采用初始化表达式初始化的变量;
  • 程序运行前执行注册
  • 实现sync.Once功能
  • 不能被其它函数调用
  • init函数没有入口参数和返回值:
  • 每个包可以有多个init函数,每个源文件也可以有多个init函数
  • 同一个包的init执行顺序,golang没有明确定义,编程时要注意程序不要依赖这个执行顺序。
  • 不同包的init函数按照包导入的依赖关系决定执行顺序。

Go 语言中iota的含义和作用?

iota是go语言的常量计数器,只能在常量的表达式中使用。
使用iota时只需要记住以下两点:

  1. iota在const关键字出现时将被重置为0。
  2. const中每新增一行常量声明将使iota计数一次(iota可理解为const语句块中的行索引)。

作用: 使用iota能简化定义,在定义枚举时很有用。

const (
		n1 = iota //0
		n2        //1
		n3        //2
		n4        //3
	)
const (
		n1 = iota //0
		n2        //1
		_		  //丢弃该值,常用在错误处理中
		n4        //3
	)
const (
		n1 = iota //0
		n2 = 100  //100
		n3 = iota //2
		n4        //3
	)
const n5 = iota //0
const (
		_  = iota
		KB = 1 << (10 * iota) // <<移位操作,速度比乘除法快 
		MB = 1 << (10 * iota) // 1<<3 相当于1*2*2*2     0001 -> 1000
		GB = 1 << (10 * iota)
		TB = 1 << (10 * iota)
		PB = 1 << (10 * iota)
	)
const (
		a, b = iota + 1, iota + 2 //1,2
		c, d                      //2,3
		e, f                      //3,4
	)

Go语言哪些值可以比较,哪些值不能比较,举例子说明?

在golang中可比较的类型有int,string,bool,pointer,channel,interface,array 不可比较的类型有slice,map,func
struct比较:

  1. 同一个struct的不同实例赋值相同,相互比较“”,为true;赋值不同,相互比较“”,为false
  2. struct中包含不可比较类型,该struct变量无法比较大小,编译报错

interface比较:

  1. interface的不同结构体实例可以比较,就算值相同,结果也不相同。返回false。
  2. 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态),返回true。
  3. 类型 T 相同,且对应的值 V 相等,返回true。

new和make的区别?

  • new只用于分配内存,返回一个指向地址的指针。它为每个新类型分配一片内存,初始化为0且返回类型*T的内存地址,它相当于&T{}
  • make只可用于slice,map,channel的初始化,返回的是引用

巧妙的方法判断一个结构体是否实现了某个接口?

type GobCodec struct{
	conn io.ReadWriteCloser
	buf *bufio.Writer
	dec *gob.Decoder
	enc *gob.Encoder
}

type Codec interface {
	io.Closer
	ReadHeader(*Header) error
	ReadBody(interface{})  error
	Write(*Header, interface{}) error
}

var _ Codec = (*GobCodec)(nil)

将nil转换为GobCodec类型,然后再转换为Codec接口,如果转换失败,说明GobCodec没有实现Codec接口的所有方法。

深拷贝和浅拷贝区别?

操作对象

深拷贝和浅拷贝操作的对象都是Go语言中的引用类型

区别如下:

引用类型的特点是在内存中存储的是其他值的内存地址;而值类型在内存中存储的是真实的值。
浅拷贝:我们在go语言中通过 := 赋值引用类型就是 浅拷贝,即拷贝的是内存地址,两个变量对应的是同一个内存地址对应的同一个值

a := []string{1,2,3} 
b := a

深拷贝:如果我们通过copy()函数进行赋值,就是深拷贝,赋值的是真实的值,而非内存地址,会在内存中开启新的内存空间。

a := []string{1,2,3} 
b := make([]string,len(a),cap(a)) 
copy(b,a)

Golang中数组与切片比较?

数组切片
是否固定长度
数据类型值类型引用类型

总结:

  1. 数组是固定长度的包含相同数据类型的元素的数据结构,而切片是非固定长度,可扩容的包含相同数据类型的元素的数据结构。
  2. 数组是值类型,而切片是引用类型。

channel特性?

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

实现原理

golang协程为什么比线程轻量?

  1. go协程切换比线程效率高。

简单回答:
线程切换需要切换内核栈和硬件上下文的,而协程切换只发生在用户态,没有时钟中断、系统调用等机制,效率更高。
详细回答:
线程是内核对外提供的服务,应用程序可以通过系统调用让内核启动线程,由内核来负责线程调度和切换。线程在等待IO操作时线程变为unrunnable状态会触发上下文切换。现代操作系统一般都采用抢占式调度,上下文切换一般发生在时钟中断和系统调用返回前,调度器计算当前线程的时间片,如果需要切换就从运行队列中选出一个目标线程,保存当前线程的环境,并且恢复目标线程的运行环境,最典型的就是切换ESP指向目标线程内核堆栈,将EIP指向目标线程上次被调度出时的指令地址。
go协程是不依赖操作系统和其提供的线程,golang自己实现的CSP并发模型实现(MGP),同时go协程也叫用户态线程,协程之间的切换发生在用户态,很轻量。在用户态没有时钟中断,系统调用等机制, 因此效率比较高。
协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

  1. go协程占用内存少。

执行go协程只需要极少的栈内存(大概是4~5KB),默认情况下,而线程栈的大小为1MB。goroutine就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈。所以它非常廉价,我们可以很轻松的创建上万个goroutine,但它们并不是被操作系统所调度执行。

如何知道一个对象是分配在栈上还是堆上?

Go和C++不同,Go局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢?

go build -gcflags '-m -m -l' xxx.go.

关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。

Go 语言GC(垃圾回收)的流程?

垃圾回收机制是Go一大特(nan)色(dian)。Go1.3采用标记清除法, Go1.5采用三色标记法,Go1.8采用三色标记法+混合写屏障
第一个阶段 gc开始 (stw)

  1. stop the world 暂停程序执行
  2. 启动标记工作携程( mark worker goroutine ),用于第二阶段
  3. 启动写屏障
  4. 将root 跟对象放入标记队列(放入标记队列里的就是灰色)
  5. start the world 取消程序暂停,进入第二阶段

第二阶段 marking(这个阶段,用户程序跟标记携程是并行的)

  1. 从标记队列里取出对象,标记为黑色
  2. 然后检测是否指向了另一个对象,如果有,将另一个对象放入标记队列
  3. 在扫描过程中,用户程序如果新创建了对象 或者修改了对象,就会触发写屏障,将对象放入单独的 marking队列,也就是标记为灰色
  4. 扫描完标记队列里的对象,就会进入第三阶段

第三阶段 处理marking过程中修改的指针 (stw)

  1. stop the world 暂停程序
  2. 将marking阶段 修改的对象 触发写屏障产生的队列里的对象取出,标记为黑色
  3. 然后检测是否指向了另一个对象,如果有,将另一个对象放入标记队列
  4. 扫描完marking队列里的对象,start the world 取消暂停程序 进入第四阶段

第四阶段 sweep 清楚白色的对象
到这一阶段,所有内存要么是黑色的要么是白色的,清楚所有白色的即可
golang的内存管理结构中有一个bitmap区域,其中可以标记是否“黑色”

golang gc 的触发时机:
  • 触发内存的阀值,内存达到上次gc后的2倍
  • 2分钟
  • 手动触发

什么是混合写屏障?

插入写屏障:

强三色不变式:不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色。
当全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况,所以要对栈重新进行三色标记扫描, 但这次为了对象不丢失, 要对本次标记扫描启动STW暂停. 直到栈空间的三色标记结束。

删除写屏障:

弱三色不变式:保护灰色对象到白色对象的路径不会断。
这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

插入写屏障和删除写屏障的短板:

插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;
删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。

混合写屏障:

GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
GC期间,任何在栈上创建的新对象,均为黑色。
被删除的对象标记为灰色。
被添加的对象标记为灰色。

slice是怎么扩容的?

Go <= 1.17:
①如果所需容量大于当前容量的2倍,则扩容到所需容量;
②如果所需容量小于等于当前容量的2倍,判读当前容量是否小于1024:
如果当前容量小于1024,则直接扩容到原来容量的2倍;
如果当前容量大于等于1024,则for循环每次按照1.25倍速度递增容量newcap += newcap/4,且把每次扩容后的容量和所需容量比较,直到满足扩容后的容量大于等于所需容量,停止for循环。
③通过roundupsize函数进行内存对齐,新分配的容量大于等于就旧容量的2倍或者1.25倍。
Go >= 1.18:
在这里插入图片描述

改动点:定义了常量threshold=256,当扩容前容量 >= 256时,会按照公式newcap += (newcap + 3*threshold) / 4进行扩容。

Go面向对象是如何实现的?

封装:对于同一个包,对象对包内的文件可见;对不同的包,需要将对象以大写开头才是可见的。
继承:结构体中嵌入匿名结构体实现,Go支持多重继承,就是在类型中嵌入所有必要的父类型。

type A struct{
    
} 
type B struct{ 
	A 
}

多态:多态是运行时特征,Go多态通过interface来实现。类型和接口是松耦合的,某个类型的实例可以赋给它所实现的任意接口类型的变量。

简述go内存管理机制?

golang内存管理基本是参考tcmalloc来进行的。go内存管理本质上是一个内存池,只不过内部做了很多优化:自动伸缩内存池大小,合理的切割内存块。

一些基本概念:
页Page:一块8K大小的内存空间。Go向操作系统申请和释放内存都是以页为单位的。
span : 内存块,一个或多个连续的 page 组成一个 span 。如果把 page 比喻成工人, span 可看成是小队,工人被分成若干个队伍,不同的队伍干不同的活。
sizeclass : 空间规格,每个 span 都带有一个 sizeclass ,标记着该 span 中的 page 应该如何使用。使用上面的比喻,就是 sizeclass 标志着 span 是一个什么样的队伍。
object : 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object 。假设 object 的大小是 16B , span 大小是 8K ,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object 。所谓内存分配,就是分配一个 object 出去。

  1. mheap

一开始go从操作系统索取一大块内存作为内存池,并放在一个叫mheap的内存池进行管理,mheap将一整块内存切割为不同的区域,并将一部分内存切割为合适的大小。

在这里插入图片描述

mheap.spans:用来存储 page 和 span 信息,比如一个 span 的起始地址是多少,有几个 page,已使用了多大等等。
mheap.bitmap: 存储着各个 span 中对象的标记信息,比如对象是否可回收等等。
mheap.arena_start: 将要分配给应用程序使用的空间。

  1. mcentral

用途相同的span会以链表的形式组织在一起存放在mcentral中。这里用途用sizeclass来表示,就是该span存储哪种大小的对象。找到合适的 span 后,会从中取一个 object 返回给上层使用。

  1. mcache
    为了提高内存并发申请效率,加入缓存层mcache。每一个mcache和处理器P对应。Go申请内存首先从P的mcache中分配,如果没有可用的span再从mcentral中获取。

mutex有几种模式?

mutex有两种模式:normalstarvation
正常模式(默认):
解释一:
正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。唤醒 的 goroutine 不会直接拥有锁,而是会和新请求 goroutine 竞争锁。新请求的 goroutine 更容易抢占:因为它正在 CPU 上执行,所以刚刚唤醒的 goroutine有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 会加入 到等待队列的前面。
解释二:
在正常模式下,协程如果加锁不成功不会立即转入等待队列,而是判断是否满足自旋的条件,如果满足则会自旋。当持有锁的协程释放锁的时候,会释放一个信号量来唤醒等待队列中的一个协程,但如果有协程正处于自旋过程中,锁往往会被该自旋协程获取到。被唤醒的协程只好再次阻塞,不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过 1ms 的话,会将 Mutex 标记为饥饿模式。

饥饿模式:
为了解决了等待 goroutine 队列的长尾问题,饥饿模式下,直接由 unlock 把锁交给等待队列中排在第一位的 goroutine (队 头),同时,饥饿模式下,新进来的 goroutine 不会参与抢锁也不会进入自旋状态,会直接进入等待队列的尾部。这样很好的解决了老的 goroutine一直抢不 到锁的场景。
饥饿模式的触发条件:当一个 goroutine 等待锁时间超过 1 毫秒时,或者当前 队列只剩下一个 goroutine 的时候,Mutex 切换到饥饿模式。

简述Go语言协程调度原理,GMP状态流转过程?

在这里插入图片描述

Go 程序的执行由两层组成:Go Program,Runtime,即用户程序和运行时。它们之间通过函数调用来实现内存管理、channel 通信、goroutines 创建等功能。用户程序进行的系统调用都会被 Runtime 拦截,以此来帮助它进行调度以及垃圾回收相关的工作。runtine维护着所有goroutine,并通过go scheduler把goroutine调度到内核线程M中执行。
三个基础结构体实现goroutine调度:M、P、G
M:内核线程,包含正在运行的goroutine等字段。
P:逻辑处理器,维护一个处于Runnable状态的goroutine队列,M需要获得P才能运行G。
G:协程goroutine,包含goroutine状态、函数地址以及函数执行的上下文信息(栈寄存器,pc寄存器的值)
调度流程:
在这里插入图片描述

  1. 我们通过go func()来创建一个goroutine
  2. 有两个存储G的队列,一个是局部调度器P的本地队列,一个是全局G队列。新创建的G会先保存在本地队列中,如果本地队列已经满了,会保存在全局队列中
  3. G只能运行在M中,一个M必须有一个P,M会从P的本地队列弹出一个可执行状态的G来执行,如果本地队列为空,会从其他队列偷一个来执行
  4. 一个M调度G执行的过程是一个循环机制s
  5. M执行某一个G时,如果发生了系统调用或其他阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除,然后再创建一个新的线程来服务这个P
  6. 当M系统调用结束时,这个G会尝试获取一个空闲的P执行,并放到P的本地队列,如果获取不到P,M就变成休眠状态,放到空闲线程中,然后G会被放入全局队列中。

系统线程不断的在一个 core 上做上下文切换一样,Goroutine 不断的在 M 上做上下文切换。

Go什么时候发生阻塞?阻塞时,调度器会怎么做?

  • channel阻塞:当goroutine读写channel发生阻塞时,会调用gopark函数,该G脱离当前的M和P,调度器将新的G放入当前M。
  • 系统调用:当某个G由于系统调用陷入内核态,该P就会脱离当前M,此时P会更新自己的状态为Psyscall,M与G相互绑定,进行系统调用。结束以后,若该P状态还是Psyscall,则直接关联该M和G,否则使用闲置的处理器处理该G。
  • 系统监控:当某个G在P上运行的时间超过10ms时候,或者P处于Psyscall状态过长等情况就会调用retake函数,触发新的调度。
  • 主动让出:由于是协作式调度,该G会主动让出当前的P(通过GoSched),更新状态为Grunnable,该P会调度队列中的G运行。

Go中GMP有哪些状态?

在这里插入图片描述

G的状态:
_Gidle:刚刚被分配并且还没有被初始化,值为0,为创建goroutine后的默认值
_Grunnable: 没有执行代码,没有栈的所有权,存储在运行队列中,可能在某个P的本地队列或全局队列中(如上图)。
_Grunning: 正在执行代码的goroutine,拥有栈的所有权(如上图)。
_Gsyscall:正在执行系统调用,拥有栈的所有权,与P脱离,但是与某个M绑定,会在调用结束后被分配到运行队列(如上图)。
_Gwaiting:被阻塞的goroutine,阻塞在某个channel的发送或者接收队列(如上图)。
_Gdead: 当前goroutine未被使用,没有执行代码,可能有分配的栈,分布在空闲列表gFree,可能是一个刚刚初始化的goroutine,也可能是执行了goexit退出的goroutine(如上图)。
_Gcopystac:栈正在被拷贝,没有执行代码,不在运行队列上,执行权在
_Gscan : GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在。
P的状态:
_Pidle :处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
_Prunning :被线程 M 持有,并且正在执行用户代码或者调度器(如上图)
_Psyscall:没有执行用户代码,当前线程陷入系统调用(如上图)
_Pgcstop :被线程 M 持有,当前处理器由于垃圾回收被停止
_Pdead :当前处理器已经不被使用
M的状态:
自旋线程:处于运行状态但是没有可执行goroutine的线程,数量最多为GOMAXPROC,若是数量大于GOMAXPROC就会进入休眠。
非自旋线程:处于运行状态有可执行goroutine的线程。

如果有一个G一直占用资源怎么办?什么是work stealing算法?

如果有个goroutine一直占用资源,那么GMP模型会从正常模式转变为饥饿模式(类似于mutex),允许其它goroutine使用work stealing抢占(禁用自旋锁)。
work stealing算法指,一个线程如果处于空闲状态,则帮其它正在忙的线程分担压力,从全局队列取一个G任务来执行,可以极大提高执行效率。

线程模型有哪些?为什么 Go Scheduler 需要实现 M:N 的方案?Go Scheduler 由哪些元素构成呢?

线程模型有哪些?

在细说 Go 的调度模型之前,先来说说一般意义的线程模型。线程模型一般分三种,由用户级线程和 OS 线程的不同对应关系决定的。

  • N:1,即全部用户线程都映射到一个OS线程上,上下文切换成本最低,但无法利用多核资源;
  • 1:1 , 一个用户线程对应到一个 OS线程上, 能利用到多核资源,但是上下文切换成本较高,这也是 Java Hotspot VM 的默认实现;
  • M:N,权衡上面两者方案,既能利用多核资源也能尽可能减少上下文切换成本,但是调度算法的实现成本偏高。
为什么 Go Scheduler 需要实现 M:N 的方案? (为什么在内核的线程调度器之外Go还需要一个自己的调度器?)
  1. 线程创建开销大。对于 OS 线程而言,其很多特性均是操作系统给予的,但对于 Go 程序而言,其中很多特性可能非必要的。这样一来,如果是 1:1 的方案,那么每次 go func(){…} 都需要创建一个 OS 线程,而在创建线程过程中,OS 线程里某些 Go 用不上的特性会转化为不必要的性能开销,不经济。
  2. 减少 Go 垃圾回收的复杂度。依据1:1方案,Go 产生所用用户级线程均交由 OS 直接调度。 Go 的垃圾回收器要求在运行时需要停止所有线程,才能使得内存达到稳定一致的状态,而 OS 不可能清楚这些,垃圾回收器也不能控制 OS 去阻塞线程。
Go Scheduler 由哪些元素构成呢?
  • G: Goroutine,Go 的用户级线程,常说的协程,真正携带代码执行逻辑的部分,由 go func(){…} 直接生成
  • P: Processor, 调度器的核心处理器,通常表示执行上下文,用于匹配 M 和 G 。P 的数量不能超过 GOMAXPROCS 配置数量,这个参数的默认值为CPU核心数;通常一个 P 可以与多个 M 对应,但同一时刻,这个 P 只能和其中一个 M 发生绑定关系;M 被创建之后需要自行在 P 的 free list 中找到 P 进行绑定,没有绑定 P 的 M,会进入阻塞态
  • M: Machine,就是 OS 线程本身,数量可配置

注:GOMAXPROCS 参数很重要,其决定了 P 的最大数量,也决定了自旋 M 的最大数量。线程自旋是相对于线程阻塞而言的,如果 G 迟迟不来,CPU 会白白浪费在这无意义的计算上。但好处也很明显,降低了 M 的上下文切换成本,提高了性能。Go 的设计者倾向于高性能的并发表现,为了避免过多浪费 CPU 资源,自旋的线程数不会超过 GOMAXPROCS。

  • 本地队列(local queue): 本地是相对 P 而言的本地,每个 P 维护一个本地队列;与 P 绑定的 M 中如若生成新的 G,一般情况下会放到 P 的本地队列;当本地队列满了的时候,才会截取本地队列中 “一半” 的元素放入全局队列中;
  • 全局队列(global queue):承载本地队列“溢出”的 G。为了保证调度公平性,schedule 过程中有 1/61 的几率优先检查全局队列,否则本地队列一直满载的情况下,全局队列中的 G 将永远无法被调度到;
  • 窃取(stealing): 这似乎和 Java Fork-Join 中的 work-stealing 模型很相似,其目的也是一样,就是为了使得空闲(idle)的 M 有活干,不空等,提高计算资源的利用率。窃取也是有章法的,规则是随机从其他 P 的本地队列里窃取 “一半” 的 G。

channel底层实现?是否线程安全?

channel底层实现在src/runtime/chan.go中channel内部是一个循环链表。chan结构体内部包含buf, sendx, recvx, lock ,recvq, sendq几个属性;

  • buf是有缓冲的channel所特有的结构,用来存储缓存数据。是个循环链表;
  • sendx和recvx用于记录buf这个循环链表中的发送或者接收的index;
  • lock是个互斥锁;
  • recvq和sendq分别是接收(<-channel)或者发送(channel <- xxx)的goroutine抽象出来的结构体(sudog)的队列。是个双向链表。

channel是线程安全的。

map的底层实现?

源码位于src\runtime\map.go 中。go的map底层实现是哈希表,包括两个部分:hmapbucket
里面最重要的是buckets(桶),buckets是一个指针,最终它指向的是一个结构体:

// A bucket for a Go map.
type bmap struct {
    tophash [bucketCnt]uint8
}

每个bucket固定包含8个key和value(可以查看源码bucketCnt=8).实现上面是一个固定的大小连续内存块,分成四部分:每个条目的状态,8个key值,8个value值,指向下个bucket的指针。
创建哈希表使用的是makemap函数.map 的一个关键点在于,哈希函数的选择。在程序启动时,会检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。这是在函数 alginit() 中完成,位于路径:src/runtime/alg.go 下。
map查找就是将key哈希后得到64位(64位机)用最后B个比特位计算在哪个桶。在 bucket 中,从前往后找到第一个空位。这样,在查找某个 key 时,先找到对应的桶,再去遍历 bucket 中的 key。

select的实现原理?

Golang实现select时,定义了一个数据结构表示每个case语句(含defaut,default实际上是一种特殊的case),select执行过程可以类比成一个函数,函数输入case数组,输出选中的case,然后程序流程转到选中的case块。

case数据结构

源码包src/runtime/select.go:scase定义了表示case语句的数据结构:

type scase struct {
    c           *hchan         // chan
    kind        uint16
    elem        unsafe.Pointer // data element
}

scase.c为当前case语句所操作的channel指针,这也说明了一个case语句只能操作一个channel。
scase.kind表示该case的类型,分为读channel、写channel和default,三种类型分别由常量定义:

  • caseRecv:case语句中尝试读取scase.c中的数据;
  • caseSend:case语句中尝试向scase.c中写入数据;
  • caseDefault: default语句

scase.elem表示缓冲区地址,跟据scase.kind不同,有不同的用途:

  • scase.kind == caseRecv : scase.elem表示读出channel的数据存放地址;
  • scase.kind == caseSend : scase.elem表示将要写入channel的数据存放地址;
select实现逻辑

源码包src/runtime/select.go:selectgo()定义了select选择case的函数,如下是代码逻辑:

	
func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool){s
    //1. 锁定scase语句中所有的channel
    //2. 按照随机顺序检测scase中的channel是否ready
    //   2.1 如果case可读,则读取channel中数据,解锁所有的channel,然后返回(case index, true)
    //   2.2 如果case可写,则将数据写入channel,解锁所有的channel,然后返回(case index, false)
    //   2.3 所有case都未ready,则解锁所有的channel,然后返回(default index, false)
    //3. 所有case都未ready,且没有default语句
    //   3.1 将当前协程加入到所有channel的等待队列
    //   3.2 当将协程转入阻塞,等待被唤醒
    //4. 唤醒后返回channel对应的case index
    //   4.1 如果是读操作,解锁所有的channel,然后返回(case index, true)
    //   4.2 如果是写操作,解锁所有的channel,然后返回(case index, false)
}

函数参数:

  • cas0:为scase数组的首地址,selectgo()就是从这些scase中找出一个返回。
  • order0:为一个两倍cas0数组长度的buffer,保存scase随机序列pollorder和scase中channel地址序列lockorder
    • pollorder:每次selectgo执行都会把scase序列打乱,以达到随机检测case的目的。
    • lockorder:所有case语句中channel序列,以达到去重防止对channel加锁时重复加锁的目的。
  • ncases:表示scase数组的长度

函数返回值:

  1. int: 选中case的编号,这个case编号跟代码一致
  2. bool: 是否成功从channle中读取了数据,如果选中的case是从channel中读数据,则该返回值表示是否读取成功。

特别说明:
对于读channel的case来说,如case elem, ok := <-chan1:, 如果channel有可能被其他协程关闭的情况下,一定要检测读取是否成功,因为close的channel也有可能返回,此时ok == false。

go的interface怎么实现的?

interface包含iface 和 eface两种类型,它们都是 Go 中描述接口的底层结构体,区别在于 iface 描述的接口包含方法,而 eface 则是不包含任何方法的空接口:interface{}。

type iface struct {
	tab  *itab
	data unsafe.Pointer
}

type itab struct {
	inter  *interfacetype//具体类型实现的接口类型
	_type  *_type//具体类型
	link   *itab
	hash   uint32 // copy of _type.hash. Used for type switches.
	bad    bool   // type does not implement interface
	inhash bool   // has this itab been added to hash?
	unused [2]byte
	fun    [1]uintptr // variable sized
}

type interfacetype struct {
	typ     _type
	pkgpath name
	mhdr    []imethod
}

type _type struct {
    // 类型大小
	size       uintptr
    ptrdata    uintptr
    // 类型的 hash 值
    hash       uint32
    // 类型的 flag,和反射相关
    tflag      tflag
    // 内存对齐相关
    align      uint8
    fieldalign uint8
    // 类型的编号,有bool, slice, struct 等等等等
	kind       uint8
	alg        *typeAlg
	// gc 相关
	gcdata    *byte
	str       nameOff
	ptrToThis typeOff
}

iface 内部维护两个指针,tab 指向一个 itab 实体, 它表示接口的类型以及赋给这个接口的实体类型。data 则指向接口具体的值,一般而言是一个指向堆内存的指针。
itab 结构体:inter 字段则描述了接口的类型;_type 字段描述了实体的类型,包括内存对齐方式,大小等;fun 字段放置和接口方法对应的具体数据类型的方法地址,实现接口调用方法的动态分派,一般在每次给接口赋值发生转换时会更新此表,或者直接拿缓存的 itab。
_type结构体:_type结构体是Go语言中最基本的数据类型,很多数据类型都包含了_type字段,增加一些额外的字段来进行管理。这里_type表示具体类型【动态类型】,而inter表示具体类型实现的接口类型【静态类型】。
interfacetype结构体:它包装了 _type 类型,_type 实际上是描述 Go 语言中各种数据类型的结构体。我们注意到,这里还包含一个 mhdr 字段,表示接口所定义的函数列表, pkgpath 记录定义了接口的包名。

eface只维护了一个 _type 字段,表示空接口所承载的具体的实体类型。data 描述了具体的值:

type eface struct {
    _type *_type
    data  unsafe.Pointer
}

go的reflect 底层实现?

go reflect源码位于src\reflect\下面,作为一个库独立存在。反射是基于接口实现的。
接口的实现在上面这个问题已经说明白了,下面看下反射,go的反射就是对其三大法则的实现:

  1. 接口对象转为反射对象
  2. 反射对象转为接口对象
  3. 如果数据可更改,可通过反射对象来修改它

反射的实现和interface的组成很相似,都是由“类型”和“数据值”构成,但是值得注意的是:interface的“类型”和“数据值”是在“一起的”,而反射的“类型”和“数据值”是分开的。
反射的意思是在运行时,能够动态知道给定数据对象的类型和结构,并有机会修改它!
现在一个数据对象,如何判断它是什么结构?数据interface中保存有结构数据呀。所以只要想办法拿到该数据对应的内存地址,然后把该数据转成interface,通过查看interface中的类型结构,就可以知道该数据的结构了呀!其实以上就是Go反射通俗的原理。
interface源码(位于”Go SDK/src/runtime/runtime2.go“)中的 eface和 iface 会和 反射源码(位于”GO SDK/src/reflect/value.go“)中的emptyInterface和nonEmptyInterface保持数据同步!
此外,还有interface源码(位于”Go SDK/src/runtime/type.go“)中的_type会和 反射源码(位于”GO SDK/src/reflect/type.go“)中的rtype也保持数据同步一致!

图可以展示为:
在这里插入图片描述

图中结构中牵扯到的指针,都是unsafe.Pointer指针。

说说context包的作用?你用过哪些,原理知道吗?

context可以用来在goroutine之间传递上下文信息,相同的context可以传递给运行在不同goroutine中的函数,上下文对于多个goroutine同时使用是安全的,context包定义了上下文类型,可以使用background、TODO创建一个上下文,在函数调用链之间传播context,也可以使用WithDeadline、WithTimeout、WithCancel 或 WithValue 创建的修改副本替换它,听起来有点绕,其实总结起就是一句话:context的作用就是在不同的goroutine之间同步请求特定的数据、取消信号以及处理请求的截止日期

并发编程

无缓冲的 channel 和有缓冲的 channel 的区别?

(这个问题笔者也纠结了很久,直到看到一篇文章,阻塞与否是分别针对发送接收方而言的,才茅塞顿开)
对于无缓冲区channel:
发送的数据如果没有被接收方接收,那么**发送方阻塞;**如果一直接收不到发送方的数据,接收方阻塞
有缓冲的channel:
发送方在缓冲区满的时候阻塞,接收方不阻塞;接收方在缓冲区为空的时候阻塞,发送方不阻塞。
可以类比生产者与消费者问题。
在这里插入图片描述

为什么有协程泄露(Goroutine Leak)?

协程泄漏是指协程创建之后没有得到释放。主要原因有:

  1. 发送数据到channel的协程缺少接收器,导致发送阻塞
  2. 接收channel数据的协程,缺少发送器,导致接收阻塞
  3. 死锁。多个协程由于竞争资源导致死锁。
  4. 创建协程的没有回收。

goroutine什么情况会发生内存泄漏?如何避免。

在Go中内存泄露分为暂时性内存泄露和永久性内存泄露。
暂时性内存泄露

  • 获取长字符串中的一段导致长字符串未释放
  • 获取长slice中的一段导致长slice未释放
  • 在长slice新建slice导致泄漏

string相比切片少了一个容量的cap字段,可以把string当成一个只读的切片类型。获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏
永久性内存泄露

  • goroutine永久阻塞而导致泄漏
  • time.Ticker未关闭导致泄漏
  • 不正确使用Finalizer(Go版本的析构函数)导致泄漏

Go 可以限制运行时操作系统线程的数量吗? 常见的goroutine操作函数有哪些?

一:可以,使用runtime.GOMAXPROCS(num int)可以设置线程数目。该值默认为CPU逻辑核数,如果设的太大,会引起频繁的线程切换,降低性能。
二:

  1. runtime.Gosched(),用于让出CPU时间片,让出当前goroutine的执行权限,调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。
  2. runtime.Goexit(),调用此函数会立即使当前的goroutine的运行终止(终止协程),而其它的goroutine并不会受此影响。runtime.Goexit在终止当前goroutine前会先执行此goroutine的还未执行的defer语句。请注意千万别在主函数调用runtime.Goexit,因为会引发panic。

如何控制协程数目?

方法一:设置GOMAXPROCS
GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。
因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。
方法二:带缓冲区的channel来控制
另外对于协程,可以用带缓冲区的channel来控制,下面的例子是协程数为1024的例子:

var wg sync.WaitGroup
ch := make(chan struct{}, 1024)
for i:=0; i<20000; i++{
	wg.Add(1)
	ch<-struct{}{}
	go func(){
		defer wg.Done()
		<-ch
	}
}
wg.Wait()

方法三:协程池:
此外还可以用协程池:其原理无外乎是将上述代码中通道和协程函数解耦,并封装成单独的结构体。常见第三方协程池库,比如tunny等。

atomic底层怎么实现的?

atomic源码位于sync/atomic。通过阅读源码可知,atomic采用CAS(CompareAndSwap)的方式实现的。所谓CAS就是使用了CPU中的原子性操作。在操作共享变量的时候,CAS不需要对其进行加锁,而是通过类似于乐观锁的方式进行检测,总是假设被操作的值未曾改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换。本质上是不断占用CPU资源来避免加锁的开销

微服务

gRPC是什么?

基于go的远程过程调用。RPC 框架的目标就是让远程服务调用更加简单、透明,RPC 框架负责屏蔽底层的传输方式(TCP 或者 UDP)、序列化方式(XML/Json/ 二进制)和通信细节。服务调用者可以像调用本地接口一样调用远程的服务提供者,而不需要关心底层通信细节和调用过程。

在这里插入图片描述

grpc为啥好,基本原理是什么,和http比呢

官方介绍:gRPC 是一个现代开源的高性能远程过程调用 (RPC) 框架,可以在任何环境中运行。它可以通过对负载平衡、跟踪、健康检查和身份验证的可插拔支持有效地连接数据中心内和跨数据中心的服务。它也适用于分布式计算的最后一英里,将设备、移动应用程序和浏览器连接到后端服务。
区别:

  • rpc是远程过程调用,就是本地去调用一个远程的函数,而http是通过 url和符合restful风格的数据包去发送和获取数据;
  • rpc的一般使用的编解码协议更加高效,比如grpc使用protobuf编解码。而http的一般使用json进行编解码,数据相比rpc更加直观,但是数据包也更大,效率低下;
  • rpc一般用在服务内部的相互调用,而http则用于和用户交互;

相似点:

  • 都有类似的机制,例如grpc的metadata机制和http的头机制作用相似,而且web框架,和rpc框架中都有拦截器的概念。grpc使用的是http2.0协议。

服务发现是怎么做的?

主要有两种服务发现机制:客户端发现服务端发现
客户端发现模式:当我们使用客户端发现的时候,客户端负责决定可用服务实例的网络地址并且在集群中对请求负载均衡, 客户端访问服务登记表,也就是一个可用服务的数据库,然后客户端使用一种负载均衡算法选择一个可用的服务实例然后发起请求。该模式如下图所示:
在这里插入图片描述

客户端发现模式
服务端发现模式:客户端通过负载均衡器向某个服务提出请求,负载均衡器查询服务注册表,并将请求转发到可用的服务实例。如同客户端发现,服务实例在服务注册表中注册或注销。
在这里插入图片描述

服务端发现模式

ETCD是什么?

etcd是一个高度一致分布式键值存储,它提供了一种可靠的方式来存储需要由分布式系统或机器集群访问的数据。它可以优雅地处理网络分区期间的领导者选举,即使在领导者节点中也可以容忍机器故障。
etcd 是用Go语言编写的,它具有出色的跨平台支持,小的二进制文件和强大的社区。etcd机器之间的通信通过Raft共识算法处理。

实战

怎么在项目中优雅地启停服务?

所谓「优雅」启停就是在启动退出服务时要满足以下几个条件:

  • 不可以关闭现有连接(进程)
  • 新的进程启动并「接管」旧进程
  • 连接要随时响应用户请求,不可以出现拒绝请求的情况
  • 停止的时候,必须处理完既有连接,并且停止接收新的连接

为此我们必须引用信号来完成这些目的:
启动:

  • 监听SIGHUP(在用户终端连接(正常或非正常)结束时发出);
  • 收到信号后将服务监听的文件描述符传递给新的子进程,此时新老进程同时接收请求;

退出:

  • 监听SIGINT和SIGSTP和SIGQUIT等。
  • 父进程停止接收新请求,等待旧请求完成(或超时);
  • 父进程退出。

go的调试/分析工具用过哪些?

go的自带工具链相当丰富,

  • go cover : 测试代码覆盖率;
  • godoc: 用于生成go文档;
  • pprof:用于性能调优,针对cpu,内存和并发;
  • race:用于竞争检测;

etcd怎么搭建的,具体怎么用的

熔断怎么做的

服务降级怎么搞

1亿条数据动态增长,取top10,怎么实现

进程挂了怎么办

nginx配置过吗,有哪些注意的点

设计一个阻塞队列

mq消费阻塞怎么办

性能没达到预期,有什么解决方案

Goroutine)有三个函数,分别打印"cat", “fish”,"dog"要求每一个函数都用一个goroutine,按照顺序打印100次。

此题目考察channel,用三个无缓冲channel,如果一个channel收到信号则通知下一个。

package main

import (
	"fmt"
	"time"
)

var dog = make(chan struct{})
var cat = make(chan struct{})
var fish = make(chan struct{})

func Dog() {
	<-fish
	fmt.Println("dog")
	dog <- struct{}{}
}

func Cat() {
	<-dog
	fmt.Println("cat")
	cat <- struct{}{}
}

func Fish() {
	<-cat
	fmt.Println("fish")
	fish <- struct{}{}
}

func main() {
	for i := 0; i < 100; i++ {
		go Dog()
		go Cat()
		go Fish()
	}
	fish <- struct{}{}

	time.Sleep(10 * time.Second)
}

当select监控多个chan同时到达就绪态时,如何先执行某个任务?

可以在子case再加一个for select语句。

func priority_select(ch1, ch2 <-chan string) {
	for {
		select {
		case val := <-ch1:
			fmt.Println(val)
		case val2 := <-ch2:
		priority:
			for {
				select {
				case val1 := <-ch1:
					fmt.Println(val1)

				default:
					break priority
				}
			}
			fmt.Println(val2)
		}
	}

}
  • 7
    点赞
  • 84
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值