侧边栏壁纸
  • 累计撰写 16 篇文章
  • 累计创建 17 个标签
  • 累计收到 1 条评论

Go MPG模型

xiuxiubiu
2021-03-03 / 0 评论 / 0 点赞 / 896 阅读 / 4,247 字 / 正在检测是否收录...

并发和并行

并发和并行的目的都是为了充分利用 CPU 的多核(多处理器)架构,但两者却有着本质的区别。
* 并发:在同一时间段内,多条指令在CPU上执行。
* 并行:在统一时刻内,多条指令在CPU上执行。

并发程序 并不要求 CPU 具备多核计算能力,只要求多个线程在同一个 Core 上进行 “分时轮询” 处理。可以在宏观上实现多线程同时执行的效果。并发程序的执行通常是不确定的,这种不确定性来源于资源之间的相关依赖和竞态条件,可能导致执行的线程间相互等待,使并发程序即使在多核环境上也无法做到真正并行执行而降级为串行执行。简而言之,并发程序通常是有状态的(非幂等性)。

并行程序 要求CPU具备多核计算能力,在同一时刻内,多个线程分别在不同的Core上同时执行。并行程序的每个执行模块在逻辑上都是独立的,即线程执行时可以独立地完成任务,从而做到同一时刻多个指令能够同时执行。并行程序通常是无状态的(幂等性)。

综上可知,我们在 Golang 中讨论的并发,并非是单存的多线程并行问题(并行不需要交互),而是 多线程间如何调度以及如何交互 的问题。

如何交互?CSP通信模型

CSP 最初在 1977 年 Tony Hoare 发表的论文中提出,它倡导使用通信的手段来共享内存。CSP 的两个核心概念:
* 并发实体:通常理解为执行线程,它们相互独立,且并发执行。
* 通道(Channel):并发实体之间使用通道发送信息。

可见,CSP 最大的特征就是并发实体之间没有共享的内存空间,并发实体之间的数据交换使用通道来完成。并发实体在通道中发送数据或接受数据都会导致并发实体的阻塞,直到通道中的数据被发送或接受完成。并发实体通过这种方式实现交互及同步。

CSP 类似于同步队列(会阻塞),关注的是消息传输的方式,发送和接收信息的并发实体可能不知道对方是谁,它们之间是互相解耦的。通道与并发实体也不是紧耦合的,通道可以独立地进行创建和释放,并在不同的并发实体中传递使用。

通道(Channel)的特性给并发编程带来了极大的灵活性,通道作为独立的对象,可以被任意的创建、释放、读取、放入数据,并在不同的并发实体中被使用。但是它也很容易导致死锁,如果一个并发实体在读取一个永远没有数据放入的通道或者把数据放入一个永远不会被读取的通道中,那么它会将被永远阻塞。

如何理解“不要通过共享内存来通信,而应该通过通信来共享内存”?

使用共享内存的话在多线程的场景下为了处理竞态,需要加锁,使用起来比较麻烦。另外使用过多的锁,容易使得程序的代码逻辑坚涩难懂,并且容易使程序死锁,死锁了以后排查问题相当困难,特别是很多锁同时存在的时候。

Go语言的Channel保证同一个时间只有一个Goroutine能够访问里面的数据,为开发者提供了一种优雅简单的工具,所以Go原生的做法就是使用Channle来通信,而不是使用共享内存来通信。

如何调度?GMP调度模型

并发或并行编程必然会涉及到操作系统对线程的分配与调度。根据访问权限的不同,操作系统会把内存分为内核空间和用户空间:
* 内核空间的指令代码具备直接调度计算机底层资源的能力,比如 I/O 资源等。
* 用户空间的代码没有访问计算底层资源的能力,需要通过系统调用(System Call)等方式切换为内核态之后再完成对计算机底层资源的申请和调度。

线程作为操作系统能够调度的最小单位,也分为用户线程和内核线程:
* 用户线程:由存放在用户空间的代码(也称用户态应用程序)创建、管理和销毁。用户线程的调度维持在一个进程的命名空间中,由应用程序的线程库完成,对 CPU 的竞争是以所属进程的维度参与的,同一进程下的所有用户线程只能分时复用分配给进程的 CPU 时间片,所以无法很好利用 CPU 多核运算的优势。好处是无需切换到内核态,资源消耗少且高效。
* 内核线程:由操作系统(Linux kernel)管理和调度,能够直接操作计算机底层的资源,开发人员可以通过系统调用使用内核线程,内核现场能够利用 CPU 多核架构进行并行计算的优势。

用户线程是无法被操作系统感知的,用户线程所属的进程或者内核线程才能被操作系统直接调度,分配CPU的使用时间。对此衍生出了不同的线程模型,它们之间对 CPU 资源的使用程度也各有千秋。

用户级线程模型(多对一)

用户级线程模型中基本是一个进程对应一个内核线程,进程内的多线程管理由用户代码完成。这使得线程的创建、切换和同步等工作显得异常轻量级和高效,但是这些复杂的逻辑需要在用户代码中实现。同时进程内的多线程无法很好利用 CPU 多核的优势,只能通过分时复用的方式轮换执行。当进程内的任意线程阻塞,比如线程 A 请求 I/O 操作被阻塞,很可能导致整个进程范围内的阻塞,因为此时进程对应的内核线程因为线程 A 的 I/O 阻塞而被剥夺 CPU 执行时间。

内核级线程模型(一对一)

内核级线程模型中,进程中的每个线程都会对应一个内核线程。进程内每创建一个新的线程都会进行系统调用在内核创建一个新的内核线程与对应,线程的管理和调度由操作系统负责,这将导致每次线程切换(线程在 Core 上切换)上下文时都会从用户态切换到内核态,会有不小的资源消耗,同时创建线程的数量也会受制于操作系统内核创建可创建的内核线程数量。好处是多线程能够充分利用 CPU 的多核并行计算能力,因为每个线程可以独立被操作系统调度分配到 CPU 上执行指令,同时某个线程的阻塞并不会影响到进程内其他线程工作的执行。

两级线程模型(多对多)

两级线程模型相当于用户级线程模式和内核级线程模型的结合,一个进程将会对应多个内核线程,由进程内的调度器决定进程内的线程如何与申请的内核线程对应。进程会预先申请一定数量的内核线程,然后将自身创建的线程与内核进程进行对应。线程的调用和管理由进程内的调度器进行,而内核线程的调度由操作系统负责。这种线程模型即能够有效降低线程创建和管理的资源消耗,也能够很好提供线程并行计算的能力,但是给开发人员带来较大的实现难度。

GMP线程模型

Golang的GMP线程模型在两级线程模型的基础上进行一定程度的改进,使它能够更加灵活地进行线程之间的调度。它由三个主要模块构成:
* M(Machine):执行者,一个 Machine 对应一个内核线程,表示执行任务(go func)的所必需的上下文环境。
* P(Processor):队列,P 的数量通常与硬件的 Processer 数相同,可以通过 GOMAXPROCS 进行修改。
* G(Goroutine):任务,一个 goroutine 表示一段 Golang 代码片段的封装,本质是一种轻量级的用户线程。我们每次调用 go func() 就是生成了一个 G。
M、P、G 三者组成了Golang的M: N调度模型:每个M都会与一个内核线程绑定,每个 P 又会与M进行一对一的绑定,而P和G的关系则是一对多。在运行过程中,P的数量通常是固定的,M的数量则会增长。M和内核线程之间对应关系的不会变化,在M的生命周期内,它只会与一个内核线程绑定,而M和P以及P和G之间的关系则是动态可变的。

Go Runtime Scheduler

Go Runtime Scheduler是Golang GMP线程模型的调度器。在Golang 实际的运行过程中,M和P的组合作为G的有效运行环境,而多个可执行G将会顺序排成一个队列挂在某个P上面,等待调度和执行

Golang会不断地在M上循环查找可运行的G来执行相应的任务:
1. 当我们执行go func() 时,实际上就是创建一个全新的Goroutine。
2. 新创建的G会被放入P 的本地队列(Local Queue)或全局队列(Global Queue)中,准备下一步的动作。
3. 唤醒或创建M以便执行G。
4. 不断地进行事件(Event)循环,寻找在可用状态下的 G 执行其任务(func)。
5. 清除后,重新进入第4步事件循环。

在很多时候M的数量可能会比P要多,如果没有足够的M来和P组合以为G提供运行环境,Golang就会创建出新的M。在单个Golang进程中,P的最大数量决定了并发的规模,且P的最大数量是由程序决定的。可以通过修改环境变量 GOMAXPROCS和调用函数runtime#GOMAXPROCS来设定P的最大值,Go 1.5 版本之前,默认使用的是单核心执行。从Go 1.5版本开始,默认执行上面语句以便让代码并发执行,最大效率地利用CPU。

Golang 会维护两种类型队列,全局队列和P的本地队列,在功能上来讲两者都是用于存放正在等待运行的G,区别在于:本地队列有数量限制,不允许超过256个。新建G时,会优先选择P的本地队列,如果本地队列满了,则将P的本地队列的一半的G移动到全局队列。当P执行G完毕后,P会将G从本地队列中弹出,同时会检查当前本地队列是否为空,如果为空,则会随机的从其他P的本地队列中尝试窃取一半可运行的G到自己的名下,也就是worke-steal(任务窃取)。这其实可以理解为调度资源的共享和再平衡。

另外,M执行任务时必须绑定到一个P,没有绑定到P的M就是空闲的,或者游离态的。这样的设计为P和M分离增加了扩展性。也就是说M和P会适时的组合和分离,保证P中的待执行G队列能够得到及时运行。举两个例子:
* 如果M被阻塞,这时队列里的G应该移交到其他的M上。在GMP模型中,只需要M释放P,然后空闲的M接管P即可。这个空闲的M可能是新创建的,也可能是从调度器空闲M列表中获取的,取决于此时的调度器空闲M列表中是否存在M,从而避免M的过多创建。
* 当M对应的内核线程被唤醒时,M将会尝试为G捕获一个P上下文,可能是从调度器的空闲P列表中获取,如果获取不成功,M会被G放入到调度器的可执行G队列中,等待其他P的查找。为了保证G的均衡执行,非空闲的P会运行完自身的可执行G队列后,周期性从调度器的可执行G队列中获取代执行的G,甚至从其他的P的可执行G队列中掠夺G。如果当前M执行完了P中所有的G,那么也不会空闲等待,而是会尝试去获取其他的G。

注意,有些特殊的M是不绑定P的。是用于监控一些阻塞的异常情况,比如一个M长时间阻塞超过10ms,那么强制把M-P解绑,把M游离出去,P绑定到一个空闲的M上,继续执行队列里的G任务。

Golang支持G-M锁定功能,通过lockOSThread和unlockOSThread来实现。主要是用于有些要求固定在一个线程上跑的库,锁定这段期间,指定M只执行指定G的任务,也就是LockOSThread会锁定当前协程只跑在一个系统线程上,这个线程里也只跑该协程。

0

评论