go数据结构、语言基础、常用关键字-摘自go语言设计与实现

一、数据结构
1、数组 类型 长度(不重要)
初始化使用NewArray
...是语法糖,编译器会自动推导,遍历元素来计算元素数量
如果数组元素小于等于4个,所有变量会在栈上初始化,如果大于4个,变量会在静态存储区初始化然后拷贝到栈上。
索引为非整数,负数,越界都会在编译期间pannic
2、切片 数组指针 长度 容量 SliceHeader(重点:扩容,拷贝)
初始化使用NewSlice
arr = []int{1,2,3,4,5} arr[0:3] len:3,cap:5
slice := []int{1,2,3} len:3, cap:3
slice := make([]int, 10) len:10, cap:10
append
如果容量够,直接追加,如果不够则扩容,期望大小是当前大小两倍以上,使用期望大小。长度小于1024,翻倍。大于1024,每次增加25%,直到大于期望容量
拷贝时,使用memmove,性能更好。
3、哈希表 (重点:哈希函数和冲突解决方法)
哈希的结果尽可能均匀。
冲突解决方法:
开放寻址法:如果发生哈希冲突,将键值对写入下一个不为空的位置。受装载因子影响。越大,查找越慢。
拉链法:如果发生哈希冲突,在链表末尾追加新的键值对
hmap
count当前哈希表中元素数量
B当前哈希表持有的buckets数量,len(buckets)==2^B
noverflow
hash0 哈希种子,创建哈希表时确定,调用哈希函数时作为参数传入
oldbuckets 哈希扩容时用于保存之前buckets字段,大小是当前buckets的一半
buckets bmap 最多存储8个值
bmap
topbits [8]uint8
keys [8]keytype 存储键
values [8]valuetype 存储值
pad uintptr
overflow uintptr 下一个溢出桶位置
初始化
make -> makemap
访问
低8位找buckets序号,高8位去tophash找
写入
先查找,存在更新,不存在添加,如果桶满了,调用newoverflow创建新桶或者使用hmap在noverflow创建好的桶来保存数据
扩容
装载因子大于6.5 增量扩容 桶数量翻倍 1次查找-2次搬迁
哈希使用太多溢出桶。 等量扩容 重哈希
hashGrow
4、字符串:字符组成的数组
StringHeader
Data 数据
Len 长度
拼接
小于等于5个 concatString{2,3,4,5}
concatstrings
二、语言基础
1、函数调用
通过堆栈传递参数,入栈顺序从右到左
函数返回值通过堆栈传递并由调用者预先分配内存空间
调用函数时都是传值,接收方先对入参进行复制再计算
2、接口
go有两种略微不同的接口,一种是带有一组方法的接口,另一种是不带任何方法的interface{}
iface结构体表示第一种接口,eface结构体表示第二种空接口。
指针可以隐式的获取指向的结构体,所以结构体指针能调用结构体方法。
eface
_type 类型的运行时表示
data 底层数据指针

iface
tab *itab
data 底层数据指针

itab
inter 类型相关
_type 类型相关
hash _type.hash的拷贝
fun 存储函数指针。

3、反射
三大法则
从 interface{} 变量可以反射出反射对象
从反射对象可以获取 interface{} 变量
要修改反射对象,其值必须可设置
由于 reflect.TypeOf、reflect.ValueOf 两个方法的入参都是 interface{} 类型,所以在方法执行的过程中发生了类型转换
v.Interface().(type)


从接口值到反射对象:
从基本类型到接口类型的类型转换;
从接口类型到反射对象的转换;
从反射对象到接口值:
反射对象转换成接口类型;
通过显式类型转换变成原始类型;


修改变量方式
调用 reflect.ValueOf 函数获取变量指针;
调用 reflect.Value.Elem 方法获取指针指向的变量;
调用 reflect.Value.SetInt 方法更新变量的值
emptyInterface
type
word
reflect.TypeOf 函数的实现原理其实并不复杂,它只是将一个 interface{} 变量转换成了内部的 emptyInterface 表示,然后从中获取相应的类型信息。
reflect.ValueOf 实现也非常简单,在该函数中我们先调用了 reflect.escapes 函数保证当前值逃逸到堆上,然后通过 reflect.unpackEface 方法从接口中获取 Value 结构体
reflect.unpackEface 函数会将传入的接口转换成 emptyInterface 结构体,然后将具体类型和指针包装成 Value 结构体并返回
三、常用关键字
1、for和range
经典循环在编译器看来是一个 OFOR 类型的节点,这个节点由以下四个部分组成
初始化循环的 Ninit;
循环的中止条件 Left;
循环体结束时执行的 Right;
循环体 NBody
for Ninit; Left; Right {
NBody
}
for和range会将ORANGE类型的节点转换成OFOR节点

分析遍历数组和切片清空元素的情况;(go会调用函数直接清楚数组对应内存空间的数据,并在执行完成后更新用于遍历数组的索引)
分析使用 for range a {} 遍历数组和切片,不关心索引和数据的情况;

ha := a
hv1 := 0
hn := len(ha)
v1 := hv1
for ; hv1 < hn; hv1++ {
...
}

分析使用 for i := range a {} 遍历数组和切片,只关心索引的情况;

ha := a
hv1 := 0
hn := len(ha)
v1 := hv1
for ; hv1 < hn; hv1++ {
v1 := hv1
...
}

分析使用 for i, elem := range a {} 遍历数组和切片,关心索引和数据的情况;

ha := a
hv1 := 0
hn := len(ha)
v1 := hv1
for ; hv1 < hn; hv1++ {
tmp := ha[v1]
v1, v2 := hv1, tmp
...
}

遍历哈希表时会使用mapiterinit和mapiternext两个运行时函数重写原始的 for/range 循环

ha := a
hit := hiter(n.Type)
th := hit.Type
mapiterinit(typename(t), ha, &hit)
for ; hit.key != nil; mapiternext(&hit) {
key := *hit.key
val := *hit.val
}

mapiterinit会通过fastrand生成随机数随机选择一个桶开始遍历
哈希表遍历的顺序,首先会选出一个绿色的正常桶开始遍历,随后遍历对应的所有黄色溢出桶,最后依次按照索引顺序遍历哈希表中其他的桶,直到所有的桶都被遍历完成。
遍历字符串的过程与数组、切片和哈希表非常相似,只是在遍历时会获取字符串中索引对应的字节并将字节转换成 rune

for i, r := range s {}
ha := s
for hv1 := 0; hv1 < len(ha); {
hv1t := hv1
hv2 := rune(ha[hv1])
if hv2 < utf8.RuneSelf {
hv1++
} else {
hv2, hv1 = decoderune(h1, hv1)
}
v1, v2 = hv1t, hv2
}
for v := range ch {}
ha := a
hv1, hb := <-ha
for ; hb != false; hv1, hb = <-ha {
v1 := hv1
hv1 = nil
...
}

2、select
select必须搭配channel使用,每个case表达式都是channel的收发操作。
select 能在 Channel 上进行非阻塞的收发操作;
select 在遇到多个 Channel 同时响应时会随机挑选 case 执行(随机的引入就是为了避免饥饿问题的发生)
select 语句在编译期间会被转换成 OSELECT 节点。每一个 OSELECT 节点都会持有一组 OCASE 节点,如果 OCASE 的执行条件是空,那就意味着这是一个 default 节点:
select不包含任何case,调用block函数,它会调用 runtime.gopark 让出当前 Goroutine 对处理器的使用权,传入的等待原因是 waitReasonSelectNoCases
空的 select 语句会直接阻塞当前的 Goroutine,导致 Goroutine 进入无法被唤醒的永久休眠状态
果当前的 select 条件只包含一个 case,那么就会将 select 改写成 if 条件语句

// 改写前
select {
case v, ok <-ch: // case ch <- v
... 
}
// 改写后
if ch == nil {
block()
}
v, ok := <-ch // case ch <- v

当 case 中的 Channel 是空指针时,就会直接挂起当前 Goroutine 并永久休眠

在默认的情况下,编译器会使用如下的流程处理 select 语句:

将所有的 case 转换成包含 Channel 以及类型等信息的 runtime.scase 结构体;
调用运行时函数 runtime.selectgo 从多个准备就绪的 Channel 中选择一个可执行的 runtime.scase 结构体;
通过 for 循环生成一组 if 语句,在语句中判断自己是不是被选中的 case一个包含三个 case 的正常 select 语句
runtime.selectgo 函数首先会进行执行必要的初始化操作并决定处理 case 的两个顺序 — 轮训顺序 pollOrder 和加锁顺序 lockOrder

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
cas1 := (*[1 << 16]scase)(unsafe.Pointer(cas0))
order1 := (*[1 << 17]uint16)(unsafe.Pointer(order0))
scases := cas1[:ncases:ncases]
pollorder := order1[:ncases:ncases]
lockorder := order1[ncases:][:ncases:ncases]
for i := range scases {
cas := &scases[i]
}
for i := 1; i < ncases; i++ {
j := fastrandn(uint32(i + 1))
pollorder[i] = pollorder[j]
pollorder[j] = uint16(i)
}
// 根据 Channel 的地址排序确定加锁顺序
...
sellock(scases, lockorder)
...
}

轮训顺序 pollOrder 和加锁顺序 lockOrder 分别是通过以下的方式确认的:
轮训顺序:通过 runtime.fastrandn 函数引入随机性;
加锁顺序:按照 Channel 的地址排序后确定加锁顺序;
随机的轮训顺序可以避免 Channel 的饥饿问题,保证公平性;而根据 Channel 的地址顺序确定加锁顺序能够避免死锁的发生。
select 结构的执行过程与实现原理,首先在编译期间,Go 语言会对 select 语句进行优化,它会根据 select 中 case 的不同选择不同的优化路径:

空的 select 语句会被转换成 runtime.block 函数的调用,直接挂起当前 Goroutine;
如果 select 语句中只包含一个 case,就会被转换成 if ch == nil { block }; n; 表达式;
首先判断操作的 Channel 是不是空的;
然后执行 case 结构中的内容;
如果 select 语句中只包含两个 case 并且其中一个是 default,那么会使用 runtime.selectnbrecv 和 runtime.selectnbsend 非阻塞地执行收发操作;
在默认情况下会通过 runtime.selectgo 函数获取执行 case 的索引,并通过多个 if 语句执行对应 case 中的代码;在编译器已经对 select 语句进行优化之后,Go 语言会在运行时执行编译期间展开的 runtime.selectgo 函数,该函数会按照以下的流程执行:

随机生成一个遍历的轮询顺序 pollOrder 并根据 Channel 地址生成锁定顺序 lockOrder;

根据 pollOrder 遍历所有的 case 查看是否有可以立刻处理的 Channel;
如果存在就直接获取 case 对应的索引并返回;
如果不存在就会创建 runtime.sudog 结构体,将当前 Goroutine 加入到所有相关 Channel 的收发队列,并调用 runtime.gopark 挂起当前 Goroutine 等待调度器的唤醒;
当调度器唤醒当前 Goroutine 时就会再次按照 lockOrder 遍历所有的 case,从中查找需要被处理的 runtime.sudog 结构对应的索引;select 关键字是 Go 语言特有的控制结构,它的实现原理比较复杂,需要编译器和运行时函数的通力合作。

3、defer

