导图社区 golang原理
golang关键功能的实现技术细节,channel、数组、内部结构等等。
编辑于2020-11-20 15:26:10GO语言
参考: https://draveness.me/golang/docs/ 面向信仰编程
channel
用于 goroutine 之间的同步、通信. Channel 在 gouroutine 间架起了一条管道,在管道里传输数据,实现 gouroutine 间的通信 它是线程安全的,所以用起来非常方便 channel 还提供“先进先出”的特性 它还能影响 goroutine 的阻塞和唤醒
数据结构
发送方式
同步模式,无缓冲
发送方和接收方要同步就绪 只有在两者都 ready 的情况下,数据才能在两者间传输(后面会看到,实际上就是内存拷贝)。 否则,任意一方先行进行发送或接收操作,都会被挂起,等待另一方的出现才能被唤醒
同步模式下,必须要使发送方和接收方配对,操作才会成功,否则会被阻塞; 异步模式下,缓冲槽要有剩余容量,操作才会成功,否则也会被阻塞
异步模式,有缓冲
在缓冲槽可用的情况下(有剩余容量),发送和接收操作都可以顺利进行。否则,操作的一方(如写入)同样会被挂起,直到出现相反操作(如接收)才会被唤醒
创建
指针类型,并且有缓冲
两次内存分配操作
先分配hchan结构体内存 c = new(hchan) 再分配缓冲的内存 c.buf = newarray(elem, int(size))
创建hchan结构体的内存
创建存放缓存的内存
不是指针类型,或者无缓冲
一次性分配内存
"hchan 结构体大小 + 元素大小*个数" 的内存
struct{} 类型长度为0
无论是有缓冲还是无缓冲
一次性分配内存
c.buf指向hchan结构体开头
因为只会用到接收和发送游标,不会真正拷贝东西到 c.buf 处
无缓冲
接受数据
接收方式
接收channel状态
不接受channel状态
阻塞接收
非阻塞接收
接收数据
忽略数据
数据通过ep传递
传递数据时,写入 ep 所指向的内存地址
ep为nil,表示接收方忽略数据
ep指向堆或者函数调用者的栈
如果是一个 nil 的 channel
非阻塞情况,直接返回false,false
阻塞情况,永久挂起,不返回也不报错
从一个nil的channel接收数据,编译时会报错,因为无法返回导致死锁
在非阻塞模式下,快速检测到失败,不用获取锁,快速返回
非缓冲型,等待发送列队 sendq 里没有 goroutine 在等待
缓冲型,但 buf 里没有元素
channel 未关闭
同时满足
channel 已关闭
循环数组 buf 里没有元素,或者无缓冲的情况
不会报错
返回数据类型的零值
有缓冲,循环数组有元素
不报错,继续将数组元素取出来
等待发送队列里有 goroutine 存在, buf 是满的
非缓冲型的 channel,直接进行内存拷贝(从 sender goroutine -> receiver goroutine)
缓冲型的 channel,但 buf 满了,接收到循环数组头部的元素,并将发送者的元素放到循环数组尾部
非阻塞接收获取锁后,发现没有发送方,直接返回,因为上面判断快速返回没有加锁,会出现这种情况
发送数据
如果是一个 nil 的 channel
非阻塞情况,直接返回false,false
阻塞情况,永久挂起,不返回也不报错
从一个nil的channel接收数据,编译时会报错,因为无法返回导致死锁
在非阻塞模式下,快速检测到失败,不用获取锁,快速返回
非缓冲型,等待接收队列 里没有 goroutine 在等待
缓冲型,但 循环数组已经装满了元素
channel 未关闭
同时满足
如果 channel 以关闭,直接报错
发送方式
阻塞发送
非阻塞发送
无法获取channel状态
接收队列有goroutine,此时无论是否有缓冲,都是空的
直接将数据复制到接收方,不用先往buf中发
没有接收方,并且缓存满了加入等待队列,等待被唤醒
被唤醒后,如果通道关了,panic
关闭channel
执行的方法
runtime.channel/closechan
将所有阻塞的接收方发送零值,加入列表等待唤醒
将所有发送方加入列表等待唤醒
将上面加入的所有goroutine唤醒, 接收方收到零值继续执行,发送方panic
等待发送队列,缓冲队列,等待接收队列 三者的关系?
缓冲队列未满,发送队列不会有内容
缓冲队列不为空,接收队列不会有内容
逃逸分析
什么是逃逸分析?
在编译原理中,分析指针动态范围的方法称之为逃逸分析
通俗来讲,当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸
简单来说,逃逸分析决定一个变量是分配在堆上还是分配在栈上
为什么需要逃逸分析?
栈上的内存分配比堆上快很多
如果变量都分配到堆上,堆不像栈可以自动清理。它会引起Go频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销
通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也会减少gc的压力,提高程序的运行速度。
具体怎么进行逃逸分析的?
如果函数外部没有引用,则优先放到栈中
如果函数外部存在引用,则必定放到堆中
定义了一个很大的数组,需要申请的内存过大,超过了栈的存储能力,分配到堆上
分析实例
代码
package main import "fmt" func foo() *int { t := 3 return &t; } func main() { x := foo() fmt.Println(*x) } ------------------------------------- foo函数返回一个局部变量的指针,main函数里变量x接收它
通过编译命令查看
go build -gcflags '-m -l' main.go ---------------------------------------------- 加-l是为了不让foo函数被内联 输出: # command-line-arguments src/main.go:7:9: &t escapes to heap src/main.go:6:7: moved to heap: t src/main.go:12:14: *x escapes to heap src/main.go:12:13: main ... argument does not escape foo函数里的变量t逃逸了,和我们预想的一致 fmt.Println(a ...interface{}),编译期间很难确定其参数的具体类型,也会发生逃逸
通过反编译查看
go tool compile -S main.go 
总结
堆上动态分配内存比栈上静态分配内存,开销大很多
变量分配在栈上需要能在编译期确定它的作用域,否则会分配到堆上
Go编译器会在编译期对考察变量的作用域,并作一系列检查,如果它的作用域在运行期间对编译器一直是可知的,那么就会分配到栈上
通过go build-gcflags'-m'命令来观察变量逃逸情况就行了
select
select 是一种与 switch 相似的控制结构,与 switch 不同的是,select 中虽然也有多个 case,但是这些 case 中的表达式必须都是 Channel 的收发操作。 select 能在 Channel 上进行非阻塞的收发操作; select 在遇到多个 Channel 同时响应时会随机挑选 case 执行;
数据结构
select 在 Go 语言的源代码中不存在对应的结构体,但是 select 控制结构中的 case 却使用 runtime.scase 结构体来表示
type scase struct { c *hchan elem unsafe.Pointer kind uint16 pc uintptr releasetime int64 }
c存储 case 中使用的 Channel
elem 是接收或者发送数据的变量地址
kind 表示 runtime.scase 的种类
nil
recv
send
default
实现原理
select 语句在编译期间会被转换成 OSELECT 节点。每一个 OSELECT 节点都会持有一组 OCASE 节点,如果 OCASE 的执行条件是空,那就意味着这是一个 default 节点 编译器在中间代码生成期间会根据 select 中 case 的不同对控制语句进行优化,这一过程都发生在 cmd/compile/internal/gc.walkselectcases 函数中
不同case不同情况
select 不存在任何的 case
直接调用阻塞函数,空的 select 语句会直接阻塞当前的 Goroutine,导致 Goroutine 进入无法被唤醒的永久休眠状态。
运行时会报错,死锁
select 只存在一个 case
将select转为if语句 如果channel为空,调用阻塞函数,挂起 否则直接将case内容提取为普通的channel用法 v, ok := <-ch // case ch <- v
select 存在多个case,其中一个 case 是 default
调用runtime.chanrecv或runtime.chansend 给他们参数block为false,非阻塞模式
select 存在多个 case,没有default
详细步骤
将所有的 case 转换成包含 Channel 以及类型等信息的 runtime.scase 结构体
调用运行时函数 runtime.selectgo 从多个准备就绪的 Channel 中选择一个可执行的 runtime.scase 结构体
初始化操作,确定两个顺序
轮询顺序
当select进入循环时,只选择一个准备好的channel进行处理 如果每次都固定顺序循环,会导致前面的channel一直被选择,后面的没有机会,造成饥饿现象 所以,每次通过fastrandn随机排序channel,每个channel都有机会被选择
加锁顺序
select处理时,会将所有case中的channel加锁 这个加锁顺序必须每次都固定, 不然两个线程同时想要加锁所有的channel,然而顺序不同,互相得不到想要的,导致死锁现象 顺序相同时,按顺序加锁,上一个线程上锁的,当前线程就不会获取到锁
根据 pollOrder 遍历所有的 case 查看是否有可以立刻处理的 Channel
如果存在就直接获取 case 对应的索引并返回
如果不存在就会创建 runtime.sudog 结构体,将当前 Goroutine 加入到所有相关 Channel 的收发队列,并调用 runtime.gopark 挂起当前 Goroutine 等待调度器的唤醒,并释放锁
当调度器唤醒当前 Goroutine 时就会再次按照 lockOrder 遍历所有的 case,从中查找需要被处理的 runtime.sudog 结构对应的索引
通过 for 循环生成一组 if 语句,在语句中判断选中的是哪个 case
数组
特点
数组在初始化之后大小就无法改变
数组是否应该在堆栈中初始化也在编译期就确定了
两个数组相同的条件
数组大小相同
数组元素类型相同
初始化
arr1 := [3]int{1, 2, 3} arr2 := [...]int{1, 2, 3}
[...]T{1, 2, 3} 和 [3]T{1, 2, 3} 在运行时是完全等价的,[...]T 这种初始化方式也只是 Go 语言为我们提供的一种语法糖,当我们不想计算数组中的元素个数时就可以通过这种方法较少一些工作。
当元素数量小于或者等于 4 个时,会直接将数组中的元素放置在栈上
当元素数量大于 4 个时,会将数组中的元素放置到静态区并在运行时取出
越界检查
比如直接数字访问,会在编译时检查出来
通过变量索引,编译是插入panic语句,运行时,如果越界,抛出异常
切片
初始化
arr[0:3] or slice[0:3]
slice := []int{1, 2, 3}
注意和数组[...]int{1,2,3}有区别
slice := make([]int, 10)
指向数组的切片
指向同一个数组的切片: 长度由索引的范围决定 cap容量为指向的开始索引到指向的数组的原始长度之间的范围 修改值: a 和b通过索引修改值,最终修改的都是同一个数组的内容 append方法: a切片 append时,会将a的切片结束索引往后移动,直到移到数组的结尾 如图,当aappend两个元素时,会和b重叠,此时修改重复位置的索引会相互影响 append超过数组的长度: 当append超过数组长度时,会发生扩容复制,长度1024之前,每次扩容为现有数组的大小2倍,之后为现有数组的25% 扩容后,切片会指向新创建的数组,而不是之前的,此时修改值便不会影响之前的数组的值  如图,扩容后,cap即为新的数组的总长度=8
接口
是否实现接口检查时机
传递参数
返回参数
变量赋值
编译器仅在需要时才对类型进行检查,类型实现接口时只需要实现接口中的全部方法,不需要像 Java 等编程语言中一样显式声明。
不同类型的接口
带有一组方法的接口
不带任何方法的 interface{}
结构体和指针实现接口,编译是否通过

类型转为接口
结构体类型转为接口
将分配在栈上的结构体转为接口类型: 构建一个接口实例 将栈上的结构体复制到堆上 将复制后的指针赋值给接口实例的成员 此行为在不优化时,即占用栈内存有占用堆内存,浪费内存

指针类型转为接口
生成一个接口类型,成员分别指向结构体类型,和具体数据

接口转为具体类型
通过对比接口实例中的类型的hash值和要转化的具体类型的hash值判断是否可转化
动态派发
动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程,它是一种在面向对象语言中常见的特性
使用结构体生成的接口进行动态派发,带来的巨大性能差异不只是接口带来的问题,带来性能问题主要因为 Go 语言在函数调用时是传值的,动态派发的过程只是放大了参数拷贝带来的影响。
总结点
内存占用(64位机器)
字符串变量占用16字节(8字节指针+8字节长度int
指针占用8字节内存
接口变量占用16字节
int类型占用8字节
相当于int64占用的大小,但是二者不可以直接转化 默认情况下,可以不用int类型最好不用,可以按需用int16,int32等
defer
关键点
defer 传入的函数不是在退出代码块的作用域时执行的,它只会在当前函数和方法返回之前被调用。
预计算参数
defer fmt.Println(time.Since(startedAt)): 参数time.Since(startedAt)是在defer声明时就计算好的.类似于: defer func(t time.Duration){ fmt.Println(t) }(time.Since(startedAt)) 建议写法: defer func() { fmt.Println(time.Since(startedAt)) }()
后调用的 defer 函数会先执行
后调用的 defer 函数会被追加到 Goroutine _defer 链表的最前面;
运行 runtime._defer 时是从前到后依次执行;
函数的参数会被预先计算
调用 runtime.deferproc 函数创建新的延迟调用时就会立刻拷贝函数的参数,函数的参数不会等到真正执行时计算;
运行时实现
runtime.deferproc 函数负责创建新的延迟调用;
将一个新的 runtime._defer 结构体追加到当前 Goroutine 的链表头;
runtime.deferreturn 函数负责在函数调用结束时执行所有的延迟调用;
从 Goroutine 的链表中取出 runtime._defer 结构并依次执行;
panic
相关现象
panic 只会触发当前 Goroutine 的延迟函数调用;
recover 只有在 defer 函数中调用才会生效;
panic 允许在 defer 中嵌套多次调用;