实现原理
init()函数
在main函数之前执行。
详解
init()
是特殊函数,由 Go 运行时系统自动调用,并且不能手动调用,在程序启动时执行一些初始化操作。
在main函数之前
- 初始化不能采用初始化表达式初始化的变量
- 程序运行前执行注册
- 实现sync.Once功能
- 不能被其它函数调用
- 没有入口参数和返回值
- 每个包可以有多个init函数,每个源文件也可以有多个init函数
- 同一个包的init执行顺序,golang没有明确定义,编程时要注意程序不要依赖这个执行顺序
- 不同包的init函数按照包导入的依赖关系决定执行顺序
按照以下规则执行:
init()
函数在每个包(包括主包和其他包)中都可以定义,且可以定义多个init()
函数。- 每个包中的
init()
函数会在该包被导入时自动执行,且只会执行一次。当一个包被多个其他包导入时,该包的init()
函数只会执行一次。 init()
函数的执行顺序是按照包导入的顺序来确定的。对于同一个包中的多个init()
函数,它们的执行顺序是不确定的。init()
函数在程序启动时执行,早于main()
函数的执行。
init()
函数通常用于执行一些初始化操作,例如初始化全局变量、注册驱动程序、解析配置文件等。
执行顺序
import -> const -> var -> init() -> main()
如何知道一个对象是分配在栈上还是堆上
go局部变量会进行逃逸分析。
如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。
那么如何判断是否发生了逃逸呢?
go build -gcflags '-m -m -l' xxx.go.
关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。
如果变量内存占用较大时,优先放在堆上;如果函数外部没有引用,优先放在栈中;如果变量在函数外部存在引用;必定在堆中。
2个interface可以比较吗
interface的内部包含两个字段,类型T和值V。
interface可以使用 == 或 != 比较。
相等的情况:
- 两个interface都等于nil , V和T都处于unset
- 类型T相同,且对应的值V相等
type Stu struct{ Name string }
type StuInt interface{}
// ?没有很懂?
func main() {
var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"}
var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"}
// stu1 和 stu2 对应的类型是 *Stu,值是 Stu 结构体的地址,两个地址不同,因此结果为 false
fmt.Println(stu1 == stu2) // false
// stu3 和 stu4 对应的类型是 Stu,值是 Stu 结构体,且各字段相等,因此结果为 true
fmt.Println(stu3 == stu4) // true
}
2个nil可能不相等吗?
可能不相等。
接口在运行时绑定值,只有值为nil的情况下接口值才为nil,但是与指针的nil不相等。
func main() {
var p *int = nil
var i interface{} = nil
if p == i {
fmt.Println("Equal")
} else {
fmt.Println("Not Equal")
}
}
简述go的gc工作原理(不懂)
垃圾回收机制是go的一大难点。
go1.3采用标记清除法
分为两个阶段:标记和清除
- 标记:从根对象出发并标记所有存活的对象
- 清除:遍历堆中的对象,回收未标记的对象,并加入空闲链表
缺点:需要暂停程序
go1.5采用三色标记法
对象分为3种颜色:
- 白:不确定对象
- 灰:存活对象,子对象待处理
- 黑:存活对象
标记开始时,先将所有对象加入白色集合(需要STW)。 首先将根对象标记为灰色,然后将一个对象从灰色集合取出,遍历其子对象,放入灰色集合。 同时将取出的对象放入黑色集合,直到灰色集合为空。 最后的白色集合对象就是需要清理的对象。
方法有一个缺陷,如果对象的引用被用户修改,那么之前的标记就无效了。故而引出写屏障技术
go1.8采用三色标记法+混合写屏障
当对象新增或者更新会将其着色为灰色。
写屏障技术
一次完成的GC分为四个阶段:
- 准备标记(需要STW),开启写屏障
- 开始标记
- 标记结束(STW),关闭写屏障
- 清理(并发)
基于插入写屏障和删除写屏障在结束时需要STW来重新扫描栈,带来性能瓶颈。
混合写屏障
- GC开始时,将栈上的全部对象标记为黑色(不需要二次扫描,无需STW);
- GC期间,任何栈上创建的新对象均为黑色
- 被删除引用的对象标记为灰色
- 被添加引用的对象标记为灰色
总而言之就是确保黑色对象不能引用白色对象,这个改进直接使得GC时间从2s降低到2us。
函数返回局部变量的指针是否安全
安全。
进行逃逸分析,如果发现局部变量的作用域超过该函数则会把指针分配到堆区,避免内存泄漏。
非接口的任意类型T()都能调用*T的方法?反过来呢?(不懂)
一个T类型的值可以调用*T类型声明的方法,当且仅当T是可寻址的。
反之: *T可以调用T()的方法,因为指针可以解引用
go slice是怎么扩容的?(不懂)
1.7版本
如果当前容量小于1024,则判断所需容量是否大于原来容量2倍,如果大于,当前容量加上所需容量;否则当前容量乘2。
如果当前容量大于1024,则每次按照1.25倍速度递增容量,也就是每次加上cap/4。
1.8版本
1.18不再以1024为临界点。
而是设定了一个值为256的threshold
,以256为临界点;
超过256,不再是每次扩容1/4,而是每次增加(旧容量+3*256)/4;
- 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
- 当原 slice 容量 < threshold 的时候,新 slice 容量变成原来的 2 倍;
- 当原 slice 容量 > threshold,进入一个循环,每次容量增加(旧容量+3*
threshold
)/4。
new和make的区别
- new只用于分配内存,返回一个指向地址的指针。它为每个新类型分配一片内存,初始化为0且返回类型*T的内存地址,它相当于
&T{}
- make只可用于slice,map,channel的初始化,返回的是引用
go面向对象如何实现?
结构体+接口
面向对象三大思想:封装、继承、多态
封装:对于同一个包,对象对包内的文件可见;对不同的包,需要将对象以大写开头才是可见的
继承:编译时特征,在struct内加入所需要继承的类即可
多态:运行时特征,go多态通过接口实现。类型和接口是松耦合的,某个类型的实例可以赋给它所实现的任意接口类型的变量
go支持多重继承,就是在类型中嵌入所有必要的父类型
代码实现
// Animal 定义一个接口
type Animal interface {
Sound() string
}
// Dog 定义一个结构体
type Dog struct {
Name string
}
// Sound 实现Animal接口的方法
func (d *Dog) Sound() string {
return "汪汪汪!"
}
// Cat 定义另一个结构体
type Cat struct {
Name string
}
// Sound 实现Animal接口的方法
func (c *Cat) Sound() string {
return "喵喵喵!"
}
func main() {
// 创建结构体指针实例
dog := &Dog{Name: "旺财"}
cat := &Cat{Name: "小花"}
// 使用Animal接口类型的指针变量来存储不同的结构体指针实例
animals := []Animal{dog, cat}
// 遍历动物列表,调用Sound()方法
for _, animal := range animals {
fmt.Println(animal.Sound())
}
}
为什么要定义空值?(没懂)
type GobCodec struct{
conn io.ReadWriteCloser
buf *bufio.Writer
dec *gob.Decoder
enc *gob.Encoder
}
type Codec interface {
io.Closer
ReadHeader(*Header) error
ReadBody(interface{}) error
Write(*Header, interface{}) error
}
var _ Codec = (*GobCodec)(nil)
将nil转换为*GobCodec
类型,然后再转换为Codec
接口,如果转换失败,说明*GobCodec
没有实现Codec
接口的所有方法。
简述go内存管理机制
Go的内存管理是基于内存池的概念。优化有:自动伸缩内存池大小,合理的切割内存块等
一些基本概念:
- span:内存块。一个或多个连续的page组成一个span。
- page:一块8K大小的内存空间。Go向操作系统申请和释放内存都是以页为单位的。
- sizeclass:空间规格。每个span都带有一个sizeclass,标记着该span中的page应该如何使用。
- object:对象,用来存储一个变量数据内存空间。
将page比喻成工人。
span可看成是小队,工人可组成了若干个工人小队,不同的队伍干不同的活。
sizeclass标志着span是一个什么样的小队。
一个span在初始化时,会被切割成一堆等大的object。
假设object的大小是16B,span大小是8K。那么span中的page会被初始化 8K/16B = 512个object 。
所谓内存分配,就是分配一个object出去。
mheap 全局堆
一开始go从操作系统索取一大块内存作为内存池,并放在一个叫mheap的内存池进行管理整个堆内存的分配和释放。
mheap将一整块内存切割为不同的区域,并将一部分内存切割为合适的大小。
- mheap.spans:用于存储page和span的信息。每个span代表一组连续的页,用于存储对象。span的信息包括起始地址、页的数量以及已使用的大小等。
- mheap.bitmap:存储了每个span中对象的标记信息,例如对象是否可回收等。
- mheap.arena_start:将要分配给应用程序使用的内存空间的起始地址。
mcentral 中央堆
mcentral负责管理不同大小类别(sizeclass)的span,以便有效地分配和回收适合大小的对象
每个sizeclass对应一种特定的对象大小范围。mcentral会将相同size class的span以链表的形式组织起来,并存放在其中。
当需要分配一个特定大小的对象时,Go的内存管理器会根据对象的大小选择合适的sizeclass,并从对应的mcentral中获取一个span。
然后,从这个span中取出一个空闲的对象返回给应用程序使用。
mcache
在Go语言中,为了提高内存并发申请的效率,引入了一个缓存层mcache。每个mcache与处理器P(Processor)对应。
当一个goroutine需要分配内存时,它首先会尝试从关联的处理器P的mcache中获取可用的span。mcache是goroutine私有的,每个goroutine都有自己独立的mcache。在mcache中分配对象相对较快,因为它是与当前goroutine直接关联的,无需进行额外的锁定或竞争。
如果在mcache中没有可用的span,那么这个goroutine会从mcentral中获取span。mcentral是用于管理不同大小类别的span的组件,它负责将相同sizeclass的span组织成链表,并存放在其中。
通过引入mcache缓存层,可以减少对mcentral的频繁访问,提高内存分配的效率。每个goroutine都有自己的mcache,避免了多个goroutine之间的竞争和锁的开销,从而提高了并发性能。因此,为了提高内存并发申请的效率,Go语言采用了mcache作为缓存层,使得内存分配可以更快速地在goroutine的本地缓存中完成,减少对mcentral的访问。这样可以提高整体的性能和并发能力。
mutex有几种模式
go如何进行调度?GMP状态流转?
GMP
- G:协程goroutine
- 状态
- Gidle:刚刚被分配并且还没有被初始化,值为0,为创建goroutine后的默认值
- Grunnable:没有执行代码,没有栈的所有权,存储在运行队列中,可能在某个P的本地队列或全局队列中
- Grunning:正在执行代码的goroutine,拥有栈的所有权
- Gsyscall:正在执行系统调用,拥有栈的所有权,与P脱离,但是与某个M绑定,会在调用结束后被分配到运行队列
- Gwaiting:被阻塞的goroutine,阻塞在某个channel的发送或者接收队列
- Gdead:当前goroutine未被使用,没有执行代码,可能有分配的栈,分布在空闲列表gFree,可能是一个刚刚初始化的goroutine,也可能是执行了goexit退出的goroutine
- Gcopystac:栈正在被拷贝,没有执行代码,不在运行队列上,执行权在系统线程上
- Gscan: GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在
- 状态
- M:线程Thread
- 状态:
- 自旋线程:处于运行状态但是没有可执行goroutine的线程,数量最多为GOMAXPROC,若是数量大于GOMAXPROC就会进入休眠
- 非自旋线程:处于运行状态有可执行goroutine的线程
- 状态:
- P:调度器Processor
- 状态:
- Pidle:处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
- Prunning:被线程 M 持有,并且正在执行用户代码或者调度器
- Psyscall:没有执行用户代码,当前线程陷入系统调用
- Pgcstop :被线程 M 持有,当前处理器由于垃圾回收被停止
- Pdead :当前处理器已经不被使用
- 状态:
调度器是M和G的桥梁
参考资料:https://juejin.cn/post/6968311281220583454
- 每个P有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而GM模型的性能开销大头就是锁竞争。
- 每个P相对的平衡上,在GMP模型中也实现了Work Stealing算法,如果P的本地队列为空,则会从全局队列或其他P的本地队列中窃取可运行的G来运行,减少空转,提高了资源利用率。
调度过程
- M尝试创建新的G,G会安排到这个线程的P的LRQ(本地队列),如果LRQ满了,会分配到全局队列(GRQ)
- 尝试获取当前线程的M,如果无法获取,就会从空闲的M列表中找一个,如果空闲列表也没有,就会创建一个M并绑定G和P运行
- 进入调度循环
- 找到一个合适的G
- 执行G,完成以后推出
- work stealing机制:当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程
- hand off机制:当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行
G一直占用资源怎么办?什么是work stealing算法?
当一个Goroutine一直占用资源,会导致其他Goroutine无法执行。
这时GMP模型会从正常模式转变为饥饿模式,采取一些策略解决问题。
work stealing策略原理:当一个线程处于空闲状态时,它会从其他正在忙碌的队列中偷取(steal)一个Goroutine任务来执行。这样做的目的是为了充分利用系统资源,提高并发执行的效率。 它首先会尝试从其他P的本地队列中偷取Goroutine任务,如果没有可偷取的任务,则会从全局队列中获取
go什么时候发生阻塞?阻塞时调度器会怎么做
由于原子、互斥量或通道操作导致协程阻塞,调度器将把当前阻塞的协程从LRQ换出,并且重新调度其他协程
由于网络请求或者IO导致的阻塞,go提供了网络轮询器来处理,后台用epoll等技术实现IO多路复用
channel阻塞:当goroutine读写channel发生阻塞时,会调用gopark函数,当G脱离当前的M和P时,调度器将新的G放入当前M
系统调用:当某个G由于系统调用陷入内核态,该P就会脱离当前M,此时P会更新自己的状态为Psyscall,而M与G相互绑定,进行系统调用。结束以后,若该P的状态还是Psyscall,则直接关联M和G,否则使用闲置的处理器处理该G
系统监控:当某个G在P上运行的时间超过10ms的时候,或者某个P处于Psyscall状态过长等情况就会调用remake函数,触发新的调度
主动让出:由于是协作式调度,该G会主动让出当前P(通过
runtime.Gosched()
),更新状态为Grunnable,该P会调度队列中的G运行