操作系统的调度基础

操作系统的cpu调度

把内核线程当成内核中的一个进程去理解。

任务系统的三个核心特征是:权限分级、数据隔离和任务切换。以X86_64架构为例,权限分级通过CPU的多模式机制和分段机制实现,数据隔离通过分页机制实现,任务切换通过中断机制和任务机制(TR/TSS)实现。

用户态内核态: 在 CPU 的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,比如清内存、设置时钟等。如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率将大大增加。所以,CPU 将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通应用程序只能使用那些不会造成灾难的指令。比如 Intel 的 CPU 将特权等级分为 4 个级别:Ring0~Ring3。其实 Linux 系统只使用了 Ring0 和 Ring3 两个运行级别(Windows 系统也是一样的)。当进程运行在 Ring3 级别时被称为运行在用户态,而运行在 Ring0 级别时被称为运行在内核态。
在这里插入图片描述
陷入内核的代价: 用户态到内核态切换途径:系统调用 、中断 、异常。系统调用的切换流程:系统调用触发中断,进入中断处理程序。若中断时,进程在执行用户态的代码,该中断会引起CPU特权级从ring3级到ring0级的切换,CPU会进行堆栈的切换,CPU会从当前任务的TSS中取到内核栈的段选择符和偏移值;CPU首先会上下文保存到内核栈,把原用户态的堆栈指针ss和esp压入内核态堆栈,随后把标志寄存器eflags的内容和此次中断的返回位置cs,eip压入内核态堆栈。当中断处理函数结束后,将恢复内核栈中的数据,并继续处理被中断的进程。每个系统调用内核要进行许多工作,耗时开销大。

栈(stack): 栈又称堆栈, 是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进后出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。

用户级线程: 用户级线程是指有关线程的管理工作都是由应用程序完成,内核意识不到用户级线程的存在,也不会对这些用户级线程进行调度。如果某个进程创建了多个用户级线程,那么所有这些用户级线程仅有一个对应的内核线程,在用户级线程策略中,内核是以进程为单位进行调度的,不管进程内有多少线程,内核一次只把一个进程分配给一个处理器,因此一个进程中只有一个线程可以执行,所以只有一个对应的内核线程。

在这里插入图片描述

内核级线程: 内核级线程是指由内核管理、只运行在内核态、不受用户态上下文拖累的线程。其依赖于操作系统核心,由内核的内部需求进行创建和撤销。内核线程的线程表位于内核中,包括了线程控制块,一旦线程阻塞,内核会从当前或者其他进程中重现选择一个线程保证程序的执行。用户应用程序通过API和系统调用(system call)来访问内核级线程。

内核级线程和用户级线程的关系: 内核线程是由操作系统维护的线程对象。它是能够被处理器调度和执行的实际线程。通常,系统线程是具有权限设置,优先级等的重量级对象。内核线程调度器负责调度内核线程。用户线程必须与内核线程相关联的原因在于用户线程本身只是用户程序中的一堆数据。内核线程是系统中的真正线程,所以为了让用户线程进步,用户程序必须让其调度程序接受用户线程,然后在内核线程上运行它。用户线程和内核线程之间的映射不一定是一对一(1:1)映射;您可以拥有多个用户线程共享相同的内核线程(每次只运行一个用户线程),并且您可以拥有单个用户线程,该线程在不同的内核线程(1:n)映射之间进行旋转。 内核级线程驻留在内核空间,它们是内核对象。有了内核线程,每个用户线程被映射或绑定到一个内核线程。用户线程在其生命期内都会绑定到该内核线程。

进程和线程的概念关系:进程作为资源拥有的基本单位,线程作为cpu调度分配的基本单位基本不拥有资源,只拥有一些必不可少的资源,如:程序计数器,局部变量,少数状态参数,返回地址以及堆栈等,这些都是线程私有的,不共享。线程是进程的一部分,描述指令流执行状态,它是进程中指令执行流的最小单元,是CPU调度的基本单位。线程在进程⾥⾯专⻔负责执⾏指令,指令是从进程.text段映射⾥读出来的,然后在进程⾥堆栈上操作⼀些数据,线程处理数据的资源边界是在进程内的。线程的切换是cpu在不同任务的任务上下文间切换。操作系统对线程进行调度(时钟中断),引导cpu不间断的执行不同任务的指令,以达到在不同任务间切换的效果。进程是资源分配的基本单位,线程是运行调度的基本单位。进程和线程的分离是对任务抽象的更加成熟的结果,是对进程内部并发需求的实现。线程的切换只是指令的切换,而进程的切换还包括资源区的切换。

进程和线程在内核区别: 对于 Linux 来讲,所有的线程都当作进程来实现,因为没有单独为线程定义特定的调度算法,也没有单独为线程定义特定的数据结构(所有的线程或进程的核心数据结构都是 task_struct)。把内核线程当成内核中的一个进程去理解。

对于一个进程,相当于是它含有一个线程,就是它自身。对于多线程来说,原本的进程称为主线程,它们在一起组成一个线程组。

进程拥有自己的地址空间,所以每个进程都有自己的页表。而线程却没有,只能和其它线程共享某一个地址空间和同一份页表。这个区别的 根本原因 是,在进程/线程创建时,因是否拷贝当前进程的地址空间还是共享当前进程的地址空间,而使得指定的参数不同而导致的。具体地说,进程和线程的创建都是执行 clone 系统调用进行的。而 clone 系统调用会执行 do_fork 内核函数,而它则又会调用 copy_process 内核函数来完成。主要包括如下操作:在调用 copy_process 的过程中,会创建并拷贝当前进程的 task_stuct,同时还会创建属于子进程的 thread_info 结构以及内核栈。此后,会为创建好的 task_stuct 指定一个新的 pid(在 task_struct 结构体中)。然后根据传递给 clone 的参数标志,来选择拷贝还是共享打开的文件,文件系统信息,信号处理函数,进程地址空间等。这就是进程和线程不一样地方的本质所在。
在这里插入图片描述

多核: 内核空间实现为每个内核支持线程设置了一个线程控制快,内核是根据该控制快而感知某个线程是否存在,并加以控制。设置了内核支持线程的系统,其调度是以内核线程为单位进行的。如果是用户级线程,操作系统看不到,就没法分配资源,无法发挥多核的价值。其中核心级线程不是两个栈,而是两套栈。因为用户级线程只会在用户栈里跑,但是使用核心级线程的程序,既需要在用户层跑,也需要在内核里跑。用户级线程的切换,是两个栈之间的切换。核心级线程的切换不是两个栈,而是两套栈。因为核心级线程必须到内核态,在用户态使用用户栈,在内核态不也得调用函数,也得使用一个栈,既要在用户态又得在内核态跑,一个核心级线程要两个栈组成的一套栈。

cpu调度: 对于 Linux 来讲,所有的线程都当作进程来实现,因为没有单独为线程定义特定的调度算法,也没有单独为线程定义特定的数据结构(所有的线程或进程的核心数据结构都是 task_struct)。Linux系统 对线程和进程并不特别区分。线程仅仅被视为一个与其他线程共享某些资源的进程。每个线程都拥有唯一自己task_struct。内核调度的对象是根据task_struct结构体。进程之间不共享地址空间,而线程与创建它的进程是共享地址空间的。

内核线程池Pool模型:当从⽤户态代码进⼊系统态代码调⽤的时候会涉及到上下⽂切换,这是要付出⼀定的代价的。很显然系统线程去创建去调度是要付出这些代价的,所以很多时候系统线程成本会⾮常的⾼,当我们频繁的去创建系统线程销掉系统线程这种代价实在太⼤了。在这基础上往往会实现这样的模型。在⽤户态抽象很多个执⾏单位,我们把这些⽤户态线程映射到少量的系统线程上⾯去,然后建⽴类似于 Pool 这样的⼀个概念可以复⽤的。内核态的系统线程专⻔负责执⾏,⽽⽤户态的线程负责存储状态,⽐如说线程栈状态,所有线程执⾏的线程栈是⽤来保存当时执⾏线程状态的,还包含寄存器相关的信息、局部变量,这样的好处是我们把建成Pool以后就不需要频繁的创建系统线程,只需要⽤户态去创建各种各样我们所需的这种抽象的专⻔⽤来存储状态的这种⽤户态线程,我们可以创建很多个,当我们创建好当需要执⾏的时候,把它绑定到⼀个系统线程上⾯去,然后去执⾏执⾏完了以后可以把这个系统线程释放掉,系统线程回到Pool⾥⾯只需要把这个状态杀掉,我们不需要消灭这个系统线程。

PCB与TCB: 在早期的操作系统实现中并不存在线程的概念,与之对应的是进程。也就是说当时的操作系统粗暴的将一个任务整体的描述为一条执行线,操作系统以任务为单位进行资源分配,并以任务为单位进行运行调度。这样分配的结果便是,每次进行运行调度即进程切换时,也要同时切换资源的权限,造成非常大的开销。于是后来的操作系统将资源分配单位和任务调度单位分离开来,也就是我们现在所说的进程与线程。以进程为单位分配系统资源,以线程为单位进行任务调度。PCB中包含了资源分配信息和运行调度信息。其中:进程状态、CPU排班法为进行运行调度时的依据,CPU寄存器、程序计数器为运行调度时保存和恢复现场的依据,存储管理器、会计信息、输入输出状态则是对进程拥有的资源的描述。对于TCB来说,不同操作系统有着不同的实现,但大致都是仅包含了运行调度时所需的信息,如线程状态、调度算法、CPU寄存器、PC计数器等。**Linux的进程控制块为一个由结构task_struct所定义的数据结构可以理解为PCB,而没有TCB的概念。**在linux中,线程和进程共用了一种数据结构(task_struct)。也就是说,linux并没有为线程设计另外的数据结构。linux中的线程是由进程模拟的。所以,linux中没有真正意义上的线程,相当于“假”的线程。注:windows操作系统中,线程就是真正意义上的线程。每一个线程都有一个”tcb”,每一个进程则都有一个”pcb”,两者各有自己的数据结构来表示。

