go学习Golang底层学习笔记

Posted 胡毛毛_三月

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了go学习Golang底层学习笔记相关的知识,希望对你有一定的参考价值。

1.1.1. Go编译

  • 词法与语法分析
    • 意义:解析源代码文件,将文件中字符串序列转换成Token序列
    • 把执行词法分析的程序称为词法解析器(lexer)
    • 语法解析的结果就是抽象语法树(AST)
    • 每个AST都对应一个单独的Go语言文件,这个抽象语法树中包括当前文件属于的包名,定义的常量,结构体和函数等
    • 如果发生错误,被语法解析器发现并将消息打印在标准输出上,编译过程直接中止
    • Go语言早期用lex做词法分析,后续还是使用Go语言实现词法分析器,自己写的词法分析器分析自己
  • 类型检查和AST转换
    • 编译器对语法树中定义和使用的类型进行检查
    • 遍历抽象节点树,保证当前节点上不会出现类型错误
    • 不仅对类型进行检查,还会对内置函数进行展开和改写,比如make关键字在这个阶段会根据子树的结构被替换成makeslice 或者 makechan 等函数
  • 通用SSA(静态单赋值)生成
    • 使用SSA特性,分析代码中无用变量和片段进行优化
    • 类型检查之后,就对Go语言项目全部函数进行编译,生成中间代码
    • 关键字和内置函数的功能其实是由语言的编译器和运行时共同完成的
  • 机器代码生成
    • 根据机器不同,生成不同的机器码

1.1.2. 函数

调用惯例

  • C语言的函数的参数是通过寄存器和栈传递的
    • x86_64的机器,6个(含6个)的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递,超过 6 个的剩余参数会通过栈进行传递
    • 函数的返回是通过eax 寄存器进行传递的,所以不支持多个返回值
  • Go语言传递和接收参数使用的都是栈
    • 但是需要注意,函数入参和出参的内存空间都需要调用方再栈上进行分配
    • 好处:
      • 能够降低实现的复杂度(不需要考虑超过寄存器个数的参数应该如何传递)
      • 更方便的兼容不同的硬件(不同cpu的寄存器差别比较大)
      • 函数可以具有多个返回值(栈上的内存地址相比寄存器的个人是无限的,使用寄存器支持多个返回值也会非常困难,超出寄存器多个返回值也需要使用栈来传递)
    • 通过堆栈传递参数,入栈的顺序都是从右到左
    • 函数返回通过堆栈传递并由调用者预先分配内存空间

参数的传递

  • 无论是传递基本类型,结构体还是指针,都会对传递的参数进行拷贝
  • 指针作为参数传入某一个函数的时候,其实在函数内部会对指针进行复制,也就是会同时出现两个指针指向原有的内存空间,所以Golang传指针也是传值
  • 调用函数都是传值,接收方会对入参进行复制再计算

1.1.3. 接口

其本质就是引入一个中间层对不同的模块进行解耦,上层的模块就不需要依赖某一个具体的实现! Go语言中的接口interface 不仅是一组方法,还是一种内置的类型

Go语言中所有的接口的实现都是隐式的

接口类型

interface类型并不表示任意类型, interface类型的变量再运行期间的类型只是interface

go/src/runtime/runtime2.go#144

带有一组方法的接口
  • 表示成 iface结构体
type iface struct 
    tab  *itab
    data unsafe.Pointer

Copy
itab结构体
// layout of Itab known to compilers
// allocated in non-garbage-collected memory
// Needs to be in sync with
// ../cmd/compile/internal/gc/reflect.go:/^func.dumptypestructs.
type itab struct 
    inter *interfacetype
    _type *_type
    hash  uint32 // copy of _type.hash. Used for type switches.
    _     [4]byte
    fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.

Copy
  • interfacetype 是对 _type类型的简单封装
  • hash_type.hash的拷贝, 它会从 interface 到具体类型的切换时用于 快速判断目标类型和接口中类型是否一致
  • fun 数组其实是一个动态大小的数组,如果数组中内容为空表示 _type没有实现inter接口,虽然这是一个大小固定的数组,但是在使用时会直接通过指针获取其中的数据并不会检查数组的边界,所以该数组中保存的元素数量是不确定的
不带有任何方法的interface类型
  • 表示成eface结构体
  • 很常见,实现成一种特殊的类型
type eface struct 
    _type *_type
    data  unsafe.Pointer

Copy
  • _type 类型有点复杂… 不看了…

动态派发

运行期间选择具体的多态操作执行的过程,在Go语言中,对于一个接口类型的方法调用,会在运行期间决定具体调用该方法的哪个实现

