goroutine
调度原理: 在Go中,内核线程是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到工作线程上。Goroutine调度器和OS调度器是通过M结合起来的,每个M都代表了1个内核线程。OS调度器负责把内核线程分配到CPU的核上执行,操作系统调度器会将系统中的多个内核线程M按照一定算法调度到物理CPU上去运行。Go调度本质是把大量的goroutine分配到少量线程M上去执行,并利用多核并行,实现更强大的并发。
- G: 表示goroutine,存储了goroutine的执行stack信息、goroutine状态以及goroutine的任务函数等;另外G对象是可以重用的。(可以理解为是用户线程)。查找调度G:P本地队列–>全局队列->网络任务->work stealing(去其它的p队列中偷取)
type g struct {
stack stack //执行栈
sched gobuf //用于保存执行现场
goid int64 //唯一序号
gopc uintptr //调用者PC/IP
start uintptr //任务函数
}
- P: 表示逻辑processor,P的数量决定了系统内最大可并行的G的数量(前提:系统的物理cpu核数>=P的数量);P的最大作用还是其拥有的各种G对象队列、链表、一些cache和状态。
type p struct {
lock mutex
id int32
status uint32 // one of pidle/prunning/...
mcache *mcache
racectx uintptr
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
runq [256]guintptr //本地队列
runnext guintptr //优先执行
// Available G's (status == Gdead)
gfree *g
gfreecnt int32
... ...
}
- M: M代表着真正的执行计算资源。在绑定有效的p后,进入schedule循环;而schedule循环的机制大致是从各种队列、p的本地队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到m,如此反复。M并不保留G状态,这是G可以跨M调度的基础。
type m struct {
g0 *g // goroutine with scheduling stack
mstartfn func()
curg *g // current running goroutine
p puintptr //绑定p
.... ..
}

协程在函数中如何退出的?
//golang-pipeline流水线模型
package main
import (
"fmt"
)
func producer(n int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; i < n; i++ {
out <- i
}
}()
return out
}
func square(inCh <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range inCh {
out <- n * n
// simulate
time.Sleep(time.Second)
}
}()
return out
}
func main() {
in := producer(10)
ch := square(in)
// consumer
for _ = range ch {
}
}
结论: A不是main程的情况下,在A程里开启B程,A程执行完,A程return之后,B程不受影响,不会挂掉。所有子协程与main程同级的,与main程伴生。
用户线程(用户空间)
现代操作系统中,实现线程库有两种方法:在用户空间中和在内核空间中。整个线程包的实现都在用户空间的话,就意味着操作系统内核对它一无所知,只知道他是一个普通的需要调度的进程。协程就是一种用户线程的实现,可以满足在一个内核线程上并发执行多个任务,coroutine和goroutine都是其典型实现。有关线程管理的所有工作都由应用程序完成,内核意识不到线程的存在. 应用程序可以通过使用线程库设计成多线程程序. 通常,应用程序从单线程起始,在该线程中开始运行,在其运行的任何时刻,可以通过调用线程库中的派生例程创建一个在相同进程中运行的新线程。
用户级线程仅存在于用户空间中,此类线程的创建、撤销、线程之间的同步与通信功能,都无须利用系统调用来实现。用户进程利用线程库来控制用户线程。由于线程在进程内切换的规则远比进程调度和切换的规则简单,不需要用户态/核心态切换,所以切换速度快。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时间相对减少为了在操作系统中加入线程支持,采用了在用户空间增加运行库来实现线程,这些运行库被称为“线程包”,用户线程是不能被操作系统所感知的。用户线程多见于一些历史悠久的操作系统,例如Unix操作系统。每个线程并不具有自身的线程上下文。因此,就线程的同时执行而言,任意给定时刻每个进程只能够有一个线程在运行,而且只有一个处理器内核会被分配给该进程。对于一个进程,可能有成千上万个用户级线程,但是它们对系统资源没有影响。运行时库调度并分派这些线程。

