[Golang并发]GMP模型
什么是Goroutine#
Goroutine = Golang + Coroutine。Goroutine是golang实现的协程,是用户级线程
。
Goroutine的特点:
- 相比线程,其启动的代价很小,以很小栈空间启动(
2Kb左右
)[这个特点使得我们可以在一台机器上并发成千上万的线程] - 能够动态地伸缩栈的大小,最大可以支持到Gb级别
- 工作在用户态,切换成本很小
- 与系统线程关系是n:m,即可以在n个系统线程上多工调度m个Goroutine
进程、线程、Goroutine#
在仅支持进程的操作系统中,进程是拥有资源和独立调度
的基本单位。
在引入线程的操作系统中,线程是独立调度
的基本单位,进程是拥有资源
的基本单位。
在同一进程中,线程的切换不会引起进程切换。在不同进程中进行线程切换,如从一个进程内的线程切换到另一个进程中的线程时,会引起进程切换
为什么需要进程#
在没有多进程之前,计算机总是串行着执行任务,每次只干一件事,干完这件事之后再去干另一件事,如果有一件事在读写磁盘,那么CPU必须等待什么事都不能干,造成了对CPU的极大浪费。于是我们有了多进程,当一件事情暂时不用CPU的时候,我们让另一件事去运行,利用空闲下来的CPU。
为什么需要线程#
从本质上说,每个进程切换由两步组成:
- 切换地址空间:切换页全局目录以安装一个新的地址空间。(页表是存在在内存中的,我们不需要临时创建页表,只是需要把新的进程的页表的起始地址写到寄存器中,另外还要刷新TLB让以前的快表失效,新进程刚上来的时候没有TLB,所以查页表比较慢,而且还可能发生多次缺页中断)
- 切换上下文:切换内核态堆栈和硬件上下文(进程恢复执行前必须装入寄存器的一组数据称为硬件上下文),因为硬件上下文提供了内核执行新进程所需要的所有信息,包含CPU寄存器。
但是在同一个进程下的线程,一定要注意是在同一个进程下的线程共享这个进程的逻辑地址,也就是说,在同一个进程下的线程发生切换的时候,是不需要切换TLB和页表的,也不会发生很多缺页,所以线程切换比进程切换要快(再次强调,必须是同一个进程下的线程切换才比较快,不同进程下的线程切换,效果等同于进程切换)
为什么需要协程#
回看上面的过程,我们节约了切换地址空间的时间,重新装载TLB的时间和发生多次缺页中断的时间,但是我们没有节约系统调用,从用户态切换到内核态还有从内核态切换回用户态
的时间。我们对这一点下手,不在内核态进行切换,而是在用户态进行切换,由程序保存协程的现场,由程序恢复协程的现场。这样就节省了切换内核态的时间。
另外一点,协程非常流行的原因是因为协程能够以非常小的栈空间启动,如果我们在服务器上并发成千上万的线程,那么需要非常大的内存(一般线程的开启栈空间是2M
)(所以服务器上一个非常重要的工作是节约线程,通过线程池复用线程或者通过IO多路复用复用线程)
线程的栈空间是什么
线程的栈空间是指在操作系统中为每个线程分配的一块内存空间,用于存储线程的局部变量、函数调用信息以及其他与线程执行相关的数据。当一个线程被创建时,操作系统会为该线程分配一块栈空间。栈空间通常是一块连续的内存区域,大小固定。线程在执行过程中,会将局部变量、函数参数、返回地址等数据存储在栈空间中。每当调用一个函数时,该函数的局部变量和其他执行上下文信息都会被推入栈空间,当函数执行完毕后,这些数据会从栈空间中弹出,恢复到调用函数的上下文。
栈空间的大小在不同的操作系统和编译器中可能会有所不同,通常在几兆字节到几十兆字节之间。当线程的栈空间不足以存储其需要的数据时,会发生栈溢出错误。因此,在编写多线程程序时,需要合理估计线程所需的栈空间大小,以避免栈溢出问题的发生。
线程创建、管理、调度等采用的方式称为线程模型。线程模型一般分为以下三种:
- 内核级线程(Kernel Level Thread)模型
- 用户级线程(User Level Thread)模型
- 两级线程模型,也称混合型线程模型
三大线程模型最大差异就在于用户级线程与内核调度实体KSE(KSE,Kernel Scheduling Entity)之间的对应关系。KSE是Kernel Scheduling Entity的缩写,其是可被操作系统内核调度器调度的对象实体,是操作系统内核的最小调度单元,可以简单理解为内核级线程。
用户级线程即协程
,由应用程序创建与管理,协程必须与内核级线程绑定之后才能执行。线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程。
用户级线程(ULT)与内核级线程(KLT)比较:
特性 | 用户级线程 | 内核级线程 |
---|---|---|
创建者 | 应用程序 | 内核 |
操作系统是否感知存在 | 否 | 是 |
开销成本 | 创建成本低,上下文切换成本低,上下文切换不需要硬件支持 | 创建成本高,上下文切换成本高,上下文切换需要硬件支持 |
如果线程阻塞 | 整个进程将被阻塞。即不能利用多处理来发挥并发优势 | 其他线程可以继续执行,进程不会阻塞 |
内核级线程模型#
用户线程与内核线程KSE是一对一(1 : 1)的映射模型,也就是每一个用户线程绑定一个实际的内核线程,而线程的调度则完全交付给操作系统内核去做,应用程序对线程的创建、终止以及同步都基于内核提供的系统调用来完成,大部分编程语言的线程库(比如Java的java.lang.Thread、C++11的std::thread等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个独立的KSE静态绑定,因此其调度完全由操作系统内核调度器去做。这种模型的优势和劣势同样明显:优势是实现简单,直接借助操作系统内核的线程以及调度器,所以CPU可以快速切换调度线程,于是多个线程可以同时运行,因此相较于用户级线程模型它真正做到了并行处理;但它的劣势是,由于直接借助了操作系统内核来创建、销毁和以及多个线程之间的上下文切换和调度,因此资源成本大幅上涨,且对性能影响很大。
用户级线程模型#
用户线程与内核线程KSE是多对一(N : 1)的映射模型,多个用户线程一般从属于单个进程并且多线程的调度是由用户自己的线程库来完成,线程的创建、销毁以及多线程之间的协调等操作都是由用户自己的线程库来负责而无须借助系统调用来实现。一个进程中所有创建的线程都只和同一个KSE在运行时动态绑定
,也就是说,操作系统只知道用户进程而对其中的线程是无感知的,内核的所有调度都是基于用户进程。许多语言实现的 协程库 基本上都属于这种方式(比如python的gevent)。由于线程调度是在用户层面完成的,也就是相较于内核调度不需要让CPU在用户态和内核态之间切换,这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的线程数量与上下文切换所花费的代价也会小得多。但该模型有个原罪:并不能做到真正意义上的并发,假设在某个用户进程上的某个用户线程因为一个阻塞调用(比如I/O阻塞)而被CPU给中断(抢占式调度)了,那么该进程内的所有线程都被阻塞(因为单个用户进程内的线程自调度是没有CPU时钟中断的,从而没有轮转调度),整个进程被挂起。即便是多CPU的机器,也无济于事,因为在用户级线程模型下,一个CPU关联运行的是整个用户进程,进程内的子线程绑定到CPU执行是由用户进程调度的,内部线程对CPU是不可见的,此时可以理解为CPU的调度单位是用户进程。所以很多的协程库会把自己一些阻塞的操作重新封装为完全的非阻塞形式,然后在以前要阻塞的点上,主动让出自己,并通过某种方式通知或唤醒其他待执行的用户线程在该KSE上运行,从而避免了内核调度器由于KSE阻塞而做上下文切换,这样整个进程也不会被阻塞了。
两级线程模型#
两级线程模型是博采众长之后的产物,充分吸收前两种线程模型的优点且尽量规避它们的缺点。在此模型下,用户线程与内核KSE是多对多(N : M)的映射模型:首先,区别于用户级线程模型,两级线程模型中的一个进程可以与多个内核线程KSE关联,于是进程内的多个线程可以绑定不同的KSE,这点和内核级线程模型相似;其次,又区别于内核级线程模型,它的进程里的所有线程并不与KSE一一绑定,而是可以动态绑定同一个KSE, 当某个KSE因为其绑定的线程的阻塞操作被内核调度出CPU时,其关联的进程中其余用户线程可以重新与其他KSE绑定运行。所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是中间态(自身调度与系统调度协同工作),因为这种模型的高度复杂性,操作系统内核开发者一般不会使用,所以更多时候是作为第三方库的形式出现,而Go语言中的runtime调度器就是采用的这种实现方案,实现了Goroutine与KSE之间的动态关联,不过Go语言的实现更加高级和优雅;该模型为何被称为两级?即用户调度器实现用户线程到KSE的『调度』,内核调度器实现KSE到CPU上的『调度』。
Golang的线程模型#
Golang在底层实现了混合型线程模型。M即系统线程,由系统调用产生,一个M关联一个KSE,即两级线程模型中的系统线程。G为Groutine,即两级线程模型的的应用级线程。M与G的关系是N:M。
每一个OS线程都有一个固定大小的内存块(一般会是2MB
)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。这个固定大小的栈同时很大又很小。因为2MB的栈对于一个小小的goroutine来说是很大的内存浪费,而对于一些复杂的任务(如深度嵌套的递归)来说又显得太小。因此,Go语言做了它自己的『线程』。
在Go语言中,每一个goroutine是一个独立的执行单元,相较于每个OS线程固定分配2M内存的模式,goroutine的栈采取了动态扩容方式, 初始时仅为2KB
,随着任务执行按需增长,最大可达1GB(64位机器最大是1G,32位机器最大是256M),且完全由golang自己的调度器 Go Scheduler 来调度。此外,GC还会周期性地将不再使用的内存回收,收缩栈空间。 因此,Go程序可以同时并发成千上万个goroutine是得益于它强劲的调度器和高效的内存模型。Go的创造者大概对goroutine的定位就是屠龙刀,因为他们不仅让goroutine作为golang并发编程的最核心组件(开发者的程序都是基于goroutine运行的)而且golang中的许多标准库的实现也到处能见到goroutine的身影,比如net/http这个包,甚至语言本身的组件runtime运行时和GC垃圾回收器都是运行在goroutine上的,作者对goroutine的厚望可见一斑。
任何用户线程最终肯定都是要交由OS线程来执行的,goroutine(称为G)也不例外,但是G并不直接绑定OS线程运行,而是由Goroutine Scheduler中的 P - Logical Processor (逻辑处理器)来作为两者的『中介』,P可以看作是一个抽象的资源或者一个上下文,一个P绑定一个OS线程,在golang的实现里把OS线程抽象成一个数据结构:M,G实际上是由M通过P来进行调度运行的,但是在G的层面来看,P提供了G运行所需的一切资源和环境,因此在G看来P就是运行它的 “CPU”,由 G、P、M 这三种由Go抽象出来的实现,最终形成了Go调度器的基本结构:
- G: 表示Goroutine,每个Goroutine对应一个G结构体,G存储Goroutine的运行堆栈、状态以及任务函数,可重用。G并非执行体,每个G需要绑定到P才能被调度执行。
- P: Processor,表示逻辑处理器, 对G来说,P相当于CPU核,G只有绑定到P(在P的local runq中)才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P的数量决定了系统内最大可并行的G的数量(前提:物理CPU核数 >= P的数量),P的数量由用户设置的GOMAXPROCS决定,但是不论GOMAXPROCS设置为多大,P的数量最大为256。
- M: Machine,OS线程抽象,代表着真正执行计算的资源,在绑定有效的P后,进入schedule循环;而schedule循环的机制大致是从Global队列、P的Local队列以及wait队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M,如此反复。M并不保留G状态,这是G可以跨M调度的基础,M的数量是不定的,由Go Runtime调整,为了防止创建过多OS线程导致系统调度不过来,目前默认最大限制为10000个。
关于P,我们需要再絮叨几句,在Go 1.0发布的时候,它的调度器其实G-M模型,也就是没有P的,调度过程全由G和M完成,这个模型暴露出一些问题:
单一全局互斥锁(Sched.Lock)和集中状态存储的存在导致所有goroutine相关操作,比如:创建、重新调度等都要上锁;
goroutine传递问题:M经常在M之间传递『可运行』的goroutine,这导致调度延迟增大以及额外的性能损耗;
每个M做内存缓存,导致内存占用过高,数据局部性较差;
由于syscall调用而形成的剧烈的worker thread阻塞和解除阻塞,导致额外的性能损耗。
这些问题实在太扎眼了,导致Go1.0虽然号称原生支持并发,却在并发性能上一直饱受诟病,然后,Go语言委员会中一个核心开发大佬看不下了,亲自下场重新设计和实现了Go调度器(在原有的G-M模型中引入了P)并且实现了一个叫做 work-stealing 的调度算法:
每个P维护一个G的本地队列;
当一个G被创建出来,或者变为可执行状态时,就把他放到P的可执行队列中;
当一个G在M里执行结束后,P会从队列中把该G取出;如果此时P的队列为空,即没有其他G可以执行, M就随机选择另外一个P,从其他可执行的G队列中取走一半。该算法避免了在goroutine调度时使用全局锁。
GMP调度流程如下:
线程M想运行任务就需得获取 P,即与P关联。然后从 P 的本地队列(LRQ)获取 G,若LRQ中没有可运行的G,M 会尝试从全局队列(GRQ)拿一批G放到P的本地队列,若全局队列也未找到可运行的G时候,M会随机从其他 P 的本地队列偷一半放到自己 P 的本地队列。拿到可运行的G之后,M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
G-M-P的数量#
- G 的数量:
理论上没有数量上限限制的。查看当前G的数量可以使用runtime. NumGoroutine() - P 的数量:
由启动时环境变量 $GOMAXPROCS 或者是由runtime.GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。 - M 的数量:
go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。 runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量 一个 M 阻塞了,会创建新的 M。M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 上周热点回顾(3.3-3.9)