动态派发会出现一些消耗,但一般项目中不可能只存在动态派发的调用,荔港南湾,如果开启默认的编译器优化,动态派发开销还会降低,所以对整体性能影响很小

结构体指针换成结构体,消耗区别有点大,原因是 Go 函数调用是值传递,会出现参数拷贝,所以对于大结构体,参数拷贝会消耗非常多资源,所以应该用指针来传递大结构体

1.1.4. 数组与切片

数组

  • 数组是由相同类型元素的集合组成的数据结构
  • 数组大小在初始化之后就无法改变
  • 编译期间,数组类型Array包含两个结构,一个是元素类型Elem, 另一个是数组的大小上限Bound,这两字段组成了数组类型
  • 数组创建可以显式指定数组的大小,也可以根据源代码自定推断数组的大小,不过后者在编译期间会被转换前一种 -
数组上限推导
  • 显式创建,变量类型会在编译进行到 类型检查 阶段就会被推断出来
  • 非显式创建,会在 编译进行 类型检查 时候也会创建一个 Array 类型,不过Bound = -1 后面会推到该数组大小
  • 所以[...]T类型的声明不是在运行是被推导的,会在类型检查期间就被推断出正确的数组大小
语句转换
  • [...]T1, 2, 3[3]T1, 2, 3 运行的时候 是等价的,理由如上
  • 根据数组元素不同,会有不同的优化
    • 当元素数量小于或者等于4个时,会直接将数组中的元素放置在栈上
    • 当元素数量大于4个时,会将数组中的元素放置到静态区并在运行时取出
  • 总结: 数组元素个数小于等于4个,所有变量会直接在栈上初始化,如果数组数量大于4个,变量就会在静态存储区初始化然后拷贝到栈上,转换后的代码才会进入 中间代码生成 和机器码生成阶段,最后生成可执行的二进制文件
访问和赋值
  • 数组在内存中其实就是一连串的内存空间,表示数组的方法就是一个指向数组开头的指针
  • 数组访问越界的判断也是在编译期间由静态类型检查完成的
  • 数组的赋值和更新 也会在生成SSA期间就计算出数组当前元素的内存地址,然后修改当前内存地址的内容
  • 数组的寻址或者赋值都是编译阶段完成,没有运行时的参与

切片

  • 切片其实就是动态数组,长度不固定
  • 切片在编译期间应该只会包含切片中的元素类型 SliceElem: elem
  • 切片的操作基本都是在编译期间完成的,除了访问切片的长度,容量或者其中的元素之外,使用range遍历切片时也是在编译期间被转换成了形式更简单的代码
切片结构
type SliceHeader struct 
    Data uintptr // 指向数组的指针
    Len  int // 当前切片长度
    Cap  int // 切片的容量

Copy
切片初始化
  • 字面量
    • slice := []int1, 2, 3 -
  • 关键字
    • slice := make([]int, 10)
切片追加
  • 追加append 会根据inplace 在中间代码生成阶段转换不同流程,一种是追加之后,不需要赋值回原有的变量append(slice, 1, 2, 3),一种是需要赋值给原有的变量slice = apennd(slice, 1, 2, 3)

  • 先对切片结构体进行解构获取数组指针,大小和容量,如果新切片大小大于容量,那么会使用

    growslice
    

    对切片进行扩容并将新的元素依次加入切片并创建新的切片

    • 如果期望容量大于当前容量的两倍就会使用期望容量

    • 如果当前切片容连小于1024就会将容连翻倍

    • 如果当前切片容量大于1024就会每次增加25%的容量,知道新容量大于期望容量

      func growslice(et *_type, old slice, cap int) slice 
      newcap := old.cap
      doublecap := newcap + newcap
      if cap > doublecap 
        newcap = cap
       else 
        if old.len < 1024 
            newcap = doublecap
         else 
            for 0 < newcap && newcap < cap 
                newcap += newcap / 4
            
            if newcap <= 0 
                newcap = cap
            
        
      
      Copy
      
  • 确定了切片的容量,就可以算内存占用了, 内存占用= 目标容量* 元素大小

切片拷贝
  • copy(a, b) 会在编译期间转换成slicecopy函数
  • 切片中全部元素通过memmove或者数组指针的方式将整块内存中的内容拷贝到目标的内存区域,所以大切片拷贝需要注意性能影响,不过比一个个的复制要有更好的性能
数组和切片总结
  • 数组大多都是在 编译期间都转换成内存的直接读写
  • 切片很多功能都需要运行时的支持

1.1.5. 哈希表

  • 哈希表示键值之间隐射关系

  • 哈希函数

    • 理想状态是 不同的键映射到不同的唯一索引上,要求哈希函数的输出范围大于输入范围,但是实际使用时这种理想状态是不可能实现的

