目录
1. string字符串
1.1. 结构
1.2. string和[]byte转换
2. Slice切片
2.1.原理
2.2.slice避坑指南
2.2.1. 母子切片共享
2.2.2. 切片导致内存泄露
2.2.3. 遍历slice时修改slice
3. Map
3.1. 底层结构
3.2.各种操作底层实现
3.2.1. 创建map
3.2.2. 查找key
3.2.3. 插入/更新key
3.2.4. 删除key
3.2.5. 迭代遍历
3.3. 扩容策略
3.4. 时间/空间复杂度
3.4.1. 时间复杂度
3.4.2.空间复杂度
4.sync.Map
4.1.介绍
4.2. 对比
4.3. 设计思想
4.3.1.空间换时间
4.3.2.读写分离
4.3.3.双检查机制
4.3.4.延迟删除
4.3.5.read优先
4.3.6.状态机机制
4.4.实现原理
4.4.1.读操作
4.4.2.写操作
4.4.3.删除操作
4.4.4.遍历操作
5. for和range几个“神奇”的问题
5.1. 循环永动机
5.2. 神奇的指针
5.3. Map遍历的值是随机的
5.4. arr / slice / map能否比较
6. defer
7. channel
7.1. 底层实现原理(数据结构)
7.2. 案例分析
7.3. 总结
7.3.1.向channel发送数据
7.3.2.从channel接收数据
7.4. 注意事项
7.5. 使用案例
8. 多路Select
8.1.概述
8.2.实现原理
9. 闭包
10. context
10.1. 一个接口
10.2. 四种实现 + 六个函数
10.2.1. emptyCtx
10.2.2. cancelCtx — 可取消的context
10.2.3. timerCtx — 超时取消的context
10.2.4. valueCtx — 支持键值对打包
11. 内存逃逸
11.1. 逃逸机制
11.2. 内存逃逸场景
11. Go面试题
11.1. new和make的区别
11.2. Golang的内存管理
11.3. 调用函数传入结构体时,应该传值还是指针?为什么?
11.4. Goroutine什么时候会发生阻塞?阻塞的话调度器会怎么做?
11.4.1. 协程阻塞的场景:协程无法释放的场景
11.4.2. 阻塞的话,调度器会怎么做?
11.4.3. 如果Goroutine一直占用资源怎么办,GMP模型怎么处理这个问题?
11.4.4. Goroutine的锁Mutex机制了解过吗?Mutex有哪几种模式?Mutex锁底层如何实现?
11.5. 在GMP模型中Goroutine有几种状态,线程几种状态
11.6. 若干线程中,有个线程OOM会怎么样?Goroutine发生OOM呢?怎么排查呢?
11.7. defer可以捕获到子Goroutine的panic吗?
11.8. 开发用过gin框架么?参数校验怎么做的?中间件middlewares怎么使用的?
11.8.1. 参数校验怎么做的
11.8.2. 中间件middlewares怎么使用的?
11.8.3. gin的route实现原理
11.9. 优雅退出
11.10. 怎么做的链接复用,怎么支持的并发请求的,go的netpoll是怎么实现的?像阻塞read一样去使用底层的非阻塞read
11.11. 父goroutine退出,如何使得子goroutine也退出
11.12. 热重启
11.13. 服务能开多少个m由什么决定?开多少个P有什么界定
12. 重要知识
12.1. Golang中的GC
12.2. GMP模型
12.3. Go内存管理
12.4. 同步原语和锁
12.4.1.互斥锁
type StringHeader struct {
Data uintptr
Len int
}
字符串是由字符组成的数组,C 语言中的字符串使用字符数组 char[]
表示。数组会占用一片连续的内存空间,而内存空间存储的字节共同组成了字符串,Go 语言中的字符串只是一个只读(只读只意味着字符串会分配到只读的内存空间)的字节数组,下图展示了 "hello"
字符串在内存中的存储方式:
编译报错:
1. 字符串和 []byte
中的内容虽然一样,但是字符串的内容是只读的,我们不能通过下标或者其他形式改变其中的数据,而 []byte
中的内容是可以读写的
2. 不过无论从哪种类型转换到另一种都需要拷贝数据,而内存拷贝的性能损耗会随着字符串和 []byte
长度的增长而增长
type SliceHeader struct {
Data uintptr // 指向数组的指针
Len int // 当前切片的长度
Cap int // 当前切片的容量
}
扩容策略:在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容
func f1(){
s:=[]int{1,2,3}
f2(s)
f3(s)
fmt.Println(s)
}
func f2(s []int){ // 相当于C++里面传入了*int,指向arr的指针
s[0]=100
}
func f3(s []int){ // 相当于C++传入了vector,不能修改,若修改,要传入vector&
s=append(s,200)// 此时的s,不是实参的s
}
func main() {
f1()
}
// 以上代码输出
// 100 2 3
parent := make([]int, 3, 5) //len=3, cap=5
child := parent[1:3] //len=2, cap=4
func returnSubSlice() []int {
parent := make([]int, TOTAL)
child := parent[begin:end]
return child
}
如上代码,假设母切片占用内存8M,child切片是在parent基础上构造的占用内存1M,子切片和母切片共享同一块内存,当函数返回后,母切片已经不在使用,本该进行释放,但是由于和子切片公用一块内存未释放母切片,造成了7M的内存泄漏
正确做法应该是给子切片开辟空间,然后for循环将需要的数据从母切片拷贝到子切片上,然后返回子切片
sli := []int{1, 2, 3}
//方法一: 修改失败,v是slice元素的拷贝,并不会影响到元素v本身
for _, v := range sli {
v = v + 1
}
//方法二: 修改成功
for i, v := range sli {
sli[i] = v + 1
}
golang 哪些类型可以作为map key?
先说结论:可以用于比较的字段可以作为key
- 可以用于比较的类型:bool、数字、string、point指针、channel、interface接口、struct、array
- 不能用于比较的类型:slice、map、func
hmap结构
type hmap struct{
count int // 键值对的个数\元素个数,调用 len(map) 时,直接返回此值
flags uint8 // 状态标记位。如是否被多线程读写、迭代器在使用新桶、迭代器在使用旧桶等
B uint8 // buckets 数组的长度就是 2^B,即bmap的个数
hash0 uint32 // 计算 key 的哈希的时候会传入哈希函数
noverflow uint32 // 记录已经使用的溢出桶的数量
bucket unsafe.Pointer // 指向 buckets 数组,大小为 2^B
oldbucket unsafe.Pointer // 扩容的时候,buckets 长度会是 oldbuckets 的两倍
nevacuate uintptr // 记录渐进式扩容阶段下一个要迁移的旧桶编号
extra *mapextra // 指向溢出桶
}
bmap桶结构
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
extra字段
hmap结构体最后还有一个extra字段,指向一个mapextra结构体,里面记录的都是溢出桶相关的信息,其中,
一般情况下,调用makemap()创建hash数组,一次性内存分配,既分配了用户预期大小的hash数组,又追加了预留的溢出桶
如何清空整个map?
Q1: 下面代码能清空整个map么?
for k, _ := range m {
delete(m, k)
}
Q2: 如何真正的释放内存?
A2: map = nil,之后坐等GC回收就好了
结论:迭代遍历过程是随机的
hash 表中数据每次插入的位置是变化的,这是因为实现的原因
所以为了防止用户错误的依赖于每次迭代的顺序,map 作者干脆让相同的 map 每次迭代的顺序也是随机的
使用哈希表的目的就是要快速查找到目标 key,然而,随着向 map 中添加的 key 越来越多,key 发生碰撞的概率也越来越大。bucket 中的 8 个 cell 会被逐渐塞满,查找、插入、删除 key 的效率也会越来越低。最理想的情况是一个 bucket 只装一个 key,这样,就能达到 O(1) 的效率,但这样空间消耗太大,用空间换时间的代价太高。
Go 语言采用一个 bucket 里装载 8 个 key,定位到某个 bucket 后,还需要再定位到具体的 key,这实际上又用了时间换空间。当然,这样做,要有一个度,不然所有的 key 都落在了同一个 bucket 里,直接退化成了链表,各种操作的效率直接降为 O(n),是不行的。因此,需要有一个指标来衡量前面描述的情况,这就是装载因子。Go 源码里这样定义 装载因子:loadFactor := count / (2^B)
case1:负载因子>6.5(有效元素很多),就会发生“翻倍扩容”,分配新桶的数目是旧桶的2倍(真扩容,扩到 hash 桶数量为原来的两倍)
解释:每个 bucket 有 8 个空位,在没有溢出,且所有的桶都装满了的情况下,装载因子算出来的结果是 8。因此当装载因子超过 6.5 时,表明很多 bucket 都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。
“翻倍扩容”:元素太多,而 bucket 数量太少,很简单:将 B 加 1,bucket 最大数量(2^B)直接变成原来 bucket 数量的 2 倍。于是,就有新老 bucket 了。注意,这时候元素都在老 bucket 里,还没迁移到新的 bucket 来。而且,新 bucket 只是最大数量变为原来最大数量(2^B)的 2 倍(2^B * 2)
buckets指向新桶,oldbuckets指向旧桶,nevacuate=0(表示接下来要迁移编号为0的旧桶),每个旧桶的键值对都会分流到两个新桶中。
case2:负载因子<=6.5(有效元素很少),溢出桶较多(当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15),会发生“等量扩容”(假扩容,hash 桶数量不变,只是把元素搬迁到新的 map)
解释:删除元素降低元素总数量,再插入很多元素,导致创建很多的溢出桶(溢出桶数量太多,导致 key 会很分散,查找插入效率低得吓人。这就像是一座空城,房子很多,但是住户很少,都分散了,找起人来很困难)
“等量扩容”:其实元素没那么多,但是 overflow bucket 数特别多,说明很多 bucket 都没装满。解决办法就是开辟一个新 bucket 空间,将老 bucket 中的元素移动到新 bucket,使得同一个 bucket 中的 key 排列地更紧密。这样,原来,在 overflow bucket 中的 key 可以移动到 bucket 中来。结果是节省空间,提高 bucket 利用率,map 的查找和插入效率自然就会提升。
go map 是 hash 实现,我们先不管具体原理,江湖人人皆知基于 hash 实现的算法其时间复杂度均为 O(1)。
Go 采用的 hash 算法是很成熟的算法,极端情况暂不考虑。所以综合情况下 Go map 的时间复杂度为 O(1)。
总结
- 采用空间换时间,存在两个map,一个read,一个dirty。所有的操作都优先读取read,当在read中读不到时,会加锁,加锁后还会先再次读取read(double check),如果还读取不到,就会去操作dirty
- dirty中存储了最新的数据(dirty的数据比read多),read和dirty之间要进行数据同步:当missed_cnt超过阈值时,会将dirty中的数据上升为read
1. mutex + map
最简单的方案就是在map上加个锁,针对map的所有操作都要提前加锁,其存在问题也很明显,锁竞争会非常频繁
2. rwmutex + map:优化读读阻塞
优化一点,依据场景,如果是读操作多于写操作,可以把mutex换成rwmutex,相比方案一,有一定优化、至少读读之间不会存在互斥,不过,读写之间还会存在阻塞
3. sync.Map:优化读写阻塞
根据锁map的优化迭代方案可知,在读读场景下,rwmutex + map可以并发、不存在阻塞,但是,读写还是存在阻塞,而sync.map要做的事情就是能进一步优化:对于map的各种操作,尽可能不阻塞
为此,sync.map采用了用空间换时间,即两个map:分别是read、dirty
sync.map中冗余的数据结构就是dirty和read,二者存放的都是key-entry,entry其实是一个指针,指向value,read和dirty各自维护一套key,key指向的都是同一个value,也就是说,只要修改了这个entry,对read和dirty都是可见的。
那空间换时间策略在sync.map中到底是如何体现的呢?到底在哪些地方减少了耗时?
核心思想就是一切操作先去read中执行,因为read是并发读安全的,无需锁,实在在read中找不到,再去dirty中。read在sycn.map中是一种冗余的数据结构,因为read和dirty中数据有很大一部分是重复的,而且二者还会进行数据同步
type Map struct {
mu Mutex // 锁,保护read和dirty字段
read atomic.Value // 存仅读数据,原子操作,并发读安全,实际存储readOnly类型的数据
dirty map[interface{}]*entry // 存最新写入的数据
misses int // 计数器,每次在read字段中没找所需数据时,+1: 当此值到达一定阈值时,将dirty字段赋值给read
}
// readOnly 存储map中仅读数据的结构体
type readOnly struct {
m map[interface{}]*entry // 其底层依然是个最简单的map
amended bool // 标志位,标识m.dirty中存储的数据是否和m.read中的不一样:flase相同,true不相同
}
sync.map中有专门用于读的数据结构:read,将其和写操作分离开来,可以避免读写冲突。而采用读写分离策略的代价就是冗余的数据结构,其实还是空间换时间的思想。
通过额外的一次检查操作,来避免在第一次检查操作完成后,其他的操作使得检查条件产生突然符合要求的可能。
在sync.map中,每次当read不符合要求,要去操作dirty前,都会上锁,上锁后先再次double-check判断是否符合要求,因为read有可能在上锁期间,产生了变化,突然又符合要求了,read符合要求了,尽量还是在read中操作,因为read并发读安全。
在删除操作中,删除kv,仅仅只是先将需要删除的kv打一个标记,这样可以尽快的让delete操作先返回,减少耗时,在后面提升dirty时,再一次性的删除需要删除的kv
需要进行读取,删除,更新操作时,优先操作read,因为read无锁的,更快。当在read中得不到结果,再去dirty中
read的修改操作需要加锁,read只是并发读安全,并发写并不安全
entry的指针p,是有状态的,分为3种状态:nil、expunged(指向被删除的元素)、正常
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
// 1.先在read中查找key
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
// 2.在read中没有找到,并且read和dirty数据不一样(dirty中有read中不存在的数据,因为写数据是直接往dirty中写的)
if !ok && read.amended {
m.mu.Lock() // 加锁,因为要操作dirty中的数据
read, _ = m.read.Load().(readOnly) // 3.双检查机制,再次在read中查找key,因为有可能read从dirty中更新了数据
e, ok = read.m[key]
// 4.在read中还是没有找到,并且read和dirty数据仍然不一致
if !ok && read.amended {
e, ok = m.dirty[key] // 直接从dirty获取数据
m.missLocked() // read不命中次数+1,到达阈值后,为了避免read命中率太低,会从dirty中更新read数据
}
m.mu.Unlock() // 解锁,后续不再操作dirty数据
}
//5.最后仍然没有找到key,说明key在map中确实不存在,返回nil
if !ok {
return nil, false
}
// 6.找到key了,返回value
return e.load()
}
通过对源码的分析,可以在宏观上总结一下搜索的流程:先在read中搜,搜不到再去dirty中搜,但是这个太宏观了,有些东西没有讨论到,比如:
上面两项操作,其实归根结底都是为了提升搜索的效率,比如read miss的统计和read数据的刷新,都是为了让直接可以在read中找到key,尽可能不去dirty中找,因为read并发读是安全的,性能很高,而去dirty中找,则需要加锁,耗时就增加了
调用Load或LoadOrStore函数时,如果在read中没有找到key,则会将miss值原子增加1,当miss值增加到和dirty长度相等时,会将dirty提升为read,以期望减少 "读 miss"。
// missLocked readmiss次数+1 ,并且判断dirty是否需要晋升(dirty置给read)
func (m *Map) missLocked() {
m.misses++ // read 没命中次数统计+1
if m.misses < len(m.dirty) { // 当misses个数 < dirty个数时,不做操作
return
}
// swap操作: dirty置给read,因为read没有命中的次数太多了,原子操作
m.read.Store(readOnly{m: m.dirty}) // read = dirty --- dirty上升成read
m.dirty = nil // dirty = nil
m.misses = 0 // misses个数 = 0
}
sync.map中添加/修改(key, val)
// Store 添加/修改 key-value
func (m *Map) Store(key, value interface{}) {
// 1. 在read中查找key,找到了,则尝试更新value
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}
m.mu.Lock() // 操作dirty,锁住先
// 2. 双检查机制,再次在read中查找key
read, _ = m.read.Load().(readOnly)
// 3. key在read中存在
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() { // 存在 && key被标记为已删除,则将k/v加入dirty中
m.dirty[key] = e
}
e.storeLocked(&value) // 无论key是否为已删除状态,都要更新key的value值
} else if e, ok := m.dirty[key]; ok {
// 4. key在dirty中存在,则直接在dirty中更新value值
e.storeLocked(&value)
} else {
// 5. key在read和dirty中都不存在,则走[新增]逻辑
// read和dirty中数据相同,则从read中刷新dirty的数据(因为dirty为nil,有可能是初始化或dirty之前提升过了),并将amended标识为read和dirty不相同,因为后面即将走新增逻辑
if !read.amended {
m.dirtyLocked()
m.read.Store(readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value) // 新增逻辑,直接在dirty中加入kv键值对
}
m.mu.Unlock() // 不再操作dirty数据,解锁啦
}
// tryStore 尝试更新value: 采用CAS更新处于未删除状态的元素
func (e *entry) tryStore(i *interface{}) bool {
for {
// 取出e.p指针指向的内容
p := atomic.LoadPointer(&e.p)
// 如果: e.p是被删除状态 ==> 无法更新
if p == expunged {
return false
}
// e.p存在&&不是被删除状态 ==> CAS机制,用i给e.p赋值(赋值成功,返回true)
// 因为entry是指针,所以read和dirty的内容都会一起被修改
if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
return true
}
}
}
// unexpungeLocked 判断是否指向expunged,如果指向expunged则修改为指向nil
func (e *entry) unexpungeLocked() (wasExpunged bool) {
// 之所以需要将指向expunged的修改为指向nil ,是因为后续会将k/v加入dirty中,都已经加入dirty中,并且不是未删除状态,当然需要指向nil啦
// 此value在read中暂时指向nil,但后续会更新value值,这样read中和dirty中都是指向同一个value的 ( Store中第四步,更新value值)
return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}
// storeLocked 更新指向的value值
func (e *entry) storeLocked(i *interface{}) {
atomic.StorePointer(&e.p, unsafe.Pointer(i))
}
// dirtyLocked 刷新dirty数据逻辑,将read中未删除的数据加入到dirty中
func (m *Map) dirtyLocked() {
// 此函数仅在以下情况会执行: read和dirty相同时,比如初始化或dirty刚提升到read,dirty肯定是nil
// dirty 非nil,则没必要走刷新dirty数据逻辑
if m.dirty != nil {
return
}
read, _ := m.read.Load().(readOnly)
m.dirty = make(map[interface{}]*entry, len(read.m)) // dirty 申请内存空间
// 1.遍历read,将read中未删除元素加入dirty中(加入的其实不是真正的底层数据副本,而是指向底层数据的指针)
for k, e := range read.m {
if !e.tryExpungeLocked() { // 保证加入dirty中都是read中未删除的元素,read中被删除状态的元素则没必要加入dirty
m.dirty[k] = e
}
}
}
大致总结一下上述流程:
// Delete 删除元素
func (m *Map) Delete(key interface{}) {
// 1.先在read中查找key
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
// 2.在read中没有找到key,并且read和dirty中数据不相同(即dirty中有read中没有的数据,因为插入数据都是直接插入到dirty中的,read还来不及根据dirty数据进行刷新)
if !ok && read.amended {
m.mu.Lock() // 操作dirty,锁住先
// 3.双检查机制,继续在read中查找key
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
// 4. 在read中没有找到key,并且read和dirty中数据不相同,则在dirty中删除key
if !ok && read.amended {
delete(m.dirty, key)
}
m.mu.Unlock() // 解锁,不再操作dirty
}
// 5. 通过key,找到了value,则删除value
if ok {
e.delete()
}
}
// delete 删除value
func (e *entry) delete() (hadValue bool) {
for {
p := atomic.LoadPointer(&e.p) // 原子操作方式加载指针
if p == nil || p == expunged { // p 指向nil或已删除元素,删除失败
return false
}
// 删除元素:将p指向nil
if atomic.CompareAndSwapPointer(&e.p, p, nil) {
return true
}
}
}
大致总结一下删除操作的流程:
// Range 回调方式遍历map
func (m *Map) Range(f func(key, value interface{}) bool) {
read, _ := m.read.Load().(readOnly)
// 1.dirty中有新数据,则提升dirty,然后再遍历
if read.amended {
m.mu.Lock() //操作dirty,锁住
read, _ = m.read.Load().(readOnly)
if read.amended { // 双检查机制,再次检测dirty中是否有新数据
read = readOnly{m: m.dirty} // 提升dirty为read,重置dirty和miss计数器
m.read.Store(read)
m.dirty = nil
m.misses = 0
}
m.mu.Unlock()
}
// 到这就代表,read中的数据和dirty中数据是一致的,直接遍历read即可
// 2.回调的方式遍历read
for k, e := range read.m {
v, ok := e.load()
if !ok {
continue
}
if !f(k, v) {
break
}
}
}
func main() {
arr := []int{1, 2, 3}
for _, v := range arr {
arr = append(arr, v) // 遍历arr的时候,向arr增加元素
}
fmt.Println(arr)
}
$ go run main.go
1 2 3 1 2 3
上述代码的输出意味着循环只遍历了原始切片中的3个元素,我们在遍历切片时追加的元素不会增加循环的执行次数,所以循环最终还是停了下来
遍历的底层原理:对于所有的 range 循环,Go 语言都会在编译期将原切片或者数组赋值给一个新变量 ha
,在赋值的过程中就发生了拷贝,而我们又通过 len
关键字预先获取了切片的长度,所以在循环中追加新的元素也不会改变循环执行的次数,这也就解释了循环永动机一节提到的现象
func main() {
arr := []int{1, 2, 3}
newArr := []*int{}
for _, v := range arr {
newArr = append(newArr, &v)
}
for _, v := range newArr {
fmt.Println(*v)
}
}
$ go run main.go
3 3 3
说明:一些有经验的开发者不经意也会犯这种错误,正确的做法应该是使用 &arr[i]
替代 &v。
而遇到这种同时遍历索引和元素的 range 循环时,Go 语言会额外创建一个新的 v2
变量存储切片中的元素,循环中使用的这个变量 v2 会在每一次迭代被重新赋值而覆盖,赋值时也会触发拷贝。
因为在循环中获取返回变量的地址都完全相同,所以会发生神奇的指针一节中的现象。因此当我们想要访问数组中元素所在的地址时,不应该直接获取 range 返回的变量地址 &v2
,而应该使用 &a[index]
这种形式
func main() {
hash := map[string]int{
"1": 1,
"2": 2,
"3": 3,
}
for k, v := range hash {
println(k, v)
}
}
$ go run main.go
2 2
3 3
1 1
$ go run main.go
1 1
2 2
3 3
两次运行上述代码可能会得到不同的结果,第一次会打印 2 3 1
,第二次会打印 1 2 3
,如果我们运行的次数足够多,最后会得到几种不同的遍历顺序。
原理
1. 首先生成一个随机数帮助我们随机选择一个遍历桶的起始位置。Go 团队在设计哈希表的遍历时就不想让使用者依赖固定的遍历顺序,所以引入了随机数保证遍历的随机性。
2. 选择桶
首先会选出一个绿色的正常桶开始遍历,随后遍历所有黄色的溢出桶,最后依次按照索引顺序遍历哈希表中其他的桶,直到所有的桶都被遍历完成
map 循环是有序的还是无序的?答:无序的,map 因扩张⽽重新哈希时,各键值项存储位置都可能会发生改变,顺序自然也没法保证了,所以官方避免大家依赖顺序,直接打乱处理。就是 for range map 在开始处理循环逻辑的时候,就做了随机播种
func fun() {
/* 数组是否可以比较? 答:可以比较 */
s1 := [3]int{1, 2, 3}
s2 := [3]int{1, 2, 3}
b := (s1 == s2) // 能不能执行成功,如果能结果是什么? 答:可以比较,返回yrue,原因数组的长度固定,可以每个元素比较
fmt.Print(b)
/* slice是否可以比较? 答:不可以比较 */
ss1 := []int{1, 2, 3}
ss2 := []int{1, 2, 3}
b = (ss1 == ss2) // 能不能执行成功,如果能结果是什么? 答:不可以比较,原因切片长度不固定,无法比较
fmt.Print(b)
/* map是否可以比较? 答:不可以比较 */
var m1 map[string]int
m1["one"] = 1 // 会panic,map使用之前必须使用make分配内存
m2 := make(map[string]int)
m2["one"] = 2
b = (m1 == m2) // 能不能执行成功,如果能结果是什么? 答:map不能比较
fmt.Print(b)
/* 下面执行结果是一样的么? 答:不一定,因为map遍历是随机选择一个桶,结果是位置的,map的遍历是无需的 */
m3 := make(map[string]int)
m3["one"] = 1
m3["two"] = 2
m3["three"] = 3
for key, _ := range m3 {
fmt.Print(key) // 输出结果是什么
}
fmt.Println()
for key, _ := range m3 {
fmt.Print(key) // 结果会跟上面一样吗
}
fmt.Println()
for key, _ := range m3 {
fmt.Print(key) // 结果会跟上面一样吗
}
}
面试题:defer 在什么时机会修改返回值;多个 defer 的顺序
作用:延迟函数,释放资源、收尾工作
1. 调用顺序:后进先出(栈)
2. 执行顺序:defer、return,return value(函数返回值)
func b() (i int) {
defer func() {
i++
fmt.Println("defer2:", i)
}()
defer func() {
i++
fmt.Println("defer1:", i)
}()
return i //或者直接写成return
}
func main() {
fmt.Println("return:", b())
}
// defer1: 1
// defer2: 2
// return: 2
3. defer底层的数据结构
1. 每个defer都对应一个_defer实例,多个实例通过指针串联成一个单链表,保存在gotoutine数据结构中
2. 每次插入_defer实例,均头插;函数结束也是从链表头部去除开始
环形数组
buf
是有缓冲的channel所特有的结构,用来存储缓存数据goroutine队列:sudog结构体
互斥锁:lock
1. 创建一个具有5个缓冲区的channel
2. 协程G1向channel写入1,2,3,4,5,6(保存在buf中),写入1~5时,不会发生阻塞,缓冲区被写满
3. 当写入6时,因为缓冲区满了,所以6无处可放,此时,协程G1就会加入到channel的sendq中。sudog是协程队列中的元素结构体,g保存了协程、elem保存了等待发送的数据6、c保存了阻塞在哪个chan
4. 当开了协程G2去channel读取数据,recvx下标向前移动
5. 此时,因为缓冲区buf中存在空闲位置了,所以会去唤醒sendq中的发送协程G1,G1会将数据发送给缓冲区buf
6. 缓冲区buf再次满了,G1继续挂起,保存在recvq中,循环执行上面步骤... ...
优先级:如果存在消费者协程,则优先将数据拷贝给消费者,然后,把多余的数据写入buf
是否阻塞取决于2个条件:① recvq中是否有消费者 ②buf是否为空
如果channel的recvq存在接收者goroutine:将数据直接发送给第一个等待的goroutine,唤醒接收的goroutine
如果channel的recvq不存在接收者goroutine:
1. 如果循环数组buf未满,那么将会把数据发送到buf的队尾
2. 如果循环数组buf已满,此时就会走阻塞发送的流程,将当前发送数据的goroutine加入sendq(不让其发送数据),并挂起等待唤醒
优先级:如果buf中有数据,则先读取buf中的数据,然后唤醒sendq中的生产者
详细介绍:
如果channel的sendq存在发送者goroutine
1. 如果是无缓冲channel,直接从第一个发送者goroutine哪里把数据拷贝给接收变量,唤醒发送的goroutine
2. 如果是有缓冲channel(已满),将循环数组buf的队首元素拷贝给接收变量,将第一个发送者goroutine的数据拷贝到buf队尾,唤醒发送的goroutine
如果channel的sendq不存在发送者goroutine
1. 如果buf非空,将循环数组buf的队首元素拷贝给接收变量
2. 如果buf为空,这个时候就会走阻塞接收的流程,将当前goroutine加入readq,并挂起等待唤醒
2种异常场景:chan的状态分别为「未初始化、关闭」
3种动作:close、send、recv
func main() {
// 操作未初始化的chan
{
// 「写入」关闭的chan
{
var ch chan int
ch <- 1 // fatal error: all goroutines are asleep - deadlock!
}
// 「读取」关闭的chan
{
var ch chan int
v := <-ch // fatal error: all goroutines are asleep - deadlock!
fmt.Println(v)
}
// 「close」关闭的chan: panic
{
var ch chan int
close(ch) // panic: close of nil channel
}
}
// 操作关闭的chan
{
// 「读取」关闭的chan
{
// 1. 有数据可以正常读
{
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
for c := range ch {
fmt.Println(c) // 1 2 3
}
}
// 2. 无数据返回零值
{
ch := make(chan int, 3)
close(ch)
v := <-ch
fmt.Println(v) // 零值
}
}
// 「写入」关闭的chan: panic
{
ch := make(chan int, 3)
close(ch)
ch <- 1 // panic: send on closed channel
}
// 「close」关闭的chan: panic
{
ch := make(chan int, 3)
close(ch)
close(ch) // panic: close of closed channel
}
}
}
channel有2种类型:无缓冲、有缓冲
channel有3种模式:写操作模式(单向通道)、读操作模式(单向通道)、读写模式(双向通道)
创建
make(chan <-int) // 写操作模式
make(<-chan int) // 读操作模式
make(chan int) // 读写操作模式
唤醒机制
与Linux内核的select类似,go的select也是监听“与channel有关的多个IO操作”
select结构组成主要是由case语句和执行的函数组成的,select的case和switch不同,只能处理channel
select
在遇到多个 channel 同时响应时,会随机执行一种情况func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)
参数
scase数组
scase
数组的长度返回值
selectgo
返回所选scase的索引(该索引与其各自的select {recv,send,default}调用的序号位置相匹配)。此外,如果选择的scase是接收操作(recv),则返回是否接收到值。(简单的理解:函数参数传入的是case语句,返回值返回被选中的case语句)scase
结构体
type scase struct {
c *hchan // 是一个channel指针,channel里面有等待队列
elem unsafe.Pointer // 读或者写的缓冲区地址
kind uint16 //case语句的类型,是default、传值写数据(channel <-) 还是取值读数据(<- channel)
pc uintptr // race pc (for race detector / msan)
releasetime int64
}
selectgo执行过程详解
有人形象的概括闭包就是:函数 + 引用环境 = 闭包,要搞清楚闭包的关键就是分析出返回的函数和它引用到哪些变量
说到Go语言的闭包,不得不说说全局变量和局部变量
全局变量的特点:1.常驻内存 2. 污染全局
局部变量的特点:1.不常驻内存 2.不污染全局
而Go语言的闭包可以做到:1.可以让变量常驻内存 2.可以让变量不污染全局 ===> 所以闭包主要是为了避免全局变量的滥用
闭包:
1.闭包是指有权访问另一个函数作用域中的变量的函数==>包括自由变量(在函数外部定义但在函数内被引用)
2.创建闭包的常见方式就是在一个函数内部创建另一个函数, 内函数可以访问外函数的变量 ==> 即使脱离了捕捉时的上下文,它也能照样执行
注意:
闭包里作用域返回的局部变量不会被立刻销毁回收,但过度使用闭包可能会占用更多内存,导致性能下降
例子1
// 函数create()的返回值是一个函数func() int
func create() func() int {
c := 2 // 通常称这个变量为"捕获变量"
return func() int { // 该函数使用了外部定义的变量c
return c
}
}
func main() {
// 即使create()结束,通过f1和f2依然能够正常调用这个闭包函数,并使用在create()函数内部定义的局部变量c
f1 := create()
f2 := create()
fmt.Println(f1(), f2()) // 2 2
}
例子2
1. addTool是一个函数,返回的数据类型是func(int)int
2. 这个匿名函数就和变量n形成一个整体,构成闭包。大家可以这样理解:闭包是类,函数是操作,n是字段,函数和它使用的变量构成闭包
3. 当我们反复调用f函数时,因为n是初始化一次,因此每调用一次就进行累计
func addTool() func(int) int {
var n = 10
return func(x int) int { // 闭包
n = n + x
return n
}
}
func main() {
f := addTool()
fmt.Println(f(1)) // 11
fmt.Println(f(2)) // 13
fmt.Println(f(3)) // 16
}
作用:在不同的协程之间,同步请求特定数据、取消信号、处理请求的截止日期
context概括为:1个接口,4种实现,6个函数
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Deadline
— 返回 context.Context 被取消的时间,也就是完成工作的截止日期Done
— 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用 Done
方法会返回同一个 ChannelErr
— 返回 context.Context 结束的原因,它只会在 Done
方法对应的 Channel 关闭时返回非空的值
Canceled
错误DeadlineExceeded
错误Value
— 从 context.Context 中获取键对应的值,对于同一个上下文来说,多次调用 Value
并传入相同的 Key
会返回相同的结果,该方法可以用来传递请求特定的数据;emptyCtx对context的实现,只是简单的返回nil、false,实际上什么也没做
type cancelCtx struct {
Context
mu sync.Mutex
done chan struct{} // 用于获取该context的取消通知
children map[canceler]struct{} // 用于存储以当前节点为根节点的所有可取消的context
err error // 存储取消时指定的错误信息
}
cancelCtx中context的实现
// 返回通道
func (c *cancelCtx) Done() <-chan struct{} {
c.mu.Lock()
if c.done == nil { // 懒创建
c.done = make(chan struct{})
}
d := c.done // 返回done
c.mu.Unlock()
return d
}
// 返回err
func (c *cancelCtx) Err() error {
c.mu.Lock()
err := c.err
c.mu.Unlock()
return err
}
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
总结:WithCancel的实现(初始化)
作用:将context包装成cancelCtx,并提供一个取消函数cancel,调用它可以cancel对应的context
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}
// 初始化:扩散取消(将child加入到parent.children列表中)
func propagateCancel(parent Context, child canceler) {
done := parent.Done() // 获取parent的channel
if done == nil {
return // 父上下文不会触发取消信号
}
// 先判断下父上下文是否已经被取消
select {
case <-done:
child.cancel(false, parent.Err()) // 父上下文已经被取消,则立即取消child
return
default:
}
if p, ok := parentCancelCtx(parent); ok { // 系统定义类型
p.mu.Lock()
if p.err != nil { // 发生了错误,直接取消
child.cancel(false, p.err)
} else { // 将child加入到children列表中
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else { // 自定义类型
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
// 判断parent是系统自定义类型?自定义类型?
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
for {
switch c := parent.(type) {
case *cancelCtx:
return c, true
case *timerCtx:
return c.cancelCtx, true
case *valueCtx:
parent = c.Context
default:
return nil, false
}
}
}
propagateCancel函数总共与父上下文相关的3种不同的情况:
parent.Done() == nil
,也就是 parent
不会触发取消事件时,当前函数会直接返回;child
的继承链包含可以取消的上下文时,会判断 parent
是否已经触发了取消信号;
child
会立刻被取消;child
会被加入 parent
的 children
列表中,等待 parent
释放取消信号;parent.Done()
和 child.Done()
两个 Channel;parent.Done()
关闭时调用 child.cancel
取消子上下文;cancel
总结:cancel的实现
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return
}
c.err = err
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
for child := range c.children {
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c)
}
}
func removeChild(parent Context, child canceler) {
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
delete(p.children, child)
}
p.mu.Unlock()
}
案例代码
func main() {
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context) {
for {
select {
case <-ctx.Done(): // 当调用cancel()函数,该chan可读,该case触发
fmt.Println("监控退出,停止了...")
return
default:
fmt.Println("goroutine监控中...")
time.Sleep(2 * time.Second)
}
}
}(ctx)
time.Sleep(10 * time.Second)
cancel()
//为了检测监控过是否停止,如果没有监控输出,就表示停止了
time.Sleep(5 * time.Second)
}
timerCtx是在cancelCtx基础上,增加了定时器/截止时间功能,这样,①既可以根据需要主动取消,②也可以到达deadline时,通过timer来调用cancelCtx的取消函数
type timerCtx struct {
*cancelCtx
timer *time.Timer // 定时器
deadline time.Time // 截止时间
}
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
总结:WithTimeout的实现(初始化)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
// 获取截止时间cur,判断截止时间是否已经到达
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// The current deadline is already sooner than the new one.
return WithCancel(parent)
}
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
propagateCancel(parent, c)
dur := time.Until(d)
if dur <= 0 { // 已经过了截止日期
c.cancel(true, DeadlineExceeded) // deadline has already passed
return c, func() { c.cancel(false, Canceled) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
c.timer = time.AfterFunc(dur, func() { // 创建 & 启动 定时器
c.cancel(true, DeadlineExceeded) // 当定时器触发时,取消ctx
})
}
return c, func() { c.cancel(true, Canceled) }
}
func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}
cancel
总结:cancel的实现
func (c *timerCtx) cancel(removeFromParent bool, err error) {
c.cancelCtx.cancel(false, err)
if removeFromParent {
// Remove this timerCtx from its parent cancelCtx's children.
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}
代码案例
作用:创建timerCtx对象,指定时间点
func sub(ctx context.Context) {
select {
case <-ctx.Done(): // 被上层的defer cancel()取消掉
fmt.Println("sub cancel")
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go func(ctx context.Context) {
sub(ctx)
}(ctx)
select {
case <-ctx.Done(): // 5s到,该读chan触发
fmt.Println("main cancel")
}
}
执行结果
sub cancel
main cancel
func sub(ctx context.Context) {
select {
case <-ctx.Done(): // 未打印该行(因为上层ctx每调用defer cancel(),导致泄露)
fmt.Println("sub cancel")
}
}
func main() {
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
// defer cancel()
go func(ctx context.Context) {
sub(ctx)
}(ctx)
select {
case <-ctx.Done():
fmt.Println("main cancel")
}
}
执行结果
main cancel
func sub(ctx context.Context) {
select {
case <-ctx.Done():
fmt.Println("sub cancel")
}
}
func test() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // 2. 函数return之前,取消ctx
go func(ctx context.Context) {
sub(ctx)
}(ctx)
select {
case <-ctx.Done():
fmt.Println("main cancel")
case <-time.After(1 * time.Second): // 1. 超时,先执行
fmt.Println("main timeout cancel")
}
}
func main() {
test()
}
type valueCtx struct {
Context
key, val interface{}
}
概念:从栈上逃逸到堆上的现象称为内存逃逸
1. 如果函数外部没有引用,则优先放到栈中
2. 如果函数外部存在引用,则必定放到堆中
3. 如果栈上放不下,则必定放到堆上
通过编译参数-gcflag=-m可以查看编译过程中的逃逸分析
1. 函数返回值为局部变量的指针,函数虽然退出了,但是因为指针的存在,指向的内存不能随着函数结束而回收,因此只能分配在堆上
2. 栈空间不足
3. 变量大小不确定
编译期间无法确定slice的长度,这种情况是为了保证内存的安全,编译器也会触发逃逸,在堆上分配内存。
4. 动态类型
动态类型就是在编译期间不能确定参数的类型,参数的长度也不确定的情况下会发生逃逸,如:空接口interface{}可以表示任意的类型,函数参数为interface{}会发生逃逸
5. 闭包引用对象
中高级 Golang 面试
1. 自我介绍
2. 代码效率分析,考察局部性原理
3. 多核CPU场景下,cache如何保持一致、不冲突?答:MESI协议
4. uint类型溢出
5. 介绍rune类型
6. 编程题:3个函数分别打印cat、dog、fish,要求每个函数都要起一个goroutine,按照cat、dog、fish顺序打印在屏幕上100次。
7. 介绍一下channel,无缓冲和有缓冲区别
8. 是否了解channel底层实现,比如实现channel的数据结构是什么?
9. channel是否线程安全?答:是,包含Mutex
10. Mutex是悲观锁还是乐观锁?悲观锁、乐观锁是什么?
11. Mutex几种模式?答:普通模式、饥饿模式
12. Mutex可以做自旋锁吗?
13. 介绍一下RWMutex。
14. 项目中用过的锁?
15. 介绍一下线程安全的共享内存方式
16. 介绍一下goroutine
17. goroutine自旋占用cpu如何解决(go调用、gmp)
18. 介绍linux系统信号
19. goroutine抢占时机(gc 栈扫描)
20. Gc触发时机
21. 是否了解其他gc机制
22. Go内存管理方式
23. Channel分配在栈上还是堆上?哪些对象分配在堆上,哪些对象分配在栈上?答:Channel 被设计用来实现协程间通信的组件,其作用域和生命周期不可能仅限于某个函数内部,所以golang 直接将其分配在堆上
24. 介绍一下大对象小对象,为什么小对象多了会造成gc压力?答:通常小对象过多会导致GC三色法消耗过多的CPU。优化思路是,减少对象分配
25. 项目中遇到的oom情况?
26. 项目中使用go遇到的坑?
27. 工作遇到的难题、有挑战的事情,如何解决?
28. 如何指定指令执行顺序?
make:分配内存、只能用于chan、map、slice、返回的是引用类型本身
new:分配内存(内存清零)、可以用于任意类型、返回的是指向类型的指针
// new
{
var sum *int
sum = new(int) // 分配空间
*sum = 98
fmt.Println(*sum) // 98
}
{
type Student struct {
name string
age int
}
var s *Student
s = new(Student) // 分配空间
s.name = "dequan"
fmt.Println(s) // &{dequan 0}
}
效率对比
1. 在不发生内存逃逸的情况下,传递指针不会发生内存拷贝,效率比传递值更高
2. 发生内存逃逸,传递指针的效率要比传递值更慢
发生内存逃逸的本质是因为该变量的作用于被扩大。
是否能修改传入参数的值
指针可以,值不可以
1. 读nil的channel
2. 协程中出现死循环
3. 协程中出现死锁
4. 执行系统调用等待结果返回
5. 数据操作的IO、等待网络请求的返回
协程G与线程M绑定,为了不阻塞线程M绑定的本地队列里面的P,需要给队列P找一个新的M绑定:若空闲队列中存在空闲的M;若不存在,则判断”普通线程+自选线程”是否小于GOMAXPROC,如果小于创建新的M接管P;如果大于,将P放入空闲P队列中
答:如果有一个Goroutine一直占用资源的话,GMP模型会从正常模式转为饥饿模式,通过信号协作强制处理在前面的Goroutine去分配使用 ==> 引出Goroutine的Mutex锁机制
互斥锁的结构体成员有2个,分别是state、sema
1. 加锁和解锁:通过atomic包提供的原子性操作state字段
2. sema是信号量,主要用于等待队列
mutex有2种模式,饥饿模式是1.9版本引出的
1. 正常模式
1. 加锁过程
一个尝试加锁的Goroutine会先自旋几次,尝试通过原子操作获得锁(假设G获取到锁,那么state=1)
若几次自旋之后,仍不能获得锁,则通过信号量排队等待;所有的等待着会按照FIFO的顺序在等待队列中排队。(此时G1、G2、G3...获取不到锁,会在信号量的等待队列中)
2. 释放锁+抢锁过程
当锁锁被G释放(state=0)后,第一个等待者被唤醒后,并不会直接拥有锁,而是需要和 后来者(处于自旋状态&&尚未进入等待队列的Goroutine)进行竞争。
这种情况下,后来者更有优势(获取锁的概率更大),原因是 ①一方面,后来者Goroutine是处于自旋状态,正在CPU上运行,自然比刚唤醒的Goroutine更有优势 ②另一方面,处于自旋状态的Goroutine有很多,而被唤醒的Goroutine只有一个
没有抢到锁的Goroutine,会重新被插入到信号量等待队列的头部。
3. 加锁等待的时间超过1ms
当一个Goroutine为了获取锁等待的时间超过了1ms后,会把当前mutex从正常模式切换到饥饿模式。
2. 饥饿模式
在饥饿模式下,
1. mutex的所有权从执行unlock的Goroutine,直接传递给等待队列头部的Goroutine
2. 后来者不会自旋,也不会获得锁(即使mutex处于unlock状态),它们会直接排队到等待队列的尾部
当一个等待这个获得锁的Goroutine之后,当发生以下2种情况时,将会从饥饿模式切回正常模式:
① 获得锁的Goroutine的等待时间<1ms
② 获得锁的Goroutine是等待队列中的最后一个等待者,此时等待队列为空
分析总结:
1. 正常模式
优点:在正常模式下,自旋和排队是同时存在的,Goroutine在尝试加锁时,会自旋,尝试过几次后如果还没有获取到锁,会进入排队状态。这种在排队之前先让大家来抢的模式,能够有更高的吞吐量(因为频繁的挂起、唤醒Goroutine会带来较多的开销)。
缺点:可能会出现等待队列尾端Goroutine迟迟抢不到锁的情况,即尾端延迟
2. 饥饿模式
饥饿模式下,为了解决尾端延迟问题,不再自旋尝试,所有Goroutine都要排队,严格的FIFO
线程状态:2种,即去抢占G的时候,会有一个自旋和非自旋的状态
Goroutine状态:
1. idle:空闲状态,刚刚被分配并且还没有被初始化
2. runnable:没有执行代码,没有栈的所有权,存储在运行队列中(等待被调度)
3. running:正在运行。可以执行代码,拥有栈的所有权,被赋予了内核线程M和处理器
4. syscall:正在执行系统调用。拥有栈的所有权,没有执行用户代码,被赋予了内核线程M,但是不在运行队列上
5. waiting:运行时被阻塞。没有执行用户代码并且不在运行队列上,但是可能存在于channel的等待队列上
6. dead:没有被使用,没有执行代码,可能有分配的栈
7. copystack:栈正在被拷贝,没有执行代码,不在运行队列上
8. preempted:由于抢占而被阻塞,没有执行用户代码并且不在运行队列,等待被唤醒
9. scan:GC正在扫描栈空间,没有执行代码,可以与其他状态同时存在
1. 线程发生OOM,也就是内存溢出,发生OOM的线程会被kill掉,其他线程不会受到影响
2. go中的内存泄露一般都是Goroutine泄露,就是Goroutine没有被关闭或者没有添加超时控制,让Goroutine一直处于阻塞状态,不能被GC
Go发生内存泄露的场景: 在Go中内存泄露分为暂时性内存泄露和永久性内存泄露
1. 暂时性内存泄露
1. 获取长字符串中一段导致长字符串未释放
2. 获取长slice中一段导致长slice未释放
3. 在长slice新建slice导致泄露
解释:string相比切片少了一个容量的cap字段,可以把string当成一个只读的切片类型。获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏
2. 永久性内存泄露
1. goroutine永久阻塞而导致泄漏
2. time.Ticker未关闭导致泄漏
3.不正确使用Finalizer导致泄漏
怎么排查内存泄露?pprof
defer只能捕获本层的panic,不能捕获子Goroutine的panic
gin框架使用http://github.com/go-playground/validator进行参数校验 在 struct 结构体添加 binding tag,然后调用 ShouldBing 方法,下面是一个示例
type SignUpParam struct {
Age uint8 `json:"age" binding:"gte=1,lte=130"`
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
RePassword string `json:"re_password" binding:"required,eqfield=Password"`
}
func main() {
r := gin.Default()
r.POST("/signup", func(c *gin.Context) {
var u SignUpParam
if err := c.ShouldBind(&u); err != nil {
c.JSON(http.StatusOK, gin.H{
"msg": err.Error(),
})
return
}
// 保存入库等业务逻辑代码...
c.JSON(http.StatusOK, "success")
})
_ = r.Run(":8999")
}
中间件middlewares使用use方法,gin的中间件其实就是一个HandlerFunc,只要我们实现一个HandlerFunc,然后作为参数传递进去
func costTime() gin.HandlerFunc {
return func(c *gin.Context) {
//请求前获取当前时间
nowTime := time.Now()
//请求处理
c.Next()
//处理后获取消耗时间
costTime := time.Since(nowTime)
url := c.Request.URL.String()
fmt.Printf("the request URL %s cost %v\n", url, costTime)
}
}
1. gin 的每种方法(POST, GET ...)都有自己的一颗树,当然这个是根据你注册路由来的,并不是一上来把每种方式都注册一遍
2. 当 gin 收到客户端的请求时, 第一件事就是去路由树里面去匹配对应的 URL,找到相关的路由, 拿到相关的处理函数(找到对应的 handler)
- 注册一个信号函数,监听信号,在信号触发时,执行优雅退出的函数gracefullyQuit
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func gracefullyQuit() {
fmt.Println("执行优雅退出的程序")
}
func main() {
fmt.Println("main start")
defer func() {
fmt.Println("byte")
gracefullyQuit()
}()
// 注册一个信号函数,监听信号,在信号触发时,执行优雅退出的函数gracefullyQuit
sig := make(chan os.Signal)
signal.Notify(sig, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGHUP)
go func() {
for s := range sig {
switch s {
case syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGHUP:
gracefullyQuit()
if i, ok := s.(syscall.Signal); ok {
os.Exit(int(i))
} else {
os.Exit(0)
}
}
}
}()
time.Sleep(10 * time.Second)
}
答:go的netpoll底层就是对IO多路复用的封装,底层实现其实和libco的协程框架一样,就是一个调度器、触发机制(超时触发/事件触发)等等
调用read等函数时,实际上会发生协程切换
说明:
1. 父goroutine退出,子goroutine实际上是不会结束的(子goroutine仍然在执行)
2. goroutine虽然不能强制结束另外一个goroutine,但是它可以通过channel通知另外一个goroutine,让其结束
父goroutine关闭子goroutine
方式1. 通过channel通知
package main
import (
"fmt"
"time"
)
func cancelByChannel(quit <-chan time.Time) {
for {
select {
case <-quit:
fmt.Println("cancel goroutine by channel!")
return
default:
fmt.Println("Im alive")
time.Sleep(1 * time.Second)
}
}
}
func main() {
quit := time.After(5 * time.Second)
go cancelByChannel(quit)
time.Sleep(10 * time.Second)
fmt.Println("Im done")
}
// Im alive
// Im alive
// Im alive
// Im alive
// Im alive
// cancel goroutine by channel!
// Im done
方式2. 通过cancelCtx
通过channel通知goroutine退出还有一个更好的方法就是使用context。没错,就是我们在日常开发中接口通用的第一个参数context。它本质还是接收一个channel数据,只是是通过ctx.Done()获取
package main
import (
"context"
"fmt"
"time"
)
func cancelByContext(ctx context.Context) {
for {
select {
case <-ctx.Done(): // Done()是监听cancel()、超时操作
fmt.Println("cancel goroutine by context!")
return
default:
fmt.Println("Im alive")
time.Sleep(1 * time.Second)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go cancelByContext(ctx)
time.Sleep(10 * time.Second)
cancel()
time.Sleep(5 * time.Second)
}
1. 功能:保证服务可用性的手段。
2. 过程:它允许服务重启期间,①不中断已经建立的连接,②老服务进程不再接收新连接请求,③新连接请求将在新服务进程中受理,④原服务进程中已经建立的连接,也可以将其设置为读关闭,等待平滑处理完连接上的请求及连接空闲后再退出。
用人话解释,
3. 优点:通过这种手段,可以保证已经建立连接不中断、连接上的事务可以正常完成、新服务进程也可以正常接收连接、处理连接上的请求
4. 原理:信号+fork
1. m和g开多少由内存决定,一个m=2M,一个g=2k
2. m的个数 > g的个数
3. p的个数由GOMAXPROC决定,可以设置
Golang中的GC回收机制:三色标记与混合写屏障_go混合写屏障
GMP模型
Golang内存管理
https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives/
type Mutex struct {
state int32 // 互斥锁的状态
sema uint32 // 信号量,用作等待队列
}
加锁和解锁是通过「原子操作」实现的(如下图所示)
1. 在「正常模式」下,一个Goroutine尝试加锁时,若当前 Goroutine 所挂载的 P 下,本地待运行队列为空,则Goroutine先自旋几次(次数 Mutex进入自旋的条件 1.锁已被占用,并且锁不处于饥饿模式 2. 当锁被释放时,第一个等待者Goroutine被唤醒后,不会直接获取锁,而是先要和「处于自旋阶段的Goroutine」竞争 说明:刚被唤醒的Goroutine竞争不过处于自选状态的Goroutine,导致产生“饥饿”。 分析原因: 当竞争失败后,Goroutine会被重新插入队列的头部 4. 当一个Goroutine加锁等待的时间超过1ms后,当前的mutex会从正常模式切换到饥饿模式 5. 在饥饿模式下, 6. 当一个等待者获取到锁之后,他会在以下2种情况时,将Mutex由饥饿模式切换回正常模式 综上所述
2.积累的自旋次数小于最大自旋次数(active_spin=4)
3.CPU 核数大于 1
4.有空闲的 P
5.当前 Goroutine 所挂载的 P 下,本地待运行队列为空