进程和线程切换的区别: 线程的切换只是指令的切换,而进程的切换还包括资源区的切换。进程切换:涉及PCB的修改。当运行的进程1要切换到进程2时,将当前CPU的状态用于更新进程1的PCB,即记录进程1运行的上下文。然后将进程2的PCB读入CPU,执行进程2。每个线程都需要创建一个自己的栈。在切换线程的时候需要切换栈。这就需要一个数据结构TCB(线程控制块)来存储栈的指针;每一个线程都有一个TCB,线程切换的时候实际上切换的是一个可以称之为线程控制块的结构TCB。里面保存所有将来用于恢复线程环境所必须的信息,包括所有的寄存器值,线程的状态等等
在这里插入图片描述

  • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
  • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据

进程切换和系统调用的区别:

  • 系统调用是从用户态到内核态的转变,需要通过系统调用来完成。比如,当我们查看文件内容时,就需要多次系统调用来完成:首先调用 open() 打开文件,然后调用 read() 读取文件内容,并调用 write() 将内容写到标准输出,最后再调用 close() 关闭文件。
    在这个过程中就发生了 CPU 上下文切换,整个过程是这样的:
    系统调用的切换流程:系统调用触发中断,进入中断处理程序。若中断时,进程在执行用户态的代码,该中断会引起CPU特权级从ring3级到ring0级的切换,CPU会进行堆栈的切换,CPU会从当前任务的TSS中取到内核栈的段选择符和偏移值;CPU首先会上下文保存到内核栈,把原用户态的堆栈指针ss和esp压入内核态堆栈,随后把标志寄存器eflags的内容和此次中断的返回位置cs,eip压入内核态堆栈。当中断处理函数结束后,将恢复内核栈中的数据,并继续处理被中断的进程。每个系统调用内核要进行许多工作,耗时开销大。
    所以,一次系统调用的过程,其实是发生了两次 CPU 上下文切换。(用户态-内核态-用户态)。系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。这跟我们通常所说的进程上下文切换是不一样的:进程上下文切换,是指从一个进程切换到另一个进程运行;而系统调用过程中一直是同一个进程在运行。所以,系统调用过程通常称为特权模式切换,而不是上下文切换。系统调用属于同进程内的 CPU 上下文切换。
  • 进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。简单来说是进程切换主要包括两部分工作: 切换全局页目录以加载一个新的地址空间、切换内核栈和硬件上下文,其中硬件上下文包括了内核执行新进程需要的全部信息,如CPU相关寄存器。

栈空间分配: 进程/线程的创建主要是由 clone 系统调用完成的。而 clone 系统调用的参数中有一个 void *child_stack,它就是用来指向所创建的进程/线程的堆栈指针。而在该进程/线程在用户态下是通过调用 pthread_create 库函数而陷入内核的。对于pthread_create 函数,它则会调用一个名为pthread_allocate_stack 的函数,专门用来为所创建的线程分配的栈空间(通过 mmap 系统调用)。然后再将这个栈空间的地址传递给 clone 系统调用。这也是为什么线程组中的每个线程都有自己的栈空间。

Linux中对进程和内核线程创建的几个系统调用发现, 创建时最终都会调用do_fork()函数,不同之处是传入的参数不同(clone_flags),最终结果就是进程有独立的地址空间和栈 ,而用户线程 可以自己制定用户栈,地址空间和父进程共享,内核线程则 只有和内核共享的一个栈,同一个地址空间。 不管是进程还是内核线程,do_fork最终会创建一个task_struct结构。

go协程调度器: Go调度器是Go runtime的一部分,且Go runtime已经内置于Go程序中。这意味着Go调度器运行在用户态,而不是内核态。当前Go调度器的实现不是抢占式,而是协作式的。协作式调度意味着调度器在做调度决策时,需要明确定义的用户态事件,这些事件发生在代码中的安全点。Go协作式调度器的出色之处就在于它看起来是抢占式的:你无法预测Go调度器将要执行的操作。这是因为协作调度器的决策权并不在开发者手中,而是在Go runtime上。把Go调度器看作是抢占式的,因为它的调度是不确定的,这是很重要的,并且这没什么大不了的。每一个Go程序都自带一个runtime,负责与OS进行交互,⽤户态只需要创建像⼤量的并发任务,中间通过调度器来实现这两个用户和内核上的绑定。

猜你喜欢

转载自blog.csdn.net/u014618114/article/details/107574425