lzh2nix / articles

用 issue 来管理个人博客
https://github.com/lzh2nix/articles
61 stars 13 forks source link

golang 强筋壮骨系列之 Low Level Concerns(高级) #80

Open lzh2nix opened 4 years ago

lzh2nix commented 4 years ago

目录

Go's work-stealing scheduler(2020.09.09) The Go scheduler(2020.09.09) Address Alignments in Go(2020.09.10) The Go scheduler II(2020.09.10) Anatomy of a function call in Go(2020.09.12) Scheduler Tracing in Go(2020.09.12) unsafe.Pointer and system calls(2020.09.12)

lzh2nix commented 4 years ago

golang work-stealing调度(2020.09.09)

原文: https://rakyll.org/scheduler/

关于调度这块看来好多文章,就是不知道如何下手。看这种概要性设计文章还好,但是看源码分析性的文章的时候就一头污水。想来想去还是回到之前的模式 ”看文章写总结“,暂时不去关心太底层的东西。

golang GMP模型

调度的目的就是充分利用计算机的资源,从 1.1 开始 golang 的调度器就是一个 M:N 的调度器, 这里的 N 是通过 GOMAXPROCS 指定的。 G: goroutine M: 系统线程 P: process

之前一直不太理解这里为啥叫process,已经他和cpu的核数有啥关系,看了这篇文章才发现这里的 P 只是一个虚拟的概念,理解成 M 执行的上下文环境 确实更合适一点,M 执行的时候需要获得一个P。 调度规则

  1. check lock queue
  2. try to steal from other Ps
  3. check global runnable queue(由于 global 这里需要加锁,61次才去check一次)

Stealing

steal 的原则就是随机选择一个P然后偷一半的goroutine 放到local queue里

Spining threads

为了避免os不断的调度 thread 带来的性能损耗(上下文切换+系统调用block的中恢复),go这边在以下几种场景里会spinning thread

  1. M获取P需要可运行的G
  2. M寻找一个可用的P
  3. 有一个idle的P但是没有空闲的M(这时候spin结束之后M可以立马找到工作)
lzh2nix commented 4 years ago

golang 调度器(2020.09.09)

原文: https://morsmachine.dk/go-scheduler

关于调度器的描述性文章都差不多,基本上就是对 GMP 的再解释。

GMP 的描述可以看前面的那篇文章

系统调用

当发生系统调用的时候当前的 M 会 block 在系统调用上,他持有的P会 handoff 给空闲的M。当从系统调用返回的时候,先看能不能从其他M偷一个P过来, 如果能获得一个P就将G放到这个P的local queue里,否则就将 G 放到 global queue里去。

这也是 go 需要多个os thread的原因,有些 M 处在等待任务的状态,如果某个 M 由于系统调用而block了, 立马接手该 M 持有的 P。

lzh2nix commented 4 years ago

golang 中的地址对齐(2020.09.10)

原文: https://go101.org/article/memory-layout.html

字节对齐这块go还是继承了c很像。语言层面并没有定义如何去对齐,都是编译器完成的。

go中个类型占用空间的大小(字节) type size in bytes
byte, uint8, int8 1
uint16, int16 2
uint32, int32, float32 4
uint64, int64 8
float64, complex64 8
complex128 16
uint, int implementation-specific,generally 4 on 32-bit architectures, and 8 on 64-bit architectures.
uintptr implementation-specific,large enough to store the uninterpreted bits of a pointer value. 在32位系统中4字节对齐, 在64位系统中8字节对齐,字节对齐的目的就是保证struct占用的内存大小是4(32位系统)或者8(64位系统)的倍数

可以通过unsafe.Alignof(t)来获取struct是几字节对齐。比如:

package main

import (
    "fmt"
    "unsafe"
)

type T1 struct {
    a int8
    b int64
    c int16
}
// 8字节对齐系统中 24=(1+7+8+2+6), 其中7,2都是为了对齐而做的填充
// 8字节对齐系统中 16=(1+3+8+2+2)

func main() {
    var t T1
    fmt.Println(unsafe.Alignof(t))
}
lzh2nix commented 4 years ago

another golang schedule(2020.09.10)

原文: https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html

另外一篇介绍golang shedule的文章,个人觉得各种调度讲的最清楚的要数国人写的 https://studygolang.com/articles/26921这篇文章

goroutine的三个状态: Waiting 等待一些被block的东西结束(syscall, mutex...) Runnable 放到GRQ/LRQ里的goroutine Executing 执行中

几个改变调度的行为:

异步的系统调用(尤其是和网络相关的)会交给network poller去完成(依赖操作系统的异步io模型), 当前的M会去执行当前P的LRQ里的其他goroutine,如果异步io完成了就继续放到P的LRQ里等等执行。

lzh2nix commented 4 years ago

解剖golang函数调用(2020.09.12)

原文: https://syslog.ravelin.com/anatomy-of-a-function-call-in-go-f6fc81b80ecc

简单介绍一下golang中函数调用相关的汇编过程。每个go routine都有自己的栈空间。在每次发生函数调用是通过当前SP(stack point)-需要的空间来给调用的函数分配栈空间。函数结束之后回收栈空间

0x8(SP) = sp地址+8 的内存地址

函数中没有局部变量

package main

func add(x, y int64) int64 {
    return x + y
}

func main() {
    add(int64(1), int64(2))
}

go build -gcflags '-N -l' . go tool objdump -s main go-assmembly 得到的汇编代码如下(只保留main和add部分的代码):

TEXT main.add(SB) /home/qx/code/golang/go-assmembly/add.go
  add.go:3      0x45da40        48c744241800000000  MOVQ $0x0, 0x18(SP) // 返回值默认为0
  add.go:4      0x45da49        488b442408      MOVQ 0x8(SP), AX // AX = a  
  add.go:4      0x45da4e        4803442410      ADDQ 0x10(SP), AX // Ax = Ax + b
  add.go:4      0x45da53        4889442418      MOVQ AX, 0x18(SP) // SP+0x18 = AX   
  add.go:4      0x45da58        c3          RET         

TEXT main.main(SB) /home/qx/code/golang/go-assmembly/add.go
  add.go:7      0x45da60        64488b0c25f8ffffff  MOVQ FS:0xfffffff8, CX          
  add.go:7      0x45da69        483b6110        CMPQ 0x10(CX), SP           
  add.go:7      0x45da6d        7631            JBE 0x45daa0                
  add.go:7      0x45da6f        4883ec20        SUBQ $0x20, SP // 开辟4 byte的空间给新的函数(2个参数+1个返回值+保存BP寄存器)              
  add.go:7      0x45da73        48896c2418      MOVQ BP, 0x18(SP) // 保护当前的BP寄存器         
  add.go:7      0x45da78        488d6c2418      LEAQ 0x18(SP), BP           
  add.go:8      0x45da7d        48c7042401000000    MOVQ $0x1, 0(SP) // 参数a赋值为1(已经在函数参数栈上了) 
  add.go:8      0x45da85        48c744240802000000  MOVQ $0x2, 0x8(SP) // 参数a赋值为2(已经在函数参数栈上了)   
  add.go:8      0x45da8e        e8adffffff      CALL main.add(SB) // 调用add          
  add.go:9      0x45da93        488b6c2418      MOVQ 0x18(SP), BP // 恢复BP寄存器            
  add.go:9      0x45da98        4883c420        ADDQ $0x20, SP  // 释放栈          
  add.go:9      0x45da9c        c3          RET                 
  add.go:7      0x45da9d        0f1f00          NOPL 0(AX)              
  add.go:7      0x45daa0        e8dbaeffff      CALL runtime.morestack_noctxt(SB)   
  add.go:7      0x45daa5        ebb9            JMP main.main(SB)           

这里 CALL main.add(SB)需要注意一下,CALL 指令回将SP寄存器会下移一个WORD(x86_64上是8byte),然后进入新函数的栈空间运行,这也是在add中main中两个参数放到SP+0, SP+8的位置,但是在add函数中从SP+8和SP+0x10中取的原因。

函数中有局部变量

package main

func add10(x int64) int64 {
    y := int64(10)
    return x + y
}

func main() {
    add10(int64(10))
}

对应的汇编代码(这里之帖了add10相关的代码)

