用户态和内核态概述

1.操作系统需要两种CPU状态

  • 内核态(Kernel Mode):运行操作系统程序,操作硬件
  • 用户态(User Mode):运行用户程序

2.指令划分

  • 特权指令:只能由操作系统使用、用户程序不能使用的指令。 举例:启动I/O 内存清零 修改程序状态字 设置时钟 允许/禁止终端 停机
  • 非特权指令:用户程序可以使用的指令。 举例:控制转移 算数运算 取数指令 访管指令(使用户程序从用户态陷入内核态)

3.特权级别

特权环:R0、R1、R2和R3
R0相当于内核态,R3相当于用户态;
不同级别能够运行不同的指令集合;

4.CPU状态之间的转换

  • 用户态—>内核态:唯一途径是通过中断、异常、陷入机制(访管指令)
  • 内核态—>用户态:设置程序状态字PSW

5.内核态与用户态的区别

  • 内核态与用户态是操作系统的两种运行级别,当程序运行在3级特权级上时,就可以称之为运行在用户态。因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;
  • 当程序运行在0级特权级上时,就可以称之为运行在内核态。
  • 运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态(比如操作硬件)。
  • 这两种状态的主要差别是
    – 处于用户态执行时,进程所能访问的内存空间和对象受到限制,其所处于占有的处理器是可被抢占的
    – 处于内核态执行时,则能访问所有的内存空间和对象,且所占有的处理器是不允许被抢占的。

6. 通常来说,以下三种情况会导致用户态到内核态的切换

系统调用

这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。比如前例中fork()实际上就是执行了一个创建新进程的系统调用。

而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。

用户程序通常调用库函数,由库函数再调用系统调用,因此有的库函数会使用户程序进入内核态(只要库函数中某处调用了系统调用),有的则不会。

异常

当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。

外围设备的中断

当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,

如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。

进程

进程的调度

  • 在计算机的世界里,内核把CPU的执行时间切分成许多时间片,比如1秒钟可以切分为100个10毫秒的时间片,每个时间片再分发给不同的进程,通常,每个进程需要多个时间片才能完成一个请求。这样,虽然微观上,比如说就这10毫秒时间CPU只能执行一个进程,但宏观上1秒钟执行了100个时间片,于是每个时间片所属进程中的请求也得到了执行,这就实现了请求的并发执行。

流水线

  • CPU就好像一个流水线上的工人,不断的处理流水线上的各种信息包裹,打开包裹读取指令并执行,遇到执行慢的IO调用(或执行时间片结束)则会暂时把它放到等候区,继续处理流水线上下一个等待处理的包裹。等候区有很多这样的包裹,等待着系统的IO执行完成,当IO调用结束后,又开始进入到等待处理队列。

进程的缺点

  • 在操作系统中,每个进程的内存空间都是独立的,这样用多进程实现并发就有两个缺点:一是内核的管理成本高,二是无法简单地通过内存同步数据,很不方便于是多线程模式就出现了。
线程

线程的调度

  • 线程,是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。线程总是在进程之内的。一个进程至少会包含一个线程。
  • 如果一个进程只包含了一个线程,那么它里面的所有代码都只会被串行地执行。每个进程的第一个线程都会随着该进程的启动而被创建,它们可以被称为其所属进程的主线程。相对应的,如果一个进程中包含了多个线程,那么其中的代码就可以被并发地执行。除了进程的第一个线程之外,其他的线程都是由进程中已存在的线程创建出来的。所以,线程可以通过共享内存地址空间,解决内核的管理成本、内存同步数据的问题。
  • 在同一个进程中并行运行多个线程,是对在同一台计算机上并行运行多个进程的模拟。因此,线程也被称为轻量级进程。与进程调度类似,CPU在线程之间快速切换,制造了线程并行运行的假象。由于各个线程都可以访问进程地址空间的每一个内存地址,所以一个线程可以读、写,甚至清除另一个线程的堆栈。也就是说,线程之间是没有保护的。但要注意的是,每个线程都有自己的堆栈、程序计数器、寄存器等信息,这些不是共享的。
  • 线程的切换是由内核控制的,什么时候会切换线程呢?不只时间片用尽,当调用阻塞方法时,内核为了CPU 充分工作,也会切换到其他线程执行。一次上下文切换的成本在几十纳秒到几微秒间,当线程繁忙且数量众多时,这些切换会消耗绝大部分的CPU运算能力。

线程所占内存

执行 ulimit -a 这条命令,查看进程创建线程时默认分配的栈空间大小,比如我这台服务器默认分配给线程的栈空间大小为 8M。
在这里插入图片描述

那么假设创建一个线程需要占用 10M 虚拟内存,总共有 3G 虚拟内存可以使用。于是我们可以算出,最多可以创建差不多 300 (3G/10M)左右的线程。
在这里插入图片描述
在这里插入图片描述
如果想使得进程创建上千个线程,那么我们可以调整创建线程时分配的栈空间大小,比如调整为 512k:

ulimit -s 512

创建线程个数的限制因素:

  • 内存的限制
  • /proc/sys/kernel/threads-max,表示系统支持的最大线程数,默认值是 14553;
  • /proc/sys/kernel/pid_max,表示系统全局的 PID 号数值的限制,每一个进程或线程都有 ID,ID 的值超过这个数,进程或线程就会创建失败,默认值是 32768;
  • 32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。
  • 64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。
  • 在这里插入图片描述
    在这里插入图片描述

参考:

协程的意义

线程和协程对比

goroutine是Go语言实现的用户态线程,主要用来解决操作系统线程太“重”的问题,所谓的太重,主要表现在以下两个方面:

  • 创建和切换太重:操作系统线程的创建和切换都需要进入内核,而进入内核所消耗的性能代价比较高,开销较大;

  • 内存使用太重(一个线程8M 默认-可修改):一方面,为了尽量避免极端情况下操作系统线程栈的溢出,内核在创建操作系统线程时默认会为其分配一个较大的栈内存(虚拟地址空间,内核并不会一开始就分配这么多的物理内存),然而在绝大多数情况下,系统线程远远用不了这么多内存,这导致了浪费;另一方面,栈内存空间一旦创建和初始化完成之后其大小就不能再有变化,这决定了在某些特殊场景下系统线程栈还是有溢出的风险。

  • 协程的优势
    在Go 语言中,一个协程初始内存空间是2KB(Linux 下线程栈大小默认是8MB),相比线程和进程来说要小很多。 协程的创建和销毁完全是在用户态执行的,不涉及用户态和内核态的切换。

goroutine 一些基本结论

  • goroutine 所占用的内存,均在栈中进行管理
  • goroutine 所占用的栈空间大小,由 runtime 按需进行分配
  • 以 64位环境的 JVM 为例,会默认固定为每个线程分配 1MB 栈空间,如果大小分配不当,便会出现栈溢出的问题
  • goroutine 相较于线程更加轻量,关键点就在于栈空间的动态分配,这样便可以最大限度的利用内存资源。既然是动态分配,那脱离实际情况而单纯说单个 goroutine 占用多大内存,就有点吹毛求疵了。

goroutine 是如何做到栈空间动态分配的

分段栈

在 Go 的早期版本中,使用分段栈的方式进行内存管理,当一个goroutine被创建时,runtime 会为协程分配 8KB 的内存区域。那么问题来了,8KB 空间不够了怎么办?

为了解决这个问题,Go 会在每个函数的入口处都插入一小段前置代码,它能够检查栈空间是否被消耗殆尽,如果用完了,便会调用 morestack() 函数来扩展空间。

连续栈

栈拷贝开始很像分段栈。协程运行,使用栈空间,当栈将要耗尽时,触发相同的栈溢出检测。
但是,不像分段栈里有一个回溯链接,栈拷贝的方式则是创建了一个新的分段,它是旧栈的两倍大小,并且把旧栈完全拷贝进来。 这样当栈收缩为旧栈大小时,runtime不会做任何事情。收缩变成了一个no op免费操作。此外,当栈再次增长时,runtime也不需要做任何事情,重新使用刚才扩容的空间即可。

Golang “调度器” 的由来?#

  1. 单进程 – 串行运行,计算机只能一个任务一个任务处理。进程阻塞所带来的 CPU 时间浪费。

  2. 多进程- 当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把 CPU 利用起来,CPU 就不浪费了。
    在这里插入图片描述
    缺点:进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,CPU 虽然利用起来了,但如果进程过多,CPU 有很大的一部分都被用来进行进程调度了。

  3. 多线程 - 引入线程- 线程是轻量级的进程

  4. 协程看做是轻量级的线程,

  5. 一个线程分为 “内核态 “线程和” 用户态 “线程
    在这里插入图片描述

  6. 一个 “用户态线程” 必须要绑定一个 “内核态线程”,但是 CPU 并不知道有 “用户态线程” 的存在,它只知道它运行的是一个 “内核态程”(Linux 的 PCB 进程控制块)。

  7. 内核线程依然叫 “线程 (thread)”,用户线程叫 “协程 (co-routine)”.
    在这里插入图片描述

  8. 既然一个协程 (co-routine) 可以绑定一个线程 (thread),那么能不能多个协程 (co-routine) 绑定一个或者多个线程 (thread) 上呢。**

3 种协程和线程的映射关系:

  1. N:1 关系
  2. 1:1 关系
  3. M:N 关系
    在这里插入图片描述
    M 个协程绑定 N 个线程,是 N:1 和 1:1 类型的结合,克服了以上 2 种模型的缺点,但实现起来最为复杂。
    协程跟线程是有区别的,线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程。

GMP模型详解

G - Goroutine,Go协程
M - Machine,指的是系统级线程(内核态线程)
P - Processor,指的是逻辑处理器, 用来管理和执行goroutine, 一个p代表了m所需要的的上下文环境 – P也可以理解为控制go代码的并行度的机制,

  • 每一个运行的m都必须绑定一个p, 线程m创建之后会去检查并且执行G
  • G需要绑定在M上才能运行,M需要绑定P才能运行。
  • P关联了的本地可运行G的队列(也称为LRQ),最多可存放256个Goroutine。
  1. 当一个正在与某个M对接并运行着的G,需要因某个事件(比如等待 I/O或锁的解除)而暂停运行的时候,调度器总会及时地发现,并把这个G与那个M分离开,以释放计算资源供那些等待运行的G使用。
  2. 而当一个G需要恢复运行的时候,调度器又会尽快地为它寻找空闲的计算资源(包括M)并安排运行。
  3. 另外,当M不够用时,调度器会帮我们向操作系统申请新的系统级线程,而当某个M已无用时,调度器又会负责把它及时地销毁掉。所以Go程序总是能高效地利用操作系统和计算机资源。
  4. 程序中的所有goroutine也都会被充分地调度,其中的代码也都会被并发地运行,即使这样的goroutine有数以十万计,也仍然可以如此。
  5. 全局队列(Global Queue):存放等待运行的G。
  6. P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G’时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
  7. P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。
  8. M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。