哈希冲突解决

  • 开放寻址法
    • 在一堆数组对元素进行探测和比较以判断待查找的目标键是否存在当前的哈希表中
    • 初始化哈希表时会创建一个新的数组,如果哈希表写入新的数据发生了冲突,就会将键值对写入到下一个不为空的位置
    • 查找时,按照我的理解,应该是先哈希key,找到对应的位置上,然后对比key,如果不一样,继续往下找,除非找到对应的key或者内存为空为止
    • 数组中元素数量与数组大小的比值叫做装载因子,随着装载因子增加,线性探索的评价用时就会逐渐增加,到百分之七十之后性能明显下降,一旦到百分之百,整个哈希表就会完全失效
  • 拉链法
    • 实现比较简单,存储节点的内存是动态申请的,比较节省空间
    • 就是数组加上链表组合起来实现哈希表,数组中每个元素都是一个链表
    • 插入时,先对key进行hash,找到数组上对应的点(桶),然后遍历桶里的链表,如果有相同的就修改,没有就加在链表最末尾
    • 性能比较好的哈希表中,每个桶里大约有0或者1个元素,偶尔会有2到3个,很少会超过这个数

结构

type hmap struct 
    count     int // 用于记录当前哈希表元素数量,这个元素让我们不在需要去遍历整个哈希表来获取长度
    flags     uint8
    B         uint8  // 表示当前哈希表持有的 `buckets` 数量,因为哈希表扩容是以2倍进行的,所以这里会使用对数来存储, 简单理解成 `len(buckets) == 2^B` 
    noverflow uint16
    hash0     uint32 // 哈希的种子,这个值会在调用哈希函数时作为参数传入进去,主要作用是为哈希函数的结果引入一定的随机性

    buckets    unsafe.Pointer
    oldbuckets unsafe.Pointer // 哈希在扩容时用于保存之前的 `buckets`的字段,它的大小是当前`buckets`的一半
    nevacuate  uintptr

    extra *mapextra

Copy

字面量

  • 编译时用maplit进行初始化

  • 当哈希表中元素数量小于等于25个时,编译器会调用addMapEntries将结构体转成单独的键值对,比如 hash[“a”] = 1, hash[“b”] = 2

  • 当元素数量超过25个,会在编译期间创建两个数组分别存储键和值的信息,这些键值会通过一个for循环假如目标的哈希

    hash := make(map[string]int, 26)
    vstatk := []string"1", "2", "3", ..."26"
    vstatv := []int1, 2, 3, ... , 26
    for i := 0; i < len(vstak); i++ 
      hash[vstatk[i]] = vstatv[i]
    
    Copy
    

运行时

  • 编译期间make转换成makemap来创建哈希表
  • 根据B算出需要创建的桶数量,在内存里分配一片连续空间用于存储数据,创建过程中,还会创建一些用于保存溢出数据的桶,数量 2^(B-4)个
  • 哈希表的桶最多只能存储8个元素,如果超过8个,效率会下降,所以会进行扩容或者使用额外的桶存储溢出的数据,不会让桶里数据超过8个

访问

  • 类型检查阶段,类似hash[key]OINDEX操作都会被转换成OINDEXMAP操作,中间代码生成阶段,在walkexpr中将这些OINDEXMAP转成

    v     := hash[key] // => v     := *mapaccess1(maptype, hash, &key)
    v, ok := hash[key] // => v, ok := mapaccess2(maptype, hash, &key)
    Copy
    
  • 当接收参数就一个时,使用mapaccess1函数,如果多加一个是否存在的布尔值就会使用mapaccess2

  • 在这个函数中我们首先会通过哈希表设置的哈希函数、种子获取当前键对应的哈希,再通过 bucketMask 和 add 函数拿到该键值对所在的桶和哈希最上面的 8 位数字,这 8 位数字最终就会与桶中存储的 tophash 作对比,每一个桶其实都存储了 8 个 tophash,就是编译期间的 topbits 字段,每一次都会与桶中全部的 8 个 uint8 进行比较,这 8 位的 tophash 其实就像是一级缓存,它存储的是哈希最高的 8 位,而选择桶时使用了桶掩码使用的是最低的几位,这种方式能够帮助我们快速判断当前的键值对是否存在并且减少碰撞,每一个桶都是一整片的内存空间,当我们发现某一个 topbits 与传入键的 tophash 匹配时,通过指针和偏移量获取哈希中存储的键并对两者进行比较,如果相同就会通过相同的方法获取目标值的指针并返回。另一个同样用于访问哈希表中数据的 mapaccess2 函数其实只是在 mapaccess1 的基础上同时返回了一个标识当前数据是否存在的布尔值

写入

  • 当哈希表没有处于扩容状态并且装载因子超过了6.5或者存在了太多溢出的桶,调用hashGrow对当前哈希表进行扩容
  • 装载因子是同时由 loadFactorNumloadFactDen 两个参数决定的,前者在 Go 源代码中的定义是 13 后者是 2,所以装载因子就是 6.5
  • 如果桶满了,会调用 newoverflow 创建一个新的桶或者使用hmap预先在noverflow中创建好的桶来保存数据,新创建的桶的指针会被追加到已有桶中,与此同时,溢出桶的创建会增加哈希表的noverflow计数器
  • 如果哈希表存储的键值是指针类型,其实就会被当前的键值对分别申请一块新的内存空间,并在插入的位置通过eypedmemmove将键移动到申请的内存空间,最后返回键对应的地址

扩容

  • 如果扩容是溢出的桶太多,那么就是 sameSizeGrow
  • 如果是一次不改变大小的扩容,evacDst结构体只会初始化一个,当哈希表容量翻倍时,一个桶中的元素会被分流到新创建的两个桶中,这两个桶会被evacDst数组引用

删除

  • delete关键字,将某一个键对应的元素从哈希表中删除,无论该键对应的值是否存在,这个内建的函数都不会返回任何的结果
  • 如果在删除期间遇到哈希表的扩容,就会对即将操作的桶进行分流,随后找到桶中的目标元素并根据数据的类型调用memclrHasPointers 或者 memclrNoHeapPointers 函数完成键值对的删除
  • delete在类型检查阶段被转换成ODELETE操作,然后在 SSA中间代码生成时被转换成mapdelete函数簇

哈希表总结

  • Go语言用拉链法来解决哈希碰撞
  • 哈希在每一个桶中存储键对应哈希的前8位,当对哈希进行操作时,这些 tophash就成了一级缓存帮助哈希快速遍历桶中元素
  • 每个桶只能存储8个键值对,一旦某个桶超过8个,新的键值对会被存储到哈希的溢出桶中
  • 当键值对数量的增加,装载因子升高,到一定范围后,会出发扩容操作,扩容时将桶的数量分配,元素再分配的过程也是在调用写操作时增量进行的,不会造成性能的瞬时巨大波动

1.1.6. 字符串

  • Go语言中的字符串是一个只读的字节数组切片
  • 如果代码中存在的字符串,会被编译期间标记成只读数据SRODATA,但这只是表示这个字符串会被分配到只读的内存空间并且这段内存不会被修改,但是运行时,依然可以将这段内存拷贝到其他的堆或者栈上,同时将变量的类型修改成 []byte,在修改之后通过类型转换变成string,如果想直接修改string类型变量的内存空间,是不支持的!

结构

  • 在运行时,用StringHeader结构体进行表示, 在运行时包的内部其实有一个私有的结构stringHeader, 它有着相同的结构,只是用于存储数据的Data字段使用了unsafe.Pointer类型
type StringHeader struct 
    Data uintptr
    Len  int

Copy
解析
  • 解析器在 词法分析 时就完成的

  • 两种字面量的方式可以声明字符串,一种是 双引号,一种是 使用反引号

    • 双引号用于简单的 单行的字符串,内部出现双引号需要用

      \\
      

      符号避免编译器的解析错误

      • 标准字符串使用双引号表示开头和结尾

      • 标准字符串中需要使用反斜线\\escape双引号

      • 标准字符串中不能出现隐式的换行符号

        \\n
        
        str := "start
        end"
        Copy
        
    • 反引号 可以摆脱单行的限制,内部可以直接使用

      "
      

      ,写

      JSON
      

      或者其他数据时候非常方便

      • 只需要把非反引号的所有字符都划分到当前字符串的范围中
拼接
  • 使用+符号,编译器在检查阶段将OADD节点转换成OADDSTR类型的节点,然后在SSA中间代码生成的阶段调用addstr函数
  • addstr函数在编译期间合选择合适的函数对字符串进行拼接,如果拼接字符串小于或者等于5个,那么活直接调用concatstring2,3,4,5 等一系列函数,如果超过5个就会直接选择concatstrings传入一个数组切片
  • 不管选择哪个函数,最终都调用concatstrings,这个函数会先对传入的切片参数进行遍历,首先会过滤空字符串比国内获取拼接后的字符串长度
类型转换
  • string(bytes)会在编译期间转换成slicebytetostring的函数调用

  • 字符串到字节数组转换使用

    stringtoslicebyte
    
    • 使用传入的缓冲区或者根据字符串的长度调用rawbyteslice创建一个新的字节切片,copy关键字会将字符串中的内容拷贝到新的字节数组中
