公告

微信

欢迎大家私信交流

Skip to content

实现原理

init()函数

在main函数之前执行。

详解

init() 是特殊函数,由 Go 运行时系统自动调用,并且不能手动调用,在程序启动时执行一些初始化操作。

在main函数之前

  • 初始化不能采用初始化表达式初始化的变量
  • 程序运行前执行注册
  • 实现sync.Once功能
  • 不能被其它函数调用
  • 没有入口参数和返回值
  • 每个包可以有多个init函数,每个源文件也可以有多个init函数
  • 同一个包的init执行顺序,golang没有明确定义,编程时要注意程序不要依赖这个执行顺序
  • 不同包的init函数按照包导入的依赖关系决定执行顺序

按照以下规则执行:

  1. init() 函数在每个包(包括主包和其他包)中都可以定义,且可以定义多个 init() 函数。
  2. 每个包中的 init() 函数会在该包被导入时自动执行,且只会执行一次。当一个包被多个其他包导入时,该包的 init() 函数只会执行一次。
  3. init() 函数的执行顺序是按照包导入的顺序来确定的。对于同一个包中的多个 init() 函数,它们的执行顺序是不确定的。
  4. init() 函数在程序启动时执行,早于 main() 函数的执行。

init() 函数通常用于执行一些初始化操作,例如初始化全局变量、注册驱动程序、解析配置文件等。

执行顺序

import -> const -> var -> init() -> main()

如何知道一个对象是分配在栈上还是堆上

go局部变量会进行逃逸分析。

如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。

那么如何判断是否发生了逃逸呢?

go
go build -gcflags '-m -m -l' xxx.go.

关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。

如果变量内存占用较大时,优先放在堆上;如果函数外部没有引用,优先放在栈中;如果变量在函数外部存在引用;必定在堆中。

2个interface可以比较吗

interface的内部包含两个字段,类型T和值V。

interface可以使用 == 或 != 比较。

相等的情况:

  1. 两个interface都等于nil , V和T都处于unset
  2. 类型T相同,且对应的值V相等
go
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不相等。

go
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分为四个阶段:

  1. 准备标记(需要STW),开启写屏障
  2. 开始标记
  3. 标记结束(STW),关闭写屏障
  4. 清理(并发)

基于插入写屏障和删除写屏障在结束时需要STW来重新扫描栈,带来性能瓶颈。

混合写屏障

  1. GC开始时,将栈上的全部对象标记为黑色(不需要二次扫描,无需STW);
  2. GC期间,任何栈上创建的新对象均为黑色
  3. 被删除引用的对象标记为灰色
  4. 被添加引用的对象标记为灰色

总而言之就是确保黑色对象不能引用白色对象,这个改进直接使得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;

  1. 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
  2. 当原 slice 容量 < threshold 的时候,新 slice 容量变成原来的 2 倍;
  3. 当原 slice 容量 > threshold,进入一个循环,每次容量增加(旧容量+3*threshold)/4。

new和make的区别

  • new只用于分配内存,返回一个指向地址的指针。它为每个新类型分配一片内存,初始化为0且返回类型*T的内存地址,它相当于&T{}
  • make只可用于slice,map,channel的初始化,返回的是引用

go面向对象如何实现?

结构体+接口

面向对象三大思想:封装、继承、多态

封装:对于同一个包,对象对包内的文件可见;对不同的包,需要将对象以大写开头才是可见的

继承:编译时特征,在struct内加入所需要继承的类即可

多态:运行时特征,go多态通过接口实现。类型和接口是松耦合的,某个类型的实例可以赋给它所实现的任意接口类型的变量

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())
    }
}

为什么要定义空值?(没懂)

go
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来运行,减少空转,提高了资源利用率。

调度过程

  1. M尝试创建新的G,G会安排到这个线程的P的LRQ(本地队列),如果LRQ满了,会分配到全局队列(GRQ)
  2. 尝试获取当前线程的M,如果无法获取,就会从空闲的M列表中找一个,如果空闲列表也没有,就会创建一个M并绑定G和P运行
  3. 进入调度循环
  4. 找到一个合适的G
  5. 执行G,完成以后推出
  6. work stealing机制:当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程
  7. 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运行

上次更新于: