进程是操作系统分配资源(CPU、内存、文件)、调度任务和执行的一个基本单位。它拥有独立的内存空间、已分配的资源和独立的执行上下文。 线程是CPU调度的基本单位,同一进程内的线程共享了进程的资源和内存空间。
系统将内存分为两个区域:
由于内核态具有非常高的权限, 用户空间的代码被限制在一个局部的内存空间, 也就是用户态(User Mode), 内核空间的代码能够访问所有的内存, 在内核空间的程序也被称为内核态(Kenranl Mode)
用户态切换内核态: 内核程序执行在内核态(Kernal Mode),用户程序执行在用户态(User Mode)。当发生系统调用时,用户态的程序发起系统调用。因为系统调用中牵扯特权指令,用户态程序权限不足,因此会中断执行,也就是 Trap(Trap 是一种中断)。发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调.用。内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作。
如果进程想要创造更多的线程,就需要思考一件事情,这个线程创建在用户态还是内核态。
很多同学会以为: 用户态的进程创建用户态的线程,内核态的进程创建内核态的线程。其实不是, 进程可以通过 API 创建用户态的线程,也可以通过系统调用创建内核态的线程。
用户态线程(User Level Thread):是一种完全由用户空间库(如线程库 Posix Pthreads)管理的线程实现。这种线程实现与操作系统内核相互独立,操作系统无法直接感知和调度这些线程。纯用户态线程仅在用户级别进行调度、上下文切换和同步操作,没有内核级别的干预。 优点:
缺点:
内核态线程(Kernel Level Thread):这种线程执行在内核态,可以通过系统调用创造一个内核级线程。
优点:
缺点:
首先理解一个概念 内核态调度实体(Kernel-level scheduling entity)
:是指在操作系统内核空间运行、被操作系统内核调度器管理的任务执行单位。在这种情况下,调度实体通常指的是线程(如内核线程)或轻量级进程(LWP)。
LWP 是 Linux (Solaris)系统中内核态调度实体的一种实现, 和内核线程都是以 task_struct
结构体表示, 具体两者的区别:
另外LWP运行在用户态还是内核态呢?
轻量级进程(LWP)通常是用户空间线程(如 POSIX 线程)和内核态调度实体之间的桥梁。从这个角度来看,LWP 既有用户态特性,又与内核态调度实体关联。但整体上说,LWP 更多地处于内核态。
在用户态,LWP 与用户级线程(如 POSIX 线程)关联,它们共享代码、数据以及操作系统资源。然而,在内核中,每个 LWP 都关联着一个唯一的内核态调度实体,负责管理 LWP 的状态、资源和调度信息等。当用户线程需要进行系统调用时,操作系统接管控制权并将执行切换到内核态。
在 Linux 系统中, POSIX 线程和 LWP 的实现已经高度集成,LWP 可以直接与内核态调度实体关联,由操作系统内核进行调度和管理。因此,在这种上下文中,LWP 主要运行在内核态,负责管理和调度用户线程与内核态调度实体之间的映射关系。
线程简单理解,就是要执行一段程序。程序不会自发的执行,需要操作系统进行调度。
如果要让一个拥有多个线程的用户态进程去执行, 最先想到的就是让一个内核去执行这个进程, 毕竟内核才能真正的调度CPU资源。这里的内核实际上是指的内核调度实体(KSE,Kernel Scheduling Entity)。内核调度实体 KSE 就是指可以被操作系统内核调度器调度的对象实体, 也可以简单的理解为内核线程。
这样所有的线程都需要自己去调度, 相当于在进程的主线程中实现分时算法调度每一个线程,也就是所有线程都用操作系统分配给主线程的时间片段执行。这种做法,相当于操作系统调度进程的主线程;进程的主线程进行二级调度,调度自己内部的线程。
这样操作劣势非常明显,比如无法利用多核优势,每个线程调度分配到的时间较少,而且这种线程在阻塞场景下会直接交出整个进程的执行权限。
用户态线程创建成本低,问题明显,不可以利用多核。内核态线程,创建成本高,可以利用多核,切换速度慢。
因此通常我们会在内核中预先创建一些线程,并反复利用这些线程。这样,用户线程与内核调度实体 KSE 之间的对应关系可以就形成了三种主要的线程模型:
也称为 用户级线程模型
用户线程与内核线程 KSE 是多对一(N : 1)的映射模型,多个用户线程的一般从属于单个进程并且多线程的调度是由用户自己的线程库来完成,线程的创建、销毁以及多线程之间的协调等操作都是由用户自己的线程库来负责而无须借助系统调用来实现。一个进程中所有创建的线程都只和同一个 KSE 在运行时动态绑定,也就是说,操作系统只知道用户进程而对其中的线程是无感知的,内核的所有调度都是基于用户进程。这种用户级线程调度都是基于基于合作式调度,而不是抢占式调度。这意味着当前运行的线程需要主动放弃执行权,以便其他线程获得 CPU 时间。由于不存在 CPU 时钟中断,线程无法在执行过程中被中断(抢占)。
这种模型相较于内核调度不需要让 CPU 在用户态和内核态之间切换,这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的线程数量与上下文切换所花费的代价也会小得多。但是这个模型有个致命的缺陷:并不能做到真正意义上的并发,假设在某个用户进程上的某个用户线程因为一个阻塞调用(比如 I/O 阻塞)而被 CPU 给中断(抢占式调度)了,那么该进程内的所有线程都被阻塞(因为单个用户进程内的线程自调度是没有 CPU 时钟中断的,从而没有轮转调度),整个进程被挂起。
因为在用户级线程模型下,一个 CPU 关联运行的是整个用户进程,进程内的子线程绑定到 CPU 执行是由用户进程调度的,内部线程对 CPU 是不可见的,此时可以理解为 CPU 的调度单位是用户进程。所以很多的协程库会把自己一些阻塞的操作重新封装为完全的非阻塞形式,然后在以前要阻塞的点上,主动让出自己,并通过某种方式通知或唤醒其他待执行的用户线程在该 KSE 上运行,从而避免了内核调度器由于 KSE 阻塞而做上下文切换,这样整个进程也不会被阻塞了。
也称为 内核级线程模型
用户线程与内核线程 KSE 是一对一(1 : 1)的映射模型,也就是每一个用户线程绑定一个实际的内核线程,而线程的调度则完全交付给操作系统内核去做。
大部分编程语言的线程库(比如 Java 的 java.lang.Thread、C++11 的 std::thread 等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个独立的 KSE 静态绑定,因此其调度完全由操作系统内核调度器去做,也就是说,一个进程里创建出来的多个线程每一个都绑定一个 KSE。这种模型的优势和劣势同样明显:优势是实现简单,直接借助操作系统内核的线程以及调度器,所以 CPU 可以快速切换调度线程,于是多个线程可以同时运行,因此相较于用户级线程模型它真正做到了并行处理;但它的劣势是,由于直接借助了操作系统内核来创建、销毁和以及多个线程之间的上下文切换和调度,因此资源成本大幅上涨,且对性能影响很大
也称为 两级线程模型
两级线程模型充分吸收前两种线程模型的优点且尽量规避它们的缺点。在此模型下,用户线程与内核 KSE 是多对多(N : M)的映射模型:首先,区别于用户级线程模型,两级线程模型中的一个进程可以与多个内核线程 KSE 关联,也就是说一个进程内的多个线程可以分别绑定一个自己的 KSE,这点和内核级线程模型相似;其次,又区别于内核级线程模型,它的进程里的线程并不与 KSE 唯一绑定,而是可以多个用户线程映射到同一个 KSE,当某个 KSE 因为其绑定的线程的阻塞操作被内核调度出 CPU 时,其关联的进程中其余用户线程可以重新与其他 KSE 绑定运行。所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是中间态(自身调度与系统调度协同工作)。由 用户调度器实现用户线程到 KSE 的调度,内核调度器实现 KSE 到 CPU 上的调度。
Go 语言中的 runtime 调度器就是采用的这种实现方案,实现了 Goroutine 与 KSE 之间的动态关联。
GO 采取 GMP来解决传统内核级线程的创建、切换、销毁开销大的问题, 其中:
goexit
做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础,M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。整体如下图所示:
Goroutine 调度器负责将 G 高效的调度到 M 上去执行, 核心思想是:
GOMAXPROCS
来保证,GOMAXPROCS
一般为 CPU 核数,因为 M 和 P 是一一绑定的,没有找到 P 的 M 会放入空闲 M 列表,没有找到 M 的 P 也会放入空闲 P 列表;总的来说可以分为两种情况: