目录
Golang中对nil的Slice和空Slice的处理是一致的吗?
怎么查看Goroutine的数量?怎么限制Goroutine的数量?
进程、线程、协程
进程:资源分配和 CPU 调度的基本单位
线程:CPU调度的基本单位,线程除了有一些自己的必要的堆栈空间之外,其它的资源都是共享的线程中的,共享的资源包括:
1.所有线程共享相同的虚拟地址空间,即它们可以访问同样的代码段、数据段和堆栈段。
2.文件描述符:进程打开的文件描述符是进程级别的资源,所以同一个进程中的线程可以共享打开的文件描述符,这
意味着它们可以同时读写同一个文件。
3.全局变量:全局变量是进程级别的变量,因此可以被同一个进程中的所有线程访问和修改。
4.静态变量:静态变量也是进程级别的变量,在同一个进程中的线程之间共享内存空间。
5.进程ID、进程组ID
独占的资源:
1、线程ID
2、寄存器组的值
3、线程堆栈
4、错误返回码
5、信号屏蔽码
6、线程的优先级
协程:用户态的线程,可以通过用户程序创建、删除。协程切换时不需要切换内核态。
协程与线程的区别:
1.线程是操作系统的概念,而协程是程序级的概念。线程由操作系统调度执行,每个线程都有自己的执行上下文,包
括程序计数器、寄存器等。而协程由程序自身控制。
2.多个线程之间通过切换执行的方式实现并发。线程切换时需要保存和恢复上下文,涉及到上下文切换的开销。而协
程切换时不需要操作系统的介入,只需要保存和恢复自身的上下文,切换开销较小。
3.线程是抢占式的并发,即操作系统可以随时剥夺一个线程的执行权。而协程是合作式的并发,协程的执行权由程序
自身决定,只有当协程主动让出执行权时,其他协程才会得到执行机会。
线程的优点
1.创建一个新线程的代价要比创建一个新进程小的多
2.线程之间的切换相较于进程之间的切换需要操作系统做的工作很少
3.线程占用的资源要比进程少很多
4.能充分利用多处理器的可并行数量
5.等待慢速 IO操作结束以后,程序可以执行其他的计算任务
缺点:
1.性能损失( 一个计算密集型线程是很少被外部事件阻塞的,无法和其他线程共享同一个处理器,当计算密集型的
线程的数量比可用的处理器多,那么就有可能有很大的性能损失,这里的性能损失是指增加了额外的同步和调度开
销,二可用资源不变。)
2.健壮性降低(线程之间是缺乏保护性的。在一个多线程程序里,因为时间上分配的细微差距或者是共享了一些不应
该共享的变量而造成不良影响的可能影响是很大的。)
3.缺乏访问控制( 因为进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响 。)
4.编程难度提高(编写和 调试一个多线程程序比单线程困难的多。)
有栈协程和无栈协程
有栈协程:把局部变量放入到新开的空间上,golang的实现,类似于内核态线程的实现,不同协程间切换还是要切换对应的栈上下文,只是不用陷入
内核
无栈协程:直接把局部变量放入系统栈上,js、c++、rust那种await、async实现,主要原理就是闭包+异步,换句话说,其实就是协程的上下文都放
到公共内存中,协程切换时,使用状态机来切换,就不用切换对应的上下文了,因为都在堆里的。比有栈协程都要轻量许多。
Go语言——垃圾回收
Go V1.3之前的标记-清除:
1.暂停业务逻辑,找到不可达的对象,和可达对象
2.开始标记,程序找出它所有可达的对象,并做上标记
3.标记完了之后,然后开始清除未标记的对象。
4.停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束
标记-清除的缺点:
STW(stop the world):让程序暂停,程序出现卡顿
标记需要扫描整个heap
清除数据会产生heap碎片
为了减少STW的时间,后来对上述的第三步和第四步进行了替换。
Go V1.5 三色标记法
1.把新创建的对象,默认的颜色都标记为“白色”
2.每次GC回收开始,然后从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合
3.遍历灰色集合,将灰色对象引用的对象从白色集合放入到灰色集合,之后将此灰色对象放入到黑色集合
4.重复第三步,直到灰色中无任何对象
5.回收所有的白色标记的对象,也就是回收垃圾
三色标记法在不采用STW保护时会出现:
1.一个白色对象被黑色对象引用
2.灰色对象与它之间的可达关系的白色对象遭到破坏
这两种情况同时满足,会出现对象丢失
解决方案:
1.强三色不变式:强制性的不允许黑色对象引用白色对象(破坏1)
2.弱三色不变式:黑色对象可以引用白色对象,白色对象存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象(破坏2)
屏障:
1.插入屏障:在A对象引用B对象的时候,B对象被标记为灰色(满足强三色不变式,黑色引用的白色对象会被强制转换为灰色)。只有堆上的对象触发插入屏障,栈上的对象不触发插入屏障。在准备回收白色前,重新遍历扫描一次栈空间。此时加STW暂停保护栈,防止外界干扰。
不足:结束时需要使用STW来重新扫描栈
2.删除屏障:被删除的对象,如果自身为灰色或者白色,那么被标记为灰色(满足弱三色不变式)。
删除屏障的不足:回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。
Go V1.8的三色标记法+混合写屏障机制
具体操作:
1.GC开始将栈上的可达对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW)
2.GC期间,任何在栈上创建的新对象,均为黑色
3.堆上被删除对象标记为灰色
4.堆上被添加的对象标记为灰色
满足:变形的弱三色不变式(结合了插入、删除写屏障的优点)
对于堆上的对象,采用三色标记法+写屏障保护
GC垃圾收集的多个阶段:
1.标记准备阶段;
启动后台标记任务
暂停程序(STW),所有的处理器在这时会进入安全点(Safe point);
如果当前垃圾收集循环是强制触发的,我们还需要处理还未被清理的内存管理单元;
将根对象入队
开启写屏障
2.标记阶段;
恢复用户协程
使用三色标记法开始标记,此时用户协程和标记协程并发执行
3.标记终止阶段;
暂停用户协程
计算下一次触发GC时需要达到的堆目标
唤醒后台清扫协程
4.清理阶段;
关闭写屏障
恢复用户协程
异步清理回收
什么是根对象?
根对象(root object)是指那些能够从全局可达的地方访问到的对象。垃圾回收器会从根对象开始,通过遍历根对象的引用关系,逐步追踪并标记所有可达的对象。任何未被标记的对象都会被认为是垃圾,最终被回收释放。
1.全局变量:全局变量可以被程序中的任何位置引用到,因此是根对象。
2.当前正在执行的函数的局部变量:当一个函数正在执行时,其局部变量可以被当前函数中的代码访问到,因此也是根对象。
3.当前正在执行的 goroutine 的栈中的变量:goroutine 是 Go语言并发编程中的轻量级线程,每个 goroutine 都有一块独立的栈空间,其中的变量可以被当前 goroutine 访问到,也是根对象。
4.其他和运行时系统相关的数据结构和变量。
三色标记法的缺点:
1.暂停时间:在进行垃圾回收时,必须停止程序执行,这会导致应用程序暂停。引入写屏障保护可以减少暂停时间,
但仍然可能导致性能下降。
2.内存开销:三色标记法需要为每个对象维护额外的状态信息,以记录其标记状态。这会增加内存开销,并可能对内
存资源造成负担。
3.频繁的垃圾回收:三色标记法需要频繁地迭代标记和清除对象,如果要回收的垃圾对象很多,可能会导致回收过程
变得非常耗时。
4.碎片化:垃圾回收过程中,如果频繁进行对象的移动和重新分配内存,可能会导致内存碎片化,降低内存的利用
率。
GC的触发条件
1.主动触发(手动触发),通过调用 runtime.GC 来触发GC,此调用阻塞式地等待当前GC运行完毕。
2.被动触发,分为两种方式:
2.1.使用步调(Pacing)算法,其核心思想是控制内存增长的比例,每次内存分配时检查当前内存分配量是否已达到阈值(环境变量GOGC):默认100%,即当内存扩大一倍时启用GC。
2.2.使用系统监控,当超过两分钟没有产生任何GC时,强制触发 GC。
GC调优
1.控制内存分配的速度,限制Goroutine的数量,提高赋值器mutator的CPU利用率(降低GC的CPU利用率)
2.少量使用+连接string
3.slice提前分配足够的内存来降低扩容带来的拷贝
4.避免map key对象过多,导致扫描时间增加
5.变量复用,减少对象分配,例如使用sync.Pool来复用需要频繁创建临时对象、使用全局变量等
6.增大GOGC的值,降低GC的运行频率
GMP调度和CSP模型
CSP模型是“以通信的方式来共享内存”,不同于传统的多线程通过共享内存来通信。用于描述两个独立的并发实体通过共享的通讯channel来进行通信的并发模型。
GMP分别是什么,分别有多少数量?
G:goroutine,go的协程,每个go关键字都会创建一个协程
M:machine,工作线程,在Go中称为Machine,数量对应真实的CPU数
P:process,包含运行Go代码所需要的必要资源,用来调度G和M之间的关联关系,其数量可以通过GOMAXPROCS来设置,默认为核心数
// src/ runtime/ runtime2.go
type g struct {
goid int64//唯一的goroutine的ID
sched gobuf //goroutine切换时,用于保存g的上下文
stack stack //栈
gopc // pc of go statement that crlated this goroutine
startpc uintptr //pc of goroutine function
...
}
type p struct {
lock mutex
id int32
status uint32 // one of pidle/ prunning / . ..
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32 //本地队列队头
runqtail uint32 //本地队列队尾
runq [256]guintptr //本地队列,大小256的数组,数组往往会被都读入到缓存中,对缓存友好,效率较高
runnext guintptr // 下一个优先执行的goroutine (一定是最后生产出来的),为了实现局部性原理,runnext中的G永远会被最先调
...
type m struct{
g0 *g
// 每个M都有一个自己的G0,不指向任何可执行的函数,在调度或系统调用时,M会切换到G0,使用G0的栈空间来调度
curg *g
//当前正在执行的G
...
}
type schedt struct{
...
runq gQueue //全局队列,链表(长度无限制)
runqsize int32 //全局队列长度
...
}
type gobuf struct{
//保存CPU的rsp寄存器的值
sp uintptr
//保存CPU的rip寄存器的值
pc uintptr
//记录当前这个gobuf对象属于哪个Goroutine
g guintptr
//保存系统调用的返回值
ret sys.Uintreg
//保存CPU的rbp寄存器的值
...
}
线程想运行任务就得获取 P,从 P 的本地队列获取 G,当 P 的本地队列为空时,M 也会尝试从全局队列或其他 P 的本地队列获取 G。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
Goroutine调度策略
1.队列轮转:P会周期性的将G调度到M中执行,执行一段时间后,保存上下文,将G放到队列尾部,然后从队列中再取出一个G进行调度,P还会周期性的查看全局队列是否有G等待调度到M中执行
2.系统调用:当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。M1的来源有可能是M的缓存池,也可能是新建的。
3.当G0系统调用结束后,如果有空闲的P,则获取一个P,继续执行G0。如果没有,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠。
Groutine的切换时机
1.select操作阻塞时
2.io阻塞
3.阻塞在channel
4.程序员显示编码操作
5.等待锁
6.程序调用
Goroutine调度原理
调度循环:每个p都有一个协程g0,调度时首先切换到协程g0,切换到接下来将要运行的协程g,再从协程g切换到协程g0,开始新一轮调度。
从协程g0调度到协程g,经历了从schedule函数到execute函数再到gogo函数的过程。
schedule函数处理具体的调度策略,选择下一个要执行的协程。
execute函数执行一些具体的状态转移、协程g与结构体m之间的绑定等操作。
gogo函数是与操作系统有关的函数,用于完成栈的切换及CPU寄存器的恢复。
从协程g切换回协程g0时,mcall函数用于保存当前协程的执行现场。
goroutine调度的本质就是将**Goroutine(G)**按照一定算法放到CPU上去执行。
CPU感知不到Goroutine,只知道内核线程,所以需要Go调度器将协程调度到内核线程上面去,然后操作系统调度器将内核线程放到CPU上去执行
M是对内核级线程的封装,所以Go调度器的工作就是将G分配到M
Go调度器的实现不是一蹴而就的,它的调度模型与算法也是几经演化,从最初的GM模型、到GMP模型,从不支持抢占,到支持协作式抢占,再到支持基于信号的异步抢占,经历了不断地优化与打磨。
被调度的对象:
G的来源
P的runnext(只有1个G,局部性原理,永远会被最先调度执行)
P的本地队列(数组,最多256个G)
全局G队列(链表,无限制)
网络轮询器network poller (存放网络调用被阻塞的G)
P的来源
全局P队列(数组,GOMAXPROCS个P)
M的来源
休眠线程队列(未绑定P,长时间休眠会等待GC回收销毁)
运行线程(绑定P,指向P中的G)
自旋线程((绑定P,指向M的GO)
运行线程数+自旋线程数<=P的数量(GOMAXPROCS),M个数>=P的个数
G的生命周期:G从创建、保存、被获取、调度和执行、阻塞、销毁,步骤如下:
步骤1:创建G,关键字go func( )创建G
步骤2∶保存G,创建的G优先保存到本地队列P,如果P满了,则会平衡部分P到全局队列中
保存G的详细流程如下:
执行go func的时候,主线程M0会调用newproc()生成一个G结构体,这里会先选定当前M0上的P结构
每个协程G都会被尝试先放到P中的runnext,若runnext 为空则放到runnext中,生产结束
若runnext满,则将原来runnext中的G踢到本地队列中,将当前G放到runnext中,生产结束
若本地队列也满了,则将本地队列中的G拿出一半,放到全局队列中,生产结束。
步骤3∶唤醒或者新建M执行任务,进入调度循环(步骤4,5,6)
步骤4∶M获取G,M首先从P的本地队列获取G,如果P为空,则从全局队列获取G,如果全局队列也为
空,则从另一个本地队列偷取一半数量的G(负载均衡),这种从其它P愉的方式称之为work stealing
步骤5: M调度和执行G,M调用G.func()函数执行G
如果M在执行G的过程发生系统调用阻塞(同步),会阻塞G和M(操作系统限制),此时P会和当前M解绑,
并寻找新的M,如果没有空闲的M就会新建一个M,接管正在阻塞G所属的P,接着继续执行P中其余的
G,这种阻塞后释放P的方式称之为hand off。当系统调用结束后,这个G会尝试获取一个空闲的P执行,
优先获取之前绑定的P,并放入到这个P的本地队列,如果获取不到P,那么这个线程M变成休眠状态,加
入到空闲线程中,然后这个G会被放入到全局队列中。
如果M在执行G的过程发生网络IO等操作阻塞时(异步),阻塞G,不会阻塞M。M会寻找P中其它可执行的
G继续执行,G会被网络轮询器network poller接手,当阻塞的G恢复后,G1从network poller被移回到P的
LRQ中,重新进入可执行状态。异步情况下,通过调度,Go scheduler成功地将I/O的任务转变成了
CPU任务,或者说将内核级别的线程切换转变成了用户级别的goroutine切换,大大提高了效率。
步骤6∶M执行完G后清理现场,重新进入调度循环(将M上运行的goroutine切换为G0,G0负责调度时
协程的切换)
调度策略:
抢占式调度
sysmon检测到协程运行过久(比如sleep,死循环)
切换到g0,进入调度循环
主动调度
新起一个协程和协程执行完毕触发调度循环
主动调用runtime.Gosched()切换到g0,进入调度循环。
垃圾回收之后。stw之后,会重新选择g开始执行
被动调度
系统调用(比如文件IO)阻塞(同步)
阻塞G和M,P与M分离,将P交给其它M绑定,其它M执行P的剩余G。
网络IO调用阻塞(异步)
阻塞G,G移动到NetPoller,M执行P的剩余G
atomic/mutex/channel等阻塞(异步)
阻塞G,G移动到channel的等待队列中,M执行P的剩余G
使用什么策略来挑选下一个goroutine执行?
由于P中的G分布在runnext、本地队列、全局队列、网络轮询器中,则需要挨个判断是否有可执行的G,大体逻辑如下:
每执行61次调度循环,从全局队列获取G,若有则直接返回
从P上的runnext看一下是否有G,若有则直接返回
从P上的本地队列看一下是否有G,若有则直接返回
上面都没查找到时,则去全局队列、网络轮询器查找或者从其他Р中窃取,t一直阻塞直到获取到一个可用
的G为止
netpoller中拿到的G是_Gwaiting状态(存放的是因为网络IO被阻塞的G),从其它地方拿到的是_Grunnable状态
Goroutine的抢占式调度
非协作式:就是由runtime来决定一个goroutine运行多长时间,如果你不主动让出,对不起,我有手段可以抢占你,把你踢出去,让后面的goroutine进来运行。
基于协作的抢占式调度流程:
1.编译器会在调用函数前插入runtime.morestack,让运行时有机会在这段代码中检查是否需要执行抢占
调度
2.Go语言运行时会在垃圾回收暂停程序、系统监控发现Goroutine运行超过10ms,那么会在这个协程设置
一个抢占标记
3.当发生函数调用时,可能会执行编译器插入的runtime.morestack,它调用的runtime.newstack会检查抢
占标记,如果有抢占标记就会触发抢占让出cpu,切到调度主协程里
基于信号的抢占式调度
真正的抢占式调度是基于信号完成的,所以也称为“异步抢占"。不管协程有没有意愿主动让出cpu运行权,只要某个协程执行时间过长,就会发送信号强行夺取cpu运行权。
M注册一个SIGURG信号的处理函数:sighandler
sysmon启动后会间隔性的进行监控,最长间隔10ms,最短间隔20us。如果发现某协程独占
P超过10ms,会给M发送抢占信号
M收到信号后,内核执行sighandler函数把当前协程的状态从_Grunning正在执行改成_Grunnable可执
行,把抢占的协程放到全局队列里,M继续寻找其他goroutine来运行
被抢占的G再次调度过来执行时,会继续原来的执行流
Context结构原理
Context(上下文)是Golang应用开发常用的并发控制技术 ,它可以控制一组呈树状结构的goroutine,每个goroutine拥有相同的上下文。Context 是并发安全的,主要是用于控制多个协程之间的协作、取消操作。
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
「Deadline」 方法:可以获取设置的截止时间,返回值 deadline 是截止时间,到了这个时间,Context 会自动发起取消请求,返回值 ok 表示是否设置了截止时间。
「Done」 方法:返回一个只读的 channel ,类型为 struct{}。如果这个 chan 可以读取,说明已经发出了取消信号,可以做清理操作,然后退出协程,释放资源。
「Err」 方法:返回Context 被取消的原因。
「Value」 方法:获取 Context 上绑定的值,是一个键值对,通过 key 来获取对应的值。
几个实现context接口的对象:
context.Background()和context.TODO()相似,返回的context一般作为根对象存在,其不可以退出,也不能携带值。要具体地使用context的功能,需要派生出新的context。
context.WithCancel()函数返回一个子context并且有cancel退出方法。子context在两种情况下会退出,一种情况是调用cancel,另一种情况是当参数中的父context退出时,该context及其关联的context都退出。
context.WithTimeout函数指定超时时间,当超时发生后,子context将退出。因此子context的退出有3种时机,一种是父context退出;一种是超时退出;一种是主动调用cancel函数退出。
context.WithDeadline()与context.WithTimeout()返回的函数类似,不过其参数指定的是最后到期的时间。
context.WithValue()函数返回待key-value的子context
具体context内容请看:context参考
Context原理
context在很大程度上利用了通道在close时会通知所有监听它的协程这一特性来实现。每一个派生出的子协程都会创建一个新的退出通道,组织好context之间的关系即可实现继承链上退出的传递。
context使用场景:
1.RPC调用
2.PipeLine
3.超时请求
4.HTTP服务器的request互相传递数据
Golang内存分配机制
Go语言内置运行时(就是runtime),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。
设计思想:
内存分配算法采用Google的 TCMalloc算法,每个线程都会自行维护一个独立的内存池,进行内存分配时
优先从该内存池中分配,当内存池不足时才会向加锁向全局内存池申请,减少系统调用并且避免不同线程
对全局内存池的锁竞争
把内存切分的非常的细小,分为多级管理,以降低锁的粒度
回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用。只有内存闲置过
多的时候,才会尝试归还部分内存给操作系统,降低整体开销
Golang的内存管理组件主要有:mspan、mcache、mcentral和mheap
内存管理单元:mspan
mspan是内存管理的基本单元,该结构体中包含next和 prev两个字段,它们分别指向了前一个和后一个mspan,每个mspan都管理npages个大小为8KB的页,一个span是由多个page组成的,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍。
type mspan struct {
next *mspan //后指针
prev *mspan //前指针
startAddr uintptr //管理页的起始地址,指向page
npages uintptr //页数
spanclass spanClass //规格,字节数
...
}
type spanclass uint8
mspan的种类
// class bytes/obj bytes/span objects tail waste max waste
//
// 1 8 8192 1024 0 87.50%
// 2 16 8192 512 0 43.75%
// 3 24 8192 341 8 29.24%
// 4 32 8192 256 0 21.88%
// 5 48 8192 170 32 31.52%
// 6 64 8192 128 0 23.44%
// 略...
// 62 20480 40960 2 0 6.87%
// 63 21760 65536 3 256 6.25%
// 64 24576 24576 1 0 11.45%
// 65 27264 81920 3 128 10.00%
// 66 28672 57344 2 0 4.91%
// 67 32768 32768 1 0 12.50%
线程缓存:mcache
mache管理线程在本地缓存的mspan,每个goroutine绑定的P都有一个mcache字段
type mcache struct{
alloc [numSpanClasses]*mspan
}
_NumSizeClasses=68
numSpanClassed=_NumSizeClassed<<1
mcache用Span classes作为索引管理多个用于分配的mspan ,它包含所有规格的mspan。它是_NumSizeClasses 的2倍,也就是68X2=136,其中*2是将spanClass分成了有指针和没有指针两种,方便与垃圾回收。对于每种规格,有2个mspan,一个mspan不包含指针,另一个mspan则包含指针。对于无指针对象的mspan在进行垃圾回收的时候无需进一步扫描它是否引用了其他活跃的对象。
mcache在初始化的时候是没有任何mspan资源的,在使用过程中会动态地从mcentral申请,
之后会缓存下来。当对象小于等于32KB大小时,使用mcache 的相应规格的mspan进行分配。
mcache拥有一个allocCache字段,作为一个位图,来标记span中的元素是否被分配。
当请求的mcache中对应的span没有可以使用的元素时,需要从mcentral中加锁查找,分别遍历mcentral中有空闲元素的nonempty链表和没有空闲元素的empty链表。没有空闲元素的empty链表可能会有被垃圾回收标记为空闲的还未来得及清理的元素,这些元素也是可用的
中心缓存:mcentral
mcentral管理全局的mspan供所有线程使用,全局mheap变量包含central字段,每个mcentral结构都维护在mheap结构内
type mcentral struct{
spanclass spanClass //指当前规格大小
partial [2]spanSet //有空闲object的mspan列表
full [2]spanSet //没有空闲object的mspan列表
}