Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Go 进阶知识 #51

Open
zhongdeming428 opened this issue Jun 19, 2022 · 0 comments
Open

Go 进阶知识 #51

zhongdeming428 opened this issue Jun 19, 2022 · 0 comments

Comments

@zhongdeming428
Copy link
Owner

zhongdeming428 commented Jun 19, 2022

1 并发相关

1.1 MPG 模型

Go 语言通过 MPG 模型实现并发,MPG 模型的主要几个概念如下:

M:Machine ,Go 语言对内核线程的抽象,内核线程由操作系统内核分配给用户程序,线程的分配调度由操作系统负责,由于分配内核线程需要系统调用并将 CPU 切换到内核态,所以存在一定的开销。M 可以被系统调度分配给不同的 CPU 进行处理,所以可以并行执行而不是并发执行,效率会更高一些。M 的具体数量取决于执行时具体的并发规模以及设定的 runtime.GOMAXPROCS 变量的值,M 的数量最大也不会超过 runtime.GOMAXPROCS 变量的值。

P:Processor ,我理解为是 Go 语言实现的用户线程,也可以理解为是 Go 语言对于 CPU 的抽象,由 Go runtime 负责创建以及调度,这一过程的开销比 M 的创建开销要小,但是多个 P 的运行可能关联在同一个 M 上,所以并不能够保证能够实现并行执行。在运行时会将 P 分配给真正的内核线程 M 进行运行。

G:Goroutine ,Go 协程,可以理解为是并发的基本单元,由 P 负责执行。P 和 G 之间也是一对多的关系。

MPG 的关系图:

image

程序初始化过程中只会创建一个 M(申请一个内核线程),随着用户创建越来越多的协程,M 的数量也会随之增加,但是最多不会超过 runtime.GOMAXPROCS 的值。

当 M 当前没有绑定到任何 G 时,它将从全局可运行 G 队列中选择一个来运行。如果 G 在运行时被阻塞,比如系统调用,那么运行这个 G 的 M 就会被阻塞。这时会在全局空闲的 M 队列中唤醒一个 M 去运行其余被阻塞的 G(G 队列上的剩余),这样可以保证当某个 M 被阻塞时,该 M 的运行队列中剩余的 G 不会被阻塞,可以切换到另一个 M 运行。当 G0 从系统调用中返回时,M0 会试图从其他地方拿一个 P 来继续执行 G0,如果拿不到的话就会把 G0 放到全局的可运行 G 队列中等待空闲的 P 把它捞起来继续执行。

image

上图中(左边)的 M0 在执行 G0 时发现 G0 由于系统调用被阻塞了,所以 M0 上的其余 G 会分配给一个新的 M 去执行(右图),避免剩余的 G 因为 G0 的阻塞而被阻塞。

阻塞在 channel 的 G 的处理方式和阻塞在系统调用的 G 的处理方式时不一样的,阻塞在系统调用的 G 必须等待内核数据返回之后才能继续执行,所以需要一直在 M 上等待。但是 channel 是 Go 语言自己实现的模型,与内核无关,当 G 阻塞在 channel 时不需要挂在 M 上继续等待,这个 G 会被 runtime 标记为 waiting,然后 M 继续执行其他 G;当 channel 不再阻塞时,G 会被重新标记为 runnable 并等待挂载到 M 上继续执行。

Go 还有均衡的分配策略,当 M 之间挂载的 G 数量差异较大,任务分配不均衡时,会让任务少的 M 直接从任务多的 M 中拿一部分过来运行。

参考:

1.2 抢占式调度

Go 在一开始的设计当中没有考虑要设计 G 之间的抢占式调度,用户需要主动通知 runtime 让出执行权,这里存在的一个问题就是当一个 G 死循环阻塞在 M 上的时候,后续的其他工作就可能会被阻塞,比如 GC。

为了解决这个问题,Go 在 1.14 版本引入了基于信号量的抢占式调度机制。Go程序启动时,runtime会去启动一个名为 sysmon(应该是 system monitor 的意思?) 的 M(一般称为监控线程),该 M 无需绑定 P 即可运行,在整个 Go 程序的运行过程中至关重要:

//$GOROOT/src/runtime/proc.go
 
// The main goroutine.
func main() {
     ... ...
    systemstack(func() {
        // newm 创建了一个新的 M 去执行 sysmon
        newm(sysmon, nil)
    })
    .... ...
}
 
// Always runs without a P, so write barriers are not allowed.
//
//go:nowritebarrierrec
func sysmon() {
    // If a heap span goes unused for 5 minutes after a garbage collection,
    // we hand it back to the operating system.
    scavengelimit := int64(5 * 60 * 1e9)
    ... ...
 
    if  .... {
        ... ...
        // retake P's blocked in syscalls
        // and preempt long running G's
        if retake(now) != 0 {
            idle = 0
        } else {
            idle++
        }
       ... ...
    }
}

sysmon每 20us~10ms 启动一次,它需要做的事情有很多,其中很重要的一件事就是发现执行时间过长的 G 并抢占它的执行权。

// forcePreemptNS is the time slice given to a G before it is
// preempted.
const forcePreemptNS = 10 * 1000 * 1000 // 10ms
 
func retake(now int64) uint32 {
          ... ...
           // Preempt G if it's running for too long.
            t := int64(_p_.schedtick)
            if int64(pd.schedtick) != t {
                pd.schedtick = uint32(t)
                pd.schedwhen = now
                continue
            }
            if pd.schedwhen+forcePreemptNS > now {
                continue
            }
            preemptone(_p_)
         ... ...
}

可以看到当一个 G 运行的时间超过了 10ms,会被抢占调度并放入等待运行的 G 队列中。这样就确保了 G 被阻塞时不会影响到其他工作的进行。

参考:https://www.cnblogs.com/sunsky303/p/11058728.html

1.3 内联编译

简单的函数可能会被编译器内联优化以提高程序性能。

内联优化是指将简单的函数调用修改成直接执行函数体内的代码,避免了函数运行带来的栈操作的开销,缺点在于会增加代码的体积。

性能优化验证代码:

//go:noinline
func maxNoinline(a, b int) int {
    if a < b {
        return b
    }
    return a
}
 
func maxInline(a, b int) int {
    if a < b {
        return b
    }
    return a
}
 
func BenchmarkNoInline(b *testing.B) {
    x, y := 1, 2
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        maxNoinline(x, y)
    }
}
 
func BenchmarkInline(b *testing.B) {
    x, y := 1, 2
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        maxInline(x, y)
    }
}

如果想要禁用内联优化,只需要在程序代码中的函数声明前增加一行 //go:noinline 即可。

参考:https://segmentfault.com/a/1190000039146279

2 range

2.1 闭包问题

range 遍历数据结构时,暂存元素的变量是同一个:

image

存在的问题是会导致如果内部有匿名函数调用的话,访问的数据是同一片内存地址,异步执行的函数访问到的变量的值可能与预期不符。

为了解决这个问题,在执行匿名函数时,通过函数入参传参的形式将外部变量的值拷贝赋值后传入到匿名函数内部。

2.2 遍历删除问题

在迭代过程中删除 slice 元素会导致迭代过程不符合预期,所以最好不要在迭代过程中操作切片,如果需要对切片进行过滤等操作,可以新建一个切片。

3 Slice

3.1 共享内存

slice 是引用数据类型,其底部引用数组存放数据,如果我们从同一个数组进行切割创建多个切片,多个切片底层引用的会是同一个数组。当我们修改这几个切片的数据时,会引起其他切片数据以及原数组数据的变化,这不是我们期望的行为。

3.2 内存泄漏

当我们从一个大数组切割创建小的切片时,如果我们不释放小切片的内存,原来的大数组的数据也一直不会被回收,这种场景可能会导致内存泄漏。

3.3 扩容问题

切片频繁 append 可能会导致性能问题,当 append 操作导致切片底层数组不足以存放新数据的时候,会进行扩容:先申请一个容量为原来两倍或者 2.25 倍(容量大于 1024 的时候)的新的数组,然后将原数组的数据拷贝到新的数组,这一过程会引起较大的开销。所以频繁对 slice 进行 append 操作时需要注意,最好在一开始创建 slice 的时候就设置一个合理的 cap 值,避免频繁扩容。

本质上上面的三个问题都是由于切片引用底层数组所导致的。

4 方法本质

Go 语言没有类,方法和类型通过 receicer 联系在一起,Go 语言方法的本质还是函数,在实际调用时方法的 receiver 会被作为第一个参数传入对应的方法。

type T struct { 
        a int
}
 
func (t T) Get() int {  
        return t.a 
}
 
func (t *T) Set(a int) int { 
        t.a = a 
        return t.a 
}

