线程/协程详解

之前一篇文章简单介绍了进程和线程,这篇文章我们将介绍一下轻量级进程、线程和协程

修改记录

  • 2021-5-26 修改并完善 内核态和用户态切换方式的描述 #如何切换

在说这写概念之前,我们需要了解一下什么是内核态用户态

内核态/用户态

由于需要限制不同的程序之间的访问能力, 防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络, CPU划分出两个权限等级 :用户态内核态

  • 内核态: CPU可以访问内存所有数据, 包括外围设备, 例如硬盘, 网卡。CPU也可以将自己从一个程序切换到另一个程序
  • 用户态: 只能受限的访问内存, 且不允许访问外围设备。 占用CPU的能力被剥夺, CPU资源可以被其他程序获取

概念

用户空间中的代码被限制了只能使用一个局部的内存空间,我们说这些程序在用户态(User Mode) 执行。
内核空间中的代码可以访问所有内存,我们称这些程序在内核态(Kernal Mode) 执行。

如何切换

所有用户程序都是运行在用户态的, 但是有时候程序确实需要做一些内核态的事情, 例如从硬盘读取数据, 或者从键盘获取输入等. 而唯一可以做这些事情的就是操作系统, 所以此时程序就需要先操作系统请求以程序的名义来执行这些操作.

这时需要一个这样的机制: 用户态程序切换到内核态, 但是不能控制在内核态中执行的指令

这种机制叫系统调用, 在CPU中的实现称之为陷阱指令(Trap Instruction)

他们的工作流程如下:

  1. 用户态程序将一些必要参数放在寄存器中,使操作系统知晓是哪种系统调用。
  2. 用户态程序执行陷入指令(非特权指令),引发内中断
  3. CPU切换到内核态, 并判断中断信号的原因(由于执行trap指令),执行相应的特权指令, 这些特权指令是操作系统的一部分, 他们具有内存保护, 不可被用户态程序访问。
  4. 系统调用完成后, 操作系统会重置CPU为用户态并返回系统调用的结果

系统调用.png
内核程序执行在内核态(Kernal Mode),用户程序执行在用户态(User Mode)。当发生系统调用时,用户态的程序发起系统调用。因为系统调用中牵扯特权指令,用户态程序权限不足,因此会中断执行,也就是 Trap(Trap 是一种中断)。发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调用。内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作。

切换方式

  • 用户态转切换到内核态:主要是用过系统调用(陷入指令),可以认为是用户进程主动发起的。

  • 系统从内核态切换到用户态:执行一条特权指令——修改PSW(程序状态字寄存器)的标志位为“用户态”,意味着操作系统将主动让出CPU的使用权

线程/协程

线程

调度器

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

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

线程分为用户态线程和内核态线程

用户态线程

用户态线程也称作用户级线程(User Level Thread)。操作系统内核并不知道它的存在,它完全是在用户空间中创建。
而这里的用户线程指的是完全建立在用户空间的线程库,用户线程的建立,同步,销毁,调度完全在用户空间完成,不需要内核的帮助。因此这种线程的操作是极其快速的且低消耗的。

用户级线程有很多优势

  • 管理开销小:创建、销毁不需要系统调用。
  • 切换成本低:用户空间程序可以自己维护,不需要走操作系统调度。

但是这种线程也有很多的缺点

  • 与内核协作成本高:比如这种线程完全是用户空间程序在管理,当它进行 I/O 的时候,无法利用到内核的优势,需要频繁进行用户态到内核态的切换。
  • 线程间协作成本高:设想两个线程需要通信,通信需要 I/O,I/O 需要系统调用,因此用户态线程需要支付额外的系统调用成本。
  • 无法利用多核优势(N:1模型中):比如操作系统调度的仍然是这个线程所属的进程,所以无论每次一个进程有多少用户态的线程,都只能并发执行一个线程,因此一个进程的多个线程无法利用多核的优势。
  • 操作系统无法针对线程调度进行优化:当一个进程的一个用户态线程阻塞(Block)了,操作系统无法及时发现和处理阻塞问题,它不会更换执行其他线程,从而造成资源浪费。

内核态线程

内核态线程也称作内核级线程(Kernel Level Thread)。这种线程执行在内核态,可以通过系统调用创造一个内核级线程。内核态线程是处理机分配的基本单位

内核级线程有很多优势

  • 可以利用多核 CPU 优势:内核拥有较高权限,因此可以在多个 CPU 核心上执行内核线程。
  • 操作系统级优化:内核中的线程操作 I/O 不需要进行系统调用;一个内核线程阻塞了,可以立即让另一个执行。

当然内核线程也有一些缺点

  • 创建成本高:创建的时候需要系统调用,也就是切换到内核态。
  • 扩展性差:由一个内核程序管理,不可能数量太多。
  • 切换成本较高:切换的时候,也同样存在需要内核操作,需要切换内核态。

区别

用户态线程调度完全由进程负责,通常就是由进程的主线程负责。相当于进程主线程的延展,使用的是操作系统分配给进程主线程的时间片段。内核线程由内核维护,由操作系统调度。

用户态线程无法跨核心,一个进程的多个用户态线程不能并发阻塞一个用户态线程会导致进程的主线程阻塞,直接交出执行权限。这些都是用户态线程的劣势。
内核线程可以独立执行,操作系统会分配时间片段。因此内核态线程更完整,也称作轻量级进程。内核态线程创建成本高,切换成本高,创建太多还会给调度算法增加压力,因此不会太多。

轻量级进程(Light Weight Process LWP)

在实际程序中我们一般不直接使用内核线程,用户线程与内核线程之间需要一种中间数据结构,它由内核支持且是内核线程的高级抽象,这个高级接口被称为轻量级进程(Light Weight Process)

轻量级进程就是我们通常意义上所讲的线程,当然也属于用户线程;由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。

LWP虽然本质上属于用户线程,但LWP线程库是建立在内核之上的,LWP的许多操作都要进行系统调用,因此效率不高。

三种线程模型

如果有一个用户态的进程,它下面有多个线程。如果这个进程想要执行下面的某一个线程,应该如何做呢?

比较常见的一种方式,就是将需要执行的程序,让一个内核线程去执行。毕竟,内核线程是真正的线程。因为它会分配到 CPU 的执行资源。
如果一个进程所有的线程都要自己调度,相当于在进程的主线程中实现分时算法调度每一个线程,也就是所有线程都用操作系统分配给主线程的时间片段执行。这种做法,相当于操作系统调度进程的主线程;进程的主线程进行二级调度,调度自己内部的线程。
这样操作劣势非常明显,比如无法利用多核优势,每个线程调度分配到的时间较少,而且这种线程在阻塞场景下会直接交出整个进程的执行权限。

由此可见,用户态线程创建成本低,问题明显,不可以利用多核内核态线程,创建成本高,可以利用多核,切换速度慢。因此通常我们会在内核中预先创建一些线程,并反复利用这些线程。
实际操作中,往往结合两者优势,将用户态线程附着在内核态线程中执行。

用户态线程和内核态线程之间的映射关系如下:

  • N:1模型
    多个用户空间线程在1个内核空间线程上运行。优势是上下文切换非常快,因为只有一个内核线程,用户线程不需要频繁系统调用,但是无法利用多核系统的优点
    多对一.png

  • 1:1模型[pthread线程库–NPTL(Native POSIX Threading Library)]
    1个内核空间线程运行一个用户空间线程。这种充分利用了多核系统的优势但是上下文切换非常慢,因为每一次调度都会在用户态和内核态之间切换,而且用户线程数量受到限制。POSIX线程模型(pthread)就是这么做的。

一对一.png

  • M:N模型
    内核开启多个内核线程,一个内核空间线程对应多个用户空间线程。效率非常高,但是管理复杂。

多对多.png

总结

线程调度模型总结.png

协程

本文将以golang中的协程为例展开

对于进程、线程,都是有内核进行调度,有CPU时间片的概念,进行抢占式调度
协程可以理解为用户态的轻量级的非抢占式的线程。适用于IO密集型。

协程,又称微线程,纤程。英文名Coroutine。协程的调用有点类似子程序,如程序A调用了子程序B,子程序B调用了子程序C,当子程序C结束了返回子程序B继续执行之后的逻辑,当子程序B运行结束了返回程序A,直到程序A运行结束。但是和子程序相比,协程有挂起的概念,协程可以挂起跳转执行其他协程,合适的时机再跳转回来

特点

  • 用户态:协程是在用户态实现调度。
  • 轻量级:协程不用内核调度,不用再内核态与用户态之间切换。
  • 非抢占:协程是由用户自己实现调度,协程自己主动交出CPU的。

go的协程调度原理

go中的goroutine本质上就是协程,但是完全运行在用户态,借鉴了M:N模型。如下图
gmp.jpg

相比其他语言,golang采用了GMP模型管理协程,更加高效,但是管理非常复杂。

  • M:指的是Machine,一个M直接关联了一个内核线程。由操作系统管理。
  • G:指的是goroutine,其实本质上也是一种轻量级的线程。包括了调用栈,重要的调度信息,例如channel等。
  • P:指的是Processor,代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器,它负责衔接MG的调度上下文,将等待执行的GM对接。

G-M-P三者的关系与特点

  • P的个数取决于设置的GOMAXPROCS,P其实限定了golang调度其的最大并发度。go新版本默认使用最大内核数,比如你有8核处理器,那么P的数量就是8
  • M的数量和P不一定匹配,可以设置很多M,**MP绑定后才可运行和对G进行调度**,多余的M处于休眠状态
  • p包含一个LRQ(Local Run Queue)本地运行队列,这里面保存着P需要执行的协程G的队列
    除了每个P自身保存的G的队列外,调度器还拥有一个全局的G队列GRQ(Global Run Queue),这个队列存储的是所有未分配的协程G

单核主机下的协程运行图:
2.jpg
红色部分表示挂起和休眠,黄色部分表示准备就绪等待运行,绿色部分表示正在运行。


单核主机只有一个处理器P,但是系统初始化了两个线程M0和M1,处理器P优先绑定了M0线程,M1进入休眠状态。
P的LRQ队列里有G1,G2,G3等待处理。P目前正在处理G0,全局等待队列GRQ里保存着G4,G5,表示这两个协程还未分配给P。
如果G0在短时间内处理完,P就会从LRQ中取出G1继续处理。并且将GRQ全局队列中的部分协程加入LRQ中。
3.jpg


假设现在G1处理速度很慢,系统就会让M0线程休眠,挂起协程G1,唤醒线程M1进行处理其他的协程。这里M1会将M0未处理的协程取走处理。
4.jpg


等到M1协程队列中所有协程处理完再次唤醒M0,或者M1处理某个协程时间较长被挂起,M0也会被唤醒。


上面的讨论是单核主机情况,如果是多核的,就会运行多个P和M,如图
5.jpg

M0和M1分别运行在不同的内核中,M0处理G1,G2,G3,M1处理G4,G5,G6。

有人会问,当M0处理完所有的协程,而M1还未处理完,系统会如何做呢?
M0会取走M1的一半数量未处理的协程。

golang协程设计非常优秀,一方面极大的利用了内核线程和处理器资源,另一方面每个处理器的LRQ队列的协程都处于用户态,这些协程的处理和挂起操作都是用户态的,协程切换开销非常小。相比其他语言的线程设计,更加轻量和高效。

Linux使用的线程库

LinuxThreads用户空间的线程库,所采用的是线程-进程1对1模型(即一个用户线程对应一个轻量级进程,而一个轻量级进程对应一个特定的内核线程),将线程的调度等同于进程的调度,调度交由内核完成,而线程的创建、同步、销毁由核外线程库完成(LinuxThtreads已绑定到 GLIBC中发行)。

相关疑问

  1. 难道不是用户态的进程创建用户态的线程,内核态的进程创建内核态的线程吗?

    其实不是,进程可以通过 API 创建用户态的线程,也可以通过系统调用创建内核态的线程,接下来我们说说用户态的线程和内核态的线程。
    例如用kthread_create创建内核态线程,用pthread_create创建用户态线程(1:1模型)

参考地址

  1. 多线程之:用户态和内核态的区别
  2. 内核线程、轻量级进程、用户线程三种线程概念解惑(线程≠轻量级进程)
  3. 用户态线程和内核态线程有什么区别?
  4. 【深度知识】GO语言的goroutine并发原理和调度机制
  5. 图解Go协程调度原理,小白都能理解
  6. Linux内核v2.6中的pthread与kthread
  7. 操作系统基础知识用户态和内核态的区别
您的支持将鼓励我继续创作!