逃逸分析,主要是Go编译器用来决定变量分配在堆或者栈的手段。
区分于C/C++手动管理内存分配,Go将这些工作交给了编译器。
解放程序员。程序员不需要手动指定指针分配内存。
灵活的内存管理。编译器机制可以高效管理内存。
编译器根据变量是否被外部引用,决定是否发生逃逸。
如果不被外部引用,则发生逃逸;反之,则发生逃逸。
运行/编译时,可以指定参数进行查看
不是。
C/C++中的堆栈,就是操作系统中传统的堆栈概念。
Go语言中则不同。Go语言中,操作系统中的栈,都提供给了Go运行时,用来处理调度器、垃圾回收、系统调用等。对于用户态的Go代码,消耗的都是操作系统中的堆内存,只是构造出了逻辑上不同的“堆”和“栈”。
延迟语句(defer)是Go语言中注册延迟调用的机制,主要用于成对操作时,如文打开文件/关闭文件、打开连接/关闭连接、加锁/释放锁。
defer语句会将调用函数压入栈中,先进后出的执行。
在defer函数定义时,对外部变量的引用有2种形式:
1.函数参数:defer定义时会把值传递给defer,并被cache起来。 2.闭包引用:真正执行时根据上下文确定参数值。
return xxx
相当于:
1.返回值=xxx
2.调用defer函数
3.return
判断依据主要与2.2的规则一样。
匿名函数也被称为闭包,一个闭包继承了函数声明时的作用域,Go语言中所有的匿名函数都是闭包。
recover()函数只在defer的函数中直接调用才有效。
设计使然。goroutine被设为为一个独立的代码执行单元,拥有自己的执行栈,不与其他goroutine共享任何数据。这意味着,无法让goroutine拥有返回值、也无法让goroutine拥有自身的ID编号等。
数组是定长的,长度是类型的一部分,所以表达能力有限,在Go语言中不常见。
切片则非常灵活,可以动态扩容,且切片的类型和长度无关。
*底层数组可以被多个切片同时指向,因此对一个切片的元素进行操作,有可能会影响到其他切片。
基于已有slice创建新slice对象,被称为reslice。新老slice共用底层数组,它们对底层数组的更改都会影响到彼此。
如果因为append操作引起了新slice或者老slice底层数组扩容,则不会相互影响。
这其中的关键就是:两者是否共用底层数组。
func main (){ slice := []int{0,1,2,3,4,5,6,7,8,9} s1 := slice[2:5] s2 := s1[2:6:7] s2 = append(s2,100) s2 = append(s2,200) s1[2] = 20 }
s1:[2 3 20] s2:[4 5 6 7 100 200] slice:[0 1 2 3 4 5 6 7 100 9]
下面的说法是不准确的:
1.当原slice容量小于1024的时候,新slice容量变成原来的2倍
2.当原slice容量超过1024,新slice容量变成原来的1.25倍
实际是:
扩容过程对newcap进行了内存对齐,而这个和内存分配策略有关。进行内存对齐后,新s的容量要大于等于老s容量的2倍或者1.25倍。
slice作为函数参数时,就是一个普通的结构体。直接传slice,实参不受影响;传入slice的指针,则会影响。
Go语言中的函数参数传递,只有值传递,没有引用传递。
1.make和new都用来分配内存,但适用类型不同。make适用于slice、map、channel等引用类型,new适用于int、数组、结构体等值类型。
2.make返回一个值,new返回一个指针。
3.make返回初始化之后的类型的引用,new会为类型的新值分配已置零的内存空间,并返回指针。
map最主要的数据结构有两种:哈希查找表(Hash table)、搜索树(search tree)
哈希查找表用一个哈希函数将key分配到不同的bucket桶,开销主要是哈希函数的计算以及数组的常数访问时间。处理碰撞的方法一般有:链表法和开放地址法。
搜索树一般采用平衡搜索树,包括AVL树、红黑树等。
Go语言使用的是哈希查找表,并且使用链表法解决哈希冲突。
map的结构体是hmap。关键字段有:
B:buckets数组的长度的对数,即buckets数组的长度为2^B,bucket里面存储了key和value,bucket是一个指针,指向的是一个结构体:bmap。
bmap就是人们常说的“桶”,桶里最多装8个
这些key之所以会落入同一个桶,是因为它们的hash结果是“一类”的,并不是完全相等。hash值的高8位,决定落入桶内的槽位。
每个bucket设计成最多只能放8个key-value对,如果有第9个key-value落入当前的bucket,则需要再构建一个bucket,并通过overflow指针连接起来。这就是所谓的“链表法”。
创建map,就是调用makemap函数,初始化hmap的各个字段。
slice和map分别作为函数参数数时有什么区别?
在函数内部对map的操作会影响map结构体;而对slice操作则不会。
主要原因:前者是指针(*hmap),后者是结构体(slice)
Go会检测cpu是否支持aes,如果支持使用aes hash,否则使用memhash。
哈希值共64个bit位(针对64位机),计算元素落入到哪个bucket,只会用到最后B个bit位。
当两个不同的key落入同一个桶中,使用链表法解决哈希冲突,即链表法:从前往后查找第一个空位。
查找时:先找到对应的桶,再去遍历桶中的所有key。如果bucket中没有找到,并且overflow不为空,则会继续在overflow bucket中寻找。
寻找某个key的底层函数是mapacess系列函数。
向map插入或者修改key,调用的是mapassign函数。赋值操作的核心仍然是一个双层循环:外层遍历bucket和overflow bucket,内层遍历单个bucket的所有槽位。有比较重要的几点:
1.写标志flags=1时,说明有其他协程在执行“写”操作,程序会panic,说明map不是协程安全的。
2.map的扩容是渐进式的,定位元素到某个bucket后,需要确保这个bucket对应的老bucket已经完成了迁移过程(老bucket中的key会被分散到2个新bucket)
3.定位元素放置的位置时,准备两个指针: inserti和insertk分别指向第一个空的tophash、第一个空闲的cell(槽)
4.如果触发扩容,则查找定位key的过程会重新执行一次。
5.最后会更新元素的值,如果是插入的话,map的count字段值加1,hasWriting清零。
底层执行mapdelete函数,主要逻辑:
1.检测并发写操作
2.计算元素的hash值,找到落入的bucket
3.设置写标志位
4.检测此map是否在扩容中,如果是,则触发一次搬迁。
5.两层循环,核心是找到key的具体位置。
6.找到对应位置后,完成清零操作。
7.将map的count字段减1,对应位置的tophash改为emptyone。
8.联动判断是否处理同bucket的其他槽位,emptyOne改成emptyRest的过程。
map在扩容时会触发搬迁。一个bucket中的元素会分散到2个。这个过程不能保证元素的顺序。
不是。如果检测到写标志flags=1则直接panic了。
可以,但是会出现精度丢失问题。float64作为key时,会转成uint64类型,再插入key中。
带comma和不带comma。
1.都为nil
2.非空、长度相等,指向同一个map实体对象
3.相同的key指向value“深度”相等。
不能。
同一个协程内边遍历边删除,并不会检测到同时读写,理论上是可以的。
如果存在多个读写同时进行的情况,推荐使用线程安全的sync.map。
不用通过共享内存来通信,而要通过通信来实现共享内存。
大多数编程语言的并发编程模型是基于线程和内存同步访问控制。Go的并发编程模型则用goroutine和channel来替代。goroutine和线程类似,channel则和mutex类似。
1.停止信号
2.定时任务(结合time包,一般有2种做法,实现超时控制、定时任务)
3.解耦生产方和消费方
4.控制并发数(使用缓存通道)
从一个有缓冲的channel里读数据,当channel被关闭,已然能读出有效值,只有当返回的ok值为false时,读出的数据才是无效的。
如果是无缓冲的呢?
所谓的优雅关闭channel,就是不关闭channel,让GC代劳。
定义:假设事件a和事件b存在happened-before关系,那么a/b完成后的结果也一定要体现这种关系。
由于现代编译器、CPU会做各种优化,包括编译器重排、内存重排等,在并发代码里,happened-before限制就非常重要了。
goroutine操作channel后,处于发送或者接收阻塞状态,而channel处于满或空的状态,一直得不到改变,垃圾回收器并不会回收此类资源。
如果一个channel,没有任何goroutine引用,GC会对其进行回收操作,不会引起内存泄露。
操作 |
nil chan |
closed chan |
not nil & not closed |
close |
panic |
panic |
正常关闭 |
读<-ch |
阻塞 |
对应类型的0值 |
1.正常读取。 2.缓冲型channel为空阻塞 无缓存型channel无发送者时阻塞 |
写ch<- |
阻塞 |
panic |
1.正常写入 2.缓冲型channel满时阻塞 非缓冲型channel无接收者时阻塞 |
Go采用的是“非侵入式”不需要显式声明,只需要定义接口定义的函数,编译器就会自动识别。
Go不要求类型显式的声明实现了某个接口,只要实现了相关地方法即可。
iface和eface都是Go中描述接口的底层结构体,区别在于iface描述的接口包含方法,而eface则是不包含任何方法的空接口。
type iface struct { tab *itab //接口的类型以及赋值给这个接口的实体类型《动态类型》 data unsafe.Pointer // 指向接口具体的值《动态值》 } type itab struct { inter *interfacetype // 接口类型 _type *_type // 赋值给这个接口的实体类型 ... } type interfacetype struct { typ _type pkgpath name // 接口的包名 mhdr []imethod // 接口定义的函数列表 }
type eface struct { _type *_type // 空接口承载的实体类型 data unsafe.Pointer // 具体的值 }
Go语言中各种数据类型都是在_type字段的基础上,增加一些额外的字段来进行管理。
主要包括:类型大小、类型的hash值、内存对齐相关、类型的编号以及GC相关的字段。
函数添加一个接收者,它就变成了方法。接收者可以是值接收者,也可以是指针接收者。
实现了接收者是值类型的方法,相当于是自动实现了接收者是指针类型方法。
实现了接收者是指针类型的方法,不会自动生成接收者是值类型的方法。
使用指针作为方法的接收者的理由如下:
1.方法能够修改接收者指向的值
2.避免在每次调用方法时复制该值,在值的类型为大型结构体时,这样做会更加高效。
多态,可以让一种类型具有多种类型的能力。
iface包含两个字段:tab是接口表指针,指向类型信息;data是数据指针。分别被称为动态类型和动态值。
var c coder var g *Gopher c = g fmt.Println(c == nil) // false
当判定一种类型是否满足某个接口时,Go将类型的方法集和接口所需的方法集进行匹配。
如果类型的方法集,完全包含接口的方法集,则可认为该类型实现了该接口。
类型转换、类型断言本质都是把一个类型转换成另外一个类型。不同之处在于,类型断言是对接口变量进行的操作。
对应类型转换,转换前后的两个类型要相互兼容才行。
因为空接口interface{}没有定义任何函数,因此Go中所有类型都实现了空接口。
当一个函数的形参是interface{},那么在函数中,需要对形参进行断言,从而得到它的真实类型。
Go语言中的switch仅执行第一个匹配成功的分支,不需要break语句;另外case不需要是常量,也不必是整数。
fallthrough关键字,表示需要执行下一个分支。
var _ io.Writer = (*myWriter)(nil)
对于一个结构体,通过offset函数可以获取结构体成员的偏移量,进而获取成员的地址,读写该地址的内存,就可以达到改变成员值的目的。
结构体被分配一块连续的内存,结构体的地址,也代表了第一个成员的地址。
主要通过unsafe.Pointer和uintptr进行转换。
type StringHeader stuct { Data uintptr Len int } type SliceHeader stuct { Data uintptr Len int Cap int }
只需要共享底层Data和Len就可以实现zero-copy。
context是goroutine的上下文,在goroutine中传递上下文信息,包含:取消信号、超时时间、截止时间、k-v等
使用context的几点建议:
1.不要讲context塞到结构体里,而是作为第一参数,一般命名为ctx 2.不用向函数传入一个含nil属性的context,可以使用todo代替
3.不用把业务参数塞到context中
4.context是并发安全的
1.传递共享的数据
2.定时取消
3.防止goroutine泄露
主要包含Context和Canceler两个接口。
1.context定义了4个方法,都是冪等的
2.Deadline()返回context的截止时间,决定是否进行后续操作
3.Done()返回一个channel,表示context被取消的信号
4.Err()返回一个错误,表示channel被关闭的原因。例如被取消还是超时
5.Value()获取之前设置的key对应的value
源码中有2个类型实现了canceler接口:*cancelCtx、*timerCtx,注意是指针类型。
设计原因主要是:
1.“取消”操作应该是建议性,而非强制性
2.“取消”操作应该可传递
Go使用error类型表示错误,是一个接口类型。
最简单的是errors.New(),如果需要具体的上下文信息,可以使用fmt.Errorf()
Go代码里error满天飞,显得非常冗长拖沓。
1.视错误为值
处理error的方式分为三种。
Sentinel errors:哨兵。处理流程停止。最大的问题在于定义error和使用error的包之间建立了依赖关系,容易引起循环调用。(不推荐)
Error Types:自定义Error类型,在error基础上,附带其他字段,外层调用者需要使用类型断言来判断错误。也存在循环调用的问题。(不推荐)
Opaque errors:黑盒error。能知道错误发生了,但是无法看到它内部到底是什么,不知道它的具体类型。
一旦出错,直接返回错误;否则继续后面的流程。
如果调用者需要判断返回的错误类型,可以判断错误是否具有某种行为,或者说实现了某个接口。
这样做的好处是:不需要import引用定义错误的包,并且不需要知道error的具体类型,只需要判断它的行为。即:面向接口编程。
2.检查并优雅的处理错误
Go1.13之前使用github.com/pkg/errors,使用Wrap可以将一个错误,加上一个字符串,“包装”成一个新的错误。Cause则是反向操作,将里层的错误还原。
3.只处理一次错误
避免函数内和函数外的调用者都处理错误。
Go1.13支持了error包裹(wrapping),fmt.Errorf增加了%w的格式,并且在error包增加了三个函数:errors.Unwrap、errors.Is、errors.As。
fmt.Errorf使用%w来生成一个嵌套的error
Unwrap将嵌套的error解析出来
Is判断err和target是同一类型,或者error嵌套的error有没有和target同一类型。
As从错误链中找到第一个和target相等的值,并且设置target指向的变量为err。
四叉堆和二叉堆本质上没有区别,它使得整体上层数更低,且时间复杂度从O(log2N)降到O(log4N)
1.固定时间间隔触发
2.固定时间间隔重复触发
3.在某个具体时刻触发
影响时间准确性的元素
1.对系统时间的依赖程度:只能依靠操作系统或者时间提供方(通常认为精度在毫秒级)
2.对运行时的依赖程度:由于运行时组件的存在,这个时间管理的准确性也将或多或少受到一定程度的影响,例如调度器的调度延迟、垃圾回收器的干扰、操作系统对应用程序进行中断产生的延迟等。(当系统出现可感知的延迟时,可以着重调试运行时本身对延迟的影响,如:调度器任务的数量、Timer/Ticker的密度和垃圾回收器的压力)
定时器可以使用链表、堆、红黑树等数据结构,也可以使用时间轮实现。流行的高效定时器有三种:Go使用的堆结构、nginx使用的红黑树、linux kernel使用的时间轮。
Go语言提供了一个机制,在允许时更新变量和检查它们的值、调用它们的方法,但是在编译时并不知道这些变量的类型,这就是反射机制。
使用反射的常用场景有以下两种:
1.不能明确接口调用哪个函数,需要根据传入的参数在运行时决定。
2.不能明确传入函数的参数类型,需要在运行时处理任意对象。
不推荐使用反射的原因:
1.代码经常难以阅读
2.编译器能提前发现一些类型错误,但对反射代码无能为力。
3.性能影响较大
反射是通过接口的类型信息实现的,建立在类型的基础上。
反射主要与interface{}有关。
接口变量,可以存储任何实现了接口定义的所有方法的变量。
GO语言reflect包里定义了一个接口和一个结构体,即reflect.Type和reflect.Value,它们提供很多函数来获取存储在接口里的类型信息。
reflect.Type提供关于类型相关的信息,和_type关联比较紧密。
reflect.Value则结合_type和data两者,因此可以获取并改变类型的值。
reflect包提供了两个基础的关于反射的函数,来获取上述的接口和结构体:TypeOf、ValueOf
TypeOf函数用来提取一个接口中值的类型信息。
ValueOf函数返回一个结构体变量,包含类型信息以及实际值。
反射三大定律
1.接口类型变量,可以转化成反射类型对象(反射类型对象,指reflect.Type、reflect.Value)
2.反射类型对象,可以转化成接口类型变量
3.如果想要操作原变量,反射变量Value必须要持有原变量的地址才行。
Go语言中提供了DeepEqual()函数进行比较。参数是两个interface。
如果是不同的类型,即使是底层类型相同,相应的值也相同,那么两者也不是深度相等。
浅拷贝:只复制指向某个对象的指针,而不复制对象本身,新旧对象中指针类型的字段还是共享同一块内存。深拷贝:创造一个内容完全相同的对象,新对象与原对象不共享内存,修改新对象不影响原对象。
实现深度拷贝可以存在多种形式,最简单、最安全也是最容易的方式是使用json.Marshal/Unmarshal。但是涉及到序列化和反序列化,性能较差。通过反射可以实现更高效的深度复制。
sync.Waitgroup可以达到并发goroutine的执行屏障的效果。
当需要对一个并行执行的代码块引入等待条件时,便可以使用Add操作来产生同步记录;而当不再需要等待条件时,则可在并发代码块中使用Done操作来促成同步屏障条件的达成。
Waitgroup的内部结构非常简单,内部由3个uint32来对并发的goroutine进行不同目的的计数,分别是运行计数、等待计数和信号计数。并通过state()函数来消除在高层实现上的差异,返回状态(运行计数和等待计数)和信号(信号计数)。
大量重复的创建很多对象,会引起GC的工作量飚升,这时可以使用sync.Pool来缓存对象,减轻对GC的消耗。
Sync.Map是线程安全的,读取、插入、删除也都保持着常数级的时间复杂度。
Sync.Map的零值是有效的,并且零值是一个空的map,它在第一次使用后,不允许被复制。
Sync.Map使用非常简单,和普通map相比,仅遍历的方式略有不同。
sync.Map数据结构:
mu Mutext 保护read和dirty字段
read是atomic.Value类型,可以并发地读。
dirty是一个非线程安全的原始map。
1.内存消耗:goroutine的栈内存消耗为2kb,创建线程需要1MB栈内存
2.创建和销毁:线程的创建和销毁消耗巨大,是内核级的,通常使用线程池提高复用;goroutine由go runtime负责管理,创建和消费的消耗非常小,是用户级的。
3.切换:线程切换需要保存各种寄存器,以便恢复;goroutine切换只需保存三个寄存器:PC、Stack Pointer和BP。
Go程序的执行有两个层面,Go program和Runtime。
Go schedule是Go运行时最重要的部分。Runtime维护所有的goroutine,并通过schedule进行调度。
对操作系统而言,只有线程的概念,并不感知goroutine。
有3个基础的结构体来实现goroutine的调度:G、P、M。
G:代表goroutine,表示goroutine栈的一些字段等。
M:代表内核线程,包含正在运行的goroutine等字段
P:代表一个虚拟的Processor,维护一个处于Runnable状态的goroutine队列。M需要获得P才能运行G。
还有一个核心结构体:sched,总览全局,负责整个调度器的运行。
Runtime起始时,会启动一些G:垃圾回收的G,执行调度的G,运行用户代码的G;并且创建一个M用来开始G的运行。
Go schedule会启动一个后台线程sysmon,来检测长时间(超过10ms)运行的goroutine,将其“停靠”到global runqueues。
初始化时,Go程序会有一个G,G在M上得到执行,内核线程是在CPU核心上调度,G则在M上进行调度。
此外,还有两个比较重要的组件:全局可运行队列(GRQ)和本地可运行队列(LRQ)。
和线程类似,goroutine的状态也有3种:Waiting(等待状态)、Runnable(就绪状态)、Executing(运行状态)。
4种情形下,goroutine可能会发生调度,但也并不是一定发生。分别是:使用go关键字、GC、系统调用、内存同步访问。
Go runtime会在程序启动后,“按需”创建N个线程,之后创建M个goroutine会依附在N个线程上执行。
Go schedule的职责就是将所有处于runnable的goroutine均匀调度到在P上运行的M。
当一个P发现自己的LRQ已经没有G时,会从其他P“偷”一些G来运行,这被称为“工作窃取”。
Go schedule每一轮调度要做的工作,就是找到处于runnable的goroutine,并执行它。顺序如下:
1.从LRQ中找
2.从GRQ中找
3.从netpoll里找
4.从其他P偷取
G:取的是goroutine的首字母,主要保持goroutine的一些状态信息,以及CPU的一些寄存器的值。G关联了两个比较重要的结构体,stack表示goroutine运行时的栈,gobuf保存PC、SP等寄存器的值。
M:取的是machine的首字母,代表一个工作线程,或者说系统线程。G需要调度到M上才能运行,M是真正工作的实体。m结构体保存了M自身需要使用的栈信息、正在M上执行的G信息、与之绑定的P信息等。
P:取processor的首字母,为M的执行提供“上下文”,保存M执行G时的一些资源,例如本地可运行G队列,memeory cache等。一个M只有绑定P才能执行goroutine,当M被阻塞时,整个P会被传递给其他M。
Go scheduler在源码中的结构体为schedt,保存调度器的状态信息、全局的可运行G队列等。
g0栈用于执行调度器的代码,它选择一个可运行的goroutine,之后跳转到执行用户代码的地方。如何跳转,这中间涉及栈和寄存器的切换。函数调用和返回主要靠的也是CPU寄存器的切换,goroutine的切换和此类似。
1.从本地队列找、2.定期从全局队列找、最后从别的P偷取。
1.抢占处于系统调用的P,让其他M接管它,以运行其他的goroutine
2.将运行时间过长的goroutine调度出去,给其他goroutine运行的机会
内存管理的动机:性能要求、解放程序员
内存管理运行时的组件:
1.页分配器:从操作系统申请内存
2.对象分配器:为用户程序分配内存
3.垃圾回收期:回收用户程序所分配的内存
4.拾荒器:向操作系统归还申请的内存
从运行时对内存的管理角度来看,内存有4种状态:空状态(None)预留态(Reserved)准备态(Prepared)以及就绪态(Ready)
运行时中的mheap结构存储了整个go堆的管理状态,涉及页分配器和对象分配器。从两个分配器视角可以将内存考虑为两种不同的粒度单位。
页分配器:堆是按照连续的页进行管理。
对象分配器:以跨度(span)进行管理。一个跨度以mspan结构进行存储,每个跨度可以存储多个分配的对象,并由多个连续的页组成。
分配的基本策略:顺序分配和自由表分配两大策略。
顺序分配:直接从一段连续空间的一端开始,按需逐次将内存分配给用户程序。(Go堆内存使用)
自由表分配:使用链表结构来维护未分配的内存,进行串联管理。(运行时对象所在的非托管内存使用)
对象分配的缓存分为两个:
1.本地跨度缓存:不需要分配新的跨度 2.中枢跨度缓存:需要分配新的跨度
_type是Go类型的实现,通过size属性可以获得该类型对应的大小。对象分配的流程分为三种基本情况:
1.微对象分配:针对小于16B的对象分配请求
2.小对象分配:针对大小介于16B和32KB之间的分配请求
3.大对象分配:针对大于32KB的对象分配请求
垃圾回收,是一种自动内存管理的机制。
当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收,并供其他代码申请内存复用,或者将其归还给操作系统,这个过程称为垃圾回收。
垃圾回收器的执行过程被划分为两个半独立的组件:
1.赋值器:代指用户态的代码,对垃圾回收器而言,用户态的代码只修改对象之间的引用关系。
2.回收器:负责执行垃圾回收的代码。
垃圾回收器在标记过程中,最先检查的对象包括:全局变量、执行栈、寄存器。
GC算法的存在形式,可以归结为:追踪和引用计数这两种形式的混合运用。
追踪式GC:从根对象出发,根据引用关系逐步扫描,确定保留的对象,从而回收所有可回收的对象。
引用计数式GC:每个对象自身包含一个被引用的计数器,计数器归零时自动回收。
三色标记法是什么?
关键是理解三色抽象以及波面推进两个概念。
三色抽象规定了三种不同类型的对象,并以不同颜色相称。
1.白色对象:未被回收器访问,初始颜色,回收结束后,均不可达。
2.灰色对象:已被回收器访问,可能指向白色对象。
3.黑色对象:已被回收器访问,所有字段已被扫描,黑色对象中任何一个指针都不可能指向白色对象。
STW是什么?
stop the world,也可以是start the world。从stop the world到start the world的这段时间间隔。
垃圾回收过程,为了保证实现的正确性,防止无止境的内存增长。