字符串总结
  • 字符串内容是只读,切片是可读写的,所以相互转换都是对内容进行拷贝,拷贝性能会因为字符串数组和字节长度的增长而增长,所以需要转换需要注意性能问题

1.1.7. for 和 range

  • forfor...range 经过优化后,变成一样

  • 结果是???

func main() 
    arr := []int1, 2, 3
    for _, v := range arr 
        arr = append(arr, v)
    
    fmt.Println(arr)

Copy
  • 只遍历了切片里的元素,追加并不会导致循环次数增加,然后最后停了下来
func main() 
    arr := []int1, 2, 3
    newArr := []*int
    for _, v := range arr 
        newArr = append(newArr, &v)
    
    for _, v := range newArr 
        fmt.Println(*v)
    

Copy
  • 结果是 , 如何避免

  • for 经典循环

  • for…range 范围循环 编译器会在编译期间将带有range的循环变成普通的经典循环,这个过程发生在 SSA中间代码 阶段,所有的range都会被walkrange 函数转换成只包含基本表达式的语句,不包含任何复杂的结构

遍历数组和切片
  • 对于所以的range循环,Go语言都会在编译期间将原切片或者数组赋值给一个新的变量ha,在赋值的过程中其实就发生了拷贝,所以我们遍历的切片其实已经不是原有的切片变量了!
  • 当同时遍历索引和元素的range循环时,Go语言会额外创建一个新的v2变量存储切片中的元素,循环中使用的这个变量v2会在每一次迭代中都被重新赋值,在赋值时也发生了拷贝, 所以我们想要访问数组中元素所在的地址,不应该直接获取range返回的v2变量的地址&v2,想要解决这个问题应该使用&a[index]这种方式获取数组中元素对应的地址
遍历哈希
  • 哈希表遍历会随机(fastrand函数)选择开始的位置,然后依次遍历桶中的元素,桶中元素如果被遍历完,就会遍历当前桶对应的溢出桶,溢出桶都遍历结束之后才会遍历哈希中下一个桶,直到所有的桶都被遍历完
遍历字符串
  • 遍历过程中会获取字符串中索引对应的字节,然后将字节转换成rune,我们在遍历字符串时拿到的值都是rune类型的变量
遍历通道
  • 循环会使用<-ch从管道中取出等待处理的值,这个操作会调用chanrecv2并阻塞当前的协程,当chanrecv2返回时会根据hb来判断当前的值是否存在,如果不存在就意味着当前的管道已经被关闭了,在正常情况下都会为v1赋值并清除hv1中的数据,然后会陷入下一次的阻塞等待接受新的数据

1.1.8. defer

  • 作用域结束之后执行函数的关键字
  • defer实现是由编译器和运行时共同完成的
func main() 
    
        defer fmt.Println("defer runs")
        fmt.Println("block ends")
    

    fmt.Println("main ends")

Copy
  • 不是在当前代码块的作用域时执行的, defer只会在当前函数和方法返回之前被调用
type Test struct 
    value int


func (t Test) print() 
    println(t.value)


func main() 
    test := Test
    defer test.print()
    test.value += 1

Copy

稍微改动一下

type Test struct 
    value int


func (t *Test) print() 
    println(t.value)


func main() 
    test := Test
    defer test.print()
    test.value += 1

Copy
  • 还是进行的值传递,不过发生复制的是指向test的指针,上面那个复制的是结构体,这段是复制的指针,修改test.value时,defer捕获的指针其实就能够访问到修改后的变量了
实现原理
type _defer struct 
    siz     int32
    started bool
    sp      uintptr
    pc      uintptr
    fn      *funcval
    _panic  *_panic
    link    *_defer

Copy
  • sppc分别指向了栈指针和调用方的程序计数器,fn存储的就是向defer关键字中传入的函数
  • defer 关键字在编译期间的SSA阶段才被stmt函数处理的,中间详情不表
  • 运行时,每一个defer关键字都会被转换成deferproc,这个函数里会为defer创建一个新的_defer结构体并设置它的fn,pcsp参数,并将defer相关的函数都拷贝到紧挨着结构体的内存空间中
总结
  • defer关键字会在编译阶段被转换成deferproc的函数并在函数返回之前插入deferreturn指令,在运行期间,每一次deferproc的调用都会将一个新的_defer结构体追加到当前Goroutine持有的链表头,而deferreturn会从Goroutine中取出_defer结构并以此执行,所有的_defer结构执行成功之后当前函数才返回!

