Golang 基础与进阶知识点
一、基础部分
1、值类型 和 引用类型
值类型:
- 包括基本数据类型,如
int
、float
、bool
、string
。 - 也包括复合数据类型,如数组和结构体(struct)。
- 变量直接存储值。
- 内存通常在栈上分配,栈在函数调用完毕后会被释放。
引用类型:
- 包括 切片(Slice)、映射(Map)、通道(Channel)和接口(Interface)。
- 变量存储的是指向实际数据的引用。
- 内存分配在堆上,生命周期由垃圾收集器管理。
这里提到了堆和栈,简单介绍下内存分配中的堆和栈:
栈
(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。堆
(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。
1.1 值传递 和 引用传递
Go的函数参数传递
没有传统的引用传递
概念,只有值传递
- 值传递:指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。
- 引用传递:指在调用函数时将实际参数的地址直接传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。
在 Go 语言中,函数的参数传递只有值传递,而且传递的实参都是原始数据的一份拷贝。如果拷贝的内容是值类型的,那么在函数中就无法修改原始数据;如果拷贝的内容是指针(或者可以理解为引用类型 map
、chan
等),那么就可以在函数中修改原始数据,这里是拷贝了指针,传递的是指针副本,也就是值传递,只是看起来像引用传递。
2、golang 中 make 和 new 的区别?
在Go语言中,make
和new
都是用于内存分配的内建函数,但它们在分配内存和初始化内存方面有所不同:
- 分配内存的区别:
new
可以分配任意类型的内存,并返回一个指向该类型的指针。make
专门用于分配slice
、map
和channel
这三种内建类型,并返回一个引用类型本身。
- 初始化的区别:
new
分配内存后,对于值类型,分配的是零值填充的内存空间,可直接使用。而对于引用类型,虽然也会分配内存并返回一个指向该内存的指针,但这块内存代表的是定义的那个引用类型,它的零值是nil
,需要进一步初始化。例如new(int)之后,对应内存空间会有一个0;而new(map[string]int)
,对应内存空间是nil,而不是{"":0}
make
在堆上分配内存后,内存会被初始化,这意味着切片的长度和容量会被设置(默认情况下长度为0),映射会初始化为空(没有任何键值对),通道会处于准备就绪状态,可以立即用于发送和接收数据。
- 返回类型的区别:
new
返回的是指针类型。make
返回的是与参数相同类型的值,而不是指针。
- 语法上的区别:
new
的语法是func new(Type) *Type
。make
的语法是func make(t Type, size ...IntegerType) Type
。
3、数组和切片的区别
- 类型:
- 数组是值类型,这意味着当你将一个数组赋值给另一个数组时,实际上是创建了数组的一个副本。因此,数组在函数参数传递时可能会导致性能问题,因为需要复制整个数组。
- 切片是引用类型,当你将一个切片赋值给另一个切片时,两个切片会引用同一个底层数组。这意味着对其中一个切片的修改也会影响到另一个切片。同理当切片作为函数参数传递修改时,会影响原数据。
- 长度和容量:
- 数组的长度是固定的,它在声明时就被确定,并且数组的每个元素类型必须相同。数组的长度是其类型的一部分,因此
[3]int
和[4]int
是不同的类型。 - 切片的长度是动态的,可以在运行时改变。切片还有一个容量属性,表示底层数组的大小,也就是Slice可以扩展的最大长度。
- 数组的长度是固定的,它在声明时就被确定,并且数组的每个元素类型必须相同。数组的长度是其类型的一部分,因此
- 底层数据结构:
- 数组是一组固定长度的元素序列,它的底层就是数组本身。
- 切片的底层是一个数组,切片是对数组的抽象和封装,提供了更加灵活的操作方式。切片包含了指向底层数组的指针、长度和容量等属性。
4、for range ,元素地址会发生变化吗
在 for a,b := range c 遍历中, a 和 b 在内存中只会存在一份,即之后每次循环时遍历到的数据都是以值覆盖的方式赋给 a 和 b,并且a,b 的内存地址始终不变。由于有这个特性,for 循环里面如果开协程做并发,不要直接把 a 或者 b 的地址传给协程。
- 解决办法:在每次循环时,创建一个临时变量传给协程。
注意:从go1.22版本开始,for循环的变量每次都是独立地址了。
5、go defer,多个 defer 的顺序,defer 在什么时机会修改返回值?
作用:defer延迟函数,释放资源,收尾工作;如释放锁,关闭文件,关闭链接;捕获panic;
避坑指南:defer函数紧跟在资源打开后面,否则defer可能得不到执行,导致内存泄露。
多个 defer 调用顺序是 LIFO(后入先出),defer后的操作可以理解为压入栈中
顺序:先为返回值赋值,即将返回值放到一个临时变量中,然后执行defer,然后return到函数被调用处。
如果所在函数为有名返回值函数,return第一步先把返回值放到有名返回值变量中,如果恰好defer函数中修改了该返回值,那么最终返回值是更新后的。但是如果所在函数为无名返回值函数,那么return第一步先把返回值放到一个临时变量中,defer函数无法获取到这个临时变量地址,所以无论defer函数做任何操作,都不会对最终返回值造成任何变动。
修改时机:有名返回值或者函数返回指针
6、uint
类型溢出问题
超过最大存储值,例如uint8
最大是255
var a uint8 = 255
var b uint8 = 1
a + b
发生溢出, 结果为0
7、 rune 类型
rune
类型是Go语言中用于表示Unicode
字符的整数类型,它是int32
的别名。
用于表示一个 Unicode 码点(Unicode Code Point)。Unicode 码点是Unicode标准中为每个字符分配的唯一整数,它可以涵盖世界上几乎所有的字符和符号。
Go 语言中的 rune
主要是为了方便处理UTF-8编码的文本,特别是多字节字符,比如中文、日文、韩文等非ASCII字符。在UTF-8编码下,单个字符可能由1到4个字节组成,而一个 rune
能够容纳任何有效的Unicode码点,确保能够完整地表示所有这些字符。
举例:
s := "你好,世界!"
runes := []rune(s)
// 对 runes 进行遍历或操作
for i, r := range runes {
fmt.Printf("索引: %d, 字符: %c, Unicode 编码: %U\n", i, r, r)
}
输出:
索引: 0, 字符: 你, Unicode 编码: U+4F60
索引: 1, 字符: 好, Unicode 编码: U+597D
索引: 2, 字符: ,, Unicode 编码: U+FF0C
索引: 3, 字符: 世, Unicode 编码: U+4E16
索引: 4, 字符: 界, Unicode 编码: U+754C
索引: 5, 字符: !, Unicode 编码: U+FF01
7、Go语言中的int
类型
go语言中的int的大小是和操作系统位数相关的,如果是32位操作系统,int类型的大小就是4字节。如果是64位操作系统,int类型的大小就是8个字节。
8、golang 中解析 tag 是怎么实现的?反射原理是什么?(中高级题目)
Tag 实现流程概要:
1. 获取结构体类型的反射对象。
2. 遍历结构体的所有字段。
3. 对于每个字段,调用 StructField.Tag.Get()
方法并传入想要获取的 tag 键名,比如 "json"
或 "xml"
。
4. 返回并处理 tag 中的值。
反射的原理,基于接口来实现:
在Go语言中,所有的类型都实现了空接口interface{}
,这使得它们都可以被转换为反射类型Type
和反射值Value
。反射过程涉及到几个步骤:
- 类型转换:将普通类型隐式转换为接口类型。这个转换过程是自动进行的,当一个变量被赋值给接口类型的值时,就会进行这个转换。
- 获取反射对象:通过转换得到的接口值,可以调用标准库
reflect
包中的函数来获取对应的反射类型Type
和反射值Value
。 - 操作反射对象:通过反射类型和反射值,可以获取到原始类型的各种信息,包括字段、方法、标签等,并且可以对这些信息进行读取和修改操作。
9、Golang 调用函数传入结构体时,应该传值还是指针?
分情况:
- 结构体的大小:如果结构体非常大,使用指针传递会更有效率,因为这样只会复制指针值(一般是8字节),而不是复制整个结构体。如果结构体小,值传递和指针传递的性能差异可能可以忽略不计。
- 是否需要修改原始结构体:如果你需要在函数中修改原始结构体,你应该使用指针传递。如果你使用值传递,函数会接收结构体的一个副本,你在函数中对结构体的修改不会影响到原始的结构体。
11、讲讲 Go 的 select 底层数据结构和一些特性
Go语言中的select
语句是一种多路复用的控制结构,它用于同时处理多个通道(channel)上的事件。其底层数据结构和特性如下:
- 数据结构:
select
的底层实现与操作系统中的I/O多路复用机制类似,如poll
和epoll
。在Go语言中,select
用于监听通道的发送和接收操作,当通道准备好进行相应的操作时,会触发select
中对应的case
分支执行。 - 特性:
- 通道操作:
select
语句只能用于通道操作,而且是单协程操作,每个case
必须是单个通道发送或接收的操作。 - 非抢占式:
select
语句会监听所有指定的通道上的操作,一旦其中一个通道准备好,就会执行相应的代码块。这与switch
语句的顺序执行不同,select
的case
执行顺序是随机的。 - 避免死锁:在使用
select
时需要注意避免死锁的情况,例如至少要有一个通道准备好,否则select
会阻塞,直至有一个通道已准备好为止。如果存在default
分支,那么select
就不会阻塞,而是执行default
分支,但default
会略影响性能。 - 超时控制:
select
可以配合default
分支实现超时控制,如果在指定的时间内没有任何通道准备好,default
分支会被执行。 - 无穿透执行:
select
语句中没有类似switch
中的fallthrough
用法,即执行完一个case
后不会继续执行下一个case
。
- 通道操作:
12、讲讲 Go 的 defer 底层数据结构和一些特性
每个 defer 语句都对应一个_defer
实例,多个实例使用指针连接起来形成一个单连表,保存在 goroutine
数据结构中,每次插入_defer
实例,均插入到链表的头部,函数结束再一次从头部取出,从而形成后进先出的效果。
defer 的规则总结:
- 延迟函数的参数是 defer 语句出现的时候就已经确定了的。
- 延迟函数执行按照后进先出的顺序执行,即先出现的 defer 最后执行。
- 延迟函数可能操作主函数的返回值。
- 建议申请资源后立即使用 defer 关闭资源。
13、单引号,双引号,反引号的区别
单引号,表示byte
类型或rune
类型,对应 uint8
和int32
类型,默认是 rune
类型。byte
用来强调数据是raw data
,而不是数字;而rune
用来表示Unicode
的code point
。
func main() {
var v rune = '你'
var v1 rune = 'k'
var v2 byte = 'k'
fmt.Println(v, v1, v2)
}
20320 107 107
双引号,里面可以是单个字符也可以是字符串,对应string
类型,实际上是字符数组。可以用索引号访问某字节,也可以用len()
函数来获取字符串所占的字节长度。双引号里的字符串可以转义,但是不能换行,可以利用\n
来实现换行。
反引号中的字符串表示其原生的意思,里面的内容不会被转义,可以换行。
14、 Go 支持默认参数或可选参数吗?
不支持。但是可以利用结构体参数,或者...
传入参数切片数组。
// 这个函数可以传入任意数量的整型参数
func sum(nums ...int) {}
15、结构体打印时,%v
和 %+v
的区别
%v
输出结构体各成员的值;
%+v
输出结构体各成员的名称和值;
%#v
输出结构体名称和结构体各成员的名称和值
16、Go 语言中如何表示枚举值
-
使用常量定义枚举值
-
使用自定义类型定义枚举
-
使用
iota
自增枚举值
17、空 struct{} 的用途
在Go语言中,空的 struct {}
类型通常被用作占位符或者信号。它不占用任何内存空间,也不包含任何字段,可以避免任何多余的内存分配。
- 实现集合类型:Go语言本身没有直接的集合类型(类似于Set),但我们可以使用map来替代,例如
type Set map[int]struct{}
。- Map的key是不允许重复的,这和Set集合性质相符,再将struct{}作为Value,即可用Map实现Set。
- 实现空通道:在Go的并发编程中,我们经常会遇到通知型channel,它们不需要传递任何数据,只是用于协调Goroutine的运行。这种情况下,使用空结构体作为通道元素类型非常合适,因为它不会增加额外的内存开销。
- 实现方法接收者:有时我们需要使用结构体类型的变量作为方法接收者,但结构体本身不包含任何字段属性。这种情况下,使用空结构体作为接收者是比较合适的,因为它不会占用额外的内存空间。
18、闭包
在Go语言中,闭包(Closure)是一个可以访问其自身范围以外的变量的函数。更具体地说,闭包是由一个函数和该函数在其定义环境中引用的所有变量组成的实体。这意味着即便定义这些变量的外部函数已经执行完毕,闭包内的函数仍然能够访问和修改这些外部变量的值。
闭包的核心特性在于它“记住”了其外部作用域中变量的环境,即便这个环境已经不再直接可用。这种能力使得闭包成为处理状态、延迟执行、函数式编程和回调函数等场景的强大工具。
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
counter := makeCounter()
fmt.Println(counter()) // 输出 1
fmt.Println(counter()) // 输出 2
}
分析以下两个函数。
在sliceCounter1
函数中,i
是在 for
循环中定义的局部变量,从go1.22
开始,for i := 0; i < 5; i++
这里的i
每次都会创建新的变量,即每次循环得到的i
的地址是不一样的,所以这里闭包捕获的i
的地址是不一样的,调用时输出各自i
地址对应的值;
在sliceCounter2
函数中,i
是在函数外部定义的变量。在循环中,匿名函数引用了相同的i
的地址,所以当调用这些匿名函数时,访问的是同一个地址的值 ,而i
的值在循环时最终已经被修改成5
了,所以调用时全输出5
;
type Hello func() int
func sliceCounter1() []Hello {
hello := make([]Hello, 0, 8)
for i := 0; i < 5; i++ {
hello = append(hello, func() int {
fmt.Println(i)
return i
})
}
return hello
}
func sliceCounter2() []Hello {
hello := make([]Hello, 0, 8)
i := 0
for i = 0; i < 5; i++ {
hello = append(hello, func() int {
fmt.Println(i)
return i
})
}
return hello
}
func main() {
sc1 := sliceCounter1()
sc2 := sliceCounter2()
for _, f1 := range sc1 {
f1()
}
fmt.Println("===========")
for _, f2 := range sc2 {
f2()
}
}
输出
0
1
2
3
4
===========
5
5
5
5
5
19、结构体方法调用
type User struct{}
// 有两种方法接受器
// 值接收器
func (u User)Create(){}
// 指针接收器
func (u *User)Update(){}
分情况
// 如果定义的变量是指针类型,则两个方法都可调用
var user *User
// 如果定义的变量是值类型,则只能调用值接收器方法
var user User
20、runtime包的使用
runtime包是Go语言的运行时系统,提供了与Go程序运行环境交互的底层操作。它包含了很多用于控制和查询Go程序运行时行为的函数。以下是runtime包的主要功能和作用:
- Goroutine管理
runtime包提供了创建、操作和查询goroutine状态的函数:
runtime.GOMAXPROCS(n) // 设置可同时执行的最大CPU数
runtime.Gosched() // 让出当前goroutine的执行权
runtime.NumGoroutine() // 返回当前存在的goroutine数量
runtime.Goexit() // 终止调用它的goroutine
- 内存管理
runtime包包含了与内存分配、垃圾回收相关的函数:
runtime.GC() // 强制进行垃圾回收
runtime.ReadMemStats(&m) // 读取内存使用统计信息
runtime.SetFinalizer(obj, finalizer) // 为对象设置终结器
- 调度器控制
提供了一些控制和查询Go调度器的函数:
runtime.LockOSThread() // 将当前goroutine锁定到当前操作系统线程
runtime.UnlockOSThread() // 解除当前goroutine与操作系统线程的锁定
- 栈管理
包含了一些与goroutine栈相关的函数:
runtime.Stack(buf []byte, all bool) // 获取goroutine的栈踪迹
runtime.Caller(skip int) (pc uintptr, file string, line int, ok bool) // 获取调用者信息
- 性能分析和调试
提供了一些用于性能分析和调试的工具:
runtime.StartTrace() // 开始执行跟踪
runtime.StopTrace() // 停止执行跟踪
runtime.SetCPUProfileRate(hz int) // 设置CPU分析的采样频率
- 系统和环境信息
提供了获取系统和运行环境信息的函数:
runtime.GOROOT() // 返回Go的安装路径
runtime.Version() // 返回Go的版本号
runtime.GOOS // 目标操作系统
runtime.GOARCH // 目标架构
runtime.NumCPU() // 返回当前系统的CPU核心数
- 垃圾回收控制
提供了一些控制垃圾回收行为的函数:
runtime.SetGCPercent(percent int) int // 设置垃圾回收的目标百分比
runtime.GC() // 强制执行一次垃圾回收
- 并发安全的Map
runtime包中还包含了一个并发安全的map实现:
var m sync.Map
m.Store(key, value)
m.Load(key)
m.Delete(key)
m.Range(func(key, value interface{}) bool {
// ...
return true
})
- 错误和异常处理
提供了一些与错误和异常处理相关的函数:
runtime.Error // runtime错误接口
runtime.Caller() // 获取调用栈信息
runtime.Callers() // 获取调用栈的程序计数器
runtime.FuncForPC(pc) // 根据程序计数器获取函数信息
- race检测
当启用race检测时,runtime包提供了一些用于数据竞争检测的函数:
runtime.RaceEnable() // 启用竞争检测
runtime.RaceDisable() // 禁用竞争检测
runtime.RaceAcquire(addr unsafe.Pointer) // 报告对addr的读操作
runtime.RaceRelease(addr unsafe.Pointer) // 报告对addr的写操作
- 内存屏障
提供了底层的内存屏障操作,用于确保内存操作的顺序:
runtime.MemoryBarrier() // 插入内存屏障,确保内存操作的顺序
- 系统监控
runtime包内部实现了一个后台监控goroutine,用于检测死锁、长时间运行的goroutine等异常情况。
- 钩子函数
提供了一些钩子函数,允许在特定事件发生时执行自定义代码:
runtime.SetFinalizer(obj, func(obj *T) {
// 对象被回收时执行的代码
})
使用runtime包需要注意:
-
大多数runtime包的函数都是底层操作,使用时需要格外小心,不当使用可能导致程序不稳定或崩溃。
-
一些函数(如SetFinalizer)可能会影响垃圾回收的行为,使用时需要充分了解其影响。
-
runtime包的一些函数可能在未来的Go版本中发生变化,使用时需要注意版本兼容性。
-
过度依赖runtime包的函数可能会使程序变得难以理解和维护,应该谨慎使用。
-
一些runtime包的函数(如LockOSThread)可能会影响程序的可移植性,使用时需要考虑跨平台兼容性。
总的来说,runtime包提供了强大的底层控制能力,使得开发者可以更深入地了解和控制Go程序的运行时行为。但是,大多数情况下,我们应该优先使用Go的高级抽象和标准库,只有在确实需要时才考虑使用runtime包的底层函数。
21、Go 多返回值怎么实现的?
- 函数签名定义: 在函数定义时,可以指定多个返回值类型
- 栈上的存储: 在函数调用时,Go 会为所有的返回值分配足够的空间,就像为参数分配空间一样。这些返回值空间会被压入调用者的栈帧上。
- 返回值传递: 当函数执行完毕并通过
return
语句返回时,会将返回值写入到之前预留的栈空间中。即便函数返回,这部分栈空间也不会立即被回收,因为它们现在包含了返回值。
22、讲讲 Go 中主协程如何等待其余协程退出?
- 使用
sync.WaitGroup
:这是最常见且推荐的方式。sync.WaitGroup
有三个方法:Add
、Done
和Wait
。你可以在启动一个协程时调用Add
来设置要等待的协程数量,每个协程完成时调用Done
,最后在主协程中调用Wait
来阻塞等待所有协程完成。 - 使用通道(Channel):你可以创建一个通道,并在每个协程中发送一个信号或特定的值。主协程可以通过
range
遍历通道或者用len
函数检查通道的长度,以等待所有协程完成。 - 使用
sync.Once
:虽然sync.Once
通常用于确保某个操作只执行一次,但你也可以将其用作同步机制。你可以在每个协程中调用Do
方法,而在主协程中调用Wait
方法来等待所有协程完成。 - 使用
context
包:如果你的程序使用了Go的context
包来管理多个协程,你可以使用context.Done()
通道来等待所有协程完成。当上下文被取消时,所有使用该上下文的协程都会收到信号并退出。 - 使用
time.Sleep
或time.After
:这种方法不是最佳实践,因为它会导致不必要的延迟。你可以通过设置一个足够长的睡眠时间来等待所有协程完成,但这是不可靠的,尤其是在不确定协程何时完成的情况下。 - 使用第三方库:有些第三方库提供了更高级的同步原语,如
golang.org/x/sync/syncutil
包中的WaitGroup
等。这些库可以提供更灵活的同步选项,但通常sync.WaitGroup
已经足够使用。
总的来说,sync.WaitGroup
是最简单且常用的方法,它能够有效地满足主协程等待其他协程退出的需求。
23、Go 语言中各种类型是如何比较是否相等?
在 Go 语言中,不同类型的数据比较是否相等有不同的规则:
-
基础类型:
- 布尔型 (
bool
):可以直接使用==
进行比较。 - 整型 (
int
,uint
,byte
,rune
等)、浮点型 (float32
,float64
) 和复数 (complex64
,complex128
):可以用==
进行比较。 - 字符串 (
string
):同样使用==
进行比较。
- 布尔型 (
-
指针类型:
- 指针类型可以直接用
==
比较,比较的是指针本身的地址是否相同,而不是它们指向的数据是否相等。
- 指针类型可以直接用
-
数组和切片:
- 数组 (
[N]T
) 可以用==
比较,前提是它们的类型相同且长度相等,且每个元素都可以比较。 - 切片 (
[]T
) 也可以用==
比较,但同样要求长度和元素都相等。如果需要比较切片内容的深拷贝相等性,可以使用reflect.DeepEqual
函数。
- 数组 (
-
结构体(struct):
- 结构体可以使用
==
进行比较,如果结构体中的所有字段都是可比较的(也就是说,它们要么是基本类型,要么也是可比较的结构体或数组等),那么整个结构体就可以通过==
来比较是否相等。
- 结构体可以使用
-
接口类型:
-
Go 语言中的接口值可以比较,但比较的前提是它们内部存储的具体类型必须是可比较的,即满足 Go 语言的比较规则(例如,基本类型、结构体类型等)。如果内部存储的是不可比较类型的值,则接口值之间也就无法进行比较。
-
两个接口值(
interface{}
)如果它们的动态类型相同,并且动态值相等,那么它们才被认为是相等的。但是,由于接口可以包含任何类型的值,因此直接使用==
通常只能比较它们是否为同一个值,不能比较它们所封装的具体值是否相等。
-
-
Map、Channel 和 Function 类型:
- Map、Channel 和 Function 类型不可以直接用
==
比较,因为它们是引用类型,比较的是它们的地址,而不是它们的内容。
- Map、Channel 和 Function 类型不可以直接用
-
自定义类型:
- 对于自定义类型,你需要提供自定义的相等性判断方法,如
Equal
函数,或者实现Equal
方法以满足某个接口要求。
- 对于自定义类型,你需要提供自定义的相等性判断方法,如
-
深层次比较:
- 若要进行复杂的深度比较,例如包含嵌套结构、切片、Map 等混合类型的结构体,可以使用
reflect.DeepEqual
函数,它会对两个任意类型的数据进行深度比较,查看它们的值是否相等。但是要注意,reflect.DeepEqual
不适合用于比较包含不可比较类型(如函数或包含 channel 的结构体)的数据。
- 若要进行复杂的深度比较,例如包含嵌套结构、切片、Map 等混合类型的结构体,可以使用
24、Go 中 init 函数的特征?
- 自动调用:
init
函数是不需要显式调用,当包被加载(imported)时,该包中的所有init
函数会自动执行。在程序启动之初,先执行所有的init
函数,然后再执行main
函数。不管包被导入多少次,包内的 init 函数只会执行一次。 - 无参数和返回值:
init
函数没有参数,也不返回任何值,其声明格式为: - 并发安全: 虽然多个包的
init
函数执行顺序不确定,但同一包内的多个init
函数是顺序执行的,并且 Go 语言保证了并发安全,即不同init
函数间的执行互不影响。 - 跨包依赖: 不同包之间的
init
函数执行顺序遵循依赖关系,如果 A 包导入了 B 包,那么 B 包的init
函数会在 A 包的init
函数执行之前被执行。这允许开发者在包间构建一种依赖链式的初始化逻辑。
25、Go 中 uintptr 和 unsafe.Pointer 的区别?
在 Go 语言中,uintptr
和 unsafe.Pointer
都与内存地址相关,但它们的用途和限制有所不同:
- uintptr:
uintptr
是 Go 语言的内建类型,代表一个无符号整数类型,足够存储指针的值。uintptr
通常用于进行指针算术(如偏移量计算),因为 Go 语言的标准指针类型不支持直接的数学运算。uintptr
类型并不能直接解引用为原来的指针类型,也不能阻止垃圾回收器对指向的对象进行回收。换句话说,uintptr
仅仅是数字,不代表它指向的内存区域是有效的或可访问的。
- unsafe.Pointer:
unsafe.Pointer
是来自unsafe
包的一个类型,它是 Go 语言中唯一的通用指针类型。unsafe.Pointer
用于在不同类型的指针之间进行转换,这在高级内存操作、低级别编程或者与 C 语言接口交互时非常有用。- 虽然可以将任何类型的指针转换为
unsafe.Pointer
,然后再转换回另一种类型的指针,但这违反了 Go 语言的安全抽象原则,因此只有在必要且知道风险的情况下才能使用。 - 转换回具体类型指针后,
unsafe.Pointer
指向的内存区域是可以被垃圾回收器追踪的,因此不会因为转换为unsafe.Pointer
而丢失对对象的引用。
26、golang共享内存(互斥锁)方法实现发送多个get请求
可以使用 sync.Mutex
或 sync.RWMutex
(读写互斥锁)来进行同步控制。
27、从数组中取一个相同大小的slice有成本吗?
在 Go 语言中,从数组中取一个相同大小的切片(slice)操作本身几乎是没有成本的。slice并不存储任何数据,它仅仅是对底层数组的一个引用,并提供对该连续片段的访问。当创建一个切片时,它实际上是创建了一个轻量级的结构体,这个结构体包含三个字段:指向底层数组的指针、长度(length)和容量(capacity)。创建切片时,并不会复制数组的元素,只是创建了一个视图(view)而已。
28、copy函数
Golang中的copy()
函数是一个内置函数,用于在切片之间或者从字符串复制数据到字节切片中。
功能说明:
copy()
函数将数据从源切片复制到目标切片中。- 如果源是字符串,那么它会被视为一个字节切片(
[]byte
),可以复制到字节切片中。 - 支持重叠的切片操作,即源切片和目标切片可以是同一个底层数组的不同视图,函数会正确处理这种重叠情况。
29、深拷贝和浅拷贝
1、对于值类型,拷贝即是深拷贝,会开辟一个新的内存空间,和原数据无关
2、对于引用类型,普通的赋值操作是浅拷贝,只是让两个不同的指针指向同一个底层数据,修改会对原数据造成影响;利用copy()
函数可进行深拷贝操作,会开辟新的内存空间,和原数据独立。
二、Map映射表相关
1、map 使用需要注意什么问题
- 初始化:在使用map之前,需要先进行初始化,否则会导致编译错误。初始化一个map可以使用字面量语法,如
m := make(map[string]int)
或m := map[string]int{}
。 - 并发安全:在高并发场景下,应当考虑使用
sync.Map
或者其他并发安全措施来保护map,因为原生的map类型不是并发安全的。 - 删除和添加操作:频繁的删除和添加元素会导致哈希表的频繁重建,这可能会影响程序的性能。因此,在设计数据结构时,应尽量避免在map中频繁地进行这些操作。
- 迭代效率:遍历大的map可能很慢,尤其是在map结构发生变化时。如果需要遍历map,应尽量在map结构稳定时进行,以提高迭代效率。
- 作为集合使用:由于Go语言中没有内置的集合类型,map经常被用作集合。当使用map作为集合时,通常不需要关心值,只需要键即可。
2、map并发安全
在Go语言中,内置的map类型并非并发安全。
- 读写冲突:当有多个
goroutine
同时对同一个map进行读写操作时,可能会发生冲突,导致程序崩溃或者数据不一致。 - 写操作的广义定义:在map的并发操作中,“写”不仅仅是指插入新的键值对,还包括更新或删除已有的键值对。
- 并发读安全:虽然map支持多个
goroutine
同时进行读取操作,但是在涉及到写操作时,就需要特别小心。 - 性能优化场景:官方文档提到,在某些特定的场景下,如键值对只被写入一次但多次读取,或者多个
goroutine
读写不同的键集合时,原生map的性能可能优于使用Mutex
或RWMutex
的情况。 - 解决方案:
- 如果需要在并发环境中使用map,可以考虑使用sync包中的
sync.Map
,它是一个并发安全的map实现。 - 可以使用读写锁(如
sync.RWMutex
)来保护对map的访问。
- 如果需要在并发环境中使用map,可以考虑使用sync包中的
3、map 循环是有序的还是无序的
无序的
- 哈希函数:map使用哈希函数来计算键的存储位置,这个过程是无序的。
- 内部结构:map的内部结构是一系列桶(bucket),每个桶是一个链表,链表中的元素是无序的。
- 随机化:Go语言的map在迭代时会生成一个随机数作为遍历的起始位置,这是为了防止哈希碰撞攻击,并且确保每次迭代的顺序都是不同的。
如何有序遍历一个Map
创建一个切片来保存 map
的键,并使用这个切片来控制遍历的顺序。
4、 map 中删除一个 key,它的内存会释放么?
不会立即释放。
在Go语言中,当你从map中删除一个键值对时,该操作并不会立即释放掉这个键值对所占用内存。这是因为map的底层实现是由若干个bmap
(桶)构成的,桶只会扩容,不会缩容 ,map内存空间本身并不会立即归还给操作系统。
如果删除的元素是值类型,如int、float、bool、string以及数组和struct,这些类型的内存通常不会自动释放。如果删除的元素是引用类型,如切片、映射和通道等,虽然它们所指向的实际数据结构可能会被释放,但map中的键值对所占用的内存同样不会立即释放。
总的来说,Go语言的垃圾收集器会在合适的时机回收未使用的内存。这意味着,即使从map中删除了元素,内存也可能不会立即得到释放,而是等待下一次垃圾收集周期进行处理。
5、nil map 和空 map 有何不同
nil map是指未初始化的map,而空map是指已初始化但不含任何键值对的map。具体区别表现在以下几个方面:
-
初始化状态:
- nil map:是指map变量被声明但未被初始化,此时它的值是nil。
- 空map:是指map已被初始化,但没有包含任何键值对,即它的长度为0。
-
内存分配:
- nil map:由于未初始化,所以不会为map分配实际的内存空间。
- 空map:虽然不包含任何元素,但是已经分配了哈希表所需的内存空间。
-
操作限制:
- nil map:
- 从
nil
的map
中读取键值不会引发运行时错误,会返回元素类型的零值,并且exists
为false
。 - 向
nil
的map
中写入键值对会引发运行时错误
- 从
- 空map:可以进行正常的map操作,包括添加、删除和读取键值对
- nil map:
6、map 的数据结构是什么?是怎么实现扩容
Go语言中的map使用哈希表(hmap
)作为其数据结构,并且采用渐进式的方式进行扩容。
Go语言的map是一种高效的键值对集合类型,它的底层实现是哈希表(hmap
)。哈希表由多个桶(buckets
)组成,每个桶用来存储具有相同哈希值的键值对。当map需要存储更多的元素时,就会触发扩容操作。这个过程涉及到以下关键步骤:
- 重新分配内存:在扩容时,Go会为哈希表分配一个新的、更大的内存区域。
- 渐进式搬迁:由于一次性搬迁大量的键值对会严重影响性能,Go map采用了渐进式搬迁的策略。这意味着在每次扩容时,只有部分数据会被迁移到新的内存地址。
- 控制搬迁数量:为了减少扩容对性能的影响,每次搬迁的键值对数量是有限的,通常最多只会搬迁2个桶。
- 保持数据访问:在扩容过程中,旧的桶(oldbuckets)仍然保持可用状态,以便在搬迁过程中可以继续访问和修改数据。
- 哈希冲突处理:为了解决哈希冲突的问题,Go map使用了拉链法,即在同一个桶内通过链表来存储具有相同哈希值的键值对。
7、golang 哪些类型不可以作为map key
任何可比较(comparable)和相等(equal)的类型都可以作为map的键。注意,空接口(interface{})可以作为map的键,但需要谨慎使用,因为它可以包含任何类型的值。
不能作为map key 的类型包括:
- slices
- maps
- functions
8、Map赋值和复制
当你把一个map赋值给另一个map时,实际上是复制了原map的引用,而不是复制了整个map的内容。这意味着新旧两个map共享相同的底层数据结构,修改其中一个map会影响到另一个。
ma := make(map[string]any)
ma["one"] = "hello"
mb := ma
mb["two"] = "world"
fmt.Println(ma)
// 输出:map[one:hello two:world]
// 表示修改mb会影响ma
如果要复制一个完全独立的副本,需要遍历目标Map来创建。
9、Map底层原理
额外提示:
*overflow
指向的bmap
并不存储在主[]bmap
数组中,而是单独分配的内存块,通过指针与主buckets
或其他overflow buckets
链接在一起。(buckets
就是bmap
)- 触发扩容条件:
- 装载因子超过阈值。装载因子 = 元素数量 / 桶数量。源码里定义的阈值是 6.5
- overflow 的 bucket 数量过多:
- 当 B < 15,也就是 bucket 总数小于 2^15 时,溢出桶数量不能大于2^B,否则触发扩容;
- 当 B >= 15,也就是 bucket 总数 >= 2^15 时,溢出桶数量不能大于2^15,否则触发扩容;
三、Slice切片相关
1、切片的底层结构
Go语言中的slice是一个结构体,包含指针、长度和容量三个部分。它的特性包括:
- 指针:Slice包含一个指针,该指针指向底层数组,即实际存储数据的数组。
- 长度
len
:Slice的长度表示当前Slice使用到的元素个数。 - 容量
cap
:Slice的容量表示底层数组的大小,也就是Slice可以扩展的最大长度。 - 动态性:Slice是灵活的,其长度可以改变,不像数组那样固定。
- 传递效率:作为函数参数时,Slice传递的是引用,而不是像数组那样传递副本,这使得Slice在函数调用中更加高效。
与Map
不同的是,m := make(map[string]int, 8)
创建的m
是指向 hmap
结构的指针。而s := make([]int, 0, 8)
创建的s
是一个切片头结构体,字段包括指向底层数组的指针、长度和容量
。一个是指针,一个是结构体,但都是引用类型。
2、在使用Slice时,需要注意以下几点
-
Slice可以通过内置的
make
、new
函数或直接对数组进行切片操作来创建。 -
修改Slice的元素会影响到底层数组,反之亦然。
-
当Slice的容量大于其长度时,可以通过append等操作来扩展Slice的长度,而不会改变底层数组的大小。
-
当Slice的长度达到容量时,再次进行append操作将会导致底层数组的重新分配和复制,这可能会影响性能。
3、append函数对切片的影响
首先,append操作会创建并返回一个新的切片头,所以s = append(s, 1)
也是赋值新的切片头。至于切片头指向的底层数组是否有变化,取决于append操作是否超过了切片的容量。
- 如果当前切片的容量足以容纳新添加的元素:
append
不会创建新的底层数组。- 直接在现有数组中添加新元素。
- 返回一个指向同一底层数组的新切片头,但长度增加。
- 如果当前切片的容量不足以容纳新元素:
append
创建一个新的、更大的底层数组。- 将原数组的内容复制到新数组。
- 添加新元素到新数组。
- 返回一个指向新底层数组的新切片头,长度和容量都会增加。
-
func main() { a := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} b := a[2:5] c := a[9:10] b = append(b, 1) c = append(c, 1) fmt.Println(a) } // 注意,这里输出的是 [0 1 2 3 4 1 6 7 8 9] // 向 b 追加 1。由于 b 的容量足够(它可以扩展到 a 的末尾),这个操作会修改原始的 a 切片。,将索引 5 的元素从 5 改为 1。 // 向 c 追加 1。由于 c 指向 a 的最后一个元素,且没有更多的容量,这个操作会创建一个新的底层数组。c 现在指向这个新数组,包含 [9, 1],但这不会影响 a。
4、切片作为函数参数
如果切片当作函数参数进行值传递,且只进行append
操作而不返回,那不会对原始切片产生影响,因为append
会创建新的切片头;如果是直接用下标来修改,例如s[0]=1
,则会对原始切片产生影响,因为传递的是切片头,切片头包含指向底层数组的指针。
func change(s []int){
//只进行append操作,不会影响原切片
}
func change(s []int) []int {
s = append(s, 1)
return s
// 对原切片进行赋值,则可以修改原切片
}
func change(s *[]int) {
*s = append(*s, 1)
// 传递切片指针进行修改,则可以修改原切片
}
四、channel 相关
1、channel 是否并发安全?为什么?
-
Channel 的线程安全性:channel 是线程安全的,这意味着多个 goroutine 可以同时对同一个 channel 进行读写操作,而不会产生数据竞争或冲突。
-
并发安全的原因:
- 内置的锁机制:每个
channel
都有一个内部锁,用于保护其状态不被同时修改。当一个goroutine尝试向channel
发送数据时,它会获取这个锁;同样,当一个goroutine从channel
接收数据时,也会获取相同的锁。这样可以确保在任何时候只有一个goroutine可以访问channel
的状态。 - 阻塞和非阻塞操作:
channel
的发送和接收操作可以是阻塞的,也可以是非阻塞的。阻塞操作意味着goroutine会在发送或接收数据时等待,直到另一个goroutine准备接收或发送数据为止。这种机制保证了数据的一致性和顺序,同时避免了竞态条件。 - 缓冲区:
channel
可以是有缓冲的,这意味着它可以存储一定数量的消息,而无需立即被接收。这种缓冲机制允许发送和接收操作在某些情况下是非阻塞的,即当缓冲区未满或未空时,操作可以直接完成,而无需等待。
- 内置的锁机制:每个
-
Channel 的操作:在使用 channel 时,你可以利用
for range
循环来持续地从 channel 中读取数据,直到它被关闭。这种方式比手动使用锁来控制数据的访问要简单和高效得多。
2、go channel 的底层实现原理 (数据结构)
- 数据结构: Go 内部对 channel 的实现采用了名为
hchan
的结构体。hchan
结构体包含了如下几个重要的字段:elemtype
: 存储 channel 中元素的数据类型信息。buf
: 一个指向存储 channel 数据的数组的指针,用于实现缓冲 channel。elemsize
: 元素的大小(以字节为单位)。closed
: 标志位,指示 channel 是否已经被关闭。recvx
和sendx
:分别代表接收和发送索引,用于记录下一个接收和发送的位置。recvq
和sendq
:分别指向等待接收和发送的 goroutine 队列,当 channel 满或者空时,相应的 goroutine 会进入相应队列等待。lock
:互斥锁,用于保证对 channel 内部数据结构的同步访问。每次读写都会锁住hchan
结构体,保证channel
读写的原子性。
- 同步机制:
- 无缓冲 channel:发送和接收操作都是同步的,也就是说,发送操作会阻塞,直到有接收者准备好;同样地,接收操作也会阻塞,直到有发送者发送数据。
- 有缓冲 channel:在缓冲区未满的情况下,发送操作可以立即完成;同理,在缓冲区非空的情况下,接收操作也可以立即完成。当缓冲区已满或空时,channel 的同步机制会启用等待队列,将 goroutine 插入到相应队列中,直至满足发送或接收条件。
- 内存管理:
- Go runtime 在创建 channel 时会分配一块内存来存储元素,并根据 channel 的缓冲大小调整这块内存的大小。
- 在进行数据传输时,runtime 会确保 goroutine 间的同步,并通过 CAS(Compare and Swap)等原子操作来更新 channel 内部的状态,从而实现线程安全的数据交换。
- 调度策略:
- Go 的运行时调度器密切关注 channel 上的活动,当一个 goroutine 因为 channel 操作被阻塞时,调度器会将该 goroutine 放入等待队列,并唤醒另一个可能已经准备好的 goroutine 继续执行。
3、nil channel、关闭的 channel、有数据的 channel,再进行读、写、关闭会怎么样
针对 Go 语言中的 channel,在不同状态下(nil、已关闭、有数据)进行读、写、关闭操作会有以下表现:
- nil channel:
- 读取:尝试从 nil channel 读取数据会导致永远阻塞,除非通过另一个 goroutine 向其发送数据或关闭 channel。
- 写入:向 nil channel 发送数据也会造成永远阻塞,必须先创建并初始化 channel 才能进行发送操作。
- 关闭:试图关闭 nil channel 会直接导致 panic 错误。
- 已关闭的 channel:
- 读取:从已关闭的 channel 读取数据,如果 channel 中还有剩余数据,那么会成功读取并返回数据;当 channel 中所有数据都被读取完毕时,再次读取会返回对应类型的零值,并且
ok
标志位为false
,表示 channel 已关闭且无数据可读。 - 写入:向已关闭的 channel 发送数据会导致 panic 错误。
- 关闭:对已关闭的 channel 再次调用 close 操作也是非法的,会导致 panic 错误。
- 读取:从已关闭的 channel 读取数据,如果 channel 中还有剩余数据,那么会成功读取并返回数据;当 channel 中所有数据都被读取完毕时,再次读取会返回对应类型的零值,并且
- 有数据的 channel:
- 读取:如果有数据,从 channel 中读取数据会成功,返回数据值,且
ok
标志位为true
。 - 写入:如果 channel 是非缓冲的(无缓冲 channel),只有当有接收方正在读取数据时才能成功发送;如果是缓冲的 channel,只要缓冲未满就能成功发送数据。
- 关闭:可以关闭有数据的 channel,关闭后不能再向其发送数据,但仍然可以从 channel 中读取剩余数据,直到数据被完全读取完。
- 读取:如果有数据,从 channel 中读取数据会成功,返回数据值,且
总结:
- 无论是 nil channel 还是已关闭的 channel,都不能进行写入操作。
- 对 nil channel 进行读取和关闭操作会导致阻塞或 panic。
- 对已关闭的 channel 进行读取取决于是否有剩余数据,写入和关闭都会导致 panic。
- 有数据的 channel 可以正常进行读写操作,关闭后不再接受新的数据,但仍能读取旧数据,直到清空。
4、向 channel 发送数据和从 channel 读数据的流程
- 向channel发送数据:
- 首先,检查channel中是否有空间存放数据。对于带缓冲的channel,如果缓冲区未满,则可以直接将数据存入缓冲区;如果缓冲区已满,则发送方会被阻塞,直到接收方从channel中读取数据并释放缓冲区空间。对于不带缓冲的channel,发送方会立即被阻塞,直到接收方准备好接收数据。
- 当有空间可以存放数据时,发送方将数据存入channel,此时数据被视为已发送但尚未被接收。
- 如果channel在发送数据后被关闭,那么发送操作会立即返回,不再阻塞。
- 从channel读取数据:
- 首先,检查channel中是否有数据可供读取。对于带缓冲的channel,如果缓冲区不为空,则可以直接从中读取数据;如果缓冲区为空,则接收方会被阻塞,直到发送方将数据发送到channel中。对于不带缓冲的channel,接收方会立即被阻塞,直到发送方将数据发送到channel中。
- 当有数据可供读取时,接收方从channel中取出数据,此时数据被视为已接收但尚未被处理。
- 如果channel在读取数据后被关闭,那么接收操作会立即返回,不再阻塞。
五、context相关
1、context 结构是什么样的?
context.Context
是 Golang
中用于处理并发编程的上下文控制,提供了截止日期、取消信号和请求相关值的传递机制。它的结构定义包括以下几个关键方法:
- Deadline: 此方法返回一个截止日期和一个布尔值,表示上下文是否设置了截止日期。
- Done: 这是一个通道,当上下文被取消或超时时,会向该通道发送一个信号。
- Err: 该方法返回上下文中发生的错误。
- Value: 这个函数用于存储和检索与上下文相关的键值对,是实现共享数据存储的地方,是协程安全的
2、context
的作用是什么
Context
在Go语言中主要扮演了以下几个关键角色:
-
取消操作:
Context
允许你取消一组可能跨多个 goroutine 或服务的操作。这对于长时间运行的任务特别有用,比如在HTTP服务器中处理请求时,如果客户端断开连接,你可以使用Context
来取消还在进行中的操作,从而释放资源。 -
设置截止时间:
Context
可以为操作设置一个截止时间,如果操作在这个时间内没有完成,则自动取消。这在防止长时间阻塞或等待操作时非常重要,可以避免不必要的资源占用。 -
传递取消信号:当在一个系统中有多个服务或组件相互协作时,
Context
提供了一种方式来传递取消信号。一旦某个地方决定取消操作,这个信号可以沿着Context
链路传播到所有相关的goroutines。 -
携带请求范围的数据:
Context
可以携带请求范围的数据,如认证信息、跟踪ID等。这些数据可以随着Context
在不同的函数和服务间传递,而无需显式地在函数签名中添加额外的参数。 -
管理资源生命周期:在处理网络请求或数据库操作时,
Context
可以帮助你更好地管理资源的生命周期。例如,在数据库查询中,你可以使用Context
来确保在操作超时或被取消时正确关闭数据库连接。
3、context.Context
结构的核心特征
- Context 的创建:
- 使用
ctx := context.Background()
创建一个顶级上下文,没有特定的取消信号和截止时间。 - 使用
ctx := context.TODO()
创建一个临时上下文,通常用于表明代码路径尚未完成,需要后续填充适当的上下文。 - 使用
ctx, cancel := context.WithCancel(parentCtx)
创建一个可以从父上下文中派生出来的新上下文,执行cancel()
可主动取消ctx
。 - 使用
ctx := context.WithoutCancel(parentCtx)
创建的新的上下文,即使parentCtx
被取消了,也不会影响新的上下文。 - 使用
ctx, cancel := context.WithDeadline(parentCtx, deadline)
创建一个在指定截止时间到达时自动取消的上下文,执行cancel()
可主动取消ctx
。 - 使用
ctx, cancel := context.WithTimeout(parentCtx, timeout)
创建一个在指定超时时间过后自动取消的上下文。 - 使用
ctx := context.WithValue(parentCtx, key, value)
创建一个携带键值对的上下文,用于传递请求级别信息。 stop := context.AfterFunc(ctx, f func() { xxx })
当ctx
被取消或超时时,f 函数会被异步调用;如果ctx
已经被取消,f 函数会立即被调用;如果在ctx
被取消之前调用了返回的 stop() 函数,f 函数将不会被调用。
- 使用
- Context 的传递:
- Context 应该作为参数传递给可能长时间运行或涉及到 Goroutine 启动的函数,以便在适当的时候能够取消操作或传播相关信息。
- 取消通知:
- 通过调用
ctx.Done()
方法可以获得一个只读的 channel,当上下文被取消时,该 channel 会被关闭。 - 通过检查
<-ctx.Done()
可以得知上下文是否已被取消,这是一种非阻塞的方式检测取消信号。
- 通过调用
- 截止时间:
- 可以通过
ctx.Deadline()
查询上下文的截止时间,如果没有设定则返回time.Time{}
(零值)。
- 可以通过
- Context 的取消:
- 创建子上下文时获得的取消函数(如
cancelFunc
)可用于主动取消上下文及所有从该上下文衍生出的子上下文。
- 创建子上下文时获得的取消函数(如
4、context
应用场景
context
包在Go语言中用于多种并发和分布式系统的设计模式中,特别是在处理长时间运行的业务逻辑或网络请求时。核心作用是在不同 goroutine 之间传递取消信号、截止时间、键值数据等上下文信息,用于控制和协调并发操作。
以下是context
的一些典型应用场景:
-
HTTP服务器:
- 当处理HTTP请求时,
context
可以用于传递请求的上下文信息,包括身份验证、跟踪ID和其他元数据。 - 如果客户端中断连接或请求超时,服务器可以使用
context
来取消与该请求相关的所有goroutines。
- 当处理HTTP请求时,
-
数据库操作:
- 数据库查询或事务可以使用
context
来设置超时,避免长时间阻塞。 - 如果在事务过程中需要取消操作,
context
可以立即传播取消信号到底层的数据库驱动。
- 数据库查询或事务可以使用
-
微服务通信:
- 在微服务架构中,
context
可以用于在服务间传递请求信息和取消信号。 - 当一个服务调用另一个服务时,它可以创建一个新的
context
,并将它传递给远程服务,这样即使在复杂的调用链中也能保持一致的取消和超时策略。
- 在微服务架构中,
-
异步任务和工作队列:
- 在处理队列中的任务时,
context
可以用于控制任务的执行时间和取消策略。 - 这对于避免僵尸任务和清理资源是非常有用的。
- 在处理队列中的任务时,
-
长时间运行的后台任务:
- 对于像定期备份、日志聚合或数据分析这样的长期运行任务,
context
可以用来监控任务的状态,并在必要时取消它们。
- 对于像定期备份、日志聚合或数据分析这样的长期运行任务,
-
RPC(远程过程调用):
- 在RPC调用中,
context
可以用于设置请求的超时,并在调用链中传递取消信号。
- 在RPC调用中,
-
链式调用和管道:
- 当执行一系列依赖的调用时,
context
可以确保如果任何一部分失败或被取消,整个链条都会被正确地终止。
- 当执行一系列依赖的调用时,
-
测试:
- 在单元测试或集成测试中,
context
可以用于模拟超时或取消行为,以测试函数的健壮性和响应能力。
- 在单元测试或集成测试中,
5、context
是并发安全的吗?
是的,Go语言中的context
包设计为并发安全的。这意味着你可以安全地在多个goroutines中共享和使用context
对象,而不需要额外的锁或其他同步机制来防止数据竞争或不一致状态。
context
的并发安全性主要归功于以下几点:
-
不可变性:
context
值通常是不可变的,这使得它们可以安全地在多个goroutines
之间共享。当你通过WithCancel
,WithDeadline
,WithTimeout
或WithValue
函数创建新的context
时,实际上是在创建一个新对象,而不是修改原始context
。这种写时复制(COW,Copy-On-Write)的策略确保了数据的一致性和安全性。 -
原子操作:
context
包内部使用了原子操作来更新和检查取消状态,这意味着取消信号的发送和接收是线程安全的。 -
并发安全的映射:在
context
中存储键值对时,使用的是并发安全的映射数据结构,这保证了即使在高并发的情况下,键值对的读取和写入也是安全的。 -
接口隐藏实现:
context
的设计通过接口隐藏了其实现细节,这有助于确保内部状态的同步和一致性,而不需要用户介入进行额外的同步控制。
六、GPM调度模型
1、什么是GPM调度模型?
Go 语言的 GPM 调度模型是 Go 运行时特有的并发调度模型,用于管理和调度 Goroutines(Go 语言的轻量级线程)。GPM 模型由三部分组成:Goroutine(G)、M(Machine)、和 P(Processor)。
-
G: 表示 Goroutine,每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。
-
P: Processor,表示逻辑处理器, 对 G 来说,P 相当于 CPU 核,G 只有绑定到 P(在 P 的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量),P 的数量由用户设置的 GOMAXPROCS 决定,但是不论 GOMAXPROCS 设置为多大,P 的数量最大为 256。
-
M: Machine(工作线程/系统线程), OS 线程抽象,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础,M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。
调度流程简述:
- 有一个全局队列和本地队列,其中本地队列的容量一般不超过256
- 当创建一个新的 Goroutine 时,它会被放入某个 P 的本地队列中。
- 若本地队列已满或者不存在,则会将 Goroutine 放入全局队列。
- M 通过与其关联的 P 获取待执行的 Goroutine,然后执行它。
- 若 M 闲置,会去全局队列或者从其他 P 抢夺 Goroutine 来执行(Work Stealing)。
- 当 Goroutine 因 I/O 操作阻塞时,对应的 M 会释放其与 P 的关联,并让出 CPU 给其他 Goroutine 执行,待阻塞解除后重新参与调度。
2、进程、线程、协程有什么区别
进程、线程、协程是三种不同的执行单元,它们在计算机程序执行中有各自的特点和用途:
-
进程(Process):
- 进程是操作系统资源分配和调度的最小单位,每个进程都有自己独立的地址空间(内存、打开的文件、设备等资源)。
- 进程之间是相互隔离的,通过 IPC(Inter-Process Communication,进程间通信)机制进行通信和数据交换。
- 创建新的进程会分配新的资源,如内存空间,有一定的系统开销。
-
线程(Thread):
- 线程是操作系统进行调度的最小单位,是进程中执行的实体,共享进程的资源,包括内存空间(除了栈空间)。
- 线程之间的切换速度快于进程,因为它们不需要创建新的地址空间,只需保存和恢复线程上下文即可。
- 多个线程可以在同一个进程中并发执行,提高了系统的并行计算能力,但也带来了线程安全问题,需要使用锁、信号量等机制来解决竞态条件和同步问题。
-
协程(Coroutine):
- 协程是一种用户层面的轻量级线程,不像线程那样由操作系统调度,而是由程序自身调度,因此协程的创建、切换等操作比线程快得多,开销极小。
- 协程存在于单一进程中,它们共享进程的资源,并且协程的切换是由用户程序控制的,通常在遇到 IO 操作或者
yield
时主动放弃执行权。 - Go 语言中的 goroutine 就是一种协程实现,它由 Go 运行时管理,相较于传统的线程,goroutine 的调度更加高效,且易于理解和使用。
- 协程间的通信和同步通常通过通道(channel)等机制来实现,更容易写出并发安全的代码。
总结来说,进程侧重于资源隔离,线程侧重于共享资源并发执行,协程则是在单个进程内的轻量级并发,具有更低的切换开销和更高的灵活性。
3、抢占式调度是如何抢占的
抢占式调度的过程通常包括以下几个步骤:
- 时间片分配:系统为每个任务分配一个固定的时间片(time quantum),即任务可以连续执行的最长时间。
- 上下文保存:当任务的时间片用完时,调度器会暂停该任务的执行,并保存其当前的执行状态(如寄存器值、程序计数器等)到任务的上下文中。
- 重新调度:调度器选择下一个要运行的任务。这个选择过程可能基于多种因素,如任务的优先级、等待时间、资源需求等。
- 上下文恢复:调度器加载新选中任务的上下文,恢复其执行状态,然后继续执行。
- 重复过程:这个过程周期性地重复,确保所有任务都有机会被执行。
4、Go语言调度模型G、M、P的数量多少合适?
- G(Goroutine):
- Goroutine 的数量理论上没有严格的上限,但在实践中,大量的并发 Goroutine 会消耗内存(每个 Goroutine 都有自己的栈空间),并且过多的 Goroutine 可能导致上下文切换的开销增大,降低性能。
- 一般建议控制 Goroutine 的数量不要过于庞大,尤其是活跃 Goroutine 数量。通过合理设计程序,避免不必要的并发,使用 Channel 和 WaitGroup 等同步机制来控制并发水平。
- P(Processor):
- P 的数量代表了并发执行的 Goroutine 的最大数量,它的默认值由
GOMAXPROCS
环境变量或者runtime.GOMAXPROCS()
函数设置,如果不设置,默认为 CPU 核心数。 - 一般来说,P 的数量设为与物理 CPU 核心数相匹配是比较合理的做法,这样可以充分利用多核优势。不过,在某些场景下,比如 CPU 密集型应用且核心数量较多时,可以尝试减小 P 的数量来观察效果,有时可能会因为减少上下文切换而提升性能。
- 如果应用存在大量 I/O 密集型操作,适度增加 P 的数量(不超过 CPU 核心数)可能有利于提高系统的整体吞吐量,因为 I/O 阻塞时,M 可以释放并服务于其他 P。
- P 的数量代表了并发执行的 Goroutine 的最大数量,它的默认值由
- M(Machine/OS Thread):
- M 的数量理论上可以大于 P,多余的部分会处于休眠状态,等待被唤醒并关联到一个 P 上执行 Goroutine。
- Go 运行时会自动管理 M 的数量,确保有足够的 M 来运行所有关联到 P 的 Goroutine。
- 在默认情况下,Go 调度器会根据 P 和 G 的数量以及系统资源动态调整 M 的数量,以达到较好的资源利用率。
总结来说,大部分情况下无需特别关注 M 的数量,只需合理设置 P 的数量以匹配系统资源和应用需求。而对于 G 的数量,应尽量控制在合理的范围内,以避免内存浪费和过度的上下文切换。通过观察和测量实际应用的性能,可以进一步微调 P 和 G 的数量来优化程序的并发处理能力。
5、GPM模型中,M 发生系统调用了, G 和 P 会怎么样
在Go语言的GMP调度模型中,当一个系统线程M(Machine)遇到需要执行系统调用时,会发生以下情况:
-
M与P解绑:M在执行系统调用前会先尝试释放与之关联的处理器P(Processor)。这是因为系统调用很可能会导致M阻塞,为了不让P闲置,M会把P交给调度器,让其他可运行的M来使用P继续执行Goroutine(G),从而提高CPU利用率。
-
G的调度:正在执行的Goroutine(G)会因为M的阻塞而暂停执行。这个G会被放回到P的本地队列或者全局队列中,等待下一个可用的M来获取并继续执行。如果G正在进行系统调用,当系统调用完成后,G需要重新获取P才能继续执行。
-
P的重分配:释放的P可以被重新分配给其他空闲的M,或者如果有新的M创建,也可以分配给新M,使得这些M能够带着P去执行Goroutine队列中的任务。
-
M的恢复:当M完成系统调用并解除阻塞后,它会尝试从调度器那里重新获取一个P,然后继续执行Goroutines。如果当前没有可用的P,M可能会进入休眠状态,直到有P可用时被唤醒。
这样的设计确保了即使某个M因为系统调用而阻塞,也不会影响到整个程序的并发执行能力,提高了系统的响应速度和吞吐量。
6、M 系统调用结束以后会怎么样
当M(Machine,代表操作系统线程)完成系统调用并结束阻塞状态后,它会尝试恢复执行Go的goroutine。具体步骤如下:
-
尝试获取P(Processor):M会首先尝试从调度器中获取一个空闲的P。P包含了执行环境和本地的任务队列,对于Goroutine的执行至关重要。
-
获取G并执行:
- 如果M成功获取到了P,它会查看P的本地队列是否有待执行的Goroutine(G)。如果有,M会从P的本地队列中取出一个G并开始执行。
- 如果P的本地队列为空,M可能会从全局队列中取出Goroutine来执行,或者如果全局队列也为空,M可能会尝试从其他P的本地队列中“偷取”Goroutine来避免空闲。
-
无P可获取:如果此时没有空闲的P可以分配给M,M会进入休眠状态或者被回收。M进入休眠意味着它不会占用CPU资源,而是等待条件满足时被唤醒,比如有新的任务到来或已有任务完成,从而可以重新获取P并开始执行Goroutine。
-
维持平衡:Go的运行时系统(runtime)会持续监控M、P、G的状态,以确保资源的有效利用和负载均衡。例如,sysmon监控线程会定期检查是否有阻塞过久的M,并可能采取行动促进其恢复工作。
7、既然goroutine要绑定系统线程,为什么还要用goroutine
-
轻量级并发:
goroutine极其轻量,可以创建成千上万个而不会耗尽系统资源。这使得开发者可以更自由地使用并发,而不必过分担心资源限制。 -
调度效率:
Go运行时使用M:N调度模型,即M个goroutine可以在N个操作系统线程上运行。这允许Go更高效地管理并发,根据需要动态调整。 -
更简单的并发模型:
goroutine和channel提供了一个更简单、更安全的并发编程模型,降低了死锁和竞态条件的风险。 -
栈管理:
goroutine使用可增长的栈,初始只有几KB。这比固定大小的线程栈更节省内存,也更灵活。 -
快速上下文切换:
goroutine的切换在用户空间完成,比系统线程的切换快得多。 -
适应现代硬件:
goroutine调度器可以更好地利用多核处理器,提高并行效率。 -
跨平台一致性:
goroutine的行为在不同操作系统上保持一致,而系统线程的行为可能因平台而异。 -
内置同步原语:
Go提供了专门为goroutine设计的同步原语(如channel),使并发编程更加直观。
七、Goroutine 并发相关
1、怎么控制并发数
- 使用goroutine:Go语言的并发模型基于goroutine,每个goroutine都是一个独立的执行单元。通过创建多个goroutine来实现并发操作,可以有效地控制并发数。你可以根据需要创建适量的goroutine,以实现对并发数的控制。
- 使用channel:Channel是Go语言中用于在不同goroutine之间传递数据的一种机制。通过使用channel,可以实现对并发数的限制。你可以创建一个带缓冲区的channel,并限制其容量,从而控制同时运行的goroutine数量。当channel满时,新的goroutine会等待直到有可用的空位。
- 使用sync包中的WaitGroup:WaitGroup是Go语言提供的一个同步原语,用于等待一组goroutine的完成。通过使用WaitGroup,你可以控制并发数,确保所有goroutine都完成后再继续执行后续操作。
- 使用context包:Context包提供了一种优雅的方式来控制并发任务的取消和超时。通过使用context,你可以设置超时时间或手动取消任务,从而控制并发数。
- 使用第三方库:有一些第三方库提供了更高级的并发控制功能,如
golang.org/x/sync/semaphore
、golang.org/x/sync/errgroup
等。这些库提供了更灵活的并发控制选项,可以根据具体需求选择适合的库进行使用。
2、多个 goroutine 对同一个 map 写会 panic,异常是否可以用 defer 和 recover 捕获?
可以。Go语言,可以使用多值返回来返回错误。不要用异常代替错误,更不要用来控制流程。在极个别的情况下,才使用Go中引入的Exception处理:defer, panic, recover Go中,对异常处理的原则是:多用error包,少用panic
3、如何优雅的实现一个 goroutine 池
4、goroutine数量过多怎么办
一个 goroutine 占用的总内存大致可以分为以下几部分:
- 栈内存:初始栈大小 2KB,后续根据需要动态调整。
- 调度器开销:几十到几百字节。
在高并发系统中,goroutine 数量过多可能会导致一系列问题,如内存压力增大、调度开销增加、系统响应变慢等。以下是一些处理 goroutine 过多问题的策略和最佳实践:
-
使用工作池(Worker Pool)
使用
sync.WaitGroup
和channel
处理工作 -
使用信号量控制并发
使用带缓冲的 channel 作为信号量来限制并发数量
- 实现自适应限流
根据系统负载动态调整worker的数量。这个工作池会监控几个关键指标,如CPU使用率、内存使用情况、任务队列长度等,然后根据这些指标调整worker数量。
import (
"runtime"
"sync"
"time"
)
type AdaptivePool struct {
workers chan struct{}
tasks chan func()
workerCount int
maxWorkers int
mutex sync.Mutex
}
func NewAdaptivePool(initialWorkers int) *AdaptivePool {
p := &AdaptivePool{
workers: make(chan struct{}, runtime.NumCPU()*100), // 设置一个较大的上限
tasks: make(chan func()),
workerCount: initialWorkers,
maxWorkers: runtime.NumCPU() * 100,
}
for i := 0; i < initialWorkers; i++ {
p.workers <- struct{}{}
}
go p.adjustWorkers()
return p
}
func (p *AdaptivePool) adjustWorkers() {
for {
time.Sleep(time.Second) // 每秒调整一次
p.mutex.Lock()
taskLength := len(p.tasks)
workerCount := p.workerCount
p.mutex.Unlock()
var m runtime.MemStats
runtime.ReadMemStats(&m)
cpuUsage := getCPUUsage() // 实现这个函数来获取CPU使用率
// 根据任务队列长度、CPU使用率和内存使用情况调整worker数量
if taskLength > workerCount && workerCount < p.maxWorkers && cpuUsage < 80 && m.Sys < 8*1024*1024*1024 {
p.addWorker()
} else if taskLength == 0 && workerCount > 1 && (cpuUsage > 90 || m.Sys > 12*1024*1024*1024) {
p.removeWorker()
}
}
}
func (p *AdaptivePool) addWorker() {
p.mutex.Lock()
defer p.mutex.Unlock()
if p.workerCount < p.maxWorkers {
p.workers <- struct{}{}
p.workerCount++
}
}
func (p *AdaptivePool) removeWorker() {
p.mutex.Lock()
defer p.mutex.Unlock()
if p.workerCount > 1 {
<-p.workers
p.workerCount--
}
}
func (p *AdaptivePool) Submit(task func()) {
p.tasks <- task
}
func (p *AdaptivePool) Run() {
for {
select {
case <-p.workers:
task := <-p.tasks
go func() {
task()
p.workers <- struct{}{}
}()
}
}
}
// 使用示例
func main() {
pool := NewAdaptivePool(runtime.NumCPU())
go pool.Run()
// 提交任务
for i := 0; i < 1000000; i++ {
pool.Submit(func() {
// 执行任务
time.Sleep(time.Millisecond * 100)
})
}
// 防止主goroutine退出
select {}
}
- 使用 context 进行超时控制
为长时间运行的 goroutine 设置超时,避免资源长期占用:
- 优化goroutine内部逻辑
- 减少阻塞操作,使用非阻塞的I/O操作。
- 合理使用缓存,减少不必要的计算和数据库访问。
- 优化算法和数据结构,提高处理效率。
- 使用协程池库
考虑使用成熟的协程池库,如 ants
- 监控和分析
定期监控系统性能和goroutine数量
- 使用负载均衡
在分布式系统中,使用负载均衡来分散请求压力,避免单个节点goroutine过多。
- 实现背压(Backpressure)机制
当系统负载过高时,暂时拒绝新的请求或降低处理速度
5、如何监控goroutine
监控 goroutine 数量和系统性能是保持 Go 应用程序健康运行的关键。以下是一些有效的监控方法和工具:
- 使用 runtime 包进行基本监控
Go 的 runtime 包提供了许多有用的函数来获取运行时信息:
package main
import (
"fmt"
"runtime"
"time"
)
func monitorSystem() {
for {
// 获取goroutine数量
fmt.Printf("Number of goroutines: %d\n", runtime.NumGoroutine())
// 获取内存使用情况
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc = %v MiB\n", bToMb(m.Alloc))
fmt.Printf("TotalAlloc = %v MiB\n", bToMb(m.TotalAlloc))
fmt.Printf("Sys = %v MiB\n", bToMb(m.Sys))
fmt.Printf("NumGC = %v\n", m.NumGC)
time.Sleep(5 * time.Second)
}
}
func bToMb(b uint64) uint64 {
return b / 1024 / 1024
}
func main() {
go monitorSystem()
// 你的主程序逻辑
select {}
}
- 使用 pprof 进行性能分析
pprof 是 Go 的性能分析工具,可以帮助你分析 CPU 使用率、内存分配、goroutine 阻塞等情况。
首先,在你的代码中导入 pprof:
import _ "net/http/pprof"
import "net/http"
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// 你的主程序逻辑
}
然后,你可以使用以下命令来分析:
go tool pprof http://localhost:6060/debug/pprof/heap # 分析内存
go tool pprof http://localhost:6060/debug/pprof/profile # 分析CPU
go tool pprof http://localhost:6060/debug/pprof/goroutine # 分析goroutine
- 使用 Prometheus 进行监控
Prometheus 是一个流行的监控系统,非常适合监控 Go 应用。使用 github.com/prometheus/client_golang
包来集成:
- 使用 trace 工具
Go 的 trace 工具可以帮助你理解程序的行为:
import "runtime/trace"
func main() {
f, err := os.Create("trace.out")
if err != nil {
log.Fatalf("failed to create trace output file: %v", err)
}
defer f.Close()
if err := trace.Start(f); err != nil {
log.Fatalf("failed to start trace: %v", err)
}
defer trace.Stop()
// 你的主程序逻辑
}
然后使用 go tool trace trace.out
来分析跟踪结果。
综合建议:
- 根据你的应用规模和需求选择合适的监控方法。
- 对于小型应用,使用 runtime 包和基本的日志记录可能就足够了。
- 对于大型或分布式系统,考虑使用 Prometheus + Grafana 这样的完整监控方案。
- 定期进行性能分析,使用 pprof 和 trace 工具深入了解应用行为。
- 设置适当的告警阈值,及时发现和解决问题。
- 持续优化你的监控策略,确保它能够反映应用的真实状态和性能瓶颈。
6、golang 竞态检测
Go 的竞态检测工具用于识别并发程序中的数据竞争。以下是如何使用它的指南:
启用竞态检测
在运行或测试 Go 程序时,使用 -race
标志:
# 运行程序
go run -race your_program.go
# 测试程序
go test -race ./...
功能
- 检测数据竞争:识别未同步的并发访问。
- 报告问题位置:提供详细的堆栈跟踪。
性能开销
竞态检测会增加 CPU 和内存使用,因此建议在开发和测试阶段使用,而不是在生产环境中。
示例
如果在运行带有 -race
标志的程序时检测到竞态条件,Go 会输出类似以下的信息:
WARNING: DATA RACE
Write at 0x00c0000b6000 by goroutine 7:
main.main.func1()
/path/to/program.go:10 +0x64
Previous read at 0x00c0000b6000 by goroutine 6:
main.main.func1()
/path/to/program.go:9 +0x44
7、goroutine和系统线程的区别
goroutine和系统线程确实有一些重要的区别。让我简要解释一下主要的不同点:
-
资源消耗:
goroutine非常轻量,仅需几KB内存。系统线程则需要较大的内存空间,通常是MB级别。 -
创建和销毁开销:
goroutine的创建和销毁速度非常快,开销很小。系统线程的创建和销毁则相对昂贵。 -
调度方式:
goroutine由Go运行时调度,实现了协作式的用户级调度。系统线程由操作系统内核调度。 -
切换成本:
goroutine的切换在用户态完成,开销很小。线程切换需要陷入内核态,开销较大。 -
数量限制:
一个程序可以轻松创建上万个goroutine。系统线程数则受到更多限制。 -
通信方式:
goroutine间可以方便地使用channel通信。线程间通信相对复杂。 -
栈大小:
goroutine使用可增长的栈,初始仅需2KB。线程栈大小固定,通常为1-2MB。
八、锁相关
1、锁的基本概念
锁(通常是指 sync.Mutex
互斥锁 或 sync.RWMutex
读写互斥锁)在 Go 语言中主要用于保护共享资源(如变量、数据结构)免受并发访问时的数据竞争。当多个 goroutine
并发访问和修改相同的资源,并且这些修改操作不能原子性完成时,就需要使用锁来确保每次只有一个 goroutine
能够访问和修改资源。
2、golang有哪些类型的锁
在 Go 语言中,标准库 sync
包提供了以下几种类型的锁来实现同步控制:
-
互斥锁(Mutex):
sync.Mutex
是最基本的互斥锁类型,它在同一时刻只允许一个 Goroutine 访问受保护的资源。提供了Lock()
和Unlock()
方法,分别用于获取和释放锁。示例:
var mu sync.Mutex mu.Lock() // 访问共享资源 mu.Unlock()
-
读写互斥锁(RWMutex):
sync.RWMutex
是更为灵活的锁,它可以允许多个 Goroutine 同时读取数据,但写入数据时会独占锁,阻止其他 Goroutine 的读写。提供了RLock()
(读取锁)和RUnlock()
(释放读取锁),以及Lock()
(写入锁)和Unlock()
(释放写入锁)方法。示例:
var rwmu sync.RWMutex rwmu.RLock() // 读取共享资源 rwmu.RUnlock() rwmu.Lock() // 写入共享资源 rwmu.Unlock()
-
一次性锁(Once):
sync.Once
用于确保某个操作(通常是一个初始化操作)只执行一次,即使在并发环境下也是如此。它通过内部的互斥锁机制实现,并提供了Do(f func())
方法。示例:
var once sync.Once var data interface{} once.Do(func() { // 只执行一次的初始化操作 data = expensiveInitialization() })
-
条件变量(Cond):
sync.Cond
用于在满足特定条件时唤醒等待的 Goroutine,它基于互斥锁实现,提供了L
字段(一个互斥锁)以及Wait()
,Signal()
和Broadcast()
方法。示例:
var cond sync.Cond cond.L = &sync.Mutex{} cond.L.Lock() // 某条件不满足 cond.Wait() // 在被 Signal() 或 Broadcast() 唤醒后,重新检查条件 cond.L.Unlock()
-
原子操作(Atomic): 虽然不是传统意义上的锁,但
sync/atomic
包提供了原子操作,如原子增加、减少、交换和比较交换等,可用于实现无锁数据结构和细粒度的同步控制,这些操作在硬件层面上保证了并发安全。
请注意,Go 语言的并发设计鼓励使用 channels(通道)进行通信来代替共享内存,但以上锁机制在某些场景下仍然是必要的,例如实现传统的锁模式或者对现有 C/C++ 库进行封装时。
3、Go 如何实现原子操作?
原子操作就是不可中断的操作,外界是看不到原子操作的中间状态,要么看到原子操作已经完成,要么看到原子操作已经结束。在某个值的原子操作执行的过程中,CPU 绝对不会再去执行其他针对该值的操作,那么其他操作也是原子操作。
在 Go 语言中,原子操作通过 sync/atomic
包来实现。sync/atomic
包提供了对整型值、指针以及其他一些类型进行原子操作的支持,这些操作在多线程或多 Goroutine 环境下是线程安全的,即在操作过程中不会被打断,保证了操作的完整性。
以下是一些 sync/atomic
包提供的原子操作函数示例:
- 整数类型的原子操作:
SwapInt32/64
:交换(替换)一个 32/64 位整数变量的值,返回旧值。CompareAndSwapInt32/64
:比较并交换,只有当当前值等于预期值时才将整数变量设置为新值,返回旧值。AddInt32/64
:原子地将指定值加到整数变量上,并返回新的值。LoadInt32/64
:原子地读取整数变量的值。StoreInt32/64
:原子地将值存储到整数变量中。
- 指针类型的原子操作:
SwapPointer
:交换指针变量的值,返回旧值。CompareAndSwapPointer
:比较并交换指针变量的值。
- 其他类型:
- 对于无符号整数类型(
uint32
、uint64
)和uintptr
类型也有类似的原子操作。 AtomicXXX
函数家族还提供了对bool
类型、Value
类型(用于封装任意类型,通过接口实现)的原子操作支持。
- 对于无符号整数类型(
4、悲观锁、乐观锁是什么?Mutex
是悲观锁还是乐观锁?
悲观锁和乐观锁是处理数据并发访问的两种不同策略:
- 悲观锁:假设数据经常发生冲突,因此在数据处理前先进行加锁。传统的关系型数据库通常使用这种类型的锁,如行锁、表锁等。它确保了操作的原子性、一致性、隔离性和持久性,但可能会降低并发性能,因为其他线程必须等待锁被释放才能访问数据。
- 乐观锁:假设数据通常不会发生冲突,因此不会在数据处理一开始就加锁,而是在数据提交更新时检查是否有冲突。如果有冲突,则重新尝试或返回错误信息。这种方式在冲突少的情况下能提高吞吐量,适用于读多写少的场景。
具体到Go语言中,sync.Mutex
是一种典型的悲观锁实现,它在多个goroutine访问共享资源时强制加锁,确保同一时间只有一个goroutine能够访问该资源。而atomic包中的函数则提供了一种乐观锁的实现方式,它们通常在无冲突或冲突较少的情况下表现得更为高效。
5、Mutex 有几种模式?
- 正常模式(Normal Mode):这是 Mutex 的默认模式。在这个模式下,当一个 goroutine 试图获取锁时,会先自旋几次尝试通过原子操作获取锁。如果在自旋过程中未能获取到锁,该 goroutine 将进入等待队列,按照先入先出(FIFO)的顺序排队等待。然而,当锁被释放时,排在队列首位的等待者并不总是能立即获得锁,它还需要与后续进入的正在自旋的 goroutine 竞争锁的所有权。
- 饥饿模式(Starvation Mode):当一个 goroutine 等待获取锁的时间超过一定阈值(例如 1ms),它会将 Mutex 切换到饥饿模式。在这种模式下,锁的所有权会直接从解锁的 goroutine 传递给等待队列中排在最前面的 goroutine,而不是让新来的 goroutine 竞争获取,这样可以防止长时间等待的 goroutine 饥饿。
此外,Mutex 还具有适应能力,能够根据当前的使用情况自动切换模式。如果持有锁的 goroutine 的总等待时间小于一定的阈值(如 1ms),或者等待队列为空,Mutex 会被置于正常模式。这种设计旨在在保持高性能的同时,减少长时间等待的风险。
6、除了 mutex 以外还有那些方式安全读写共享变量
可以通过Channel和原子操作来安全地读写共享变量
-
Channel:Goroutine之间可以通过Channel进行通信,无缓冲的Channel确保了发送和接收操作是同步的。
-
原子操作:Go语言中的原子操作可以用于对共享变量进行无锁(lock-free)的读写。例如可以用个数为 1 的信号量(semaphore)实现互斥
7、goroutine 的自旋占用资源如何解决
自旋锁是一种互斥锁的实现方式,当线程尝试获得一个锁时,如果发现这个锁已经被其他线程占用,它会不断地重复尝试获取锁,而不是放弃 CPU 的控制权。这个过程被称为自旋,它能够有效地减少线程切换的开销,提高锁的性能。 自旋锁同时避免了进程上下文的调度开销,因此对于短时间内的线程阻塞场景是有效的。
以下是一些解决方案:
- 减少自旋次数:可以通过调整 Mutex 的自旋次数来减少 goroutine 的自旋时间。例如,可以将自旋次数从默认的多次减少到一次或两次。
- 使用超时机制:可以在 Mutex 上设置超时时间,当超过该时间后,goroutine 将放弃自旋并进入阻塞状态等待锁释放。这可以避免 goroutine 长时间占用 CPU 资源。
- 使用其他同步原语:除了 Mutex 外,还可以使用其他的同步原语,如 Channel、WaitGroup 等来实现并发控制。这些原语可以更有效地利用系统资源,避免 goroutine 的自旋操作。
- 优化代码逻辑:通过优化代码逻辑,减少 goroutine 之间的竞争和冲突,从而降低自旋操作的频率和持续时间。
九、GC相关
1、go gc 是怎么实现的?(必问)
简单总结:
1、三色标记法:类似深度遍历,一开始全部节点是白色,从根节点出发,自身变成黑色,可达子节点全部变成灰色,遍历可达子节点,子节点自身变成黑色,把自身的可达子节点变成灰色,以此类推,直至所有可达节点变成黑色,白色就清除掉。
2、STW:暂停整个程序。如果不暂停程序, 程序的逻辑改变对象引用关系, 这种动作如果在标记阶段做了修改,会影响标记结果的正确性。
3、混合写屏障:
- GC刚开始的时候,会将栈上的可达对象全部标记为黑色。
- GC期间,任何在栈上新创建的对象,均为黑色。
上面两点只有一个目的,将栈上的可达对象全部标黑,最后无需对栈进行STW,就可以保证栈上的对象不会丢失。有人说,一直是黑色的对象,那么不就永远清除不掉了么,这里强调一下,标记为黑色的是可达对象,不可达的对象一直会是白色,直到最后被回收。
-
堆上被删除的对象标记为灰色
-
堆上新添加的对象标记为灰色
4、GC的主要阶段、三色标记法、混合写屏障、STW的关系,以及栈内存和堆内存的区别:
- a. 初始标记:需要STW。此阶段采用三色标记法,从栈内存中的根集对象开始标记堆内存中的活动对象。
- b. 并发标记:不需要STW。此阶段采用三色标记法,混合写屏障在此阶段生效,用于跟踪并标记堆内存中新创建或修改的对象。
- c. 重新标记:需要STW。此阶段采用三色标记法,确保堆内存中所有存活对象都已正确标记。
- d. 清除:部分实现需要STW,但有些实现可以并发进行。此阶段根据三色标记法的结果清除堆内存中不再使用的对象。
栈内存和堆内存的区别:
- 栈内存用于存储局部变量和方法调用链,生命周期与方法调用一致,管理简单,不需要GC垃圾回收。
- 堆内存用于存储对象实例,生命周期不确定,由垃圾回收器管理,通过标记-清除、标记-压缩、分代等算法进行内存回收。
2、go 是 gc 算法是怎么实现的?(中高级)
3、gc中STW的时机是什么?各个阶段都要解决什么?
在 Go 中,GC 回收工作中需要停止所有的 goroutine,这个过程称为 STW(Stop-The-World)。Go 的 GC 会在以下几种情况下触发 STW:
- 手动调用:通过 runtime.GC() 函数手动触发。
- 内存分配:当程序运行时分配的内存超过一定阈值时,GC 会自动触发。
- 定时器:GC 也可以定期触发,以确保程序不会长时间运行而出现内存问题。
在 GC 的标记和清理两个阶段中,STW 的解决方式略有不同。
-
标记
在标记阶段,Go 的 GC 使用三色标记算法进行回收。为了使标记阶段尽可能短且高效,Go 采用了并发标记和增量标记两种策略来减少 STW 的影响。
- 并发标记:在标记阶段,并发地标记和清除对象。这允许同时执行垃圾回收和程序代码,从而最大限度地减少 STW 时间。
- 增量标记:增量标记可以将标记阶段拆分成多个小步骤,垃圾回收器可以在每个小步骤之间恢复程序运行,这样可以更好地控制 STW 时间。
-
清理
在清理阶段,Go 的 GC 采用了三色标记-清除算法进行垃圾回收。为了尽可能地减少 STW 时间和避免内存碎片问题,Go 引入了两个概念:根对象和写屏障。
- 根对象:GC 将所有全局变量、栈、寄存器和程序计数器等标识为根对象,并将其作为垃圾回收的起点。这些对象是一定会被访问到的,因此可以保证它们不会被回收。
- 写屏障:当程序向一个指针类型的变量赋值时,会触发一个写屏障。写屏障可以用于检测对象是否从白色变为黑色,如果是,则需要将该对象加入到待清理列表中。
因此,在清理阶段期间,GC 只需要扫描根对象和待清理列表来确定哪些对象需要被释放即可,在这个过程中不需要遍历整个堆。这样可以显著减少 STW 时间和避免内存碎片问题。
4、GC 的触发时机
分为系统触发和主动触发。
1)gcTriggerHeap:当所分配的堆大小达到阈值(由控制器计算的触发堆的大小)时,将会触发。
2)gcTriggerTime:当距离上一个 GC 周期的时间超过一定时间时,将会触发。时间周期以runtime.forcegcperiod 变量为准,默认 2 分钟。
3)gcTriggerCycle:如果没有开启 GC,则启动 GC。
4)手动触发的 runtime.GC 方法。
5、说说 sync/singleflight
工具
sync/singleflight
是 Go 语言标准库中的一个并发控制工具,它提供了一种避免多个 Goroutine 同时执行相同的工作(例如远程调用或昂贵的计算)的功能,这被称为“单飞”模式。当有多个 Goroutine 同时请求相同的任务时,SingleFlight 会确保这些请求合并为一个,仅执行一次,并将结果广播给所有等待的 Goroutine。
十、内存相关
1、谈谈内存泄露,什么情况下内存会泄露?
go 中的内存泄漏一般都是 goroutine 泄漏,就是 goroutine 没有被关闭,或者没有添加超时控制,让 goroutine 一只处于阻塞状态,不能被 GC。
内存泄露有下面一些情况
- Goroutine泄漏:如果Goroutine在执行时被阻塞而无法退出,就会导致Goroutine的内存泄露,一个Goroutine的最低栈大小为2KB,在高并发的场景下,对内存的消耗也是非常恐怖的。
-
互斥锁未释放或者造成死锁会造成内存泄漏
-
资源未关闭:
- 如果程序打开了文件、数据库连接、网络连接或其他资源,但在使用完毕后没有及时调用对应的关闭方法(如
Close()
或Release()
),则这些资源所占用的内存在程序运行期间将无法被释放。
- 如果程序打开了文件、数据库连接、网络连接或其他资源,但在使用完毕后没有及时调用对应的关闭方法(如
-
循环引用:
- 若两个或多个对象之间形成循环引用,即使它们已经不再被其他任何变量引用,但由于彼此之间的强引用关系,垃圾回收器无法识别它们为可回收对象,这也会导致内存泄漏。
-
不正确使用channel:
-
从空channel读取数据,导致接收方 Goroutine 被阻塞并无法退出,间接引发内存泄漏。
-
向已满的channel写入数据,导致发送方 Goroutine 被阻塞并无法退出,间接引发内存泄漏。
-
-
字符串的截取引发临时性的内存泄漏
func main() {
var str0 = "12345678901234567890"
str1 := str0[:10]
}
- 切片截取引起子切片内存泄漏
func main() {
var s0 = []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := s0[:3]
}
- 函数数组传参引发内存泄漏【如果我们在函数传参的时候用到了数组传参,且这个数组够大(我们假设数组大小为 100 万,64 位机上消耗的内存约为 800w 字节,即 8MB 内存),或者该函数短时间内被调用 N 次,那么可想而知,会消耗大量内存,对性能产生极大的影响,如果短时间内分配大量内存,而又来不及 GC,那么就会产生临时性的内存泄漏,对于高并发场景相当可怕。】
2、怎么定位排查内存泄漏问题?
-
使用 go tool pprof
-
pprof
是 Go 自带的性能分析工具,可以帮助分析程序运行时的 CPU、内存和阻塞调用等情况。 -
具体做法是开启 pprof 服务(可以在代码中嵌入或者通过 HTTP 方式开启),然后使用命令行工具抓取内存快照:
go tool pprof http://localhost:port/debug/pprof/heap
获取 heap 快照后,可以使用 top、list、web 等命令分析内存分配详情,找出可能的内存泄漏点。
-
-
编写和运行单元测试
- 编写针对可能存在问题的模块或功能的单元测试,结合内存分析工具观察测试过程中的内存使用情况,通过重复执行测试,观察内存是否持续增长,以此来定位潜在的内存泄漏。
-
监控 Goroutine 数量
- 如果内存泄漏伴随着 Goroutine 数量的异常增长,可能是 Goroutine 泄漏或资源没有正确释放导致的。使用
runtime.NumGoroutine()
监控 Goroutine 数量,同时结合 pprof 查看 Goroutine 栈信息。
- 如果内存泄漏伴随着 Goroutine 数量的异常增长,可能是 Goroutine 泄漏或资源没有正确释放导致的。使用
3、知道 golang 的内存逃逸吗?什么情况下会发生内存逃逸?(必问)
内存逃逸:本该分配到栈上的变量,跑到了堆上,这就导致了内存逃逸。
栈是高地址到低地址,栈上的变量,函数结束后变量会跟着回收掉,不会有额外性能的开销。变量从栈逃逸到堆上,如果要回收掉,需要进行 gc,那么 gc 一定会带来额外的性能开销。编程语言不断优化 gc 算法,主要目的都是为了减少 gc 带来的额外性能开销,变量一旦逃逸会导致性能开销变大。
内存逃逸在 Go 语言中通常发生在以下几种情况:
- 函数返回局部变量的指针:
- 当函数内部创建了一个变量,并且函数返回的是指向这个局部变量的指针或引用时,由于返回的指针允许外部代码在函数结束后还能继续访问该变量,所以该变量必须在堆上分配,以保证其生命周期超过函数作用域。
- 外部引用:
- 函数内部引用了外部作用域的变量,并且这个外部变量有可能在函数执行完毕后仍被函数内部生成的对象所引用,这时编译器无法确定何时可以释放外部变量,故外部变量及其被引用的部分可能会在堆上分配。
- 闭包引用外部变量:
- 在闭包(匿名函数)中捕获了外部作用域的变量,如果闭包在其定义的函数返回后还能访问这些变量,那么这些变量的生命周期会延长至闭包被释放,因此它们可能会从栈上逃逸到堆上。
- 动态类型和接口转换:
- 当一个变量需要存储在 interface{} 类型中时,编译器无法预知其具体类型和大小,可能会导致逃逸到堆上。
- 切片扩容:
- 当切片(slice)在函数内部初始化后,由于 append 操作可能会导致其底层数组超出当前容量,编译器为了确保在函数返回后切片仍能正确工作,会将底层数组分配在堆上。
- 栈空间不足以容纳变量:
- 如果局部变量过大,超过编译器预设的栈空间大小限制,那么即使该变量的生命周期并未超出函数作用域,也会分配在堆上。
- 发送指针或包含指针的值到 channel:
- 当将一个局部变量的指针或者其他含有指针的复合类型值发送到 channel 中时,由于编译器无法得知何时 channel 的接收端会接收到数据,因此不能确定何时可以释放这些变量,因此可能导致内存逃逸。
3、请简述 Go 是如何分配内存的?
Go语言的内存分配机制:
- Go语言的内存管理主要涉及到堆内存的管理。Go语言抛弃了C/C++中的开发者管理内存的方式,实现了主动申请与主动释放管理,增加了逃逸分析和垃圾回收(GC),将开发者从内存管理中释放出来,让开发者有更多的精力去关注软件设计,而不是底层的内存问题¹。
- Go语言每次以
heapArena
为单位向虚拟内存申请内存空间,每次申请的内存单位为64MB。所有的heapArena
组成了mheap
(Go的堆内存)。Go语言是一个一个内存块(heapArena
)申请内存。 - Go的堆对象的分配内存采用线性分配或者链表分配的性能不高并且会出现内存碎片,Go语言中采用了分级分配的策略。将一个
heapArena
中划分成许多大小相等的小格子,空间大小相同的格子划分为一个等级⁴。
TCMalloc算法的思想:
- TCMalloc (Thread-Caching Malloc,线程缓存的malloc)是Google开发的内存分配算法库,最初作为Google性能工具库 perftools 的一部分,提供高效的多线程内存管理实现,用于替代操作系统的内存分配相关的函数(malloc、free,new,new []等),具有减少内存碎片、适用于多核、更好的并行性支持等特性⁷。
- TCMalloc将内存空间分成了不同尺寸大小的块,申请内存时会分配一个合适大小的内存块,这些空闲块通过链表维护。有些尺寸小的块是线程自主使用,不够用的时候才会从共有块中申请。
- TCMalloc特别对多线程做了优化,对于小对象的分配基本上是不存在锁竞争,而大对象使用了细粒度、高效的自旋锁(spinlock)。分配给线程的本地缓存,在长时间空闲的情况下会被回收,供其他线程使用,这样提高了在多线程情况下的内存利用率,不会浪费内存。
4、Channel 分配在栈上还是堆上?
在 Go 语言中,Channel(信道)本身是一个引用类型,它的实例始终分配在堆上。
5、Go 哪些对象分配在堆上,哪些对象分配在栈上?
- 栈上分配:
- 局部变量(包括简单类型如整数、浮点数、布尔值等)在函数内部创建且其生命周期仅限于函数内部时,通常会被分配在栈上。
- 小的、生命周期短的结构体或数组等,如果编译器通过逃逸分析确定它们不会被函数外部访问或其生命周期没有超出函数范围,也可能被分配在栈上。
- 堆上分配:
- 所有通过
new
或make
函数创建的对象都会分配在堆上,因为它们的生命周期不受函数调用栈的限制。 - 如果一个局部变量被指针引用并且这个指针可能会被函数外部持有,或者传递给其他goroutine,那么这个局部变量将会逃逸到堆上。
- 当局部变量的大小超过了编译器设置的栈空间大小限制时,也会被分配在堆上。
- 切片(slice)、通道(channel)、映射(map)等动态大小的数据结构,无论其生命周期如何,都在堆上分配。
- 结构体或数组即使在函数内部创建,如果它们作为函数返回值的一部分,或者被捕获在闭包中,也会发生逃逸,分配在堆上。
- 所有通过
准确地说,变量存储在堆上还是栈上,是由逃逸分析、作用域、生命周期以及变量的类型和大小等因素共同决定的,和具体的语法没有很大的关系。变量的生命周期和存储位置由以下几个因素决定:
- 作用域和生命周期:
- 如果变量是函数内的局部变量,并且它的生命周期仅限于函数执行期间,那么它通常会被分配在栈上。一旦函数执行结束,栈上的变量就会被自动释放。
- 如果变量的生命周期超出了当前函数的作用域(如通过返回指针、闭包引用等),则编译器可能会选择将其分配在堆上以确保在函数结束后的存活。
- 逃逸分析:
- Go 编译器通过逃逸分析来确定变量是否需要分配在堆上。即使一个变量原本应该分配在栈上,但如果它满足逃逸条件(如返回指针、被全局引用等),编译器就会将其分配在堆上。
- 类型和大小:
- 对于大型对象或动态大小的类型(如切片、映射、通道和某些结构体),它们总是存储在堆上,因为栈的大小有限,不适合存储这些大小不固定的对象。
6、介绍一下大对象小对象,为什么小对象多了会造成 gc 压力?
在 Go 语言中,内存分配通常会区分小对象和大对象:
- 小对象:指的是较小尺寸的内存分配请求。在 Go 语言中,小对象通常是指大小小于一定阈值(通常是 32KB 或由编译器设定)的对象,一般小对象通过 mspan 分配内存。
- 大对象:相比之下,大对象则是指大于上述阈值的内存分配请求,如大尺寸的数组或大型结构体等。大对象通常直接从全局堆(mheap)分配,并且分配和回收的开销相对较大。
小对象多了造成 GC(垃圾回收)压力的原因主要有以下几点:
- 内存碎片:小对象频繁分配和回收容易导致内存碎片,即使空闲内存总量足够,但由于分散成众多小块,难以分配给较大的内存请求,从而迫使 GC 更频繁地进行压缩和整理内存空间。
- 扫描成本增加:GC 在执行时需要遍历整个堆内存来标记活跃对象。小对象数量增多意味着有更多的内存区域需要扫描,这对 CPU 资源的需求更高,从而加大了 GC 期间的停顿时间(STW,Stop-The-World)。
- 元数据开销:每一个分配的小对象都需要额外的元数据来管理,包括但不限于对象头部信息、span(内存区块)管理信息等,这些都会增加堆内存的整体使用量。
- 同步开销:小对象分配频繁,可能导致线程本地缓存(mcache)与全局堆(mheap)之间的同步操作增多,尤其是在多线程环境下,同步锁的竞争可能导致性能下降。
- 分配/回收开销:频繁的小对象分配和回收操作涉及内存管理单元(如 mcache、mcentral 等)的查找、分配、合并等一系列操作,这些操作本身也会带来一定的计算开销。