库调度器从进程的多个线程中选择一个线程,然后该线程和该进程允许的一个内核线程关联起来。内核线程将被操作系统调度器指派到处理器内核。用户级线程是一种”多对一”的线程映射。
协程
协程可以理解为纯用户态的线程,其通过协作而不是抢占来进行切换。相对于进程或者线程,协程所有的操作都可以在用户态完成,创建和切换的消耗更低。一个进程内部可以运行多个线程,而每个线程又可以运行很多协程。线程要负责对协程进行调度,保证每个协程都有机会得到执行。当一个协程睡眠时,它要将线程的运行权让给其它的协程来运行,而不能持续霸占这个线程。同一个线程内部最多只会有一个协程正在运行。
内核线程
在内核级线程中,内核线程建立和销毁都是由操作系统负责、通过系统调用完成的。在内核的支持下运行,无论是用户进程的线程,或者是系统进程的线程,他们的创建、撤销、切换都是依靠内核实现的。线程管理的所有工作由内核完成,应用程序没有进行线程管理的代码,只有一个到内核级线程的编程接口。内核为进程及其内部的每个线程维护上下文信息,调度也是在内核基于线程架构的基础上完成。内核线程驻留在内核空间,它们是内核对象。有了内核线程,每个用户线程被映射或绑定到一个内核线程。用户线程在其生命期内都会绑定到该内核线程。一旦用户线程终止,两个线程都将离开系统。这被称作”一对一”线程映射, 线程的创建、撤销和切换等,都需要内核直接实现,即内核了解每一个作为可调度实体的线程,这些线程可以在全系统内进行资源的竞争。内核空间内为每一个内核支持线程设置了一个线程控制块(TCB),内核根据该控制块,感知线程的存在,并进行控制。
组合
POSIX thread: POSIX thread是个库,但在Linux系统中POSIX thread库在pthread_create创建的时候会调用系统调用clone,接着操作系统帮忙创建内核级线程,所以,Linux系统中pthread创建的是内核线程而非用户线程。在Linux中使用pthread_create创建的“用户线程”准确讲应该叫轻量级进程。每使用pthread_create创建一次轻量级进程,OS都会相应地为应用程序生成一个可供内核调度的实体即内核线程。红帽Redhat公司的NPTL(Native POSIX Thread Library)这就是Linux线程管理中的NPTL 1:1模型,即1个轻量级线程对应一个内核级线程,pthread是1:1线程模型,实现是NPTL库。