1.1.9. panic 和 recover

  • panic 能改变程序的控制流,当函数调用执行panic,它会立刻停止执行函数其他的代码,而是会运行其中的defer函数,执行成功返回到调用方
  • 调用导致panic和直接调用panic类似,执行所有的defer函数并返回到它的调用方,这个过程会一直进行到当前的Goroutine的调用栈不包含任何的函数,这时整个程序才会崩溃
  • panic导致的恐慌状态其实可以被defer中的recover中止,recover是一个只在defer中能够发挥作用的函数,在正常的控制流程中,recover会直接返回nil并没有任何的作用,如果当前的Goroutine发生了恐慌,recover就能够捕获到panic抛出的错误并阻止恐慌的继续传播
数据结构
type _panic struct 
    argp      unsafe.Pointer // 指向`defer`调用时参数的指针
    arg       interface // 调用`panic`时传入的参数
    link      *_panic // 指向更早调用的`_panic`结构
    recovered bool // 当前的`_panic`是否被`recover` 恢复
    aborted   bool // 表示当前的`panic`是否被强行终止

Copy
  1. 获取当前panic调用所在的Goroutine协程
  2. 创建并初始化一个 _panic结构体
  3. 从当前Goroutine的链表获取一个_defer结构体
  4. 如果当前的_defer存在,调用reflectcall执行_defer中的代码
  5. 将下一位的_defer结构设置到Goroutine上并返回到3
  6. 调用fatalpanic中止整个程序(会在中止整个程序之前可能会通过printpanics打印出全部的panic消息以及调用时传入的参数)
panic和recover总结
  • 编译过程中会将panicrecover 分别转换成gopanicgorecover函数,同时将defer转换成deferproc 函数并在调用defer的函数和方法末尾增加deferreturn的指令

  • 在运行过程中遇到gopanic方法时,会从当前Goroutine中取出_defer的链表并通过reflectcall调用用于收尾的函数

  • 如果在

    reflectcall
    

    调用时遇到了

    gorecover
    

    就会直接将当前的

    _panic.recovered
    

    标记成

    true
    

    并返回

    panic
    

    传入的参数(在这时

    recover
    

    就能够获取到

    panic
    

    的信息)

    • 在这次调用结束后,gopanic会从_defer结构体中取出程序计数器pc和栈指针sp并调用recovery方法进行恢复
    • recovery会根据传入的pcsp跳转到deferproc函数
    • 编译器自动生成的代码会发现deferproc的返回值不为0,这时就会直接跳到deferreturn函数中并恢复到正常的控制流程(依次执行剩余的defer并正常退出)
  • 如果没有遇到gorecover就会一次遍历所有的_defer结构,并在最后调用fatalpanic中止程序,打印panic参数并返回错误码2

1.1.10. make 和 new

  • make
    

    用于创建 切片,哈希表和管道等内置数据结构

    • 在 类型检查 阶段,根据不同类型转换成OMAKESLICE,OMAKEMAP,OMAKECHAN三种不同类型的节点
  • new
    

    用于分配并创建一个指向对应类型的指针

    • SSA 代码生成 阶段经过 callnew 函数的处理,如果请求创建的类型大小是0,那么就会返回一个表示空指针的zerobase变量,在遇到其他情况会将关键字转换成newobject
    • 如果声明的变量或者参数不需要在当前作用域外生存,不会初始化在当前函数的栈中并随着函数调用的结束而被销毁

1.1.11. 上下文 Context

  • Go语言中每一个请求都是通过一个单独的Goroutine进行处理的,Context主要作用就是在 不同的 Goroutine 之间同步请求特定的数据,取消信号以及处理请求的截至日期
  • 每一个 Context都会从最顶层的 Goroutine 一层一层传递到最下层,如果没有 Context,上层执行操作出现错误时,下层不会收到错误而会继续执行下去
  • 减少额外的资源的消耗,还能携带以请求为作用域的键值对信息
接口
type Context interface 
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct
    Err() error
    Value(key interface) interface

Copy
  • Deadline 方法需要返回当前Context 被取消的时间,也就是完成工作的截至日期

  • Done 方法需要返回一个 Channel, 这个 Channel 会在当前工作完成或者上下文被取消之后关闭, 多次调用 Done 方法会 返回同一个 Channel

  • Err
    

    方法会返回当前

    Context
    

    结束的原因, 它只会在

    Done
    

    返回的 Channel 被关闭才会返回非空的值

    • 如果当前 Context 被取消就会返回 Canceled 错误
    • 如果当前 Context 超时就会返回 DeadlineExceeded 错误
  • Value 方法会从 Context 中返回键对应的值,对于同一个上下文来说,多次调用Value 并传入相同的Key 会返回相同的结果,这个功能可以用来传递特定的数据

  • BackgroudTODO 方法在某种层面上看也是互为别名,两者没有太大区别,不过context.Background() 是上下文中最顶层的默认值,所有其他的上下文都应该从context.Background()演化出来

  • 我们应该只在不确定时使用context.TODO(),在多数情况下如果函数没有上下文作为入参,我们往往都会使用context.Background() 作为起始的Context向下传递

  • WithCancel 方法能从 Context 中创建出一个新的子上下文,同时还会返回用于取消该上下文的函数,也就是CancelFunc

  • WithDeadlineWithTimeout 也都能创建可以被取消的上下文,WithTimeout 只是context 包为我们提供的便利方法,能让我们更方便地创建timeCtx