上方的方法在实际编译后等价于下方函数:

func Get(t T) int {  
        return t.a 
}
 
func Set(t *T, a int) int { 
        t.a = a 
        return t.a 
}

这一过程由编译器在编译过程中实现。

所以 Go 语言中方法的本质是一个以方法所绑定类型实例为第一个参数的普通函数。

参考:https://www.helloworld.net/p/b3wAImKtj2ij5

5 interface 相关

接口在 Go 语言中是一个很重要的概念,接口的存在使得 Go 语言代码可以变得十分优雅,隐式的接口实现也使得 Go 语言的接口继承更加简洁。

下面关注下 Go 语言接口的声明与实现以及简单的接口实现原理。

5.1 实现接口

声明一个接口很简单:

type Person interface {
    Eat (food string)
    Drink (liquid string)
    Sleep (start, end time.Time)
}

实现上方的接口:

type Pers struct{}
 
func (p Pers) Drink(liquid string) {
    println("dundundun!")
}
 
func (p Pers) Eat(food string) {
    println("yummy!")
}
 
func (p Pers) Sleep(start, end time.Time) {
    fmt.Printf("sleep from %v to %v", start, end)
}
 
var p Person = Pers{}

上方通过实现 receiver 为 Pers 结构体的方法来实现接口,所以我们还可以通过结构体指针的形式初始化 p:

var p Person = &Pers{} // this is ok

但是当我们通过实现 receiver 为 Pers 结构体指针的方法来实现接口时,就只能通过结构体指针的形式初始化 p 变量:

type Pers struct{}
 
func (p *Pers) Drink(liquid string) {
    println("dundundun!")
}
 
func (p *Pers) Eat(food string) {
    println("yummy!")
}
 
func (p *Pers) Sleep(start, end time.Time) {
    fmt.Printf("sleep from %v to %v", start, end)
}

初始化 Pers 结构体实现 Person 接口:

var p Person = &Pers{} // this is ok
var p Person = Pers{} // error! cannot use (Pers literal) (value of type Pers) as Person value in variable declaration: missing method Drink (Drink has pointer receiver)

这是因为当我们通过实现 receiver 为结构体指针的方法来实现接口,方法内部可能会通过 receiver 修改结构体内部的值,如果初始化 p 变量时传递的是一个结构体而非指针,编译器不能根据结构体推导出结构体指针来传入方法内部(按值传递,结构体的地址不等同于最开始初始化时的结构体地址了),也就不能说结构体类型的值实现了这个接口了。

实现接口和初始化接口变量的四种场景:

结构体实现接口 结构体指针实现接口
结构体初始化变量 通过 不通过
结构体指针初始化变量 通过 不通过

5.2 基本原理

Go 语言的空接口不是任意类型。 当我们声明一个空接口类型的值时,表示我们在使用时暂不关心传入类型的值的类型,到了运行时才去判断。

Go 的编译器如何在运行时知道对应变量的类型呢?

Go 通过下面的数据结构表示空接口类型的变量:

type eface struct { // 16 字节
    _type *_type
    data  unsafe.Pointer
}

data 是指向数据值的具体指针,_type 字段表示值的真实类型;_type类型是 Go 表示数据类型的数据结构,包含了很多类型的元信息, 比如类型的大小、哈希、对齐以及种类等。

所以 Go 在运行时可以同时获取到空接口变量的类型信息和原始数据,这也是 Go 实现反射的基础之一。

回顾前文提到的问题:

package main
 
func main() {
    var a *string
    var b interface{} = a
 
    println(a == nil, b == nil) // true false
}

这是因为当我们将 a 赋值给 b 时会发生类型转换,b 会存储 a 变量的类型信息(*string)和数据信息(nil),只有当 b 的类型信息和数据信息都为 nil 时,b 才等于 nil。所以上方的代码中 b != nil。

非空接口的运行时表示如下:

type iface struct { // 16 字节
    tab  *itab
    data unsafe.Pointer
}
 
type itab struct { // 32 字节
    inter *interfacetype
    _type *_type
    hash  uint32
    _     [4]byte
    fun   [1]uintptr
}

data 同样表示指向原始数据值的指针,tab 存储的信息很多,包含接口方法(fun 数组)、数据类型(inter 和 _type)等。

参考:https://draveness.me/golang/docs/part2-foundation/ch04-basic/golang-interface

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant