协程
go中最大的特点就是它的协程就是被称为groutine的家伙这玩意可以在高并发场景下不需要去创建线程去造成不必要的开销,先介绍下进程、线程、协程之间的关系吧
进程
进程是cpu分配资源的最小单位,在开辟一个进程的时候操作系统会在内存上分配堆空间、栈空间、方法区等一块连续的空间,然后cpu在这些线程之间轮询执行从而实现同一时刻运行多个应用,同时操作系统使用了虚拟内存技术让每一个进程都认为自己独占内存便于控制
虽然这样可以让cpu同时执行多个应用但是,如果我们想在一个应用中处理多个process的话就得继续开辟进程这样是十分不合理的,因为每一次都会重新开辟一个内存空间并且还要复制其中的数据这样就十分的慢于是有了线程
线程
线程是cpu调度的最小单位,它可以使用进程中公共部分比如堆空间、本地方法栈、元空间等通信起来比较方便,但是需要自己维护一个程序计数器和栈空间去存储数据和切换线程的时候存储一些状态
基本就是以上这种状态可以利用多个cpu去操作一个进程中的不同线程然后线程之间和主线程通信最后返回结果
现在的程序基本都是多线程的将程序拆分为不同模块然后之间相互调度通信从而实现效果比如浏览器就分为渲染线程、网络线程、js解析线程、事件循环线程,同时浏览器中每开启一个网页就代表着开启一个进程
所以应用程序一般是有多个进程,然后进程又包含多个线程,线程又包含多个协程这种关系
我们看起来进程已经相当完美了那为什么go还会出现协程呢主要还是
- 线程比较占空间 一般一个进程4GB,用户态占3GB内核态占1GB,一个线程10MB左右的话一个进程也就能开辟300个
- 切换的时候涉及到系统调用和用户态到内核态的切换
- 重新分配栈空间
- 切换时报错上下文状态都是十分消耗性能的
所以就出现了在应用层设计出来的协程,去减少线程之间的切换而是让应用去调度提高性能
协程
协程可以理解为轻量级线程,它是通过不同语言去实现的,然后语言去调度不同的协程去线程中执行
先说一下协程的基本概念吧就是一个方法可以在执行一半的时候返回值,下一次继续执行然后返回第二个返回值
执行流程大概是这样,其实就是让functionB可以挂起然后继续执行,这样就是实现了线程那种挂起唤醒的机制但是不需要消耗那么多性能
优点:
- 寄存器和挂起的状态直接在堆空间创建不需要开辟额外的空间
- 可以动态的修改协程的空间大小,因此可以创建多个
- 通过不同语言的调取器实现高效的切换和调度
- go语言中创建简单高并发场景下可以充分利用多核cpu性能
go语言中groutine实现的非常好并且使用十分简单,因此在编程或者理解的时候就可以将其理解为用户线程
调度模型
先说一下几种常见的关系
- N:1多个用户线程对应一个系统线程,上下文切换方便但是无法充分利用cpu
- 1:1一个用户线程对应一个系统线程java就是这么做的可以充分利用cpu但是切换很慢
- M:N多对多充分利用cpu而且切换很快,缺点就是设计算法比较复杂go使用的就是这种
go协程调度模型中有三个实体 Machine、Processor、Groutine,分别代表工作线程、处理器、go协程
M必须有P才能执行,M由操作系统进行调用,P一般设置为cpu核数,M的数量一般比P的数量多一些因为可能会有阻塞的M
需要执行的G都会先放到全局队列中,每一个P都有自己的一个队列同时都有自己的一个M,他们会根据复杂的调度策略去获取G然后依次执行,如果G执行的时候阻塞了P就会抛弃这个M然后去新的M上继续执行,然后在合适的时间去唤醒
原理看起来也十分简单就是维护队列然后根据算法去获取G然后执行
调度策略有:
- 队列轮询:每一个P维护一个队列然后依次执行,执行过程中也会周期性去全局队列中获取G避免全局队列的groutine饥饿
- 系统调用:发起系统调用的G会被P抛弃然后合适的时间去唤醒
- 窃取:一个P队列中很少的话会从别的P中获取使其均匀分布
- 抢占:一个G执行过长的话就会把他挤开执行下一个G,这个G状态被记录重新加入队列等待下一次执行
内存分配
go中设计的是空间换时间,别的语言基本上是通过调用操作系统提供的malloc或者mmap去分配空间,而go是在运行的时候向操作系统申请一大块内存空间然后自己管理,不够的话再向操作系统申请
go是谷歌开发的,谷歌之前研发了TCMalloc算法因此go中也借鉴了这种算法,它是采用了多级缓存的思想,go内存分配主要有几个关键词mcache,mcentral,mheap,mspan主要的关系如下
mcache表示的是线程缓存,在go中就表示的执行器的缓存也就是P,每一个P都独立有一个缓存,因此是线程独立的无需施加锁,同时它里面还有tiny allocator微对象分配器,会将大小小于16B的对象直接分配到上面,如果内存不够就向mcentral申请,一个mcache由多个mspan组成,根据不同词spanClass又可以对一个span分割成不同的object进行存储
mcentral表示的是中心分配器,一个mcentral对应一个spanClass,聚合了其所有的mspan,它不是线程私有的因此它会有一个锁来保证线程安全,如果它里面内存不够的话会向mheap去申请内存
mheap就是全局堆,负责将连续的页组装成mspan,它将内存以8KB分割成页进行管理同时记录了heapArena去记录page和mspan的关系,方便查找,如果里面的内存不够了再去调用系统去分配内存
sizeclass就是上面说的它用来定义一个mspan中划分object的大小和数量,以下是源码中的注解,一共有67个等级 源码地址
go
// 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%
......
// 65 27264 81920 3 128 10.00%
// 66 28672 57344 2 0 4.91%
// 67 32768 32768 1 0 12.50%
同时go中还对对象大小进行区分,分为微对象、小对象、大对象
微对象直接分配到mcache中,小对象就是先看mcache中有没有足够的内存如果没有再根据sizeclass去mcentral中获取内存如果再不够就向mheap中获取一般的对象都属于这一类,大对象就是直接分配到mheap中
select
select是操作系统的一个函数我们经常使用select进行io多路复用,go语言中select和操作系统中的类似,go中的select主要是用于监听多个clannel的读写,在clannel状态未改变之前进行阻塞
select特点
- 它是非阻塞的接收和发送channel数据,如果所有case都阻塞且有default的话,select直接执行default不会发生阻塞
- 它在监听多个channel的时候,多个case同时触发只会随机执行一个
实现原理
select在底层其实是调用了一个selectgo这个方法去实现的
go
func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)
type scase struct {
c *hchan // chan
elem unsafe.Pointer // data element
}
第一个参数表示case进行解析后的结构体就是存储一下clan的地址,第二个参数·的大小是第一个参数的两倍分为两部分,分别表示循环顺序和加锁顺序,select最后执行的顺序就是根据这个循环顺序的,这个是在调用的时候随机生成的,加锁顺序是在执行的时候需要对channel进行加锁这个解锁的顺序就是根据这个的不然可能会出现死锁
执行流程是先对所有的select进行实现看是否有直接返回的如果有就结束,如果没有就将当前groutine添加到各个channel中的senq或者recvq队列中,然后当其被唤醒的时候删除其他channel队列中的信息然后将返回值返回流程就是这样
同时go编译器还对其做了一定的优化
- 当其没有case的时候直接阻塞
- 当只有一个case的时候,就将select优化为if函数
- 当有两个case其中一个是default的时候就将其优化成if+selectnbrecv|selectnbsend函数的形式这样就不需要调用selectgo了selectnbrecv是异步发送selectnbsend是异步接收
- 最后在调用selectgo函数