加入收藏 | 设为首页 | 会员中心 | 我要投稿 我爱制作网_池州站长网 (https://www.0566zz.com/)- 科技、建站、经验、云计算、5G、大数据,站长网!
当前位置: 首页 > 服务器 > 搭建环境 > Unix > 正文

6.进程与线程

发布时间:2022-10-14 11:02:39 所属栏目:Unix 来源:
导读:  简而言之,进程是系统进行资源调度和分配的的基本单位,线程是CPU调度和分派的基本单位

  进程

  现代操作系统需要运行各种各样的程序,为了管理这些程序的运行,操作系统提出了进程(process) 的抽象
  简而言之,进程是系统进行资源调度和分配的的基本单位,线程是CPU调度和分派的基本单位
 
  进程
 
  现代操作系统需要运行各种各样的程序,为了管理这些程序的运行,操作系统提出了进程(process) 的抽象:每个进程都对应于一个运行中的程序。有了进程的抽象,应用程序在运行时仿佛独占了整个CPU而不用考虑何时需要把CPU让给其他应用程序。同时进程的管理、CPU资源的分配等任务也交给操作系统。
 
  在内核中,每个进程都通过一个数据结构来保存它的相关状态,如它的进程标识符( Process IDentifier, PID)进程状态、虚拟内存状态、打开的文件等。这个数据结构称为进程控制块( Process Control Block, PCB)。在不同操作系统中进程控制块包含的内容可能有所不同,例如Linux中,PCB对应的数据结构为task_ struct ,该结构体中有5个重要的成员:信号,文件系统,文件、内存和上下文。
 
  进程状态
 
  单个task_struct的状态有6个:
 
  进程管理
 
  在Linux中,由于进程都是通过fork创建的,操作系统会以fork作为线索记录进程之间的关系。每个进程的task_ struct都会记录自己的父进程和子进程,进程之间因此构成了进程树结构。内核正是通过这种进程树结构来对进程进行管理的。处于进程树根部的是init进程,它是操作系统创建的第一个进程,之后所有的进程都是由它直接或者间接创建出来的。
 
  为了方便shell环境中的进行进程管理,内核还定义了可以由多个进程组合而成的“小集体”。
 
  进程切换
 
  为了使多个进程能够同时执行,操作系统进一步提出了上下文切换( context switch)机制,通过保存和恢复进程在运行过程中的上下文,使进程可以暂停、切换和恢复,从而实现了CPU资源的共享。同时,利用前文提到的虚拟内存机制,操作系统为每个进程提供了独立的虚拟地址空间,使多个进程能够安全且高效地共享物理内存资源。
 
  线程
 
  在早期的操作系统中,进程就是操作系统用来管理运行程序的最小单位。但是,随着硬件技术的发展,计算机拥有了更多的CPU核心,程序的可并行度提高,进程这一抽象开始显得过于笨重。第一,创建进程的开销较大,需要完成创建独立的地址空间、载入数据和代码段、初始化堆等步骤。即使使用fork接口创建进程,也需要对父进程的状态进行大量拷贝。第二,由于进程拥有独立的虚拟地址空间,在进程间进行数据共享和同步比较麻烦,一般只能基于共享虚拟内存页(粒度较粗)或者基于进程间通信(开销较高)。因此,操作系统的设计人员提出在进程内部添加可独立执行的单元,它们共享进程的地址空间,但又各自保存运行时所需的上下文,即线程。之后,线程取代进程,成为操作系统调度和管押程序的最小单位,线程也继承了操作系统为进程定义的部分概念(如状态和上下文切换)。
 
  多线程的地址空间布局
 
  由于每个线程的执行相对独立,进程为每个线程都准备了不同的栈,供它们存放临时数据。在内核中,每个线程也有对应的内核栈。当线程切换到内核中执行时,它的栈指针就会切换到对应的内核栈。
 
  进程除栈以外的其他区域由该进程的所有线程共享,包括堆、数据段、代码段等。当同一个进程的多个线程需要动态分配更多内存时(在C语言中可通过调用malloc函数实现),它们的内存分配操作都是在同一个堆上完成的。因此malloc的实现需要使用同步原语,使每个线程能正确地获取到可用的内存空间。
 
  unix线程切换_线程池与线程_线程 线程池
 
  多线程模型
 
  根据线程是由用户态应用还是由内核创建与管理,可将线程分为两类:用户态线程( user-level thread) 与内核态线程( kernel-level thread)。 其中,内核态线程由内核创建,受操作系统调度器直接管理,而用户态线程则是应用自己创建的。与内核态线程相比,用户态线程更加轻量级,创建开销更小,但功能也较为受限,与内核态相关的操作(如系统调用)需要内核态线程协助才能完成。
 
  为了实现用户态线程与内核态线程的协作,操作系统会建立两类线程之间的关系,这种关系称为多线程模型( multithreading model)。一般来说,多线程模型主要有三种:
 
  线程本地存储
 
  与进程类似,线程也有自己的线程控制块( Thread Control Block,TCB), 用于保存与自身相关的信息。在目前主流的一对一线程模型中,内核态线程和用户态线程会各自保存自己的TCB。其中,内核态的TCB结构与前面介绍的PCB相似,会存储线程的运行状态、内存映射、标识符等信息。而用户态TCB的结构则主要由线程库决定。例如,对于Linux平台上使用pthread线程库的应用来说,pthread结构体就是用户态的TCB。用户态的TCB可以认为是内核态的扩展,可以用来存储更多与用户态相关的信息,其中一项重要的功能就是线程本地存储(Thread Local Storage, TLS)。
 
  在多线程编程中,可以通过TLS实现“一个名字,多份拷贝(与线程数量相同)”的全局变量。这样,当不同的线程在使用该变量时,虽然从代码层次看访问的是同一个变量,但实际上访问的是该变量的不同拷贝,于是可以很方便地实现线程内部(而不是进程)的全局变量。例如,一个多线程的应用程序可以通过__thread int count;为每个线程定义变量count。当某个线程对count赋值时,只会修改自己的拷贝,并不会对其他线程产生影响。在运行过程中,线程库会为每个线程创建结构完全相同的TLS,保存在内存的不同地址上。在每个线程的TLS中,count都处于相同的位置,即每份count的拷贝相对于TLS起始位置的偏移量都相等
 
  线程实现
 
  在早期的Linux中,进程还是调度的基本单位,操作系统并没有对线程提供支持。为了提供POSIX线程支持,以Xavier Leroy为首的研究人员开发了LinuxThreads项目,使用当时已有的clone系统调用实现线程,即不管是进程还是线程,都用task_struct表示,只是属于同一个进程的线程共享相同的资源(指针指向相同地方)。多进程就是许多个资源不共享的task_struct在运行,而多线程其实就是多个资源共享的task_struct在运行。
 
  unix线程切换_线程池与线程_线程 线程池
 
  但是,由于clone本身是用来创建进程的,因此使用LinuxThreads创建出来的线程有些“不伦不类”。比如,有的线程理论上应当属于同一进程,但clone会为它们创建不同的PID。随着应用对线程这一轻量级抽象的需求越发强烈,Linux开始在内核中为POSIX线程提供支持。由Red Hat公司开发的Native POsIX Thread Library (NPTL)修改了Linux内核,使操作系统能够正确地创建和管理线程。NPTL在Linux 2.6版本进入内核主线,并一直沿用至今。
 
  多线程 VS 多进程纤程
 
  由于上下文切换需要进入内核,开销较大,后续又引入了纤程(fiber) 这一抽象,允许上下文直接在用户态交换。但是纤程也有着致命缺点,其无法很好利用多处理器的优势,即从内核角度看,多个纤程其实就是一个线程,因此内核只会把他们放在一个核上运行,而不会分散到多个核去同时运行。
 
  线程切换
 
  这里以xv6的切换过程为例进行介绍,linux的调度过程与此总体思路是一致的。
 
  xv6的线程模型为1对1模型,即每个用户线程映射一个单独的内核线程。用户线程与其对应的内核线程是共用同一个结构体的(proc.h/proc),只是从用户态切换进入内核态时,该结构体中的某些成员变量值会发生变化,而恢复成用户态时其成员变量又会恢复回去。由于陷入内核时线程们会切换为同一个内核页表,所以所有的内核线程都共享了内核内存。不过与linux不同的是unix线程切换,xv6中每个进程仅仅包含一个用户态线程。
 
  每个CPU里还有一个特殊的内核线程,即调度器线程(scheduler thread)。该线程在本节内容中很重要。
 
  切换过程
 
  用户态线程是无法直接完成切换的,切换过程中必须得有内核的参与才行,即大致过程为:用户态线程A->线程A陷入内核态->内核线程切换为调度器线程->调度器线程选好目标线程B->切换为线程B的内核线程->内核线程B返回用户态。
 
  首先A线程进入到内核中,保存用户态的上下文。然后A线程对应的内核线程需要获取自己的线程锁:马上就会将该线程的状态改为 RUNNABLE,加锁可以防止其他CPU核的调度器线程看到该线程的状态为RUNABLE并尝试运行它(proc.c/yield)。同时系统需要检查该线程是否持有其他的锁,xv6不允许线程在切换时持有任何除了本线程的线程锁以外的锁,这是为了避免死锁产生:在一个单核的机器上可能会发生如下场景, P1持有L锁时切换为P2,P2也尝试获取L1锁。(proc.c/sched)。
 
  切换函数的切换过程主要就是巧用了ra寄存器,该寄存器元本用于函数调用,存储当本次函数调用结束后,程序会跳转回去继续运行的地址,即程序在运行至ra寄存器存储的地址的上一条指令时,程序调用了某个函数。该函数运行结束后,程序需要返回原来的地方继续运行。代表CPU的结构体中会存储调度函数的地址,这里会把该地址存入 ra寄存器里(swtch.S/swtch),然后切换函数里会保存当前线程的寄存器到某个属于该线程到地方(tramframe结构体中的context成员变量),接着恢复调度器线程的寄存器值到寄存器中(也从代表CPU的结构体中获取了)。线程切换函数和普通的函数调用没有区别,因此这里会跳转到ra寄存器中的地址继续运行,也就是我们刚才填入的调度器线程运行地址。
 
  切换函数中只保存并恢复了14个寄存器,这是因为切换函数是按照一个普通函数来调用的。Caller Saved Register会被C编译器保存在当前的栈上,当函数返回时,这些寄存器会自动恢复。所以我们在这里只需要处理C编译器不会保存的寄存器。
 
  调度
 
  调度器需要抹去cpu对于原线程的记录,释放对于原线程的线程锁,然后找到下一个运行的线程。找到目标线程后,调度器将其状态改为 RUNNING ,在cpu中记录该线程,然后调用切换函数保存调度器线程的寄存器,恢复目标线程的内核线程(proc.c/sheduler)。当我们要恢复一个线程的运行时,也需要获取该线程的锁(这个过程会关闭中断)。这是因为如果我们刚把目标线程的状态修改为 RUNNING但是其上下文未完全恢复时就发生了中断,那么系统会将这个进程切换走。我们希望恢复一个线程的过程也具有原子性。
 
  xv6调度器直接调度在线程数组里第一个遇见的 runnable线程。但在实际中这里涉及调度算法选择。以linux为例,它有两种线程,一种是高实时线程,一种是普通线程。linux中的线程有139个线程优先级,0-99是高实时,100以后是普通线程,优先级的数值越低,优先级越高。
 
  当系统中只有普通线程时,则采用cfs算法,其核心数据结构为红黑树,节点值代表运行到目前为止的virtual runtime,而vruntime = pruntime/权重。权重由nice值决定(优先级为100线程nice值-20,139为19),nice值越小,权重越小。而cfs算法则每次调度virtual runtime最小的线程。(一般随便写一个程序跑起来上nice值为0的普通线程)插曲:内核如何知道当前cpu编号
 
  XV6为每个CPU维护一个cpu结构体(kernel/proc.h),并在里面存储当前运行的线程,调度器线程的上下文以及自旋锁的嵌套层数。RISC-V会赋予每个CPU一个hartid,并把其值存储在CPU的tp寄存器中。某些场景下,我们需要通过cpu编号去索引cpu结构体数组,找到当前CPU所对应的结构体并从中读取信息(例如查看当前哪个线程跑在此cpu上)
 
  然而保证CPU的tp总是能正确保存CPU的hartid是有一点复杂的,因为RISC-V不允许在管理员模式下直接读取 hartid(仅机器模式可以获得)。所以我们只在机器模式下获取 hartid并存入寄存器tp。当CPU准备运行一个用户程序时,内核会在恢复用户态运行前将tp寄存器的值保存至用户线程结构体某个位置,(即前面提到的trapframe)。用户态运行时,用户是无法修改trapframe中保存的 hartid的值的,所以在用户线程下一次进入内核时,可以从中取出 hartid存入寄存器tp。这样类似接力棒的形式,保证了hartid的正确性.
 

(编辑:我爱制作网_池州站长网)

【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!

    推荐文章