1.2. 定时器

type timer struct 
    tb *timersBucket
    i  int

    when   int64
    period int64
    f      func(interface, uintptr)
    arg    interface
    seq    uintptr

Copy
  • timer 就是 Golang 定时器内部表示,每一个timer 其实都存在堆中

  • tb 就是用于存储当前定时器的桶

  • i 是当前定时器在堆中的索引,可以通过这两个变量找到当前定时器在堆中的位置

  • when 表示当前定时器(Timer) 被唤醒的时间

  • period 表示两次被唤醒的间隔,每当定时器被唤醒时都会调用f(args,now) 函数并传入args 和当前时间作为参数

  • 这里的timer作为一个私有结构体其实只是定时器的运行时表示,time 包对外暴露的定时器是如下结构

    type Timer struct 
      C <-chan Time
      r runtimeTimer
    
    Copy
    
  • Timer 定时器必须通过NewTimer 或者 AfterFunc 函数进行创建,其中的runtimeTimer 其实就是上面的timer结构体, 当定时器失效时,失效的时间就会被发送给当前定时器持有的ChannelC, 订阅管道中消息的Goroutine就会接收到当前定时器失效的时间

创建
  • time
    

    包对外提供了两种创建定时器的方法

    • NewTimer 接口创建用于通知触发时间的Channel,调用 startTimer 方法并返回一个创建指向Timer结构体的指针

    • AfterFunc 也提供了相似的结构,与上面不同的是,它只会在定时器到期时调用传入的方法

    • startTimer
      

      是创建定时器的入口,所有的定时器的创建和重启基本上都需要这个函数

      • 调用addTimer函数,首先通过assignBucket方法为当前定时器选择一个timersBucket 桶,根据当前的Goroutine所在处理器P的id选择一个合适的桶,随后调用addTimerLocked方法将当前定时器加入桶中
      • addtimerLocked 会先将最新加入的定时器加到队列的末尾,随后调用siftipTimer将当前定时器与四叉树(或者四叉堆)中的父节点进行比较,保证父节点的到期时间一定小于子节点
触发
  • 定时器的触发都是由timerproc中的一个双层for循环控制的,外层的for循环主要负责对当前的Goroutine 进行控制,它不仅会负责锁的获取和释放,还会在合适的时机触发当前Goroutine的休眠
  • 内部循环
    • 如果桶中不包含任何定时器就会直接返回并陷入休眠等待定时器加入当前桶
    • 如果四叉树最上面的定时器还没有到期会通过notetsleepg方法陷入休眠等待最近定时器的到期
    • 如果四叉树最上面的定时器已经到期
      • 当定时器 preiod > 0 就会设置下一次会触发定时器的时间并将当前定时器向下移动到对应位置
      • 当定时器preios <= 0 就会将当前定时器从四叉树中移除
    • 在每次循环的最后都会从定时器中取出定时器中的函数,参数 和序列号并调用函数触发该计数器
  • 使用NewTimer创建的定时器,传入的函数时sendTime,它会将当前时间发送到定时器持有的Channel中,而使用AfterFunc 创建的定时器,在内层循环中调用的函数就会是调用方法传入的函数了
休眠
  • timeSleep 会创建一个新的timer结构体,在初始化的过程中我们会传入当前Goroutine 应该被唤醒的时间以及唤醒时需要调用的函数goroutineReady,随后会调用 goparklock 将当前GOroutine陷入休眠状态,当定时器到期时也会调用 goroutineReady 方法唤醒当前的Goroutine
  • time.Sleep 方法其实只是创建了一个会在到期时唤醒当前Goroutine的定时器并通过goparkunlock将当前的协程陷入休眠状态等待定时器触发的唤醒
Ticker
  • 除了只用于一次的定时器(Timer)之外,Go语言的time包中还提供了用于多次通知的Ticker计时器,计时器中包含了一个用于接受通知的Channel 和一个定时器,这个两个字段组成了用于连续多次触发事件的计时器
  • 想要在Go中创建一个计时器只有两种方法,一种是使用NewTicker方法显示的创建Ticker计时器指针,另一种可以直接通过Tick方法获取一个会定期发送消息的Channel
  • 每一个NewTicker方法开启的计时器都需要在不需要使用时调用Stop进行关闭,如果不显示调用Stop方法,创建的计时器就没有办法被垃圾回收,而通过Tick创建的计时器由于只对外提供了Channel,所以是一定没有办法关闭的,我们一定要谨慎使用这一接口创建计时器
