Java和Golang的线程模型


最近再去看Golang的G-M-P线程模型时发现自己以前理解的不够清楚明白,于是再去仔细拜读了一下Golang线程模型相关的书籍,同时对比着Java的线程模型做了一下梳理,在此记录一下心得。要理解Golang的线程模型必须得从操作系统的线程调度说起,此外还需理解几个重要的概念

  • 调度器(Thread Scheduler):内核通过操纵调度器对内核线程进行调度,并负责将线程的任务映射到各个处理器上

  • 内核线程(Kernel Level Thread):简称KLT,每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情。同时支持多线程的内核就叫做多线程内核

  • 轻量级进程(Light Weight Process):简称LWP,在实际程序中我们一般不直接使用内核线程,用户线程与内核线程之间需要一种中间数据结构,它由内核支持且是内核线程的高级抽象,这个高级接口被称为轻量级进程(Light Weight Process)轻量级进程就是我们通常意义上所讲的线程,当然也属于用户线程;由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。通俗理解把轻量级进程当做内核线程在用户空间的代理线程就好了

  • 用户线程(User Level Thread):简称ULT,LWP虽然本质上属于用户线程,但LWP线程库是建立在内核之上的,LWP的许多操作都要进行系统调用,因此效率不高。而这里的用户线程(User Thread)指的是完全建立在用户空间的线程库,用户线程的建立,同步,销毁,调度完全在用户空间完成,不需要内核的帮助。因此这种线程的操作是极其快速的且低消耗的。

操作系统线程模型

在解释了那几个重要概念后,接下来就需要知道三种线程模型了,在内存方面,操作系统会将物理内存地址(内存卡)虚拟化成逻辑内存空间,基于对系统层和用户层的应用所拥有的权限不同,将内存划分为用户空间内核空间,以确保内核空间的安全 。内核空间中存放的是内核的代码和数据,而用户空间中存放的是用户程序的代码和数据。

内核态用户态的切换:用户态是指在用户空间中正在执行的代码和数据,内核态是指在内核空间中正在执行的代码和数据。当一个应用的进程由于中断或系统调用从用户态转换为内核态时,进程所使用的栈也要从用户栈切换到内核栈。进程进入内核态之后,把用户态的堆栈地址保存在内核态堆栈中,然后设置堆栈寄存器地址为内核栈地址,这样就从用户栈转换成内核栈。当进程从内核态转换到用户态时,将堆栈寄存器的地址再重新设置成用户态的堆栈地址。

线程实现在用户空间中

当线程在用户空间下实现时,操作系统对线程的存在一无所知,操作系统只能看到进程,而不能看到线程。所有的线程都是在用户空间实现。在操作系统看来,每一个进程只有一个线程。过去的操作系统大部分是这种实现方式,这种方式的好处之一就是即使操作系统不支持线程,也可以通过库函数来支持线程。在用户空间实现线程时,每一个进程针对自己的线程维护了一个用于保存线程运行的各种变量,比如寄存器,PC,状态等信息的线程表(Thread Table),该线程表在进程的Run-Time System中维护,当一个线程被block,她的当前运行状态会被保存在线程表中,当再次启动时,也会读取线程表中已经保存的状态,从该状态进行再次运行。用户线程的创建、调度、同步和销毁全由库函数在用户空间完成,完全不需要内核的帮助。

优点:

  1. 线程的调度只是在用户态,减少了操作系统从内核态到用户态的切换开销。
  2. 用户空间的线程可以自定义调度算法,程序员完全可以自己写一套针对自己程序的线程调度算法。

缺点:

  1. 对于内核来说,不管进程里面有多少个线程,内核仍然按照单线程进程来处理这个进程,所以同一时间一个进程里面只能有一个线程运行,就算有多个cpu空闲,也只能有一个线程运行,所以无法最大限度的使用资源
  2. 当一个进程中的某一个线程进行系统调用时,比如缺页中断而导致线程阻塞,即使这个进程中其它线程还在工作,此时操作系统会阻塞整个进程。

由于每个进程中只有一个内核线程,所有用户态的线程共用一个内核线程,所有这又被称作 N:1线程模型

线程实现在操作系统内核中

内核线程是建立在内核空间的线程库,只运行在内核态。内核线程就是内核的分身,一个分身可以处理一件特定事情。内核线程同样有线程表(Thread Table),该线程表在内核中维护,内核线程可以在全系统内进行资源的竞争,它们的建立和销毁都是由操作系统负责、通过系统调用完成的。通俗的将就是,程序员直接使用操作系统中已经实现的线程,而线程的创建、销毁、调度和维护,都是靠操作系统(准确的说是内核)来实现,程序员只需要使用系统调用,而不需要自己设计线程的调度算法和线程对CPU资源的抢占使用。

内核线程驻留在内核空间,它们是内核对象。有了内核线程,每个用户线程被映射或绑定到一个内核线程。用户线程在其生命期内都会绑定到该内核线程。一旦用户线程终止,两个线程都将离开系统。这被称作1:1线程映射

用户线程加轻量级进程混合实现

一个应用程序中的多个用户级线程被映射到一些(小于或等于用户级线程的数目)内核级线程上。在这种混合实现下,即存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的线程模型

明白了前面两种模型,就应该很好理解这种线程模型了,但实际上现在主流的操作系统已经不太常用这种线程模型了。目前来说,作为异步回调以外的另一种解决方案,这种m:n的线程模型可以说大有可为,Golang的协程就是使用了这种模型,在用户态,协程能快速的切换,避免了线程调度的CPU开销问题,协程相当于线程的线程。

Java线程在操作系统上本质

在JDK1.2之前,开发者们为JVM开发了自己的一个线程调度内核,而到操作系统层面就是用户空间内的线程实现。而到了JDK1.2及以后,JVM选择了更加稳健且方便使用的操作系统原生的线程模型,通过系统调用,将程序的线程交给了操作系统内核进行调度

Java线程在JDK1.2之前,是基于称为“绿色线程”(Green Threads)的用户线程实现的,而在JDK1.2中,线程模型替换为基于操作系统原生线程模型来实现。因此,在目前的JDK版本中,操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的,这点在不同的平台上没有办法达成一致,虚拟机规范中也并未限定Java线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是透明的。

对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的。也就是说,现在的Java中线程的本质,其实就是操作系统中的线程,Linux下是基于pthread库实现的轻量级进程,Windows下是原生的系统Win32 API提供系统调用从而实现多线程

Golang的线程模型

说到go的线程模型,绕不开的就是go的协程调度器 G-P-M 模型。在 Go 语言中,每一个 goroutine 是一个独立的执行单元,相较于每个 OS 线程固定分配 2M 内存的模式,goroutine 的栈采取了动态扩容方式, 初始时仅为2KB,随着任务执行按需增长,最大可达 1GB(64 位机器最大是 1G,32 位机器最大是 256M),且完全由 golang 自己的调度器 Go Scheduler 来调度。此外,GC 还会周期性地将不再使用的内存回收,收缩栈空间。 因此,Go 程序可以同时并发成千上万个 goroutine 是得益于它强劲的调度器和高效的G-P-M内存模型。

  • 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 个。
  • Local Run Queue:简称 LRQ,每个 P 维护一个 G 的本地队列,本地等待运行的 G,LRQ存的数量有限,不超过 256 个。新建 G 时,G 优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列,
    LRQ 不加锁,GRQ加锁
  • Global Run Queue:简称GRQ,当一个 G 被创建出来,或者变为可执行状态时,就把他放到 P 的 LRQ 队列中,如果满了则放入GRQ

G-P-M调度器的调度过程如下


Author: 顺坚
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source 顺坚 !
评论
 Previous
LeetCode-数组问题 LeetCode-数组问题
最近在LeetCode刷到一类数组问题,在这里总结一下,数组问题无非是排序,合并,移位之类的,大致思路是通过指针遍历或双指针从数组两头遍历,还有就是交换思想,这个用的好可以减少空间复杂度。下面是几道算法题的Go语言实现 移动零 给定一个数组
2021-03-13
Next 
Golang的CSP并发模型 Golang的CSP并发模型
Go实现了两种并发形式,第一种是大家普遍认知的多线程共享内存,其实就是 Java或 C++等语言中的多线程开发;另外一种是Go语言特有的,也是Go语言推荐的 CSP(communicating sequential processes)并发
2021-03-02
  TOC