Linux进程管理与调度

目录

一、进程描述符     

二、进程切换

三、进程创建与终止

四、用户线程,内核线程和轻量级进程

 五、三种线程模型和Linux线程实现

六、进程与线程的区别

七、实时线程与实时操作系统

八、进程(线程)调度


一、进程描述符     

     进程描述符保存了与进程相关的一切信息,其数据类型为task_struct,Linux用双向链表和类似HashMap的散列表来保存所有的进程描述符,前者用于调度按照进程优先级快速选择一个可执行的进程,后者用于按照进程pid或者tgid快速查找一个进程或者进程组,给其发送信号等操作。进程描述符中包含很多的字段,重点关注一下几个:

(1) 、state字段

      表示进程的状态,共有6种:

      1、TASK_RUNNING,表示进程要么正在执行,要么准备执行,等待cpu时间片的调度

      2、TASK_INTERRUPTIBLE,表示进程被挂起(睡眠),直到某个条件成立触发CPU中断或者发送信号唤醒该进程,将其状态改成TASK_RUNNING,比如某个TASK_RUNNING的进程需要读取文件,发起系统调用从用户态进入内核态,内核将其状态改成TASK_INTERRUPTIBLE,然后调用磁盘驱动程序读取文件,CPU执行其他任务;待磁盘读取文件完毕,磁盘发送CPU中断信号,CPU将读取的文件内容传给进程,进程由内核态切换到用户态,处理文件内容。一般情况下,进程列表中的绝大多数进程都处于TASK_INTERRUPTIBLE状态,除非机器的负载很高。

      3、TASK_UNINTERRUPTIBLE,与TASK_INTERRUPTIBLE类似,区别是不能被外部信号唤醒,只能通过CPU中断唤醒。该状态总是非常短暂的,通过ps命令基本上不可能捕捉,主要用于避免内核某些处理过程被中断,如进程与设备交互的过程,中断会造成设备陷入不可控的状态。

     4、TASK_STOPPED,表示进程的执行已停止,向进程发送一个SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU信号,它就会因响应该信号而进入TASK_STOPPED状态,向进程发送一个向进程SIGCONT信号,可以让其恢复到TASK_RUNNING状态。

    5、TASK_TRACED,表示进程的执行已停止,等待跟踪它的进程对它进行操作,比如在gdb中对被跟踪的进程下一个断点,进程在断点处停下来的时候就处于TASK_TRACED状态。处于TASK_TRACED状态的进程不能响应SIGCONT信号而被唤醒,只能等到调试进程通过ptrace系统调用执行PTRACE_CONT、PTRACE_DETACH等操作或调试进程退出,被调试的进程才能恢复TASK_RUNNING状态。

    6、EXIT_ZOMBIE,表示进程已终止,正等待其父进程执行wait类系统调用收集关于它的一些统计信息如退出码,内核此时无法回收该进程的进程描述符。如果父进程未执行wait类系统调用并退出了,子进程会转交给上一级的父进程,直到最终的init进程,由上一级父进程执行wait类系统调用。

  7、EXIT_DEAD,表示进程已终止,父进程已经执行wait类系统调用,进程即将被内核删除,该状态非常短暂。

      Linux Kernel 2.6.25 引入了一种新的进程睡眠状态,TASK_KILLABLE:当进程处于这种可以终止的新睡眠状态中,它的运行原理类似于 TASK_UNINTERRUPTIBLE,只不过可以响应致命信号。     

(2)、pid和tgid字段

   pid标识一个唯一的进程,从0开始逐渐递增,到最大值后就开始利用空闲的未分配pid;tgid标识当前进程所属的进程组的id,在Linux系统中,该ID就是该进程组的领头进程(该组中的第一个轻量级进程)相同的PID。

(3)、stack字段

    该字段是一个指针变量,表示当前进程的thread_info的地址,thread_info和内核态堆栈紧挨着,存放在两个连续的页框中,通过thread_info中的进程描述符指针快速访问进程描述符,其结构如下图:

Linux进程管理与调度_第1张图片

图中,esp寄存器用来存放栈顶单元的地址。在80x86系统中,栈起始于顶端,并朝着这个内存区开始的方向增长。从用户态刚切换到内核态以后,进程的内核栈总是空的。因此,esp寄存器指向这个栈的顶端。一旦数据写入堆栈,esp的值就递减。为了快速获取当前CPU上运行进程的task_struct结构,内核提供了current宏, 该宏就是通过esp寄存器保存的栈顶地址快速获取对应的task_struct。

(4)、mm和active_mm字段

       mm标识进程所拥有的用户空间内存描述符,内核线程无的mm为NULL;active_mm指向进程运行时所使用的内存描述符, 对于普通进程而言,这两个指针变量的值相同。但是内核线程kernel thread是没有进程地址空间的,所以内核线程的tsk->mm域是空(NULL)。但是内核线程需要访问内核空间,因为所有进程的内核空间都一样,所以它的active_mm成员被初始化为前一个运行进程的mm值,借此访问内核空间。

(5)、thread 字段

        该字段是一个指针变量,数据结构为thread_struct,用于进程切换时保存除通用寄存器以外的寄存器的内容,用于恢复进程执行上下文使用,跟CPU架构强相关。通用寄存器的内容保存在内核堆栈中。

     参考:Linux进程状态解析 之 R、S、D、T、Z、X (主要有三个状态)

               TASK_KILLABLE:Linux 中的新进程状态

               Linux进程描述符task_struct结构体详解--Linux进程的管理与调度(一)

               Linux进程地址管理之mm_struct

               linux thread_info 与thread_struct

二、进程切换

     因为所有进程共享CPU寄存器,所以在恢复一个进程的执行前,内核必须确保每个寄存器装入了挂起进程时的值。进程恢复执行前必须装入寄存器的一组数据称为硬件上下文,是进程的可执行上下文的子集。

     早期Linux以硬件方式切换进程:x86架构下,每个进程有一个TSS(task state segment)任务状态段,用来存放硬件上下文,还有一个特殊的寄存器TR(Task Register),指向当前进程的TSS。当TR更改,指向新进程的TSS时,将会触发硬件保存cpu所有寄存器的值到当前进程的TSS中,然后从新进程的TSS中读出所有寄存器值,加载到cpu对应的寄存器中。整个过程中cpu寄存器的保存、加载,无需软件参与。该方式由如下缺点:

  • 每个进程都需要一个TSS,全局段描述符表(GDT)支持的分段数量有限,从而限制了进程的数量;
  • 部分寄存器值并不会更改,更新全部寄存器效率低,而且全部更新无法校验装入寄存器的内容,有安全风险
  • 不能兼容其他CPU架构,代码可移植性差

     后面Linux改成用一组mov指令来逐一把寄存器的内容保存到进程描述中的theard字段中,即软件切换。因为x86架构下必须给TR提供一个TSS,Linux为每个CPU都创建了一个TSS,让TR永远指向该TSS,即对CPU而言,永远只有一个进程在运行,从而规避了硬件切换方式,TSS对应的描述符为tss_sruct。Linux只用到了TSS中的esp0和iomap字段,esp0是内核态栈指针,iomap是IO许可权位图,每次用户态进程访问I/O端口时会根据iomap检查进程是否有访问指定端口的权利。软件切换的大致流程如下:

    1、当A进程切换至B进程时,A进程从用户态进入内核态,内核从tss_sruct->x86_tss.sp0读取内核栈顶地址,把ESP寄存器的用户栈顶地址保存到内核栈,重置ESP寄存器为内核栈顶地址,接着利用汇编指令保存寄存器的内容至内核栈和thread字段中;

   2、A进程的硬件上下文保存完毕后,内核从B进程的thread.sp读取内核栈顶地址并重置ESP寄存器,重置TSS段中的tss_sruct->x86_tss.sp0字段,即内核栈顶地址,将内核栈和thread字段中的寄存器内容逐一恢复至对应的寄存器

  3、B进程的硬件上下文恢复后,执行必要的内核操作后,从内核态切换至用户态,内核弹出内核栈中的用户栈顶地址并重置ESP寄存器,即切换至用户栈,恢复用户态代码执行。注意此时内核栈中的内容都已弹出,所以内核栈是空的,所以从用户态切换到内核态时内核栈总是空的。

     进程切换的核心逻辑通过switch_to(prev, next, last)宏实现,注意这是一个宏,不是函数,它的参数prev, next, last不是值拷贝,而是它的调用者context_switch()的局部变量,当内核堆栈切换时取值会跟着改变。最后一个参数last是一个输出参数,指定将prev变量写入到next变量对应进程的内核栈的什么变量中,context_switch()调用时采用switch_to(prev, next, prev)的方式,以A切换至B为例说明,切换前prev和next都是A进程内核栈的内容,这两个变量是context_switch传递进来的,prev指当前进程描述符即A,next是切换至下一个的进程即B,将prev放到寄存器eax中;切换至B进程后,此时prev和next都是B进程的内核栈的变量,此时的取值是上一次调度完成后保留在内核栈的值,可能指向任何进程描述符,此时将eax寄存器中A进程描述符的地址写入到B进程内核栈的prev变量,保证prev变量准确指向从哪个进程切换过来的,后续操作会利用该变量。

     参考:TSS详解 ——《x86汇编语言:从实模式到保护模式》读书笔记33

               【内核】进程切换 switch_to 与 __switch_to

               linux进程切换(linux3.4.5,x86)

三、进程创建与终止

      创建进程有三个函数,fork(),vfork()和clone(),fork创建的子进程地址空间与父进程独立,但是指向相同的物理页框,当父子进程任何一方想要修改其中的数据则将为子进程单独分配一个页框,并将对应的父进程的页复制到子进程新分配的页框中,即写时复制技术,并且内核确保子进程先于父进程被调度执行,从而避免父进程先执行修改数据造成不必要的页复制。vfork()创建的子进程共享父进程的地址空间,但是不共享打开文件表,信号处理程序表,根目录等其他进程资源,并且为了防止父进程重写子进程需要的数据,内核会阻塞父进程的执行,直到子进程执行完成。Linux中fork()和vfork()函数都是通过clone()函数实现的,区别在于传递的参数不同而已。Linux中的轻量级进程就是通常的线程,通过clone()函数创建,子进程与父进程共享一切进程资源。clone()函数通过do_fork()函数实现,do_fork()通过copy_process()函数完成进程描述符及其所需要的其他数据结构的创建,必要时从父进程拷贝对应数据结构的内容,并利用父进程的内核栈完成子进程内核栈的初始化。

    专门执行内核函数的进程称为内核线程,如kswapd进程执行内存回收,pdflush刷新缓冲区中的脏数据到磁盘中。有一个特殊的内核线程,进程0,又称idle进程,swapper进程,进程0是所有进程的祖先,是Linux内核初始化阶段创建的第一个进程。进程1时进程0创建的第一个普通进程,又称init进程,在系统关闭之前,该进程一直存活,用于创建和监控在操作系统外层执行的所有进程的活动。

    终止进程需要调用exit()函数,核心函数是do_exit(),该函数会回收目标进程的进程描述符,打开文件表等所有资源,并从内核数据结构中如进程链表删除对目标进程的引用。终止一个进程组调用exit_group(),核心函数是do_group_exit(),该函数向进程组的其他进程发送SIGKILL信号并调用do_exit() 杀死所有的线程。

    参考: 《深入理解Linux 内核》(第三版)

四、用户线程,内核线程和轻量级进程

      用户线程指的是完全建立在用户空间的线程库,用户线程的建立,同步,销毁,调度完全在用户空间完成,不需要内核的帮助。内核线程就是直接由操作系统内核(Kernel)支持的线程,建立,同步,销毁,调度都在内核空间完成,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核(Multi-Threads Kernel),两者区别如下:

  1. 内核支持线程是OS内核可感知的,而用户级线程是OS内核不可感知的,OS内核看到的只有进程
  2. 用户级线程的创建、撤消和调度不需要OS内核的支持,是在语言(如Java)这一级由应用程序处理的;而内核支持线程的创建、撤消和调度都需OS内核提供支持,而且与进程的创建、撤消和调度大体是相同的。
  3. 用户级线程执行系统调用指令时将导致其所属进程被中断,因为该线程所属的进程内只有该线程被OS内核执行,而内核支持线程执行系统调用指令时,只导致该线程被中断,因为该线程所属的进程内可能多个线程同时被OS执行
  4. 在只有用户级线程的系统内,CPU调度还是以进程为单位,处于运行状态的进程中的多个线程,由用户程序控制线程的轮换运行;在有内核支持线程的系统内,CPU调度则以线程为单位,由OS的线程调度程序负责线程的调度。
  5. 用户级线程的程序实体是运行在用户态下的程序,而内核支持线程的程序实体则是可以运行在任何状态下的程序。

      轻量级进程,其本质仍然是进程,与普通进程相比,LWP与其父进程共享所有(或大部分)逻辑地址空间和系统资源,因为同父进程资源共享,创建TWP所需的执行上下文即资源更少,所以称为轻量级进程;一个进程可以创建多个LWP,每个LWP有独立的进程标识符,并和创建LWP的进程有着父子关系;LWP由内核管理并像普通进程一样被调度。Linux内核在 2.0.x版本就已经实现了轻量进程,应用程序可以通过一个统一的clone()系统调用接口,用不同的参数指定创建轻量进程还是普通进程,通过参数决定子进程和父进程共享的资源种类和数量,这样就有了轻重之分。在内核中, clone()调用经过参数传递和解释后会调用do_fork(),这个核内函数同时也是fork()、vfork()系统调用的最终实现。注意Linux系统没有线程的概念,只有轻量级进程,windows有线程概念。

 五、三种线程模型和Linux线程实现

     第一种多对一模型,进程内的多线程调度由应用程序负责,进程调度时只执行进程内的某一个线程,即同一进程内的多个线程对应一个与该进程绑定的内核线程,最大的问题是线程如果阻塞了则该线程所属的进程也会被阻塞。

    第二种一对一模型,与多对一模型相比,最大的区别是进程内的每个线程都对应一个内核线程,应用程序内的线程与内核中的线程生命周期一致,不同进程的多个不同线程调度等同于进程调度,最大的问题是内核线程频繁的创建销毁会占用大量的有限的内核资源。

    第三种是多对多模型,将上述两种混合起来,同一进程内的多个线程对应多个内核线程,线程销毁,与之对应的内核线程不会销毁而是为其他的线程继续提供服务。

   目前Linux是基于轻量级进程实现的一对一模型,即一个线程实体对应一个核心轻量级进程,而线程之间的管理在核外函数库中实现,最为理想的多对多模型因为实现复杂而被抛弃。

    对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的。而在Solari平台中,由于操作系统的线程特性可以同时支持一对一(通过Bound Threads或Alternate Libthread实现)及一对多(通过LWP/Thread Based Synchronization实现)的线程模型,因此在Solaris版的JDK中也对应提供了两个平台专有的虚拟机参数:-XX:+UseLWPSynchronization(默认值)和-XX:+UseBoundThreads来明确指定虚拟机使用哪种线程模型。

详情参考: 线程的3种实现方式--内核级线程, 用户级线程和混合型线程

                   内核线程、轻量级进程、用户线程三种线程概念解惑(线程≠轻量级进程)

                   Linux 线程实现机制分析 Linux 线程模型的比较:LinuxThreads 和 NPTL

六、进程与线程的区别

    进程是操作系统分配资源的最小单元,是程序执行的一个实例;线程是操作系统调度的最小单元,代表进程的一个执行流,Linux中线程就是轻量级进程,两者区别如下:

  1. 对Linux,进程采用fork创建,线程采用clone创建,clone是轻量级的fork,clone和fork都是基于父进程
  2. 进程fork创建的子进程的逻辑流位置在fork返回的位置,线程clone创建的KSE的逻辑流位置在clone调用传入的方法位置,比如Java的Thread的run方法位置
  3. 进程拥有独立的虚拟内存地址空间和内核数据结构(页表,打开文件表等),当子进程修改了虚拟页之后,会通过写时拷贝创建真正的物理页。线程共享进程的虚拟地址空间和内核数据结构,共享同样的物理页
  4. 多个进程通信只能采用进程间通信的方式,比如信号,管道,而不能直接采用简单的共享内存方式,原因是每个进程维护独立的虚拟内存空间,所以每个进程的变量采用的虚拟地址是不同的。多个线程通信就很简单,直接采用共享内存的方式,因为不同线程共享一个虚拟内存地址空间,变量寻址采用同一个虚拟内存
  5. 进程上下文切换需要切换页表等重量级资源,线程上下文切换只需要切换寄存器等轻量级数据,从进程演化出线程,最主要的目的就是更好的支持SMP(对称多处理器系统)以及减小(进程/线程)上下文切换开销
  6. 进程的用户栈独享栈空间,线程的用户栈共享虚拟内存中的栈空间,没有进程高效
  7. 一个应用程序可以有多个进程,执行多个程序代码,多个线程只能执行一个程序代码,共享进程的代码段
  8.  进程采用父子结构,线程采用对等结构

       参考: 计算机底层知识拾遗(二)深入理解进程和线程

七、实时线程与实时操作系统

      实时操作系统(Real Time Operating System,简称RTOS)是指当外界事件或数据产生时,能够接受并以足够快的速度予以处理,其处理的结果又能在规定的时间之内来控制生产过程或对处理系统作出快速响应,并控制所有实时任务协调一致运行的操作系统。因而,提供及时响应和高可靠性是其主要特点。实时操作系统有硬实时和软实时之分,硬实时要求在规定的时间内必须完成操作,这是在操作系统设计时保证的,比如VxWorks ;软实时则只要按照任务的优先级,尽可能快地完成操作即可,比如Linux。

      分时操作系统(Time-sharing Operating System,简称TSOS)是指将系统CPU时间与内存空间按一定的时间分割(每个时间段称为时间片),轮流地切换给的程序使用的操作系统。由于时间间隔很短,每个用户的感觉就像他独占计算机一样。分时操作系统的特点是可有效增加资源的使用率。

     实时任务是指要求操作系统能够在规定的时间之内快速接受,处理并作出快速响应的任务,比如当车辆发生碰撞时要求安全气囊快速展开的任务,处理这类实时任务的线程(进程)就称为实时线程(进程)。与之相对的是分时任务,即对操作系统接收,处理任务并作出响应没有强制的时间要求,处理分时任务的线程(进程)就是分时线程(进程)。

     Linux同时支持实时进程和分时进程,默认参数下创建的都是分时进程,可以通过修改进程的调度策略等属性将其改成实时进程。Linux中实时进程和分时进程由不同的调度器调度,实时进程的调度器的优先级最高。Linux上JVM创建的线程默认是分时进程。

 详情参考: 什么是真正的实时操作系统

                    Linux操作系统实时性分析

八、进程(线程)调度

      进程通常可以分为IO密集型和CPU密集型两种,前者频繁使用IO设备,花费很多时间等待IO操作完成,后者需要大量的CPU时间片完成计算。另一种分类法把进程分成三种:

1、交互式进程,如命令行Shelll工具,文本编辑器等,这类进程经常与用户交互,花费很多时间等待键盘和鼠标操作。

2、批处理进程,如数据库搜索引擎,通常的业务应用程序,这类进程不需要与用户交互,经常在后台运行。

3、实时进程,如视频和音频应用程序,从物理传感器收集数据的程序,这类进程绝不会被优先级低的进程阻塞,要求在很短且比较稳定的时间范围内得到快速响应。

Linux可以通过调度算法识别实时进程,无法准确识别交互式进程和批处理进程,只能通过基于进程过去行为的启发式算法做推测判断。

      进程调度整体上可以分为两种:

1. 非抢占方式。采用这种调度方式,一旦把处理机分配给某进程后,便让该进程一直执行,直到该进程完成或发生某事件而被阻塞,才再把处理机分配给其他进程,决不允许某进程抢占已经分配出去的处理机。显然它难于满足紧急任务的要求,实时系统中不宜采用这种调度方式。

2. 抢占方式。允许调度程序根据某种原则,去停止某个正在执行的进程,将已分配给该进程的处理机,重新分配给另一进程。抢占的原则有:

- 时间片原则。各进程按时间片运行,当一个时间片用完后,便停止该进程的执行而重新进行调度,即CPU分时技术,依赖分时中断。时间片的长短对系统性能是很关键的,太短进程切换开销大,太长进程看起来不再是并发执行,降低系统的响应能力,Linux单凭经验选择尽可能长且响应时间良好的时间片

- 优先权原则。当一个进程到来时,如果其优先级比正在执行的进程的优先级高,便停止正在执行的进程,将处理机分配给优先级高的进程,使之执行。Linux中进程的优先级是动态的,调度程序跟踪进程的运行,并周期调整他们的优先级,以避免进程饥饿现象,提升系统吞吐量。

     对抢占式调度,常见的调度策略有三种:

- 优先级抢占式
    采用基于优先级的抢占式调度,系统中每个任务都有一个介于最高0到最低255之间的优先级。任一时刻,系统内核一旦发现一个优先级更高的任务转变为就绪态,内核就保存当前任务的上下文并把当前任务状态转换为阻塞态,同时切换到这个高优先级任务的上下文执行。
- 轮转调度算法
    采用轮转调度算法,系统让处于就绪态的优先级相同的一组任务依次轮流执行预先确定长度的时间片。这是一种处理机平均分配的方法。如果不使用轮转调度算法,优先级相同的一组任务中第一个获得处理机的任务将不会被阻塞而独占处理机,如果没有阻塞或其他情况发生,它不会放弃处理机的使用权。
- 抢占调度与轮转调度混合方式
    有时,基于优先级的抢占式调度可与轮转调度相结合。当优先级相同的一组任务依次轮流平均分配处理机时,若有高优先级的任务转变为就绪态则可抢占该组任务。直到再一次符合执行条件时,该组任务才可再次共享处理机。

     根据抢占式调度发生的位置可以分为两种:

  -用户抢占

      用户抢占是发生在用户空间的抢占现象,通常在从系统调用返回用户空间或者从中断(异常)处理程序返回用户空间时发生用户抢占,即上一个程序执行完成时执行用户抢占。

  -内核抢占

     内核抢占就是指一个在内核态运行的进程, 可能在执行内核函数期间,CPU被另一个进程抢占了。内核抢占主要是从实时系统中引入的, 在非实时系统中的确也能提高系统的响应速度,但是内核不能在任意点被中断,比如执行系统调度的时候就不能允许中断所以关闭了内核抢占,幸运的是, 大多数不能中断的点已经被底层硬件驱动实现标识出来了。linux内核抢占是在Linux2.5.4版本发布时加入的, 尽管使内核可抢占需要的改动特别少, 但是该机制不像抢占用户空间进程那样容易实现。

       linux内核目前实现了6中调度策略(即调度算法), 用于对不同类型的进程进行调度, 或者支持某些特殊的功能

  1. SCHED_NORMAL和SCHED_BATCH调度普通的非实时进程
  2. SCHED_FIFO和SCHED_RR和SCHED_DEADLINE则采用不同的调度策略调度实时进程
  3. SCHED_IDLE则在系统空闲时调用idle进程.

      而依据其调度策略的不同实现了5个调度器类, 一个调度器类可以用一种或者多种调度策略调度某一类进程, 也可以用于特殊情况或者调度特殊功能的进程.其所属进程的优先级顺序为:

     stop_sched_class -> dl_sched_class -> rt_sched_class -> fair_sched_class -> idle_sched_class

     每个调度类都有自身的优先级,Linux调度管理基础代码会遍历在内核中注册了的调度类,选择高优先级的调度类,然后让此调度类按照自己的调度算法选择下一个执行的线程。内核中区分普通线程与实时线程是根据线程的优先级,实时线程拥有实时优先级(real-time priority),默认取值为0~99,数值越高优先级越高,而普通线程只具有nice值,nice值映射到用户层的取值范围为-20~+19,数值越高优先级越低,默认初始值为0 ,子线程会继承父线程的优先级。对于实时线程,Linux系统会尽量使其调度延时在一个时间期限内,但是不能保证总是如此,不过正常情况下已经可以满足比较严格的时间要求了。

     Linux上创建的java线程采用的是默认的SCHED_NORMAL策略。

详情参考:Linux用户抢占和内核抢占详解(概念, 实现和触发时机)--Linux进程的管理与调度(二十)

                   Linux系统调度简介

                  linux内核调度详解

                  

      

     

 

 

 

 

     

你可能感兴趣的:(Hotspot和Linux内核)