-
多对一模型: 将多个用户级线程映射到一个内核级线程,线程管理在用户空间完成。用户级线程对操作系统不可见(透明)。优点: 线程上下文切换都发生在用户空间,避免模态切换(mode switch),从而对于性能有 积极的影响。线程管理是在用户空间进行的,因而效率比较高。缺点: 一个线程在使用内核服务时被阻塞,整个进程都会被阻塞。
-
一对一模型: 将每个用户级线程映射到一个内核级线程。优点: 多处理器硬件下,内核空间线程模型支持了真正的并行,当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强,可以充分利用多核。不需要自己写调度(pthread库的线程模型)。缺点: 每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。切换的开销比较大(可能比用户线程高一个数量级),只能由内核进行调度。
-
多对多模型:将n个用户级线程映射到m个内核级线程上,要求m<=n;优点: 将上述两种模型的特点进行综合,即将多个用户线程映射到少数但不只一个内核线程中去。多对多模型对用户线程的数量没有什么限制,在多处理器系统上也会有一定的性能提升,不过提升的幅度比不上一对一模型。多对多模型中,结合了1:1和M:1的优点,避免了它们的缺点。每个线程可以拥有多个调度实体,也可以多个线程对应一个调度实体。这种模型实际上是多个线程被绑定到了多个内核线程上,这使得大部分的线程上下文切换都发生在用户空间,而多个内核线程又可以充分利用处理器资源。缺点: M:N模型的最大问题是过于复杂。线程调度任务由内核及用户空间的线程库共同承担,二者之间势必要进行分工协作和信息交换。(go协程方案:Goroutine调度器和OS调度器)
线程中断阻塞
阻塞延时的阻塞是指线程调用该延时函数后,线程会被剥离CPU使用权,然后进入阻塞状态,直到延时结束,线程会重新获取CPU使用权才可继续运行。在线程阻塞的这段时间,CPU可以去执行其他的线程,如果其它的线程也在延时状态,那么CPU就将运行空闲线程。如果有一个用户空间线程使用了系统调用而阻塞,那么整个进程都会被挂起。而当一个内核线程阻塞时,内核根据选择可以运行另一个进程的线程,而用户空间实现的线程中,运行时系统始终运行自己进程中的线程。多处理器系统中,内核能够并行执行同一进程内的多个线程。
cpu调度
1、多核CPU调度算法
全局队列调度: 操作系统维护一个全局的任务等待队列。当系统中有一个CPU核心空闲时,操作系统就从全局任务等待队列中选取就绪任务开始在此核心上执行。这种方法的优点是CPU核心利用率较高。
局部队列调度: 操作系统为每个CPU内核维护一个局部的任务等待队列。当系统中有一个CPU内核空闲时,便从该核心的任务等待队列中选取恰当的任务执行。这种方法的优点是任务基本上无需在多个CPU核心间切换,有利于提高CPU核心局部Cache命中率。目前多数多核CPU操作系统采用的是基于全局队列的任务调度算法。
2、单核cpu调度算法
单核CPU调度算法是多核CPU调度算法的基础,多核CPU调度算法是单核CPU调度算法的延伸和综合使用。
单核调度算法: 先到先服务调度算法:FCFS(first-come,first-served)、最短作业优先调度算法:SJF(shortest-job-first) 、最短剩余时间优先调度算法SRTF(Shortest Remaining Time First)、优先级调度算法、轮转法调度算法 、最高响应比优先调度算法 、多级反馈队列调度算法 (unix os)。
多级反馈队列调度算法: 多级反馈队列调度算法是一种根据先来先服务原则给就绪队列排序,为就绪队列赋予不同的优先级数,不同的时间片,按照优先级抢占CPU的调度算法。应用于UNIX操作系统。算法的实施过程如下:
- 按照先来先服务原则排序,设置N个就绪队列为Q1,Q2…QN,每个队列中都可以放很多作业;
- 为这N个就绪队列赋予不同的优先级,第一个队列的优先级最高,第二个队列次之,其余各队列的优先权逐个降低;
- 设置每个就绪队列的时间片,优先权越高,算法赋予队列的时间片越小。时间片大小的设定按照实际作业(进程)的需要调整;
- 进程在进入待调度的队列等待时,首先进入优先级最高的Q1等待。
- 首先调度优先级高的队列中的进程。若高优先级中队列中已没有调度的进程,则调度次优先级队列中的进程。例如:Q1,Q2,Q3三个队列,只有在Q1中没有进程等待时才去调度Q2,同理,只有Q1,Q2都为空时才会去调度Q3。
- 对于同一个队列中的各个进程,按照时间片轮转法调度。比如Q1队列的时间片为N,那么Q1中的作业在经历了时间片为N的时间后,若还没有完成,则进入Q2队列等待,若Q2的时间片用完后作业还不能完成,一直进入下一级队列,直至完成。
- 在低优先级的队列中的进程在运行时,又有新到达的高优先级作业,那么在运行完这个时间片后,CPU马上分配给新到达的高优先级作业即抢占式调度CPU。
协程适用的场景
-
计算密集型任务是指在任务执行过程中需要进行大量的计算,较为消耗CPU资源,如复杂的算法模型运算、高清视频解码等,这类任务通常适合使用多进程来提升程序运行效率。计算密集型:多进程,可以最大限度发挥CPU运算能力。
-
IO密集型任务特点是CPU消耗少,涉及到网络、磁盘IO的任务都是IO密集型任务,任务的大部分时间都在等待IO操作完成。以网络爬虫为例,在请求网页时可能会需要一定时间等待目标网站响应,此时可以多线程或协程提升程序运行效率。IO 密集型:推荐优先使用协程,内存开销少,执行效率高;其次是多线程,虽然不如协程高效,但同样能极大提升程序运行效率。Go 适合 IO 密集型的场景。但其实这里并不准确。更准确的是 Go 适合的是网络 IO 密集型的场景,而非磁盘 IO 密集型。甚至可以说,Go 对于磁盘 IO 密集型并不友好。
-
Go 对于 网络 IO 和磁盘 IO为什么会有差别?根本原因:在于网络 socket 句柄和文件句柄的不同。网络 IO 能够用异步化的事件驱动的方式来管理,磁盘 IO 则不行。这个在我之前 Linux 句柄系列也详细提过这个。
-
socket 句柄可读可写事件都有意义,socket buffer 里有数据,说明对端网络发数据过来了,即满足可读事件。有 buffer 可以写,那么说明还能发送数据,满足可写事件。所以 socket 的句柄实现了
.poll方法,可以用 epoll 池来管理。文件句柄可读可写事件则没有意义,因为文件句柄理论上是永远都是可读可写的,不会阻塞调用。所以文件的.poll一般是不实现的,所以自然也用不了 epoll 池来管理。而能否用 epoll 池来管理 fd 则决定了能否在 Go 里用 epoll 池 IO 复用的形式来实现 IO 并发。 -
socket 句柄可以设置为 noblocking (非阻塞的方式),这样当网络 IO 还未就绪的时候就可以在 Go 代码里把调度权切走,去执行其他协程,这样就实现了网络 IO 的并发。但是磁盘 IO 则不行,文件 IO 的 read/write 都是同步的 IO ,没有实现
.poll所以也用不了 epoll 池来监控读写事件。所以磁盘 IO 的完成只能同步等待。然而磁盘 IO 的等待则会带来 Go 最不能容忍的事情:卡线程。接下来就来看看磁盘 IO 的 read/write 等系统调用的原理。
-
-
为什么 Go 不能容忍卡线程?
- Go 的代码执行者是系统线程,也就是 G-M-P 模型的 M ,M 不断的从队列 P 中取 G(协程任务)出来执行。当 G 出现等待事件的时候(比如网络 IO),那么立马切走,取下一个执行。这样让 M 一直不停的满载,就能保证 Go 协程任务的高吞吐。 那么问题来了,如果某个 G 卡线程了,就相当于这个 M 被废了,吞吐能力就下降。如果 M 全卡住了那相当于整个程序卡死了。这个是 Go 绝对无法容忍的。
- 然而对于类似系统调用这种卡线程却是无法人为控制的。Go runtime 为了解决这个问题,就只能创建更多的线程来保证一直有可运行的 M 。所以,你经常会发现,当系统调用很慢的时候,M 的数量会变多,甚至会暴涨。磁盘大量随机读,并且压力过载的情况,Go 程序线程数持续上涨,最终超过 1 万个被 panic 了。
-
本文深入探讨了Go语言中goroutine的调度原理,包括G-M-P模型、用户级线程与内核线程的区别,以及Go如何实现高效的并发处理。同时,文章分析了不同线程模型的优缺点,并讨论了协程在计算密集型与IO密集型任务中的应用。
668

被折叠的 条评论
为什么被折叠?