defer 关键字的调用时机以及多次调用 defer 时执行顺序是如何确定的;(后进先出)
defer 关键字使用传值的方式传递参数时会进行预计算,导致不符合预期的结果(调用 defer 关键字会立刻对函数中引用的外部参数进行拷贝)
想要解决这个问题的方法非常简单,我们只需要向 defer 关键字传入匿名函数
defer 关键字的运行时实现分成两个部分:
runtime.deferproc 函数负责创建新的延迟调用;
runtime.deferreturn 函数负责在函数调用结束时执行所有的延迟调用;
编译期;
将 defer 关键字被转换 runtime.deferproc;
在调用 defer 关键字的函数返回之前插入 runtime.deferreturn;
运行时:
runtime.deferproc 会将一个新的 runtime._defer 结构体追加到当前 Goroutine 的链表头;
runtime.deferreturn 会从 Goroutine 的链表中取出 runtime._defer 结构并依次执行;
后调用的 defer 函数会先执行:
后调用的 defer 函数会被追加到 Goroutine _defer 链表的最前面;
运行 runtime._defer 时是从前到后依次执行;
函数的参数会被预先计算;
调用 runtime.deferproc 函数创建新的延迟调用时就会立刻拷贝函数的参数,函数的参数不会等到真正执行时计算;
4、pannic和recover
panic 能够改变程序的控制流,函数调用panic 时会立刻停止执行函数的其他代码,并在执行结束后在当前 Goroutine 中递归执行调用方的延迟函数调用 defer;
recover 可以中止 panic 造成的程序崩溃。它是一个只能在 defer 中发挥作用的函数,在其他作用域中调用不会发挥任何作用;
recover 只有在发生 panic 之后调用才会生效。我们需要在 defer 中使用 recover 关键字。
panic 函数是如何终止程序的。编译器会将关键字 panic 转换成 runtime.gopanic,该函数的执行过程包含以下几个步骤:

创建新的 runtime._panic 结构并添加到所在 Goroutine _panic 链表的最前面;
在循环中不断从当前 Goroutine 的 _defer 中链表获取 runtime._defer 并调用 runtime.reflectcall 运行延迟调用函数;
调用 runtime.fatalpanic 中止整个程序;
编译器会将关键字 recover 转换成 runtime.gorecover
程序崩溃和恢复的过程:

编译器会负责做转换关键字的工作;
将 panic 和 recover 分别转换成 runtime.gopanic 和 runtime.gorecover;
将 defer 转换成 deferproc 函数;
在调用 defer 的函数末尾调用 deferreturn 函数;
在运行过程中遇到 gopanic 方法时,会从 Goroutine 的链表依次取出 _defer 结构体并执行;
如果调用延迟执行函数时遇到了 gorecover 就会将 _panic.recovered 标记成 true 并返回 panic 的参数;
在这次调用结束之后,gopanic 会从 _defer 结构体中取出程序计数器 pc 和栈指针 sp 并调用 recovery 函数进行恢复程序;
recovery 会根据传入的 pc 和 sp 跳转回 deferproc;
编译器自动生成的代码会发现 deferproc 的返回值不为 0,这时会跳回 deferreturn 并恢复到正常的执行流程;
如果没有遇到 gorecover 就会依次遍历所有的 _defer 结构,并在最后调用 fatalpanic 中止程序、打印 panic 的参数并返回错误码
5、make和new
make 的作用是初始化内置的数据结构,也就是切片、哈希表和 Channel
new 的作用是根据传入的类型在堆上分配一片内存空间并返回指向这片内存空间的指针
在编译期间的类型检查阶段,Go 语言就将代表 make 关键字的 OMAKE 节点根据参数类型的不同转换成了 OMAKESLICE、OMAKEMAP 和 OMAKECHAN 三种不同类型的节点,这些节点会调用不同的运行时函数来初始化相应的数据结构

 
posted @ 2020-12-20 18:47  菲菲菲菲菲常新的新手  阅读(210)  评论(0编辑  收藏  举报