Go语言的并发模型

Go语言的并发模型

1.线程模型

​ 在现代的操作系统中,线程是CPU调度和分配的基本单位,而进程作为资源则是这些基本单位的拥有者。每一个进程都拥有一个主干线程,它由系统自动进行创建。用户可根据自己所需创建线程,多个线程可以并发的运行在同一个进程中。

​ 无论语言层面何种并发模型,到了操作系统层面,一定是以线程的形态存在的。而操作系统根据资源访问权限的不同,体系架构可分为用户空间和内核空间;内核空间主要操作访问CPU资源、I/O资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源,用户空间呢就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过“系统调用”、“库函数”或“Shell脚本”来调用内核空间提供的资源。

线程的实现模型主要有3个,分别是:用户级线程模型、内核级线程模型和两级线程模型。它们之间最大的差异就在于线程与内核调度实体( Kernel Scheduling Entity,简称KSE)之间的对应关系上。

1.1 内核级线程模型

用户线程与KSE是1对1关系(1:1)。大部分编程语言的线程库(如linux的pthread,Java的java.lang.Thread,C++11的std::thread等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每一个线程都与一个KSE静态关联,因此其调度完全是由OS调度器去完成。这种实现方式很简单,直接借助OS提供的线程能力,且每一个线程之间也不会相互影响。但由于其创建、销毁以及多个线程之间的上下文切换都是由OS层面直接处理,因此在多线程的情况下对OS的性能影响较大。

img

优点:在多核处理器的硬件的支持下,内核空间线程模型支持了真正的并行,当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强。

缺点:每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。

1.2 用户级线程模型

​ 用户线程与KSE是多对1关系(M:1),这种线程的创建、销毁、调度等操作都是由用户自己来实现,对OS透明,一个进程中所创建的多个线程都与同一个KSE在运行时动态关联。这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的数量与上下文切换所花费的代价也会小得多。

img

优点:上下文切换发生在用户空间,避免的模态切换(mode switch),从而对于性能有积极的影响。

缺点:所有用户线程都基于一个KSE,这意味着只有一个处理器可以被利用,在多核时代这是不允许被接受的,会造成资源的浪费。同时当一个用户线程阻塞系统调用时,与其关联的KSE将会被内核调度出CPU,从而导致剩下的所有对应的用户线程全都会变为阻塞状态。

1.3 两级线程模型

​ 用户线程与KSE是多对多的关系(M:N),该线程模型的实现综合了前两种模型的优点,为一个进程中创建多个KSE,并且线程可以与不同的KSE在运行时进行动态关联,当某个KSE由于其上工作的线程的阻塞操作被内核调度出CPU时,当前与其关联的其余用户线程可以重新与其他KSE建立关联关系。当然这种动态关联机制的实现很复杂,也需要用户自己去实现,这算是它的一个缺点吧。Go语言中的并发就是使用的这种实现方式,Go为了实现该模型自己实现了一个运行时调度器来负责Go中的"线程"与KSE的动态关联。即用户调度器实现用户线程到KSE的“调度”,内核调度器实现KSE到CPU上的调度

img

2.Go并发调度 G-M-P模型

goroutine机制实现了M : N的线程模型,goroutine机制是协程(coroutine)的一种实现,golang内置的调度器,可以让多核CPU中每个CPU执行一个协程。

2.1 调度器是如何工作的

Go语言中支撑整个scheduler实现的主要有4个重要结构,分别是M、G、P、Sched, 前三个定义在runtime.h中,Sched定义在proc.c中。

  • sched结构就是调度器,它维护有G、M的队列及其调度器的一些调度信息。
  • G结构是goroutine实现的核心结构,它包含了栈,指令指针,以及其他对调度goroutine很重要的信息。
  • M结构是Machine,即系统线程,它由操作系统管理的,goroutine就是跑在M之上的。
  • P结构是Processor,即处理器,它的主要用途就是用来执行goroutine的,它包含了运行 goroutine 的资源,它用于处理M与G的关系。它维护了一个goroutine队列,即runqueue。(P的个数在程序启动时决定,默认等同与CPU的核数,通过 runtime.GOMAXPROCS() 设置P的个数)

每一个M系统线程维护一个Processor,任何时刻,一个Processor中只有一个goroutine,其他goroutine在runqueue中等待。一个goroutine运行完自己的时间片后,让出上下文,回到runqueue中。 多核处理器的场景下,为了运行goroutines,每个M系统线程会持有一个Processor。

img

2.2 线程阻塞

当正在运行的goroutine阻塞的时候,例如进行系统调用,会再创建一个系统线程(M1),当前的M线程放弃了它的Processor,P转到新的线程中去运行。

img

2.3 runqueue执行完成

当其中一个Processor的runqueue为空,没有goroutine可以调度。它会从另外一个上下文偷取一半的goroutine。

img

小结

a.如何在一个多核心系统上尽量合理分配G到多个M上运行,充分利用多核,提高并发能力呢?

​ 如果我们在一个Goroutine中通过go关键字创建了大量G,这些G虽然暂时会被放在同一个队列, 但如果这时还有空闲P(系统内P的数量默认等于系统cpu核心数),Go运行时系统始终能保证至少有一个(通常也只有一个)活跃的M与空闲P绑定去各种G队列去寻找可运行的G任务,该种M称为自旋的M。一般寻找顺序为:自己绑定的P的队列,全局队列,然后其他P队列。如果自己P队列找到就拿出来开始运行,否则去全局队列看看,由于全局队列需要锁保护,如果里面有很多任务,会转移一批到本地P队列中,避免每次都去竞争锁。如果全局队列还是没有,就要开始玩狠的了,直接从其他P队列偷任务了(偷一半任务回来)。这样就保证了在还有可运行的G任务的情况下,总有与CPU核心数相等的M+P组合 在执行G任务或在执行G的路上(寻找G任务)。

b. 如果某个M在执行G的过程中被G中的系统调用阻塞了,怎么办?

​ 在这种情况下,这个M将会被内核调度器调度出CPU并处于阻塞状态,与该M关联的其他G就没有办法继续执行了,但Go运行时系统的一个监控线程(sysmon线程)能探测到这样的M,并把与该M绑定的P剥离,寻找其他空闲或新建M接管该P,然后继续运行其中的G,大致过程如下图所示。然后等到该M从阻塞状态恢复,需要重新找一个空闲P来继续执行原来的G,如果这时系统正好没有空闲的P,就把原来的G放到全局队列当中,等待其他M+P组合发掘并执行。

c. 如果某一个G在M运行时间过长,有没有办法做抢占式调度,让该M上的其他G获得一定的运行时间,以保证调度系统的公平性? 我们知道linux的内核调度器主要是基于时间片和优先级做调度的。对于相同优先级的线程,内核调度器会尽量保证每个线程都能获得一定的执行时间。为了防止有些线程"饿死"的情况,内核调度器会发起抢占式调度将长期运行的线程中断并让出CPU资源,让其他线程获得执行机会。当然在Go的运行时调度器中也有类似的抢占机制,但并不能保证抢占能成功,因为Go运行时系统并没有内核调度器的中断能力,它只能通过向运行时间过长的G中设置抢占flag的方法温柔的让运行的G自己主动让出M的执行权。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值