Go后端开发 常问八股

golang 中 make 和 new 的区别?(基本必问)

*共同点:**给变量分配内存 不同点: 1)作用变量类型不同,new给string,int和数组分配内存,make给切片,map,channel分配内存; 2)返回类型不一样,new返回指向变量的指针,make返回变量本身; 3)new 分配的空间被清零。make 分配空间后,会进行初始化; 4) 字节的面试官还说了另外一个区别,就是分配的位置,在堆上还是在栈上?这块我比较模糊,大家可以自己探究下,我搜索出来的答案是golang会弱化分配的位置的概念,因为编译的时候会自动内存逃逸处理,懂的大佬帮忙补充下:make、new内存分配是在堆上还是在栈上?

go语言中的值类型包括:整型,浮点,字符串,数组,bool,struct,声明的变量直接存储值,分配栈区的内存空间,这些变量所占据的空间在函数被调用完毕后自动释放

引用类型:channel,map,slice,指针,这些类型的变量存储的是一个地址(可以理解为指针),指针指向内存中真正存储数据的首地址,内存通常在堆上分配。通过GC回收。

new函数接收类型,返回类型指针,make函数返回类型本身

同:分配内存

不同:make new

切片,map,channe | sring int 数组

分配内存后初始化|分配内存后归零

返回变量本身|返回指向变量的指针

堆上|栈上

for range 的时候它的地址会发生变化么?

答:在 for a,b := range c 遍历中, a 和 b 在内存中只会存在一份,即之后每次循环时遍历到的数据都是以值覆盖的方式赋给 a 和 b,a,b 的内存地址始终不变。由于有这个特性,for 循环里面如果开协程,不要直接把 a 或者 b 的地址传给协程。解决办法:在每次循环时,创建一个临时变量。

go defer,多个 defer 的顺序,defer 在什么时机会修改返回值?

defer延迟函数,释放资源,收尾工作;如释放锁,关闭文件,关闭链接;捕获panic; 避坑指南:defer函数紧跟在资源打开后面,否则defer可能得不到执行,导致内存泄露。 多个 defer 调用顺序是 LIFO(后入先出),defer后的操作可以理解为压入栈中 defer,return,return value(函数返回值) 执行顺序:首先return,其次return value,最后defer。defer可以修改函数最终返回值,修改时机:有名返回值或者函数返回指针

调用函数传入结构体时,应该传值还是指针? (Golang 都是传值)

Go 的函数参数传递都是值传递。所谓值传递:指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。参数传递还有引用传递,所谓引用传递是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数 因为 Go 里面的 map,slice,chan 是引用类型。变量区分值类型和引用类型。所谓值类型:变量和变量的值存在同一个位置。所谓引用类型:变量和变量的值是不同的位置,变量的值存储的是对值的引用。但并不是 map,slice,chan 的所有的变量在函数内都能被修改,不同数据类型的底层存储结构和实现可能不太一样,情况也就不一样。

讲讲 Go 的 defer 底层数据结构和一些特性?

答:每个 defer 语句都对应一个_defer 实例,多个实例使用指针连接起来形成一个单连表,保存在 gotoutine 数据结构中,每次插入_defer 实例,均插入到链表的头部,函数结束再一次从头部取出,从而形成后进先出的效果。 defer 的规则总结: 延迟函数的参数是 defer 语句出现的时候就已经确定了的。 延迟函数执行按照后进先出的顺序执行,即先出现的 defer 最后执行。 延迟函数可能操作主函数的返回值。 申请资源后立即使用 defer 关闭资源是个好习惯。

Go出现panic的场景

● 数组/切片越界

● 空指针调用。比如访问一个 nil 结构体指针的成员

● 过早关闭 HTTP 响应体

● 除以 0

● 向已经关闭的 channel 发送消息

● 重复关闭 channel

● 关闭未初始化的 channel

● 未初始化 map。注意访问 map 不存在的 key 不会 panic,而是返回 map 类型对应的零值,但是不能直接赋值

● 跨协程的 panic 处理

● sync 计数为负数。

● 类型断言不匹配。var a interface{} = 1; fmt.Println(a.(string)) 会 panic,建议用 s,ok := a.(string)

值拷贝 与 引用拷贝,深拷贝 与 浅拷贝

map,slice,chan 是引用拷贝;引用拷贝 是 浅拷贝 其余的,都是 值拷贝;值拷贝 是 深拷贝

深浅拷贝的本质区别:

是否真正获取对象实体,而不是引用 深拷贝: 拷贝的是数据本身,创造一个新的对象,并在内存中开辟一个新的内存地址,与原对象是完全独立的,不共享内存,修改新对象时不会影响原对象的值。释放内存时,也没有任何关联。 值拷贝: 接收的是 整个array的值拷贝,所以方法对array中元素的重新赋值不起作用。

package main

import "fmt"

func modify(a [3]int) {

a[0] = 4

fmt.Println("modify",a) // modify [4 2 3]

}

func main() {

a := [3]int{1, 2, 3}

modify(a)

fmt.Println("main",a) // main [1 2 3]

}

浅拷贝: 拷贝的是数据地址,只复制指向的对象的指针,新旧对象的内存地址是一样的,修改一个另一个也会变。释放内存时,同时释放。 引用拷贝: 函数的引用拷贝与原始的引用指向同一个数组,所以对数组中元素的修改,是有效的

package main

import "fmt"

func modify(s []int) {

s[0] = 4

fmt.Println("modify",s) // modify [4 2 3]

}

func main() {

s := []int{1, 2, 3}

modify(s)

fmt.Println("main",s) // main [4 2 3]

}

Go 多返回值怎么实现的?

答:Go 传参和返回值是通过 FP+offset 实现,并且存储在调用函数的栈帧中。FP 栈底寄存器,指向一个函数栈的顶部;PC 程序计数器,指向下一条执行指令;SB 指向静态数据的基指针,全局符号;SP 栈顶寄存器。

Go 语言中不同的类型如何比较是否相等?

答:像 string,int,float interface 等可以通过 reflect.DeepEqual 和等于号进行比较,像 slice,struct,map 则一般使用 reflect.DeepEqual 来检测是否相等。

数组和切片的区别 (基本必问)

相同点: 1)只能存储一组相同类型的数据结构 2)都是通过下标来访问,并且有容量长度,长度通过 len 获取,容量通过 cap 获取 区别: 1)数组是定长,访问和复制不能超过数组定义的长度,否则就会下标越界,切片长度和容量可以自动扩容 2)数组是值类型,切片是引用类型,每个切片都引用了一个底层数组,切片本身不能存储任何数据,都是这底层数组存储数据,所以修改切片的时候修改的是底层数组中的数据。切片一旦扩容,指向一个新的底层数组,内存地址也就随之改变 简洁的回答: 1)定义方式不一样 2)初始化方式不一样,数组需要指定大小,大小不改变 3)在函数传递中,数组切片都是值传递。 数组的定义 var a1 [3]int var a2 [...]int{1,2,3} 切片的定义 var a1 []int var a2 :=make([]int,3,5) 数组的初始化 a1 := [...]int{1,2,3} a2 := [5]int{1,2,3} 切片的初始化 b:= make([]int,3,5) 数组和切片有什么异同 - 码农桃花源 【引申1】 [3]int 和 [4]int 是同一个类型吗? 不是。因为数组的长度是类型的一部分,这是与 slice 不同的一点。

讲讲 Go 的 slice 底层数据结构和一些特性?

答:Go 的 slice 底层数据结构是由一个 array 指针指向底层数组,len 表示切片长度,cap 表示切片容量。slice 的主要实现是扩容。

对于 append 向 slice 添加元素时,假如 slice 容量够用,则追加新元素进去,slice.len++,返回原来的 slice。当原容量不够,则 slice 先扩容,扩容之后 slice 得到新的 slice,将元素追加进新的 slice,slice.len++,返回新的 slice。

