go基础知识面试备忘录
一、Go相关
参考书籍:
【1】Go专家编程
【2】Go语言设计与实现
tcmalloc
https://blog.csdn.net/aaronjzhang/article/details/8696212
http://legendtkl.com/2015/12/11/go-memory/
tcmalloc是线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数
tcmalloc分配的内存主要来自两个地方:全局缓存堆和进程的私有缓存。对于一些小容量的内存申请试用进程的私有缓存,私有缓存不足的时候可以再从全局缓存申请一部分作为私有缓存。对于大容量的内存申请则需要从全局缓存中进行申请。而大小容量的边界就是32k。缓存的组织方式是一个单链表数组,数组的每个元素是一个单链表,链表中的每个元素具有相同的大小
0、内存分配
Go实现的内存管理简单的说就是维护一块大的全局内存,每个线程(Go中为Processer)维护一块小的私有内存,私有内存不足的时候再从全局申请。
* go程序启动时会申请一块大内存,并划分为spans/bitmap/arena区域
* arena区域按页划分为一个个小块
* span管理一个或多个页
* mcentral管理多个span供线程申请使用
* mcache作为线程私有资源,资源来源于mcentral
1、GC垃圾回收
垃圾回收的核心就是标记出哪些内存还在使用中(即被引用到),哪些内存不再使用了(即未被引用),把未被引用的内存回收掉,以供后续内存分配时使用。
当前Golang使用的垃圾回收机制是三色标记法配合写屏障和辅助GC,三色标记法是标记-清除法的一种增强版本(减少stw暂停时间)。
三色标记法
三色标记,通过字面意思我们就可以知道它由3种颜色组成:
-
黑色 Black:表示对象是可达的,即使用中的对象,黑色是已经被扫描的对象。
-
灰色 Gary:表示被黑色对象直接引用的对象,但还没对它进行扫描。
-
白色 White:白色是对象的初始颜色,如果扫描完成后,对象依然还是白色的,说明此对象是垃圾对象。
三色标记规则:黑色不能指向白色对象。即黑色可以指向灰色,灰色可以指向白色。
三色标记法是对标记阶段的改进,原理如下:
- 初始状态所有对象都是白色。
- 从root根出发扫描所有根对象,将他们引用的对象标记为灰色
- 分析灰色对象是否引用了其他对象。如果没有引用其它对象则将该灰色对象标记为黑色;如果有引用则将它变为黑色的同时将它引用的对象也变为灰色
- 重复步骤3,直到灰色对象队列为空。此时白色对象即为垃圾,进行回收。
(root区域主要是程序运行到当前时刻的栈和全局数据区域。)
GC工作的完整流程:
- Mark: 包含两部分:
- Mark Prepare: 初始化GC任务,包括开启写屏障(write barrier)和辅助GC(mutator assist),统计root对象的任务数量等。这个过程需要STW
- GC Drains: 扫描所有root对象,包括全局指针和goroutine(G)栈上的指针(扫描对应G栈时需停止该G),将其加入标记队列(灰色队列),并循环处理灰色队列的对象,直到灰色队列为空。该过程后台并行执行
- Mark Termination: 完成标记工作,重新扫描(re-scan)全局指针和栈。因为Mark和用户程序是并行的,所以在Mark过程中可能会有新的对象分配和指针赋值,这个时候就需要通过写屏障(write barrier)记录下来,re-scan 再检查一下。这个过程也是会STW的。
- Sweep: 按照标记结果回收所有的白色对象,该过程后台并行执行
- Sweep Termination: 对未清扫的span进行清扫, 只有上一轮的GC的清扫工作完成才可以开始新一轮的GC。
如何实现GC和用户代码并行呢?
1.写屏障(Write Barrier)
垃圾回收触发时机
1)内存分配量达到阈值出发GC
每次内存分配时都会检查当前内存分配量是否已达到阈值,如果达到阈值则立即启动GC。
阈值 = 上次GC内存分配量 * 内存增长率
内存增长率由环境变量GOGC
控制,默认为100,即每当内存扩大一倍时启动GC。
2)定期触发GC
默认情况下,最长2分钟触发一次GC,这个间隔在src/runtime/proc.go:forcegcperiod
变量中被声明:
3)手动触发
程序代码中也可以使用runtime.GC()
来手动触发GC,这主要用于GC性能测试和统计
GC性能优化
GC性能与对象数量负相关,对象越多GC性能越差,对程序影响越大。
所以GC性能优化的思路之一就是减少对象分配个数,比如对象复用或使用大对象组合多个小对象等等。
另外,由于内存逃逸现象,有些隐式的内存分配也会产生,也有可能成为GC的负担。
2、逃逸分析
https://zhuanlan.zhihu.com/p/145468000
https://books.studygolang.com/GoExpertProgramming/chapter04/4.3-escape_analysis.html
逃逸分析(Escape analysis)是指由编译器决定内存分配的位置,不需要程序员指定。
逃逸策略
https://books.studygolang.com/GoExpertProgramming/chapter04/4.3-escape_analysis.html
每当函数中申请新的对象,编译器会跟据该对象是否被函数外部引用来决定是否逃逸:
- 如果函数外部没有引用,则优先放到栈中;
- 如果函数外部存在引用,则必定放到堆中;
注意,对于函数外部没有引用的对象,也有可能放到堆中,比如内存过大超过栈的存储能力。
逃逸场景:指针逃逸、栈空间不足逃逸、动态类型逃逸、闭包引用对象逃逸
逃逸总结:
- 栈上分配内存比在堆中分配内存有更高的效率
- 栈上分配的内存不需要GC处理
- 堆上分配的内存使用完毕会交给GC处理
- 逃逸分析目的是决定内分配地址是栈还是堆
- 逃逸分析在编译阶段完成
go通过go build -gcflags=m
命令来观察变量逃逸情况。
函数传递指针真的比传值效率高吗? 我们知道传递指针可以减少底层值的拷贝,可以提高效率,但是如果拷贝的数据量小,由于指针传递会产生逃逸,可能会使用堆,也可能会增加GC的负担,所以传递指针不一定是高效的。
3、协程调度
对于 进程、线程,都是有内核进行调度,有 CPU 时间片的概念,进行 抢占式调度(有多种调度算法)
对于 协程(用户级线程),这是对内核透明的,也就是系统并不知道有协程的存在,是完全由用户自己的程序进行调度的,因为是由用户程序自己控制,那么就很难像抢占式调度那样做到强制的 CPU 控制权切换到其他进程/线程,通常只能进行 协作式调度,需要协程自己主动把控制权转让出去之后,其他协程才能被执行到。
Golang 运行调度。在 Golang 里面有三个基本的概念:G, M, P。
- G: Goroutine 执行的上下文环境。
- M: 操作系统线程。
- P: Processer。进程调度的关键,调度器,也可以认为约等于 CPU。
M必须拥有P才可以执行G中的代码,P含有一个包含多个G的队列,P可以调度G交由M执行。
P的个数在程序启动时决定,默认情况下等同于CPU的核数,由于M必须持有一个P才可以运行Go代码,所以同时运行的M个数,也即线程数一般等同于CPU的个数,以达到尽可能的使用CPU而又不至于产生过多的线程切换开销。
程序中可以使用runtime.GOMAXPROCS()
设置P的个数,在某些IO密集型的场景下可以在一定程度上提高性能。
Goroutine调度策略
每个P维护着一个包含G的队列,不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度。
除了每个P维护的G队列以外,还有一个全局的队列,每个P会周期性的查看全局队列中是否有G待运行并将期调度到M中执行,全局队列中G的来源,主要有从系统调用中恢复的G。之所以P会周期性的查看全局队列,也是为了防止全局队列中的G被饿死。
2)系统调用
一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。
当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。而M0由于陷入系统调用而进被阻塞,M1接替M0的工作,只要P不空闲,就可以保证充分利用CPU。
M1的来源有可能是M的缓存池,也可能是新建的。当G0系统调用结束后,跟据M0是否能获取到P,将会将G0做不同的处理:
- 如果有空闲的P,则获取一个P,继续执行G0。
- 如果没有空闲的P,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠。
3)工作量窃取
多个P中维护的G队列有可能是不均衡的,P已经将G全部执行完,然后去查询全局队列,全局队列中也没有G,而另一个M中除了正在运行的G外,队列中还有3个G待运行。此时,空闲的P会将其他P中的G偷取一部分过来,一般每次偷取一半。
GOMAXPROCS设置对性能的影响
一般来讲,程序运行时就将GOMAXPROCS大小设置为CPU核数,可让Go程序充分利用CPU。 在某些IO密集型的应用里,这个值可能并不意味着性能最好。 理论上当某个Goroutine进入系统调用时,会有一个新的M被启用或创建,继续占满CPU。 但由于Go调度器检测到M被阻塞是有一定延迟的,也即旧的M被阻塞和新的M得到运行之间是有一定间隔的,所以在IO密集型应用中不妨把GOMAXPROCS设置的大一些,或许会有好的效果。
协程泄露
有channel一直没结束,会一直占用cpu。可以通过设置time out以及select会结束channel。
4、并发编程
4.1 Channel
channel是一个通信机制,它可以让一个goroutine通过它给另一个goroutine发送值信息。也可用于并发控制。
使用channel来控制子协程的优点是实现简单,缺点是当需要大量创建协程时就需要有相同数量的channel,而且对于子协程继续派生出来的协程不方便控制。
channel有哪些状态
channel有三种状态:
- nil,未初始化的状态,只进行了声明,或者手动赋值为nil
- active,正常的channel,可读可写
- closed,已关闭
channel可进行三种操作:
- 读
- 写
- 关闭
这三种操作和状态可以组合出九种情况:
操作 | nil的channel | 正常channel | 已关闭channel |
---|---|---|---|
<-ch (读) | 阻塞 | 成功或阻塞 | 读到零值 |
ch<- (写) | 阻塞 | 成功或阻塞 | panic |
close(ch) (关闭) | panic | 成功 | panic |
无缓冲的 channel 和有缓冲的 channel 的区别?
非缓冲 channel,channel 发送和接收动作是同时发生的
例如 ch := make(chan int) ,如果没 goroutine 读取接收者<-ch ,那么发送者ch<- 就会一直阻塞
缓冲 channel 类似一个队列,只有队列满了才可能发送阻塞
如何优雅的退出协程?
- 发送协程主动关闭通道,接收协程不关闭通道。技巧:把接收方的通道入参声明为只读,如果接收协程关闭只读协程,编译时就会报错。
- 协程处理1个通道,并且是读时,协程优先使用
for-range
,因为range
可以关闭通道的关闭自动退出协程。 ,ok
可以处理多个读通道关闭,需要关闭当前使用for-select
的协程。- 显式关闭通道
stopCh
可以处理主动通知协程退出的场景。
CSP 模型由并发执行的实体(线程或者进程)所组成,实体之间通过发送消息进行通信,这里发送消息时使用的就是通道,或者叫 channel。
4.2 WaitGroup
WaitGroup,可理解为Wait-Goroutine-Group,即等待一组goroutine结束。
内部实现使用了信号量:
信号量是Unix系统提供的一种保护共享资源的机制,用于防止多个线程同时访问某个资源。
可简单理解为信号量为一个数值:
- 当信号量>0时,表示资源可用,获取信号量时系统自动将信号量减1;
- 当信号量==0时,表示资源暂不可用,获取信号量时,当前线程会进入睡眠,当信号量为正时被唤醒;
WaitGroup实现中也使用了信号量。
WaitGroup对外提供三个接口:
- Add(delta int): 将delta值加到counter中
- Wait(): waiter递增1,并阻塞等待信号量semaphore
- Done(): counter递减1,按照waiter数值释放相应次数信号量
整体处理过程:
- 启动goroutine前将计数器通过Add(2)将计数器设置为待启动的goroutine个数。
- 启动goroutine后,使用Wait()方法阻塞自己,等待计数器变为0。
- 每个goroutine执行结束通过Done()方法将计数器减1。
- 计数器变为0后,阻塞的goroutine被唤醒。
4.3 Context
它与WaitGroup最大的不同点是context对于派生goroutine有更强的控制力,它可以控制多级的goroutine。
context翻译成中文是"上下文",即它可以控制一组呈树状结构的goroutine,每个goroutine拥有相同的上下文。
Deadline() 该方法返回一个deadline和标识是否已设置deadline的bool值,如果没有设置deadline,则ok == false,此时deadline为一个初始值的time.Time值 Done() 该方法返回一个channel,需要在select-case语句中使用,如"case <-context.Done():"。 当context关闭后,Done()返回一个被关闭的管道,关闭的管理仍然是可读的,据此goroutine可以收到关闭请求; 当context还未关闭时,Done()返回nil。 Err() 该方法描述context关闭的原因。关闭原因由context实现控制,不需要用户设置。比如Deadline context,关闭原因可能是因为deadline,也可能提前被主动关闭,那么关闭原因就会不同: 因deadline关闭:“context deadline exceeded”; 因主动关闭: "context canceled"。 当context关闭后,Err()返回context的关闭原因; 当context还未关闭时,Err()返回nil; Value() 有一种context,它不是用于控制呈树状分布的goroutine,而是用于在树状分布的goroutine间传递信息。 Value()方法就是用于此种类型的context,该方法根据key值查询map中的value。具体使用后面示例说明。
context包中定义了一个空的context, 名为emptyCtx,用于context的根节点,空的context只是简单的实现了Context,本身不包含任何值,仅用于其他context的父节点。context包中定义了一个公用的emptCtx全局变量,名为background,可以使用context.Background()获取它。
* Context仅仅是一个接口定义,跟据实现的不同,可以衍生出不同的context类型;
* cancelCtx实现了Context接口,通过WithCancel()创建cancelCtx实例;
* timerCtx实现了Context接口,通过WithDeadline()和WithTimeout()创建timerCtx实例;
* valueCtx实现了Context接口,通过WithValue()创建valueCtx实例;
* 三种context实例可互为父节点,从而可以组合成不同的应用形式;
5、函数、方法、Interface与反射
函数:Func + 函数名 + 参数 + 返回值(可选) + 函数体
方法:一个方法就是一个包含了接受者的函数,接受者可以是命名类型或者结构体类型的接收者可以是值接收者,也可以是指针接收者。所有给定类型的方法属于该类型的方法集。Go语言不允许为简单的内置类型添加方法。
如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法。
如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身。
使用指针作为方法的接收者的理由:
* 方法能够修改接收者指向的值。
* 避免在每次调用方法时复制该值,在值的类型为大型结构体时,这样做会更加高效。
Interface:
interface是一组method签名的组合,我们通过interface来定义对象的一组行为。interface类型定义了一组方法,如果某个对象实现了某个接口的所有方法,则此对象就实现了此接口。
通过类型断言可以判断interface的类型或者switch语句判断。
反射:
首先需要把它转化成reflect对象(reflect.Type或者reflect.Value,根据不同的情况调用不同的函数)。这两种获取方式如下:
1
2
|
t := reflect.TypeOf(i) //得到类型的元数据,通过t我们能获取类型定义里面的所有元素 v := reflect.ValueOf(i) //得到实际的值,通过v我们获取存储在里面的值,还可以去改变值 |
反射的话,那么反射的字段必须是可修改的。
6、数据结构
1)struct
继承、封装、多态
继承:一个对象获得另一个对象的属性的过程
go可以实现多继承
- 一个struct嵌套了另一个匿名struct,那么这个struct可以直接访问匿名机构提的方法,从而实现集成
- 一个struct嵌套了另一个命名的struct,那么这个模式叫做组合
- 一个struct嵌套了多个匿名struct,那么这个结构可以直接访问多个匿名struct的方法,从而实现多重继承
封装:自包含的黑盒子,有私有和公有的部分,公有可以被访问,私有的外部不能访问
- go通过约定来实现权限控制。变量名首字母大写,相当于public,首字母小写,相当于private。在同一个包中访问,相当于default。由于在go中没有继承,所以就没有protected
多态:允许用一个接口在访问同一类动作的特性
- go中的interface通过
合约
方式实现,只要某个struct实现了某个interface中的所有方法,那么它就隐式的实现了该接口
struct比较:
实例不能比较,指针可以比较,能否比较看字段是否有不可比较的数据类型。
- 可排序的数据类型有三种,Integer,Floating-point,和String
- 可比较的数据类型除了上述三种外,还有Boolean,Complex,Pointer,Channel,Interface和Array
- 不可比较的数据类型包括,Slice, Map, 和Function
2) 数组和slice
array特点:
- go的数组是值类型,也就是说一个数组赋值给另一个数组,那么实际上就是真个数组拷贝了一份,需要申请额外的内存空间
- 如果go中的数组做为函数的参数,那么实际传递的参数是一份数组的拷贝,而不是数组的指针
- array的长度也是Type的一部分,这样就说明[10]int和[20]int是不一样的
slice特点:
- slice是一个引用类型,是一个动态的指向数组切片的指针
- slice是一个不定长的,总是指向底层的数组array的数据结构
区别:
- 声明时:array需要声明长度或者…
- 做为函数参数时:array传递的是数组的副本,slice传递的是指针
3)map
map底层通过哈希表实现。
go的map并发访问是不安全的,会出现未定义行为,导致程序退出。
go1.6之前,内置的map类型是部分goroutine安全的,并发的读没有问题,并发的写可能有问题。go1.6之后,并发的读写map会报错。
go1.9之后,实现了sync.Map。
sync.Map
的实现有几个优化点:
- 空间换时间。通过冗余的两个数据结构(read,dirty),实现加锁对性能的影响
- 使用只读数据(read),避免读写冲突
- 动态调整,miss次数多了之后,将dirty数据提升为read
- double-checking
- 延迟删除。删除一个键值只是打标记,只有在提升dirty的时候才清理删除的数据
- 优先从read读取、更新、删除,因为对read的读取不需要锁
map删除元素:
delete删除元素,只是将map的key对应的元素置为empty。并没有删除内存中的数据。
- map 被清空。执行完之后调用
len
函数,结果肯定是0; - 内存没有释放。清空只是修改了一个标记,底层内存还是被占用了;
- 循环遍历了
len(m)
次。上面的代码每一次遍历都会删除一个元素,而遍历的次数并不会因为之前每次删一个元素导致减少。
如何真正释放内存?
map = nil
7、关键字
1)defer
- defer定义的延迟函数参数在defer语句出时就已经确定下来了
- defer定义顺序与实际执行顺序相反
- return不是原子操作,执行过程是: 保存返回值(若有)-->执行defer(若有)-->执行ret跳转
- 申请资源后立即使用defer关闭资源是好习惯
2)select
- select语句中除default外,每个case操作一个channel,要么读要么写
- select语句中除default外,各case执行顺序是随机的
- select语句中如果没有default语句,则会阻塞等待任一case
- select语句中读操作要判断是否成功读取,关闭的channel也可以读取
3)range
range是Golang提供的一种迭代遍历手段,可操作的类型有数组、切片、Map、channel等,
4)mutex/rwmutex
8、其他知识点
new和make的区别
- new和make都在堆上分配内存,但是它们的行为不同,适用于不同的类型。
- new(T) 为每个新的类型T分配一片内存,初始化为 0 并且返回类型为*T的内存地址:这种方法 返回一个指向类型为 T,值为 0 的地址的指针,它适用于值类型如数组和结构体;它相当于 &T{}。
- make(T) 返回一个类型为 T 的初始值,它只适用于3种内建的引用类型:slice、map 和 channel。
- 换言之,new 函数分配内存,make 函数初始化;
make
返回的还是这三个引用类型本身;而new
返回的是指向类型的指针。
go方法传参比起python、java有什么区别
- go中的函数的参数传递采用的是值传递
go init方法
大家都知道golang里的main函数是程序的入口函数,main函数返回后,程序也就结束了。golang还有另外一个特殊的函数init函数,先于main函数执行,实现包级别的一些初始化操作,今天我们就深入介绍下init的特性。
init函数的主要作用:
- 初始化不能采用初始化表达式初始化的变量。
- 程序运行前的注册。
- 实现sync.Once功能。
- 其他
init函数的主要特点:
- init函数先于main函数自动执行,不能被其他函数调用;
- init函数没有输入参数、返回值;
- 每个包可以有多个init函数;
- 包的每个源文件也可以有多个init函数,这点比较特殊;
- 同一个包的init执行顺序,golang没有明确定义,编程时要注意程序不要依赖这个执行顺序。
- 不同包的init函数按照包导入的依赖关系决定执行顺序。
golang程序初始化
golang程序初始化先于main函数执行,由runtime进行初始化,初始化顺序如下:
- 初始化导入的包(包的初始化顺序并不是按导入顺序(“从上到下”)执行的,runtime需要解析包依赖关系,没有依赖的包最先初始化,与变量初始化依赖关系类似,参见golang变量的初始化);
- 初始化包作用域的变量(该作用域的变量的初始化也并非按照“从上到下、从左到右”的顺序,runtime解析变量依赖关系,没有依赖的变量最先初始化,参见golang变量的初始化);
- 执行包的init函数;
Golang 大杀器之性能剖析 PProf
PProf
想要进行性能优化,首先瞩目在 Go 自身提供的工具链来作为分析依据,本文将带你学习、使用 Go 后花园,涉及如下:
- runtime/pprof:采集程序(非 Server)的运行数据进行分析
- net/http/pprof:采集 HTTP Server 的运行时数据进行分析
是什么
pprof 是用于可视化和分析性能分析数据的工具
pprof 以 profile.proto 读取分析样本的集合,并生成报告以可视化并帮助分析数据(支持文本和图形报告)
profile.proto 是一个 Protocol Buffer v3 的描述文件,它描述了一组 callstack 和 symbolization 信息, 作用是表示统计分析的一组采样的调用栈,是很常见的 stacktrace 配置文件格式
支持什么使用模式
- Report generation:报告生成
- Interactive terminal use:交互式终端使用
- Web interface:Web 界面
可以做什么
- CPU Profiling:CPU 分析,按照一定的频率采集所监听的应用程序 CPU(含寄存器)的使用情况,可确定应用程序在主动消耗 CPU 周期时花费时间的位置
- Memory Profiling:内存分析,在应用程序进行堆分配时记录堆栈跟踪,用于监视当前和历史内存使用情况,以及检查内存泄漏
- Block Profiling:阻塞分析,记录 goroutine 阻塞等待同步(包括定时器通道)的位置
- Mutex Profiling:互斥锁分析,报告互斥锁的竞争情况
二、操作系统
- 系统调用
系统调用:用户在编程时可以调用的操作系统功能
系统调用是操作系统提供给编程人员的唯一接口,使CPU状态从用户态陷入内核态
1)系统调用机制的设计
1.中断/异常机制支持系统调用服务的实现;2.选择一条特殊指令:陷入指令(访管指令),引发异常,完成用户态到内核态的切换;3.系统调用号和参数:每个系统调用都事先给定一个编号(功能号)4. 系统调用表:存放系统调用服务例程的入口地址
2)参数传递过程问题
怎样实现用户程序的参数传递给内核?
3种常用实现方法
1.由陷入指令自带参数:陷入指令的长度有限,且还要携带系统调用功能号,只能自带有限的参数;2. 通过通用寄存器传递参数,这些寄存器是操作系统和用户程序都能访问的,但寄存器的个数会限制传递参数的数量 3. 在内存中开辟专用堆栈区来传递参数。
3)系统调用执行过程
当CPU执行到特殊的陷入指令时:
- 中断/异常机制:硬件保护现场;通过查中断向量表把控制权转给系统调用总入口程序
- 系统调用总入口程序:保存现场;将参数保存在内核堆栈里;通过查系统调用表把控制权转给相应的系统调用处理例程或内核函数
- 执行系统调用例程
- 恢复现场,返回用户程序
三、网络
- 四次挥手最后一个报文丢失会出现什么情况?
四次挥手最后一个报文丢失,此时client处于time wait状态,server处于last ack状态
如果 TIME-WAIT 等待足够长的情况就会遇到两种情况:
- 服务端正常收到四次挥手的最后一个
ACK
报文,则服务端正常关闭连接。 - 服务端没有收到四次挥手的最后一个
ACK
报文时,则会重发FIN
关闭连接报文并等待新的ACK
报文。