//这里在函数add10中又会为内部变量开辟栈空间
TEXT main.add10(SB) /home/qx/code/golang/go-assmembly/add.go
  add.go:3      0x45da40        4883ec10        SUBQ $0x10, SP // 为内部变量开辟栈空间(变量+BP)     
  add.go:3      0x45da44        48896c2408      MOVQ BP, 0x8(SP) // 保存BP    
  add.go:3      0x45da49        488d6c2408      LEAQ 0x8(SP), BP    
  add.go:3      0x45da4e        48c744242000000000  MOVQ $0x0, 0x20(SP) //初始化返回值
  add.go:4      0x45da57        48c704240a000000    MOVQ $0xa, 0(SP)    // y 放到SP+0处
  add.go:5      0x45da5f        488b442418      MOVQ 0x18(SP), AX // AX = x(SP+0x18)    
  add.go:5      0x45da64        4883c00a        ADDQ $0xa, AX   //  AX = AX+10(其实这里经过优化之后y变量直接没有用到)
  add.go:5      0x45da68        4889442420      MOVQ AX, 0x20(SP) // 返回值放到 SP+0x20  
  add.go:5      0x45da6d        488b6c2408      MOVQ 0x8(SP), BP // 恢复 BP   
  add.go:5      0x45da72        4883c410        ADDQ $0x10, SP  // 恢复 SP    
  add.go:5      0x45da76        c3          RET 

对于函数调用首先在使用沾来传递函数参数,在被调用函数中如果没有局部变量就直接使用寄存器,否则需要再分配栈空间。

lzh2nix commented 4 years ago

解码scheduler tracing (2020.09.12)

原文: https://www.ardanlabs.com/blog/2015/02/scheduler-tracing-in-go.html

golang 提供了强大的debug,连他的scheduler 都可以trace,而且trace又是超级方便的。

package main

import (
    "sync"
    "time"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(100)

    for i := 0; i < 100; i++ {
        go work(&wg)

    }

    wg.Wait()
    time.Sleep(3 * time.Second)

}
func work(wg *sync.WaitGroup) {
    time.Sleep(time.Second)

    var counter int
    for i := 0; i < 1e10; i++ {
        counter++

    }

    wg.Done()

}

只需要执行的时候加上 GOMAXPROCS=1 GODEBUG=schedtrace=1000 既可以进行trace

➜  GOMAXPROCS=8 GODEBUG=schedtrace=1000 ./sche
SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [1 0 0 0 0 0 0 0]
SCHED 1004ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=0 [50 1 13 16 0 1 6 5]
SCHED 2011ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=55 [1 9 10 1 9 0 0 7]
SCHED 3018ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=76 [1 1 5 2 3 2 2 0]
SCHED 4024ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=63 [5 2 3 5 4 3 3 4]
SCHED 5028ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=58 [1 5 5 6 6 6 5 0]
SCHED 6035ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=57 [6 7 6 7 2 7 0 0]
SCHED 7041ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=62 [0 2 0 0 3 8 8 9]
SCHED 8048ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=74 [2 3 2 1 3 1 3 3]
SCHED 9054ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=62 [6 3 1 4 2 6 5 3]

通过这个可以观察到goroutine的调度情况,以下是每个字段的解析: image 进一步每个goroutine的执行情况和通过go tool trace查看https://github.com/lzh2nix/articles/issues/77#issuecomment-683261463 trace章节

lzh2nix commented 4 years ago

unsafe.Pointer 与syscall tracing (2020.09.12)

原文: https://blog.gopheracademy.com/advent-2017/unsafe-pointer-and-system-calls/

正常业务代码中确实很少遇到unsafe包,所以对我来说这也是最神秘的一个包。 就和他名字一样,这里的用法都是不安全的,所以使用起来需要谨慎一点,感觉和c中的void *强转有点像。转换需要遵守以下的原则:

这篇文章作者也举了类型转换和系统调用两个例子,里面的各种转换看着还是挺复杂(感觉又回到了c中括号对齐的年代)

func Float64bits(f float64) uint64 {
    return *(*uint64)(unsafe.Pointer(&f))
}

解析过程:

有了上面的例子下面这个就看起来很好理解:

// Verify that the byte slice containing a unix.Taskstats is the
// size expected by this package, so we don't blindly cast the
// byte slice into a structure of the wrong size.
const sizeofTaskstats = int(unsafe.Sizeof(unix.Taskstats{}))

if want, got := sizeofTaskstats, len(buf); want != got {
    return nil, fmt.Errorf("unexpected taskstats structure size, want %d, got %d", want, got)
}

stats := *(*unix.Taskstats)(unsafe.Pointer(&buf[0]))

在发生系统调用的时候需要将go的数据结构转换成系统调用对应的类型:

// Context ID is populated by Ioctl.
var cid uint32

// Retrieve the context ID of this machine from /dev/vsock.
Ioctl(f.Fd(), unix.IOCTL_VM_SOCKETS_GET_LOCAL_CID, unsafe.Pointer(&cid))

可以同时看下ioctl的文档 image 这里就是把cid的地址传递给ioctl

unsafe 能使用的一个最大原因是不是 go 和c中的内存对齐原则是一样的???