对于切片的扩容规则:当切片比较小时(容量小于 1024),则采用较大的扩容倍速进行扩容(新的扩容会是原来的 2 倍),避免频繁扩容,从而减少内存分配的次数和数据拷贝的代价。当切片较大的时(原来的 slice 的容量大于或者等于 1024),采用较小的扩容倍速(新的扩容将扩大大于或者等于原来 1.25 倍),主要避免空间浪费,网上其实很多总结的是 1.25 倍,那是在不考虑内存对齐的情况下,实际上还要考虑内存对齐,扩容是大于或者等于 1.25 倍。

注:Go的切片扩容源代码在runtime下的growslice函数 (关于刚才问的 slice 为什么传到函数内可能被修改,如果 slice 在函数内没有出现扩容,函数外和函数内 slice 变量指向是同一个数组,则函数内复制的 slice 变量值出现更改,函数外这个 slice 变量值也会被修改。如果 slice 在函数内出现扩容,则函数内变量的值会新生成一个数组(也就是新的 slice,而函数外的 slice 指向的还是原来的 slice,则函数内的修改不会影响函数外的 slice。)

golang中数组和slice作为参数的区别?slice作为参数传递有什么问题?

1. 当使用数组作为参数和返回值的时候,传进去的是值,在函数内部对数组进行修改并不会影响原数据

2. 当切片作为参数的时候穿进去的是值,也就是值传递,但是当我在函数里面修改切片的时候,我们发现源数据也会被修改,这是因为我们在切片的底层维护这一个匿名的数组,当我们把切片当成参数的时候,会重现创建一个切片,但是创建的这个切片和我们原来的数据是共享数据源的,所以在函数内被修改,源数据也会被修改

3. 数组还是切片,在函数中传递的时候如果没有指定为指针传递的话,都是值传递,但是切片在传递的过程中,有着共享底层数组的风险,所以如果在函数内部进行了更改的时候,会修改到源数据,所以我们需要根据不同的需求来处理,如果我们不希望源数据被修改话的我们可以使用copy函数复制切片后再传入,如果希望源数据被修改的话我们应该使用指针传递的方式

slic扩容:

新旧扩容策略

1.18之前

Go 1.18版本 之前扩容原理 在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

1. 如果期望容量大于当前容量的两倍就会使用期望容量;

2. 如果当前切片的长度小于 1024 就会将容量翻倍;

3. 如果当前切片的长度大于等于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

注:解释一下第一条: 比如 nums := []int{1, 2} nums = append(nums, 2, 3, 4),这样期望容量为2+3 = 5,而5 > 2*2,故使用期望容量(这只是不考虑内存对齐的情况下)

1.18版本 之后扩容原理

和之前版本的区别,主要在扩容阈值,以及这行源码:newcap += (newcap + 3*threshold) / 4。

在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

1. 如果期望容量大于当前容量的两倍就会使用期望容量;

2. 如果当前切片的长度小于阈值(默认 256)就会将容量翻倍;

3. 如果当前切片的长度大于等于阈值(默认 256),就会每次增加 25% 的容量,基准是 newcap + 3*threshold,直到新容量大于期望容量;

4. 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;

5. 当原 slice 容量 < threshold(阈值默认 256) 的时候,新 slice 容量变成原来的 2 倍;

6. 当原 slice 容量 > threshold(阈值默认 256),进入一个循环,每次容量增加(旧容量+3*threshold)/4。

下对应的“扩容系数”:

oldcap 扩容系数

256 2.0

512 1.63

1024 1.44

2048 1.35

4096 1.30

可以看到,Go1.18的扩容策略中,随着容量的增大,其扩容系数是越来越小的,可以更好地节省内存

总的来说

Go的设计者不断优化切片扩容的机制,其目的只有一个:就是控制让小的切片容量增长速度快一点,减少内存分配次数,而让大切片容量增长率小一点,更好地节省内存。 如果只选择翻倍的扩容策略,那么对于较大的切片来说,现有的方法可以更好的节省内存。 如果只选择每次系数为1.25的扩容策略,那么对于较小的切片来说扩容会很低效。 之所以选择一个小于2的系数,在扩容时被释放的内存块会在下一次扩容时更容易被重新利用。

Go语言中的map并发安全性:

● Go语言中的map类型并不是并发安全的。这意味着,如果有多个goroutine尝试同时读写同一个map,可能会导致竞态条件和数据损坏。

● 为了在并发环境下安全地使用map,可以采取以下几种策略:

a. 使用互斥锁(sync.Mutex):在读写map的操作前后加锁,确保同一时间只有一个goroutine可以访问map。

b. 使用读写互斥锁(sync.RWMutex):如果读操作远多于写操作,可以使用读写锁来提高性能。读写锁允许多个goroutine同时读取map,但在写入时需要独占访问。

c. 使用并发安全的map(sync.Map):从Go 1.9版本开始,标准库中的sync包提供了sync.Map类型,这是一个专为并发环境设计的map。它提供了一系列方法来安全地在多个goroutine之间共享数据。

结论: 在使用map时,需要注意其key的唯一性和不可变性,以及初始化和并发安全性的问题。特别是在并发环境下,应该采取适当的措施来确保map的安全访问,以避免竞态条件和数据不一致性。在Go语言中,可以通过使用互斥锁、读写互斥锁或并发安全的map(sync.Map)来实现这一点。

sync.map底层实现:

、sync.Map 的实现原理可概括为:

a、过 read 和 dirty 两个字段将读写分离,读的数据存在只读字段 read 上,将最新写入的数据则存在 dirty 字段上

b、读取时会先查询 read,不存在再查询 dirty,写入时则只写入 dirty

c、读取 read 并不需要加锁,而读或写 dirty 都需要加锁

d、另外有 misses 字段来统计 read 被穿透的次数(被穿透指需要读 dirty 的情况),超过一定次数则将 dirty 数据同步到 read 上

e、对于删除数据则直接通过标记来延迟删除

map 循环是有序的还是无序的?

在Go语言中,map的循环(遍历)是无序的。这意味着当你遍历map时,每次遍历的顺序可能都不同。Go语言的map是基于哈希表的,因此元素的存储顺序是不确定的,并且可能会随着元素的添加、删除等操作而改变。 如果你需要按照特定的顺序处理map中的元素,你应该先将key提取到一个切片中,对切片进行排序,然后按照排序后的顺序遍历切片,并从map中取出对应的值。这样,你就可以按照特定的顺序处理map中的元素了。

map 中删除一个 key,它的内存会释放么?

在Go语言中,从map中删除一个key时,其内存释放的行为并非直观且立即的,这涉及到Go语言的内存管理机制。具体来说,删除map中的key后,其内存释放情况如下:

内存标记与垃圾回收

1. 删除操作:使用delete函数从map中删除一个key时,该key及其关联的值会被从map的内部数据结构中移除。此时,这些值在逻辑上不再属于map的一部分。

2. 内存标记:删除操作后,如果没有任何其他变量或数据结构引用被删除的值,那么这些值就变成了垃圾回收器的目标。Go语言的垃圾回收器(Garbage Collector, GC)会定期扫描内存,标记那些不再被使用的内存区域。

3. 内存释放:在垃圾回收过程中,被标记为垃圾的内存区域会被释放回堆内存,供后续的内存分配使用。然而,这个过程并不是立即发生的,而是由垃圾回收器的触发条件和回收策略决定的。

注意事项

1. 内存释放时机:由于垃圾回收器的非确定性,删除map中的key后,其内存释放的时机是不确定的。因此,不能依赖删除操作来立即释放内存。

2. map底层存储不变:删除操作只是逻辑上移除了key-value对,但map底层分配的内存(如哈希表的桶和溢出桶)并不会立即减小。这是因为map的设计优先考虑的是访问速度,而不是空间效率。如果需要释放大量内存,一种方法是创建一个新的map,并将旧map中需要保留的元素复制过去。

3. 并发安全:如果map在多个goroutine之间共享,那么删除操作需要考虑并发安全问题。可以使用互斥锁(如sync.Mutex)来保护对map的访问,或者使用Go 1.9引入的sync.Map,它提供了内置的并发安全机制。

怎么处理对 map 进行并发访问?有没有其他方案? 区别是什么?

处理对map进行并发访问的问题,主要需要确保在多个goroutine同时访问map时不会出现竞态条件和数据不一致的情况。以下是几种处理并发访问map的方案及其区别:

使用互斥锁(sync.Mutex)

方案描述: 使用sync.Mutex或sync.RWMutex(读写互斥锁)来控制对map的访问。在访问map之前加锁,访问完成后释放锁。这样可以保证在同一时间内只有一个goroutine可以访问map。 优点:

● 实现简单,容易理解。

● 对于写操作频繁的场景,能够较好地保证数据一致性。

缺点:

● 在读多写少的场景下,性能可能不是最优的,因为读操作也需要获取锁。

● 锁的粒度较大,可能会影响并发性能。

使用读写互斥锁(sync.RWMutex)

方案描述: 与sync.Mutex类似,但sync.RWMutex允许多个goroutine同时读取map,只有在写入时才需要独占访问。 优点:

● 在读多写少的场景下,性能优于sync.Mutex,因为读操作不需要获取写锁。

缺点:

● 写入操作仍然需要独占访问,可能会影响并发写入的性能。

● 实现略复杂于sync.Mutex,需要区分读写操作。

使用并发安全的map(sync.Map)

方案描述: 从Go 1.9版本开始,标准库中的sync包提供了sync.Map类型,它是一个专为并发环境设计的map。sync.Map内部使用了读写锁和其他同步机制来保证并发访问的安全性。 优点:

● 无需显式加锁,简化了并发编程。

● 针对读多写少的场景进行了优化,如读写分离等,提高了并发性能。

● 提供了特定的方法(如Load、Store、Delete等)来安全地访问map。

缺点:

● 在某些情况下,性能可能不如使用sync.RWMutex的自定义map(尤其是在写入操作频繁时)。

● sync.Map的API与内置map不同,可能需要适应新的使用方式。

区别总结

方案 实现复杂度 性能(读多写少) 性能(写多) 使用场景

sync.Mutex 低 中等 中等 写操作频繁,对并发性能要求不高

sync.RWMutex 中等 高 中等 读多写少,需要较高并发读性能

sync.Map 低(API不同) 高 中等偏下 读多写少,追求简洁的并发编程模型

注意事项

● 在选择方案时,需要根据实际的应用场景(如读写比例、并发级别等)来决定使用哪种方案。

● 如果并发级别不高,且对性能要求不高,也可以考虑使用简单的锁机制(如sync.Mutex)来简化实现。

● 对于性能要求极高的场景,可能需要考虑更复杂的并发数据结构或算法来优化性能。

综上所述,处理对map的并发访问需要根据具体情况选择合适的方案,并在实际使用中不断优化和调整以达到最佳性能。

nil map 和空 map 有何不同?

在Go语言中,nil map和空map之间存在一些关键的不同点,主要体现在它们的初始状态、对增删查操作的影响以及内存占用等方面。

初始状态与内存占用

● nil map:未初始化的map的零值是nil。这意味着map变量被声明后,如果没有通过make函数或其他方式显式初始化,它将保持nil状态。nil map不占用实际的内存空间来存储键值对,因为它没有底层的哈希表结构。

● 空map:空map是通过make函数或其他方式初始化但没有添加任何键值对的map。空map已经分配了底层的哈希表结构,但表中没有存储任何键值对。因此,空map占用了一定的内存空间,尽管这个空间相对较小。

对增删查操作的影响

● nil map:

○ 添加操作:向nil map中添加键值对将导致运行时panic,因为nil map没有底层的哈希表来存储数据。

○ 删除操作:在早期的Go版本中,尝试从nil map中删除键值对也可能导致panic,但在最新的Go版本中,这一行为可能已经被改变(具体取决于Go的版本),但通常不建议对nil map执行删除操作。

○ 查找操作:从nil map中查找键值对不会引发panic,但会返回对应类型的零值,表示未找到键值对。

● 空map:

○ 添加操作:向空map中添加键值对是安全的,键值对会被添加到map中。

○ 删除操作:从空map中删除键值对是一个空操作,不会引发panic,因为map中原本就没有该键值对。

○ 查找操作:从空map中查找不存在的键值对也会返回对应类型的零值,表示未找到键值对。

map 的数据结构是什么?

golang 中 map 是一个 kv 对集合。底层使用 hash table,用链表来解决冲突 ,出现冲突时,不是每一个 key 都申请一个结构通过链表串起来,而是以 bmap 为最小粒度挂载,一个 bmap 可以放 8 个 kv。在哈希函数的选择上,会在程序启动时,检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。每个 map 的底层结构是 hmap,是有若干个结构为 bmap 的 bucket 组成的数组。每个 bucket 底层都采用链表结构。

hmap 的结构如下:

type hmap struct {

count int // 元素个数

flags uint8

B uint8 // 扩容常量相关字段B是buckets数组的长度的对数 2^B

noverflow uint16 // 溢出的bucket个数

hash0 uint32 // hash seed

buckets unsafe.Pointer // buckets 数组指针

oldbuckets unsafe.Pointer // 结构扩容的时候用于赋值的buckets数组

nevacuate uintptr // 搬迁进度

extra *mapextra // 用于扩容的指针

}

下图展示一个拥有4个bucket的map: 本例中, hmap.B=2, 而hmap.buckets长度是2^B为4. 元素经过哈希运算后会落到某个bucket中进行存储。查找过程类似。 bucket很多时候被翻译为桶,所谓的哈希桶实际上就是bucket。

bucket数据结构

bucket数据结构由runtime/map.go:bmap定义:

type bmap struct {

tophash [8]uint8 //存储哈希值的高8位

data byte[1] //key value数据:key/key/key/.../value/value/value...

overflow *bmap //溢出bucket的地址

}

每个bucket可以存储8个键值对。

● tophash是个长度为8的数组,哈希值相同的键(准确的说是哈希值低位相同的键)存入当前bucket时会将哈希值的高位存储在该数组中,以方便后续匹配。

● data区存放的是key-value数据,存放顺序是key/key/key/…value/value/value,如此存放是为了节省字节对齐带来的空间浪费。

● overflow 指针指向的是下一个bucket,据此将所有冲突的键连接起来。

注意:上述中data和overflow并不是在结构体中显示定义的,而是直接通过指针运算进行访问的。 下图展示bucket存放8个key-value对:

解决哈希冲突(四种方法)

哈希冲突

当有两个或以上数量的键被哈希到了同一个bucket时,我们称这些键发生了冲突。Go使用链地址法来解决键冲突。 由于每个bucket可以存放8个键值对,所以同一个bucket存放超过8个键值对时就会再创建一个键值对,用类似链表的方式将bucket连接起来。 下图展示产生冲突后的map: bucket数据结构指示下一个bucket的指针称为overflow bucket,意为当前bucket盛不下而溢出的部分。事实上哈希冲突并不是好事情,它降低了存取效率,好的哈希算法可以保证哈希值的随机性,但冲突过多也是要控制的,后面会再详细介绍。

链地址法:

将所有哈希地址相同的记录都链接在同一链表中。

● 当两个不同的键通过哈希函数计算得到相同的哈希值时,Go的map并不直接覆盖旧的值,而是将这些具有相同哈希值的键值对存储在同一个桶(bucket)中的链表中。这样,即使哈希值相同,也可以通过遍历链表来找到对应的键值对。

● 当桶中的链表长度超过一定阈值时(通常是8个元素),Go的map会进行扩容和重新哈希,以减少哈希冲突,并优化查找、插入和删除操作的性能。

负载因子

负载因子用于衡量一个哈希表冲突情况,公式为:

负载因子 = 键数量/bucket数量

例如,对于一个bucket数量为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为1. 哈希表需要将负载因子控制在合适的大小,超过其阀值需要进行rehash,也即键值对重新组织:

● 哈希因子过小,说明空间利用率低

● 哈希因子过大,说明冲突严重,存取效率低

每个哈希表的实现对负载因子容忍程度不同,比如Redis实现中负载因子大于1时就会触发rehash,而Go则在在负载因子达到6.5时才会触发rehash,因为Redis的每个bucket只能存1个键值对,而Go的bucket可能存8个键值对,所以Go可以容忍更高的负载因子。

是怎么实现扩容?

map 的容量大小

底层调用 makemap 函数,计算得到合适的 B,map 容量最多可容纳 6.52B 个元素,6.5 为装载因子阈值常量。装载因子的计算公式是:装载因子=填入表中的元素个数/散列表的长度,装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。底层调用 makemap 函数,计算得到合适的 B,map 容量最多可容纳 6.52B 个元素,6.5 为装载因子阈值常量。装载因子的计算公式是:装载因子=填入表中的元素个数/散列表的长度,装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

触发 map 扩容的条件

为了保证访问效率,当新元素将要添加进map时,都会检查是否需要扩容,扩容实际上是以空间换时间的手段。 触发扩容的条件有二个:

1. 负载因子 > 6.5时,也即平均每个bucket存储的键值对达到6.5个。

2. overflow数量 > 2^15时,也即overflow数量超过32768时。

增量扩容

当负载因子过大时,就新建一个bucket,新的bucket长度是原来的2倍,然后旧bucket数据搬迁到新的bucket。 考虑到如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,Go采用逐步搬迁策略,即每次访问map时都会触发一次搬迁,每次搬迁2个键值对。 下图展示了包含一个bucket满载的map(为了描述方便,图中bucket省略了value区域): 当前map存储了7个键值对,只有1个bucket。此地负载因子为7。再次插入数据时将会触发扩容操作,扩容之后再将新插入键写入新的bucket。 当第8个键值对插入时,将会触发扩容,扩容后示意图如下: hmap数据结构中oldbuckets成员指身原bucket,而buckets指向了新申请的bucket。新的键值对被插入新的bucket中。 后续对map的访问操作会触发迁移,将oldbuckets中的键值对逐步的搬迁过来。当oldbuckets中的键值对全部搬迁完毕后,删除oldbuckets。 搬迁完成后的示意图如下: 数据搬迁过程中原bucket中的键值对将存在于新bucket的前面,新插入的键值对将存在于新bucket的后面。 实际搬迁过程中比较复杂,将在后续源码分析中详细介绍。

等量扩容

所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,进而保证更快的存取。 在极端场景下,比如不断地增删,而键值对正好集中在一小部分的bucket,这样会造成overflow的bucket数量增多,但负载因子又不高,从而无法执行增量搬迁的情况,如下图所示: 上图可见,overflow的bucket中大部分是空的,访问效率会很差。此时进行一次等量扩容,即buckets数量不变,经过重新组织后overflow的bucket数量会减少,即节省了空间又会提高访问效率。

查找过程

查找过程如下:

1. 根据key值算出哈希值

2. 取哈希值低位与hmap.B取模确定bucket位置

3. 取哈希值高位在tophash数组中查询

4. 如果tophash[i]中存储值也哈希值相等,则去找到该bucket中的key值进行比较

5. 当前bucket没有找到,则继续从下个overflow的bucket中查找。

6. 如果当前处于搬迁过程,则优先从oldbuckets查找

注:如果查找不到,也不会返回空值,而是返回相应类型的0值。

插入过程

新元素插入过程如下:

1. 根据key值算出哈希值

2. 取哈希值低位与hmap.B取模确定bucket位置

3. 查找该key是否已经存在,如果存在则直接更新值

4. 如果没找到将key,将key插入

增删查的时间复杂度 O(1)

1. 在Go语言中,对于map的查找、插入和删除操作,在大多数情况下,它们的时间复杂度都可以视为O(1),即常数时间复杂度。

2. map的读写效率之所以在平均情况下能达到O(1),是因为Go语言的map实现采用了哈希表的方式,通过哈希函数将键映射到哈希表的某个位置(哈希桶)上,从而在常数时间内完成读写操作。

3. 然而,需要明确的是,这个O(1)的复杂度是基于平均情况或假设哈希函数分布均匀的前提下的。在实际应用中,如果哈希函数设计不当或发生了大量的哈希冲突,那么这些操作的时间复杂度可能会受到影响,甚至退化为O(n),其中n是map中元素的数量。但在正常、合理的使用场景下,这种极端情况是非常罕见的。

sync.map的锁机制跟你自己用锁加上map有区别么

sync.Map 的锁机制和自己使用锁(如 sync.Mutex 或 sync.RWMutex)加上 map 的方式有一些关键区别: 自己使用锁和 map

1. 全局锁:

○ 你需要自己管理锁,通常是一个全局的 sync.Mutex 或 sync.RWMutex。

○ 对于读多写少的场景,使用 sync.RWMutex 可以允许多个读操作同时进行,但写操作依然会阻塞所有读操作。

2. 手动处理:

○ 你需要自己编写代码来处理加锁、解锁、读写操作。

○ 错误使用锁可能导致死锁、竞态条件等问题。

3. 简单直观:

○ 实现简单,容易理解和调试。

**sync.Map**

1. 读写分离:

○ sync.Map 内部使用读写分离的策略,通过只读和读写两个 map 提高读操作的性能。

○ 读操作大部分情况下是无锁的,只有在只读 map 中找不到数据时,才会加锁访问读写 map。

2. 延迟写入:

○ 写操作更新读写 map(dirty),但不会立即更新只读 map(read)。只有当读操作发现只读 map 中的数据过时时,才会将读写 map 的数据同步到只读 map 中。

3. 内置优化:

○ sync.Map 内部有各种优化措施,如原子操作、延迟写入等,使得它在读多写少的场景下性能更高。

区别总结

● 并发性能:sync.Map 通过读写分离和延迟写入在读多写少的场景下提供更高的并发性能,而使用全局锁的 map 在读写频繁时性能较低。

● 复杂性和易用性:sync.Map 封装了复杂的并发控制逻辑,使用起来更简单,而自己管理锁和 map 需要处理更多的并发控制细节。

● 适用场景:**sync.Map**** 适用于读多写少的场景**,而使用全局锁的 map 适用于读写操作较均衡或者对性能要求不高的场景。

如果你的应用场景是读多写少且对性能要求较高,sync.Map 会是一个更好的选择。而对于简单的并发访问控制,使用 sync.Mutex 或 sync.RWMutex 加上 map 也可以满足需求。

值接收者和指针接收者的区别

方法

方法能给用户自定义的类型添加新的行为。它和函数的区别在于方法有一个接收者,给一个函数添加一个接收者,那么它就变成了方法。接收者可以是值接收者,也可以是指针接收者。 在调用方法的时候,值类型既可以调用值接收者的方法,也可以调用指针接收者的方法;指针类型既可以调用指针接收者的方法,也可以调用值接收者的方法。 也就是说,不管方法的接收者是什么类型,该类型的值和指针都可以调用,不必严格符合接收者的类型。 实际上,当类型和方法的接收者类型不同时,其实是编译器在背后做了一些工作,用一个表格来呈现:

- 值接收者 指针接收者

值类型调用者 方法会使用调用者的一个副本,类似于“传值” 使用值的引用来调用方法,上例中,qcrao.growUp() 实际上是 (&qcrao).growUp()

指针类型调用者 指针被解引用为值,上例中,stefno.howOld() 实际上是 (*stefno).howOld() 实际上也是“传值”,方法里的操作会影响到调用者,类似于指针传参,拷贝了一份指针

值接收者和指针接收者

前面说过,不管接收者类型是值类型还是指针类型,都可以通过值类型或指针类型调用,这里面实际上通过语法糖起作用的。 先说结论:实现了接收者是值类型的方法,相当于自动实现了接收者是指针类型的方法;而实现了接收者是指针类型的方法,不会自动生成对应接收者是值类型的方法。 所以,当实现了一个接收者是值类型的方法,就可以自动生成一个接收者是对应指针类型的方法,因为两者都不会影响接收者。但是,当实现了一个接收者是指针类型的方法,如果此时自动生成一个接收者是值类型的方法,原本期望对接收者的改变(通过指针实现),现在无法实现,因为值类型会产生一个拷贝,不会真正影响调用者。 最后,只要记住下面这点就可以了:

如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法。

两者分别在何时使用

如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身。 使用指针作为方法的接收者的理由:

● 方法能够修改接收者指向的值。

● 避免在每次调用方法时复制该值,在值的类型为大型结构体时,这样做会更加高效。

是使用值接收者还是指针接收者,不是由该方法是否修改了调用者(也就是接收者)来决定,而是应该基于该类型的本质。 如果类型具备“原始的本质”,也就是说它的成员都是由 Go 语言里内置的原始类型,如字符串,整型值等,那就定义值接收者类型的方法。像内置的引用类型,如 slice,map,interface,channel,这些类型比较特殊,声明他们的时候,实际上是创建了一个 header, 对于他们也是直接定义值接收者类型的方法。这样,调用函数时,是直接 copy 了这些类型的 header,而 header 本身就是为复制设计的。 如果类型具备非原始的本质,不能被安全地复制,这种类型总是应该被共享,那就定义指针接收者的方法。比如 go 源码里的文件结构体(struct File)就不应该被复制,应该只有一份实体。

iface 和 eface 的区别是什么

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

}

iface 内部维护两个指针,tab 指向一个 itab 实体, 它表示接口的类型以及赋给这个接口的实体类型。data 则指向接口具体的值,一般而言是一个指向堆内存的指针。 再来仔细看一下 itab 结构体:_type 字段描述了实体的类型,包括内存对齐方式,大小等;inter 字段则描述了接口的类型。fun 字段放置和接口方法对应的具体数据类型的方法地址,实现接口调用方法的动态分派,一般在每次给接口赋值发生转换时会更新此表,或者直接拿缓存的 itab。 这里只会列出实体类型和接口相关的方法,实体类型的其他方法并不会出现在这里。 另外,你可能会觉得奇怪,为什么 fun 数组的大小为 1,要是接口定义了多个方法可怎么办?实际上,这里存储的是第一个方法的函数指针,如果有更多的方法,在它之后的内存空间里继续存储。从汇编角度来看,通过增加地址就能获取到这些函数指针,没什么影响。顺便提一句,这些方法是按照函数名称的字典序进行排列的。 再看一下 interfacetype 类型,它描述的是接口的类型:

type interfacetype struct {

typ _type

pkgpath name

mhdr []imethod

}

可以看到,它包装了 _type 类型,_type 实际上是描述 Go 语言中各种数据类型的结构体。我们注意到,这里还包含一个 mhdr 字段,表示接口所定义的函数列表, pkgpath 记录定义了接口的包名。 这里通过一张图来看下 iface 结构体的全貌: 接着来看一下 eface 的源码:

type eface struct {

_type *_type

data unsafe.Pointer

}

相比 iface,eface 就比较简单了。只维护了一个 _type 字段,表示空接口所承载的具体的实体类型。data 描述了具体的值。

ontext 结构是什么样的?context 使用场景和用途?

(难,也常常问你项目中怎么用,光靠记答案很难让面试官满意,反正有各种结合实际的问题) 参考链接: go context详解 - 卷毛狒狒 - 博客园www.cnblogs.com/juanmaofeifei/p/14439957.html 答:Go 的 Context 的数据结构包含 Deadline,Done,Err,Value。Deadline 方法返回一个 time.Time,表示当前 Context 应该结束的时间,ok 则表示有结束时间,Done 方法当 Context 被取消或者超时时候返回的一个 close 的 channel,告诉给 context 相关的函数要停止当前工作然后返回了,Err 表示 context 被取消的原因,Value 方法表示 context 实现共享数据存储的地方,是协程安全的。context 在业务中是经常被使用的, 其主要的应用 : 1:上下文控制,2:多个 goroutine 之间的数据交互等,3:超时控制:到某个时间点超时,过多久超时。

context在go中一般可以用来做什么?

在 Go 语言中,context 包提供了一种管理多个 goroutine 之间的截止时间、取消信号和请求范围数据的方法。以下是 context 常见的用途:

1. 取消信号:

○ context 可以用来向多个 goroutine 传递取消信号。当一个 goroutine 需要取消其他 goroutine 时,可以调用 context 的 CancelFunc。

○ 例如,在处理 HTTP 请求时,如果客户端关闭了连接,可以使用 context 取消所有相关的后台操作。

2. 截止时间/超时控制:

○ context 可以设置一个截止时间或超时。当超过这个时间或超时发生时,context 会自动取消操作。

○ 例如,在数据库查询或网络请求时,可以使用 context 设置一个超时时间,以防止长时间的等待。

3. 传递请求范围的数据:

○ context 可以在多个 goroutine 之间传递请求范围的数据,例如请求的唯一 ID、用户认证信息等。

○ 例如,在处理 HTTP 请求时,可以将请求的元数据存储在 context 中,并在各个处理函数之间传递这些数据。

具体示例

1. 创建带取消功能的 context:

ctx, cancel := context.WithCancel(context.Background())

defer cancel()

go func() {

// 执行一些操作

// 在需要取消操作时调用 cancel

cancel()

}()

select {

case <-ctx.Done():

fmt.Println("操作取消")

case result := <-someOperation():

fmt.Println("操作结果:", result)

}

1. 创建带超时的 context:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)

defer cancel()

select {

case <-ctx.Done():

if ctx.Err() == context.DeadlineExceeded {

fmt.Println("操作超时")

}

case result := <-someOperation():

fmt.Println("操作结果:", result)

}

1. 传递请求范围的数据:

ctx := context.WithValue(context.Background(), "requestID", "12345")

go func(ctx context.Context) {

requestID := ctx.Value("requestID").(string)

fmt.Println("处理请求ID:", requestID)

}(ctx)

常用函数

● context.Background(): 返回一个空的 Context,通常用于根 Context。

● context.TODO(): 返回一个空的 Context,用于暂时不知道该使用什么 Context 的情况。

● context.WithCancel(parent Context) (Context, CancelFunc): 创建一个可以取消的 Context。

● context.WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc): 创建一个带超时的 Context。

● context.WithDeadline(parent Context, d time.Time) (Context, CancelFunc): 创建一个带截止时间的 Context。

● context.WithValue(parent Context, key, val interface{}) Context: 创建一个携带值的 Context。

通过这些功能,context 在 Go 中为管理 goroutine 的生命周期和跨 goroutine 传递数据提供了便利和强大的支持。

Channel 是否线程安全?锁用在什么地方?

1. Golang的Channel,发送一个数据到Channel 和 从Channel接收一个数据 都是 原子性的。

2. 而且Go的设计思想就是:不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。

3. 也就是说,设计Channel的主要目的就是在多任务间传递数据的,这当然是安全的

channel的底层数据结构

channel是golang中用来实现多个goroutine通信的管道,它的底层是一个叫做hchan的结构体。在go的runtime包下。

数据结构

go

代码解读

复制代码type hchan struct {

//channel分为无缓冲和有缓冲两种。

//对于有缓冲的channel存储数据,借助的是如下循环数组的结构

qcount uint // 循环数组中的元素数量

dataqsiz uint // 循环数组的长度

buf unsafe.Pointer // 指向底层循环数组的指针

elemsize uint16 //能够收发元素的大小

closed uint32 //channel是否关闭的标志

elemtype *_type //channel中的元素类型

//有缓冲channel内的缓冲数组会被作为一个“环型”来使用。

//当下标超过数组容量后会回到第一个位置,所以需要有两个字段记录当前读和写的下标位置

sendx uint // 下一次发送数据的下标位置

recvx uint // 下一次读取数据的下标位置

//当循环数组中没有数据时,收到了接收请求,那么接收数据的变量地址将会写入读等待队列

//当循环数组中数据已满时,收到了发送请求,那么发送数据的变量地址将写入写等待队列

recvq waitq // 读等待队列

sendq waitq // 写等待队列

lock mutex //互斥锁,保证读写channel时不存在并发竞争问题

}

对应图解如下:

总结hchan结构体的主要组成部分有四个:

● 用来保存goroutine之间传递数据的循环链表。=====> buf。

● 用来记录此循环链表当前发送或接收数据的下标值。=====> sendx和recvx。

● 用于保存向该chan发送和从改chan接收数据的goroutine的队列。=====> sendq 和 recvq

● 保证channel写入和读取数据时线程安全的锁。 =====> lock

举个栗子

go

代码解读

复制代码

//G1

func sendTask(taskList []Task) {

...

ch:=make(chan Task, 4) // 初始化长度为4的channel

for _,task:=range taskList {

ch <- task //发送任务到channel

}

...

}

//G2

func handleTask(ch chan Task) {

for {

task:= <-ch //接收任务

process(task) //处理任务

}

}

ch是长度为4的带缓冲的channel,G1是发送者,G2是接收者

● 初始hchan结构体重的buf为空,sendx和recvx均为0。

● 当G1向ch里发送数据时,首先会对buf加锁,然后将数据copy到buf中,然后sendx++,然后释放对buf的锁。

● 当G2消费ch的时候,会首先对buf加锁,然后将buf中的数据copy到task变量对应的内存里,然后recvx++,并释放锁。

可以发现整个过程,G1和G2没有共享的内存,底层是通过hchan结构体的buf,并使用copy内存的方式进行通信,最后达到了共享内存的目的,这里也体现了Go中的CSP并发模型,(PS: Copy 可以防止数据在传输过程中被意外或恶意修改。如果某个 goroutine 在发送数据同时修改了数据,那么接收方得到的可能是篡改后的数据。通过将数据 copy 到 channel,然后再 copy 到接收 goroutine,可以确保数据在传输过程中不会被修改。)。

Go中的CSP并发模型即是通过goroutine和channel实现的。

CSP并发模型:不要以共享内存的方式来通信,相反,要通过通信的方式来共享内存。

那么当channel中的缓存满了之后会发生什么呢?

首先简单了解下GMP的概念。相关的数据模型如下图:

【G】goroutine是Golang实现的用户空间的轻量级的线程

【M】代表操作系统线程

【P】处理器, 它包含了待运行goroutine。

如果线程M想运行goroutine,必须先获取P,从P中获取goroutine执行。

当G1向buf已经满了的ch发送数据的时候,检测到hchan的buf已经满了,会通知调度器,调度器会将G1的状态设置为waiting, 并移除与线程M的联系,然后从P的runqueue中选择一个goroutine在线程M中执行,此时G1就是阻塞状态,但是不是操作系统的线程阻塞,所以这个时候只用消耗少量的资源。

那么G1设置为waiting状态后去哪了?怎们去resume呢?我们再回到hchan结构体,注意到hchan有个sendq的成员,其类型是waitq,查看源码如下:

go

代码解读

复制代码type hchan struct {

...

recvq waitq // 读等待队列

sendq waitq // 写等待队列

...

}

type waitq struct {

first *sudog

last *sudog

}

实际上,当G1变为waiting状态后,会创建一个代表自己的sudog的结构,然后放到sendq这个list中,sudog结构中保存了channel相关的变量的指针(如果该Goroutine是sender,那么保存的是待发送数据的变量的地址,如果是receiver则为接收数据的变量的地址,之所以是地址,前面我们提到在传输数据的时候使用的是copy的方式)

当G2从ch中接收一个数据时,会通知调度器,设置G1的状态为runnable,然后将加入P的runqueue里,等待线程执行.

go

代码解读

复制代码func G2(){

t := <-ch

}

前面我们是假设G1先运行,如果G2先运行会怎么样呢?

如果G2先运行,那么G2会从一个empty的channel里取数据,这个时候G2就会阻塞,和前面介绍的G1阻塞一样,G2也会创建一个sudog结构体,保存接收数据的变量的地址,但是该sudog结构体是放到了recvq列表里。

当G1向ch发送数据的时候,为了提升效率,runtime并不会对hchan结构体题的buf进行加锁,而是直接将G1里的发送到ch的数据copy到了G2 sudog里对应的elem指向的内存地址!【不通过buf】

讲讲 Go 的 chan 底层数据结构和主要使用场景

答:channel 的数据结构包含 qccount 当前队列中剩余元素个数,dataqsiz 环形队列长度,即可以存放的元素个数,buf 环形队列指针,elemsize 每个元素的大小,closed 标识关闭状态,elemtype 元素类型,sendx 队列下表,指示元素写入时存放到队列中的位置,recv 队列下表,指示元素从队列的该位置读出。recvq 等待读消息的 goroutine 队列,sendq 等待写消息的 goroutine 队列,lock 互斥锁,chan 不允许并发读写。

无缓冲和有缓冲区别: 管道没有缓冲区,从管道读数据会阻塞,直到有协程向管道中写入数据。同样,向管道写入数据也会阻塞,直到有协程从管道读取数据。管道有缓冲区但缓冲区没有数据,从管道读取数据也会阻塞,直到协程写入数据,如果管道满了,写数据也会阻塞,直到协程从缓冲区读取数据。

channel 的一些特点 1)、读写值 nil 管道会永久阻塞 2)、关闭的管道读数据仍然可以读数据 3)、往关闭的管道写数据会 panic 4)、关闭为 nil 的管道 panic 5)、关闭已经关闭的管道 panic

向 channel 写数据的流程: 如果等待接收队列 recvq 不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从 recvq 取出 G,并把数据写入,最后把该 G 唤醒,结束发送过程; 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程; 如果缓冲区中没有空余位置,将待发送数据写入 G,将当前 G 加入 sendq,进入睡眠,等待被读 goroutine 唤醒;

向 channel 读数据的流程: 如果等待发送队列 sendq 不为空,且没有缓冲区,直接从 sendq 中取出 G,把 G 中数据读出,最后把 G 唤醒,结束读取过程; 如果等待发送队列 sendq 不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把 G 中数据写入缓冲区尾部,把 G 唤醒,结束读取过程; 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;将当前 goroutine 加入 recvq,进入睡眠,等待被写 goroutine 唤醒; 使用场景: 消息传递、消息过滤,信号广播,事件订阅与广播,请求、响应转发,任务分发,结果汇总,并发控制,限流,同步与异步

什么是 GMP?(必问)

答:G 代表着 goroutine,P 代表着上下文处理器,M 代表 thread 线程, 在 GPM 模型,有一个全局队列(Global Queue):存放等待运行的 G,还有一个 P 的本地队列:也是存放等待运行的 G,但数量有限,不超过 256 个。 GPM 的调度流程从 go func()开始创建一个 goroutine,新建的 goroutine 优先保存在 P 的本地队列中,如果 P 的本地队列已经满了,则会保存到全局队列中。 M 会从 P 的队列中取一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会从其他的 MP 组合偷取一个可执行的 G 来执行, 当 M 执行某一个 G 时候发生系统调用或者阻塞,M 阻塞, 如果这个时候 G 在执行,runtime 会把这个线程 M 从 P 中摘除,然后创建一个新的操作系统线程来服务于这个 P,当 M 系统调用结束时,这个 G 会尝试获取一个空闲的 P 来执行,并放入到这个 P 的本地队列,如果这个线程 M 变成休眠状态,加入到空闲线程中,然后整个 G 就会被放入到全局队列中。 G,P,M 的个数问题:

1. G 的个数理论上是无限制的,但是受内存限制,

2. P 的数量一般建议是逻辑 CPU 数量的 2 倍,

a. 由启动时环境变量GOMAXPROCS个goroutine在同时运行。

3. M 的数量

a. go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000.但是内核很难支持这么多的线程数,所以这个限制可以忽略。

b. runtime/debug中的SetMaxThreads函数,设置M的最大数量

c. 一个M阻塞了,会创建新的M。

4. M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来。

work stealing(工作量窃取) 机制:会优先从全局队列里进行窃取,之后会从其它的P队列里窃取一半的G,放入到本地P队列里。 hand off (移交)机制:当前线程的G进行阻塞调用时,例如睡眠,则当前线程就会释放P,然后把P转交给其它空闲的线程执行,如果没有闲置的线程,则创建新的线程

1. 概述:

○ GMP 模型是 Go 语言运行时用于管理 goroutine 的调度模型。

○ GMP 分别代表 Goroutines (G)、Machine (M) 和 Processor (P)。

2. G (Goroutine):

○ G 代表 Goroutine,是 Go 语言中的轻量级线程。

○ 每个 goroutine 包含需要执行的函数、栈空间以及其他调度信息。

3. M (Machine):

○ M 代表 Machine,是操作系统的内核线程。

○ M 负责执行 goroutine,多个 M 可以同时运行在多个 CPU 核上。

4. P (Processor):

○ P 代表 Processor,是一个逻辑处理器,管理 goroutine 的执行队列。

○ 每个 P 维护一个本地队列,存储要运行的 goroutine。

○ P 的数量由 GOMAXPROCS 环境变量设置,默认值为 CPU 核心数。

5. GMP 模型的工作原理:

○ 当一个 goroutine 被创建时,它会被放入到某个 P 的本地队列中。

○ P 从本地队列中取出 goroutine 并分配给 M 执行。

○ 如果一个 P 的本地队列为空,会从其他 P 的队列中窃取任务,保证工作负载均衡。

○ 当一个 goroutine 发生阻塞时,M 会释放 P,去寻找其他可运行的 goroutine,避免资源浪费。

6. 优势:

○ GMP 模型使得 goroutine 调度高效且灵活,最大化 CPU 使用率。

○ goroutine 的轻量级特性和 GMP 模型的结合,使 Go 语言可以高效地处理大量并发任务。

总结:

● GMP 模型是 Go 语言的核心调度机制,通过 G、M 和 P 的协同工作,实现高效的 goroutine 管理和调度,从而提高程序的并发处理能力。

为什么要有 P?

带来什么改变 加了 P 之后会带来什么改变呢?我们再更显式的讲一下。

● 每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。

● 每个 P 相对的平衡上,在 GMP 模型中也实现了 Work Stealing (工作量窃取机制)算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。

为什么要有 P 这时候就有小伙伴会疑惑了,如果是想实现本地队列、Work Stealing 算法,那为什么不直接在 M 上加呢,M 也照样可以实现类似的组件。为什么又再加多一个 P 组件? 结合 M(系统线程) 的定位来看,若这么做,有以下问题:

● 一般来讲,M 的数量都会多于 P。像在 Go 中,M 的数量默认是 10000,P 的默认数量的 CPU 核数。另外由于 M 的属性,也就是如果存在系统阻塞调用,阻塞了 M,又不够用的情况下,M 会不断增加。

● M 不断增加的话,如果本地队列挂载在 M 上,那就意味着本地队列也会随之增加。这显然是不合理的,因为本地队列的管理会变得复杂,且 Work Stealing 性能会大幅度下降。

● M 被系统调用阻塞后,我们是期望把他既有未执行的任务分配给其他继续运行的,而不是一阻塞就导致全部停止。

因此使用 M 是不合理的,那么引入新的组件 P,把本地队列关联到 P 上,就能很好的解决这个问题。

调度器的设计策略

复用线程:避免频繁的创建、销毁线程,而是对线程的复用。 1)work stealing(工作量窃取)机制 当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。 2)hand off(移交)机制 当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。 利用并行:GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。 抢占:在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。 全局G队列:在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。

抢占式调度是如何抢占的?

基于协作式抢占 基于信号量抢占 就像操作系统要负责线程的调度一样,Go的runtime要负责goroutine的调度。现代操作系统调度线程都是抢占式的,我们不能依赖用户代码主动让出CPU,或者因为IO、锁等待而让出,这样会造成调度的不公平。基于经典的时间片算法,当线程的时间片用完之后,会被时钟中断给打断,调度器会将当前线程的执行上下文进行保存,然后恢复下一个线程的上下文,分配新的时间片令其开始执行。这种抢占对于线程本身是无感知的,系统底层支持,不需要开发人员特殊处理。 基于时间片的抢占式调度有个明显的优点,能够避免CPU资源持续被少数线程占用,从而使其他线程长时间处于饥饿状态。goroutine的调度器也用到了时间片算法,但是和操作系统的线程调度还是有些区别的,因为整个Go程序都是运行在用户态的,所以不能像操作系统那样利用时钟中断来打断运行中的goroutine。也得益于完全在用户态实现,goroutine的调度切换更加轻量。 上面这两段文字只是对调度的一个概括,具体的协作式调度、信号量调度大家还需要去详细了解,这偏底层了,大厂或者中高级开发会问。(字节就问了)

调度器的生命周期

特殊的M0和G0

M0

M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。

G0

G0是每次启动一个M都会第一个创建的goroutine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。 我们来跟踪一段代码

package main

import "fmt"

func main() {

fmt.Println("Hello world")

}

接下来我们来针对上面的代码对调度器里面的结构做一个分析。 也会经历如上图所示的过程:

1. runtime创建最初的线程m0和goroutine g0,并把2者关联。

2. 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表。

3. 示例代码中的main函数是main.main,runtime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列。

4. 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine。

5. G拥有栈,M根据G中的栈信息和调度信息设置运行环境

6. M运行G

7. G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序。

调度器的生命周期几乎占满了一个Go程序的一生,runtime.main的goroutine执行之前都是为调度器做准备工作,runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束。

Go 中主协程如何等待其余协程退出?

答:Go 的 sync.WaitGroup 是等待一组协程结束,sync.WaitGroup 只有 3 个方法,Add()是添加计数,Done()减去一个计数,Wait()阻塞直到所有的任务完成。Go 里面还能通过有缓冲的 channel 实现其阻塞等待一组协程结束,这个不能保证一组 goroutine 按照顺序执行,可以并发执行协程。Go 里面能通过无缓冲的 channel 实现其阻塞等待一组协程结束,这个能保证一组 goroutine 按照顺序执行,但是不能并发执行。 **啰嗦一句:**循环智能二面,手写代码部分时,三个协程按交替顺序打印数字,最后题目做出来了,问我代码中Add()是什么意思,我回答的不是很清晰,这家公司就没有然后了。Add()表示协程计数,可以一次Add多个,如Add(3),可以多次Add(1);然后每个子协程必须调用done(),这样才能保证所有子协程结束,主协程才能结束。

三色标记算法是对标记阶段的改进,原理如下:

● 起初所有对象都是白色。

● 从根出发扫描所有可达对象,标记为灰色,放入待处理队列。

● 从队列取出灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色。

● 重复 3,直到灰色对象队列为空。此时白色对象即为垃圾,进行回收。

三色法标记垃圾回收

1. 简介

三色法标记是垃圾回收算法的一种,其主要流程包括扫描所有对象进行标记(黑色、灰色和白色),以及清扫垃圾(清理白色对象)。标记过程中,黑色代表使用中的对象,白色代表垃圾对象,灰色是白色对象过渡到黑色对象的中间状态。

2. 标记阶段

2.1 准备工作

1. STW(Stop The World):暂停所有正在执行的用户线程/协程,进行垃圾回收操作。

2. 开启写屏障(Write Barrier,WB):开启写屏障,把全局变量以及每个goroutine中的Root对象收集起来。Root对象是标记扫描的源头,可以从Root对象依次索引到使用中的对象,为垃圾对象的扫描和标记提供必要的条件。

2.2 标记过程

1. 栈扫描:

○ 每个P都有一个mcache,每个mcache都有一个Span用来存放TinyObject(不包含指针的对象),这些对象可以直接标记为黑色。

○ 关闭STW后,每个P都有一个进行扫描标记的goroutine,可以进行并发标记。它们会将前面收集的Root对象标记为黑色,然后把这些对象引用的对象标记为灰色,并加入到gcWork队列。gcWork队列保存了灰色对象,每个灰色对象都是一个Work。

2. 标记:

○ 不断从gcWork队列中取出work,将所指向的对象标记为黑色,并将该对象指向的对象标记为灰色,然后加入队列,直到队列为空。

3. 标记结束:

○ 再次开启STW,不同版本的Go处理方式有所不同:

■ Go 1.7版本:使用Dijkstra写屏障,只监控堆上指针数据的变动。由于应用goroutine和GC的标记goroutine都在运行,当栈上的指针指向的对象变更为白色对象时,这个白色对象应当标记为黑色,需要再次扫描全局变量和栈,以免释放这类不该释放的对象。

■ Go 1.8及以后版本:引入了混合写屏障,依然不监控栈上指针的变动,但策略使得无需再次扫描栈和全局变量,但依然需要STW然后进行一些检查。

3. 清扫阶段

1. 清扫goroutine唤醒:标记结束阶段的最后会关闭写屏障,然后关闭STW,唤醒负责清扫垃圾的goroutine。

2. 垃圾清理:清扫goroutine会把白色对象逐个清理掉,清扫goroutine和应用goroutine是并发进行的。清扫完成后,清扫goroutine再次进入睡眠状态,等待下次被唤醒。

4. 后续处理

1. 数据统计和状态修改:执行一些数据统计和状态修改的工作,并设置好触发下一轮GC的阈值,把GC状态设置为Off。

2. 帮助清扫:在Go 1.12版本及以后,标记结束后会设置各种状态数据,并将GC状态设置为Off。当开启新一轮GC时,如果检测到当前状态不是Off,则当前goroutine会调用清扫函数,帮助清扫goroutine一起清扫span。

5. Go的对象大小定义

● 大对象:大于32KB。

● 小对象:16KB到32KB。

● Tiny对象:大小在1Byte到16Byte之间并且不包含指针的对象。

6. 优点

三色标记法的一个明显好处是能够让用户程序和mark并发进行。

混合写屏障规则是(GoV1.8 )

在Go语言的垃圾回收(GC)机制中,对象的标记和写屏障的应用过程可以优化描述为以下步骤:

1. GC 开始与根集合标记:

○ GC启动时,会先进行一次短暂的停顿(STW),以初始化GC的内部状态。

○ 在这次停顿中,所有活跃的对象(如全局变量、活跃栈帧中的指针等)被识别为根对象,并标记为灰色,表示它们需要被进一步扫描以确定其可达性。

2. 并发标记阶段:

○ GC与用户程序并发运行,从根集合开始,递归地扫描并标记所有可达的对象。

○ 插入写屏障:当对象A新增一个指向对象B的指针时,如果对象B是白色(即未被标记),则将其标记为灰色。这确保了新增的引用不会导致对象B被错误地回收。

○ 删除写屏障(在某些Go版本或实现中可能涉及,但具体行为可能有所不同):当对象A删除一个指向对象B的指针时,如果对象B是灰色或白色,则将其重新标记为灰色(如果是白色,则直接标记为灰色;如果是灰色,则保持灰色状态)。这样做可以确保在后续扫描中,对象B仍然会被访问到,从而防止其被错误地回收。

3. 栈上对象的处理:

○ 在GC期间,栈上创建的新对象最初是未标记的(即白色的),但由于它们是活跃对象,因此它们会很快被GC识别并处理。具体来说,当GC的标记器遍历到包含这些新对象的栈帧时,它们会被标记为灰色,并在后续的扫描过程中变为黑色。

4. 标记完成与清理:

○ 当并发标记阶段完成足够的工作量或达到预定条件后,GC会再次执行STW,以完成剩余的标记工作,并准备进入清理阶段。

○ 清理阶段与用户程序并发进行,回收所有未被标记为可达(即黑色和灰色之外的对象)的内存。

5. 对象删除与可达性:

○ 需要注意的是,对象被删除(即其引用被移除)并不直接导致其被标记为灰色或任何其他特定颜色。相反,对象的可达性是通过GC的扫描过程来确定的。如果一个对象在扫描过程中没有被任何可达对象引用,则它最终会被识别为不可达,并在清理阶段被回收。

综上所述,Go语言的GC机制通过并发标记、写屏障和清理阶段来优化内存管理,减少STW时间,并提高程序的性能和响应速度。关于对象的标记和写屏障的具体行为,需要根据Go的当前版本和具体实现来准确理解。

Go V1.8 引入的混合写屏障(Hybrid Write Barrier)是一种优化垃圾回收(GC)性能的机制,它结合了插入写屏障(Insert Write Barrier)和删除写屏障(Delete Write Barrier)的优点,以减少垃圾回收过程中的停顿时间(STW,Stop The World)。混合写屏障规则可以概括如下:

插入写屏障规则

插入写屏障在对象A新增一个指向对象B的指针时触发。具体规则如下:

● 标记阶段:当对象A新增一个指向对象B的指针时,如果对象B是白色(未被标记),则将其标记为灰色(表示其需要被进一步扫描)。这样做可以确保在标记过程中不会遗漏任何可达对象。

● 目的:防止在并发标记过程中,由于新增的指针导致原本应该被回收的对象(白色对象)被错误地保留下来。

删除写屏障规则

删除写屏障在对象A删除一个指向对象B的指针时触发。具体规则如下:

● 标记阶段:当对象A删除一个指向对象B的指针时,如果对象B是灰色或白色,则将其重新标记为灰色(如果是白色,则直接标记为灰色;如果是灰色,则保持灰色状态)。这样做可以确保在后续扫描中,对象B仍然会被访问到,从而防止其被错误地回收。

● 清除阶段:在清除阶段开始时,所有在堆上的灰色对象都会被视为可达对象,因此不会被回收。删除写屏障确保了在并发修改指针的情况下,对象的可达性状态能够正确地被维护。

混合写屏障的优势

● 减少STW时间:通过并发标记和写屏障机制,Go V1.8 能够显著减少垃圾回收过程中的STW时间,从而提高程序的并发性能和响应速度。

● 提高内存使用效率:写屏障机制有助于更准确地识别垃圾对象,减少内存碎片的产生,提高内存的使用效率。

● 增强并发安全性:在并发环境下,写屏障机制能够确保垃圾回收过程的安全性和正确性,防止由于并发修改导致的内存错误。

总之,Go V1.8 的混合写屏障规则通过结合插入写屏障和删除写屏障的优点,有效地优化了垃圾回收的性能和安全性,为Go语言的高并发特性提供了坚实的支撑。

GC 的触发时机?

初级必问,分为系统触发和主动触发。 1)gcTriggerHeap:当所分配的堆大小达到阈值(由控制器计算的触发堆的大小)时,将会触发。 2)gcTriggerTime:当距离上一个 GC 周期的时间超过一定时间时,将会触发。时间周期以runtime.forcegcperiod 变量为准,默认 2 分钟。 3)gcTriggerCycle:如果没有开启 GC,则启动 GC。 4)手动触发的 runtime.GC 方法。

golang 的内存逃逸吗?什么情况下会发生内存逃逸?(必问)

答:1)本该分配到栈上的变量,跑到了堆上,这就导致了内存逃逸。2)栈是高地址到低地址,栈上的变量,函数结束后变量会跟着回收掉,不会有额外性能的开销。3)变量从栈逃逸到堆上,如果要回收掉,需要进行 gc,那么 gc 一定会带来额外的性能开销。编程语言不断优化 gc 算法,主要目的都是为了减少 gc 带来的额外性能开销,变量一旦逃逸会导致性能开销变大。

内存逃逸的情况如下: 1)方法内返回局部变量指针。 2)向 channel 发送指针数据。 3)在闭包中引用包外的值。 4)在 slice 或 map 中存储指针。 5)切片(扩容后)长度太大。 6)在 interface 类型上调用方法。

#我的求职思考#
后端笔记 java/go全包含 文章被收录于专栏

及时更新的 个人笔记 包含系统设计和场景题 有需要也可以向我私信

全部评论
感谢佬
1 回复 分享
发布于 10-26 15:16 辽宁
new和make分配在堆上还是栈上应该要看逃逸分析吧,make分配的内建类型可能会在函数外部使用,所以会分配到堆上,new创建的类型分配在堆上,如果函数返回后变量任然被外部使用,就会逃逸到堆上。
1 回复 分享
发布于 10-26 15:25 辽宁
若有遗漏的 可以私信我补充 是自己学习过程中的笔记直接搬上来了
点赞 回复 分享
发布于 10-16 00:09 天津
收藏了,感谢佬
点赞 回复 分享
发布于 10-18 21:43 湖南
谢谢
点赞 回复 分享
发布于 10-28 16:36 安徽

相关推荐

点赞 评论 收藏
分享
6 61 评论
分享
牛客网
牛客企业服务