性能分析
  • 定时器在内部使用四叉树的方式进行实现和存储,高并发的场景下会有比较明显的性能问题

1.3. 同步原语与锁

  • 锁的主要作用就是保证多个线程或者Goroutine在访问同一片内存时不会出现混乱的问题
  • 锁其实是一种并发编程中的同步原语(Synchronization Promitives)

基本原语

  • Go语言在 sync 包中提供了用于同步的一些基本原语,包括常见的互斥锁Mutex 与读写互斥锁 RWMutex以及 Once, WaitGroup
Mutex
  • Go语言中的互斥锁在sync中, 由statesema 组成, state 表示 当前互斥锁的状态, 而sema 真正用于控制锁状态的信号量, 这两个加起来只占8字节空间的结构体就表示了Go语言中的互斥锁

    type Mutex struct 
      state int32
      sema  uint32
    
    Copy
    
  • 互斥锁的状态是用int32来表示的,但是锁的状态并不是互斥的,它的最低三位分别表示mutexLocked,mutexWoken,mutexStarving,剩下的位置都用来表示当前有多少个Goroutine等待互斥锁被释放

  • 互斥锁在被创建出来时,所有的状态位的默认值都是0,当互斥锁被锁定时,mutexLocked 就会被置成1,当互斥锁被在正常模式下被唤醒mutexWoken就会被置成1,mutexStarving用于表示当前的互斥锁进入了状态,最后的几位是在当前互斥锁上等待的Goroutine个数

饥饿模式 ✦ ✦ ✦ ✦
  • 饥饿模式是在Go语音1.9版本引入的特性,主要功能就是保证互斥锁的获取的公平性
  • 互斥锁可以同时处于两种不同的模式,也就是正常模式和饥饿模式, 在正常模式下,所有锁的等待者都会按照先进先出的顺序获得锁,但是如果一个刚刚被唤醒的 Goroutine 遇到了新的Goroutine 进程也调用了 Lock 方法时,大概率获取不到锁,为了减少这种情况的出现,防止Goroutine被 饿死, 一旦 Goroutine超过1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式
  • 在饥饿模式中,互斥锁会被直接交给等待队列最前面的 Goroutine,新的Goroutine在这时不能获取锁,也不会进入自旋的状态,它们只会在队列的末尾等待,如果一个Goroutine获得了互斥锁并且它是队列中最末尾的协程或者它等待的时间少于1ms,那么当前的互斥锁就会被切换回正常模式
  • 相比于饥饿模式,正常模式下的互斥锁能够提供更好的性能,饥饿模式的主要作用就是避免一些Goroutine由于陷入等待无法获取锁而造成较高的尾延迟,这也是对Mutex的一个优化
加锁

饥饿模式不会进入进入自旋,那么如果是正常模式转成饥饿模式,自旋还有么???

  • 互斥锁Mutex的加锁是靠Lock 方法完成的,最新的 Go语言源代码中已经将Lock 方法进行了简化,方法的主干只保留了最常见,简单并且快速的情况,当锁的状态是 0 时直接将mutexLocked 位置成 1

  • Lock 方法被调用时Mutex的状态不是 0 时就会进入 lockSlow 方法尝试通过自旋或者其他方法等待锁的释放并获取互斥锁

  • 自旋其实是在多线程同步的过程中使用的一种机制,当前的进程在进入自旋的过程中会一直保持CPU的占用,持续检查某个条件是否为真,在多核的CPU上,自旋的优点是避免了Goroutine的切换,如果使用恰当会对性能带来非常大的增益

  • 在 Go语言 的

    Mutex
    

    互斥锁中,只有在普通模式下才可能进入自旋,除了模式的限制之外,

    runtime_canSpin
    

    方法中会判断当前方法是否可以进入自旋,进入自旋的条件非常苛刻

    • 运行在多CPU的机器上
    • 当前Goroutine 为了获取该锁进入自旋的次数小于四次
    • 当前机器上至少存在一个正在运行的处理器P并且处理的运行队列是空的
  • runtime_SemacquireMutex 方法主要作用就是通过Mutex的使用互斥锁中的信号量保证资源

    以上是关于go学习Golang底层学习笔记的主要内容,如果未能解决你的问题,请参考以下文章

    学习笔记 - Go - 简介

    学习笔记 - Go - 简介

    学习笔记 - Go - 简介

    学习笔记 - Go - 简介

    面向校招全力备战2023Golang实习与校招

    学习笔记Golang语法学习笔记