Linux操作系统底层原理:进程创建、等待与终止
在 Linux 的世界里,进程就像是一个个充满活力的 “小生命”,它们是程序的执行实例,承载着程序在系统中的运行使命。简单来说,当你在 Linux 系统中启动一个程序时,系统就会为这个程序创建一个进程,这个进程包含了程序运行所需的各种资源和环境信息,如内存空间、文件描述符、CPU 时间等。可以说,进程是程序在运行时的具体体现,是操作系统进行资源分配和调度的基本单位。
每个进程都有自己的 “身份标识”,也就是进程 ID(PID),系统通过 PID 来唯一地识别和管理进程,就像每个人都有一个独一无二的身份证号码一样 。同时,进程还有自己的内存空间,包括代码、数据和堆栈等。通过这些内存空间,进程可以在其生命周期内存储状态和数据,并与其他进程进行通信。 进程还有不同的状态,如运行、阻塞、就绪等,这些状态反映了进程当前的执行情况和资源需求。打个比方,运行状态的进程就像是正在赛道上全力奔跑的运动员;阻塞状态的进程则像是在等待某个条件满足(比如等待数据读取完成)而暂时停下脚步的运动员;就绪状态的进程就像是已经做好起跑准备,等待裁判发令的运动员 。
一、Linux进程是什么?
进程(Process)是指计算机中已运行的程序,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。进程是程序真正运行的实例,若干进程可能与同一个程序相关,且每个进程皆可以同步或异步的方式独立运行。
狭义定义:进程是正在运行的程序的实例。广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。
1.1描述进程PCB
进程:资源的封装单位,linux用一个PCB来描述进程,即task_struct, 其包含mm,fs,files,signal…
(1)root目录,是一个进程概念,不是系统概念将分区/dev/sda5挂载到/mnt/a,调用chroot,改变root目录,当前进程下的文件b.txt即位于当前进程的根目录。
(2)fd也是进程级概念总用量 0
Linux总的PID是有限的,用完PID
每个用户的PID也是有限的
ulimit -u 最大进程数ulimit –a1.2 task_ struct内容分类
在进程执行时,任意给定一个时间,进程都可以唯一的被表征为以下元素:
标示符: 描述本进程的唯一标示符,⽤用来区别其他进程。状态: 任务状态,退出代码,退出信号等。优先级: 相对于其他进程的优先级。程序计数器: 程序中即将被执行的下一条指令的地址。内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针上下文数据: 进程执行时处理器的寄存器中的数据I/O状态信息: 包括显⽰示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。1.3Linux进程的组织方式
linux里的多个进程,其实就是管理多个task_struct,那他们是怎么组织联系的呢?
组织task_struct的数据结构:
a.链表,遍历进程b.树:方便查找父子相关进程c.哈希表:用于快速查找用三种数据结构来管理task_struct,以空间换时间。父进程监控子进程,linux总是白发人送黑发人。父进程通过wait,读取task_struct的退出码,得知进程死亡原因。并且清理子进程尸体。
Android/或者服务器,都会用由父进程监控子进程状态,适时重启等;
1.4进程的状态和转换
(1)五种状态进程在其生命周期内,由于系统中各进程之间的相互制约关系及系统的运行环境的变化,使得进程的状态也在不断地发生变化(一个进程会经历若干种不同状态)。通常进程有以下五种状态,前三种是进程的基本状态。
运行状态:进程正在处理机上运行。在单处理机环境下,每一时刻最多只有一个进程处于运行状态。就绪状态:进程已处于准备运行的状态,即进程获得了除处理机之外的一切所需资源,一旦得到处理机即可运行。阻塞状态,又称等待状态:进程正在等待某一事件而暂停运行,如等待某资源为可用(不包括处理机)或等待输入/输出完成。即使处理机空闲,该进程也不能运行。创建状态:进程正在被创建,尚未转到就绪状态。创建进程通常需要多个步骤:首先申请一个空白的PCB,并向PCB中填写一些控制和管理进程的信息;然后由系统为该进程分 配运行时所必需的资源;最后把该进程转入到就绪状态。结束状态:进程正从系统中消失,这可能是进程正常结束或其他原因中断退出运行。当进程需要结束运行时,系统首先必须置该进程为结束状态,然后再进一步处理资源释放和 回收等工作。注意区别就绪状态和等待状态:就绪状态是指进程仅缺少处理机,只要获得处理机资源就立即执行;而等待状态是指进程需要其他资源(除了处理机)或等待某一事件。之所以把处理机和其他资源划分开,是因为在分时系统的时间片轮转机制中,每个进程分到的时间片是若干毫秒。
也就是说,进程得到处理机的时间很短且非常频繁,进程在运行过程中实际上是频繁地转换到就绪状态的;而其他资源(如外设)的使用和分配或者某一事件的发生(如I/O操作的完成)对应的时间相对来说很长,进程转换到等待状态的次数也相对较少。这样来看,就绪状态和等待状态是进程生命周期中两个完全不同的状态,很显然需要加以区分。
(2)状态转换就绪状态 -> 运行状态:处于就绪状态的进程被调度后,获得处理机资源(分派处理机时间片),于是进程由就绪状态转换为运行状态。运行状态 -> 就绪状态:处于运行状态的进程在时间片用完后,不得不让出处理机,从而进程由运行状态转换为就绪状态。此外,在可剥夺的操作系统中,当有更高优先级的进程就 、 绪时,调度程度将正执行的进程转换为就绪状态,让更高优先级的进程执行。运行状态 -> 阻塞状态:当进程请求某一资源(如外设)的使用和分配或等待某一事件的发生(如I/O操作的完成)时,它就从运行状态转换为阻塞状态。进程以系统调用的形式请求操作系统提供服务,这是一种特殊的、由运行用户态程序调用操作系统内核过程的形式。阻塞状态 -> 就绪状态:当进程等待的事件到来时 ,如I/O操作结束或中断结束时,中断处理程序必须把相应进程的状态由阻塞状态转换为就绪状态。二、Linux进程的创建
在 Linux 中,有多种方式可以创建进程,其中最常见的两种方式是:通过运行可执行程序来创建进程,以及使用系统调用接口来创建进程 。当我们在命令行中输入一个可执行程序的名称并按下回车键时,系统就会创建一个新的进程来运行这个程序。例如,当我们输入 “ls” 命令时,系统会创建一个进程来执行 “ls” 程序,该进程会读取当前目录下的文件和目录信息,并将其显示在终端上 。这种方式创建进程非常简单直接,我们在日常使用 Linux 系统时经常会用到。
另一种常见的方式是使用系统调用接口,在 Linux 中,最常用的创建进程的系统调用是 fork () 。fork () 函数就像是一个神奇的 “分身术”,它可以从一个已存在的进程(父进程)中创建出一个新的进程(子进程),这个新创建的子进程几乎是父进程的一个完全拷贝 。通过使用 fork () 函数,我们可以在程序中灵活地创建新的进程,实现多任务处理等功能。 除此之外,还有一些其他的系统调用函数,如 vfork () 和 clone (),它们也可以用于创建进程,不过它们的使用场景和功能略有不同 。
2.1fork () 函数
fork函数的原型非常简洁:pid_t fork(void); 。这个函数就像是一个神奇的开关,当它被调用时,会在操作系统中引发一系列奇妙的变化。它会创建一个新的进程,这个新进程就是子进程,而调用fork的进程则是父进程。
从实现原理上看,fork函数会复制父进程的几乎所有资源,包括虚拟地址空间、堆栈、打开的文件描述符等。在虚拟地址空间方面,父子进程各自拥有自己独立的虚拟地址空间,但它们共享代码段(因为代码段通常是只读的,不需要为每个进程单独复制一份)。这就好比父子俩住在各自的房子里(虚拟地址空间),但他们共享同一个图书馆(代码段) 。
在早期的 Unix 系统中,fork创建子进程时会直接复制父进程的整个地址空间,这会导致大量的内存拷贝操作,效率非常低下。后来引入了写时拷贝(Copy-On-Write,COW)技术,大大提高了fork的效率。写时拷贝技术的原理是,在fork创建子进程时,并不立即复制父进程的地址空间,而是让父子进程共享相同的物理内存页面。只有当其中一个进程试图修改共享的内存页面时,系统才会为修改的页面创建一个副本,分别分配给父子进程。这就好比父子俩一开始共享同一本书(物理内存页面),当其中一个人想要在书上做笔记(修改内存页面)时,才会复制一本新的书给他 。
fork () 函数是 Linux 系统中创建进程的核心函数,它的作用是从一个已存在的进程中创建一个新的进程 。这个新创建的进程被称为子进程,而原来的进程则被称为父进程 。fork () 函数的使用非常简单,只需要在程序中调用 fork () 函数即可。例如:
在这个例子中,我们调用 fork () 函数创建了一个子进程。fork () 函数返回后,会有两个进程在执行,一个是父进程,一个是子进程 。父进程和子进程从 fork () 函数返回后,会根据 fork () 函数的返回值来区分自己是父进程还是子进程 。如果返回值为 0,则表示当前进程是子进程;如果返回值大于 0,则表示当前进程是父进程,返回值就是子进程的 PID;如果返回值小于 0,则表示 fork () 函数调用失败 。
fork () 函数在创建子进程时,会在内核中进行一系列复杂的操作 。内核会为子进程分配一个新的进程控制块(PCB),这个 PCB 就像是子进程的 “身份证”,里面记录了子进程的各种信息,如进程 ID、状态、优先级、内存映射等 。同时,内核还会为子进程分配独立的内存空间,包括代码段、数据段、堆栈段等 。不过,在 Linux 系统中,为了提高效率,子进程并不会立即复制父进程的所有内存内容,而是采用了一种写时复制(Copy - on - Write,COW)的技术 。也就是说,在子进程创建初期,子进程和父进程共享相同的内存页面,只有当子进程或父进程对某个内存页面进行写操作时,系统才会为写操作的进程复制一份该内存页面的副本,从而保证两个进程的内存独立性 。
子进程创建后,它和父进程之间的关系就像是父子关系一样 。子进程会继承父进程的许多属性和资源,如打开的文件描述符、信号处理方式、当前工作目录等 。不过,子进程也有一些自己独有的属性,如进程 ID、父进程 ID 等 。子进程的父进程 ID 就是创建它的父进程的进程 ID 。通过这种父子关系,系统可以方便地管理和调度进程 。 例如,父进程可以通过 wait () 函数等待子进程结束,并获取子进程的退出状态;子进程也可以通过 exec () 函数族来执行一个新的程序,从而替换自己的代码和数据 。
其他资源大体与fs类似,最复杂的是mm拷贝,需借助MMU来完成拷贝;
即写时拷贝技术:
写时拷贝技术带来了很多好处。首先,它节省了内存开销,因为在大多数情况下,父子进程在fork之后并不会立即修改共享的内存,所以不需要一开始就复制大量的内存。其次,它提高了进程创建的效率,减少了fork操作的时间开销 。
第一阶段:只有一个进程P1,数据段可读可写:
第二阶段,调用fork之后创建子进程P2,P2完全拷贝一份P1的mm_struct,其指针指向相同地址,即P1/P2虚拟地址,物理地址完全相同,但该内存的页表地址变为只读;
第三阶段:当P2改写data时,子进程改写只读内存,会引起内存缺页中断,在ISR中申请一片新内存,通常是4K,把P1进程的data拷贝到这4K新内存。再修改页表,改变虚实地址转换关系,使物理地址指向新申请的4K,这样子进程P2就得到新的4K内存,并修改权限为可读写,然后从中断返回到P2进程写data才会成功。整个过程虚拟地址不变,对应用程序员来说,感觉不到地址变化。
谁先写,谁申请新物理内存;Data=20;这句代码经过了赋值无写权限,引起缺页中断,申请内存,修改页表,拷贝数据…回到data=20再次赋值,所以整个执行时间会很长。
这就是linux中的写时拷贝技术(copy on write), 谁先写谁申请新内存,没有优先顺序;cow依赖硬件MMU实现,没有MMU的系统就没法实现cow,也就不支持fork函数,只有vfork;
2.2vfork () 函数
vfork () 函数也是 Linux 系统中用于创建进程的函数,它和 fork () 函数非常相似,但也有一些重要的区别 。vfork () 函数创建的子进程与父进程共享数据段,而不是像 fork () 函数那样子进程拷贝父进程的数据段 。这意味着在子进程调用 exec () 或 exit () 之前,子进程和父进程的数据是共享的,子进程对数据的修改会直接影响到父进程 。vfork () 函数保证子进程先运行,在子进程调用 exec () 或 exit () 之后,父进程才可能被调度运行 。这与 fork () 函数不同,fork () 函数创建的父子进程的执行次序是不确定的 。
vfork () 函数的使用场景相对较少,主要用于当子进程需要立即执行 exec () 函数族中的某个函数,替换自身的代码和数据时 。因为在这种情况下,子进程不需要自己独立的数据段,共享父进程的数据段可以节省内存和时间开销 。不过,由于 vfork () 函数中子进程和父进程共享数据段,且子进程先运行,如果在子进程调用 exec () 或 exit () 之前,子进程依赖于父进程的进一步动作,就可能会导致死锁 。所以在使用 vfork () 函数时需要特别小心,确保子进程能够及时调用 exec () 或 exit () 。
2.3clone () 函数
clone () 函数是 Linux 系统中另一个用于创建进程的系统调用,它比 fork () 和 vfork () 函数更加灵活和强大 。clone () 函数可以创建一个新的进程,并且可以指定新进程与调用进程之间共享的资源,如文件描述符、内存空间、信号处理等 。这使得 clone () 函数不仅可以用于创建普通的进程,还可以用于创建线程 。在 Linux 系统中,线程实际上就是一种特殊的进程,它们共享同一个进程的地址空间和其他资源 。clone () 函数的原型如下:
其中,fn 是一个函数指针,指向新进程(或线程)开始执行的函数;child_stack 是新进程(或线程)使用的堆栈指针;flags 是一个标志位,用于指定新进程与调用进程之间共享的资源;arg 是传递给 fn 函数的参数 。通过设置不同的 flags 标志位,可以实现不同的共享策略 。例如,如果设置 CLONE_VM 标志位,则新进程与调用进程共享同一个内存空间,这就相当于创建了一个线程;如果不设置 CLONE_VM 标志位,则新进程拥有自己独立的内存空间,这就相当于创建了一个普通的进程 。clone () 函数的使用相对复杂一些,需要对 Linux 系统的进程和内存管理有深入的了解 。不过,它提供了更高的灵活性和控制权,适用于一些对进程创建有特殊需求的场景 。
2.4内核线程
内核线程是独立运行在内核空间的特殊进程,它的运行不受用户空间的干扰,就像在操作系统内核这个神秘世界里的 “隐形工作者”,默默地执行着一些关键的系统任务 。内核线程与普通进程相比,有着独特的性质 。它没有独立的地址空间,mm 指针被设置为 NULL 。这意味着它不能像普通进程那样访问用户空间的内存,只能在内核空间中活动 。
内核线程只在内核态运行,从来不切换到用户空间去 。这使得它的运行环境相对单纯,避免了用户空间的复杂性和潜在的干扰 。不过,内核线程和普通进程一样,可以被调度,也可以被抢占 。这保证了它能够在合适的时机得到 CPU 的执行时间,完成自己的任务 。
do_fork () 函数:
在 Linux 系统中,无论是普通进程还是内核线程的创建,最终都离不开一个关键的函数 ——do_fork () 。这个函数就像是进程创建的 “幕后大导演”,负责协调和执行一系列复杂的操作,确保新的进程或内核线程能够顺利诞生 。do_fork () 函数的主要功能是生成一个子进程,并把它加入到 CPU 就绪队列,等待 CPU 调度 。在这个过程中,它会调用 copy_process () 函数,从函数名就可以看出,这个函数的作用是将父进程的相关资源复制到子进程,执行生成子进程的工作 。
具体来说,copy_process () 函数会为子进程分配一个新的 task_struct 内存空间,task_struct 就像是进程的 “身份证”,里面记录了进程的各种信息 。同时,还会为子进程分配两个内存页(32 位操作系统中为 8KB),用于存放 thread_union 联合 。这个联合包含两个成员,一个是 thread_info 结构,内核通过该结构能够快速获得进程结构体 task_struct;另一个是 stack 结构,用于保存进程内核栈 。
除了资源复制,do_fork () 函数还会为新进程分配唯一的进程 ID(PID) 。PID 就像是进程的 “学号”,系统通过它来唯一地识别和管理进程 。do_fork () 函数会将新进程加入到 CPU 就绪队列 。就绪队列就像是一个 “等待执行的队伍”,新创建的进程会在这里排队,等待 CPU 的调度,获得执行的机会 。
三、Linux进程终止
就像任何生命都有终结的时刻一样,Linux 进程也会迎来它的终止。进程终止是指操作系统将正在运行的程序结束掉的过程。当进程终止时,操作系统会回收该进程所占用的系统资源,如内存空间、文件描述符、CPU 资源等,确保系统资源高效利用 。进程终止的原因多种多样,总体可以分为正常终止和异常终止两大类。
3.1进程终止的含义与场景
进程终止意味着进程生命周期的结束,它标志着进程不再执行任何指令,操作系统会回收进程占用的所有资源,将其从系统中移除 。正常终止通常是进程完成了它被设计要执行的任务后,主动请求操作系统终止运行。比如,当我们运行一个计算 1 到 100 之和的程序,程序计算完成并输出结果后,就会正常终止 。此时,进程的退出状态通常为 0,表示成功退出 。
而异常终止则是指进程在运行过程中遇到了无法处理的错误或被外部信号强制终止 。例如,程序试图访问一个不存在的文件,并且没有合适的错误处理机制,可能会因为文件读取错误而崩溃终止;或者进程接收到某些信号,如 SIGINT(通常由 Ctrl+C 触发)、SIGKILL(无法被捕获或忽略)等,也会导致进程异常终止 。异常终止时,进程的退出状态通常为非零值,具体值取决于错误的类型或信号的编号 。
3.2正常终止的方式
在 Linux 中,进程正常终止有几种常见的方式 。一种是在 main 函数内执行 return 语句,return 语句的返回值会作为进程的退出码 。例如,在下面的代码中,return 0 表示进程正常结束:
另一种方式是调用 exit 函数,exit 函数是一个标准库函数,定义在<stdlib.h>头文件中 。它用于正常或异常地终止程序,并执行一些清理操作 。在调用 exit 时,程序会执行以下操作:调用所有已注册的 atexit 函数,这些函数可以用于释放资源、关闭文件等;刷新所有输出缓冲区,确保所有数据都被写入;关闭所有打开的文件描述符 。例如:
还有一种方式是调用_exit 或_Exit 函数,它们是系统调用,定义在<unistd.h>头文件中 。_exit 和_Exit 函数用于立即终止程序,不执行任何清理操作 。这意味着它们不会调用通过 atexit 注册的函数,也不会刷新输出缓冲区 。例如:
3.3异常终止的原因
进程异常终止通常是由程序错误、资源问题或信号等原因导致的 。程序错误是导致进程异常终止的常见原因之一,例如段错误(Segmentation Fault),当程序试图访问它没有权限访问的内存地址,如空指针引用或者越界访问数组时,就会发生段错误 。以下是一个段错误的示例代码:
除零错误也是一种常见的程序错误,当程序尝试除以零时,就会引发除零错误 。例如:
资源问题也可能导致进程异常终止 。当进程使用的资源,如内存、文件描述符等,超过了系统设定的限制时,就会出现资源不足的情况 。例如,当进程申请的内存空间超过了系统可用内存时,就会导致内存耗尽,进程可能会被操作系统终止 。信号也是导致进程异常终止的一个重要原因 。在 Linux 系统中,有许多不同类型的信号,其中一些信号是致命的,会导致进程立即终止 。
例如,SIGSEGV 信号表示段错误,当进程发生段错误时,操作系统会向该进程发送 SIGSEGV 信号,导致进程异常终止 ;SIGABRT 信号表示程序异常终止,通常是由 abort 函数调用或其他严重错误引起的 。还有一些非致命信号,如 SIGINT(通常由 Ctrl+C 触发)用于中断进程,SIGHUP 用于通知进程挂起 。这些信号可以被进程捕获并处理,如果进程没有处理这些信号,它们也可能导致进程异常终止 。
3.4僵尸进程与托孤进程
(1)僵尸进程在进程的世界里,有两种特殊的进程状态,那就是僵尸进程和托孤进程,它们有着独特的性质和特点 。僵尸进程是指一个子进程已经终止,但其父进程尚未调用 wait () 或 waitpid () 系统调用来获取子进程的终止状态,导致子进程的进程描述符仍然存在于系统中 。简单来说,僵尸进程就像是一个已经 “死亡” 但还没有被 “埋葬” 的进程,它虽然不再占用 CPU 等运行资源,但仍然占据着进程表中的一个位置,消耗着系统的一些资源 。僵尸进程的存在可能会导致一些问题,比如如果系统中存在大量的僵尸进程,可能会耗尽系统的进程 ID 资源,因为每个进程都需要一个唯一的进程 ID 。
僵死进程,也被称为僵尸进程,是 Linux 系统中一种特殊的进程状态 。当子进程先于父进程退出,且父进程没有及时读取子进程的退出状态时,子进程就会进入僵死状态,成为僵死进程 。这就好比一个孩子提前离开了舞台,但家长却没有来接他,他只能在舞台边等待 。
僵死进程会在系统中保留其进程描述符、进程 ID 等信息,虽然它不再占用大量的系统资源,但如果大量的僵死进程存在,会占用有限的进程 ID 资源,导致系统无法创建新的进程 。这就像是舞台边挤满了等待家长的孩子,使得新的演员无法上台表演 。
为了避免僵死进程的产生,可以采取以下几种方法 。父进程可以调用wait系列函数(如wait、waitpid)来等待子进程结束,并获取子进程的退出状态 。wait函数会使父进程阻塞,直到有子进程退出,然后它会收集子进程的信息,并把它彻底销毁 。waitpid函数则更加灵活,它可以指定等待特定的子进程,并且可以设置非阻塞模式 。这就好比家长在孩子表演结束后,及时到舞台边接孩子,将孩子安全地带回家 。
父进程可以安装SIGCHLD信号的处理函数 。当子进程退出时,系统会向父进程发送SIGCHLD信号,父进程可以在信号处理函数中调用waitpid函数来处理子进程的退出,这样可以避免父进程阻塞,提高程序的并发性能 。这就好比家长给孩子设置了一个信号器,当孩子表演结束时,信号器会通知家长,家长可以及时去接孩子 。
还可以使用 “两次fork” 的技巧 。父进程先fork出一个子进程,然后子进程再fork出一个孙子进程,接着子进程立即exit退出 。这样,孙子进程就会成为孤儿进程,被init进程收养,init进程会负责清理孙子进程,从而避免了僵死进程的产生 。这就好比家长让孩子先找到一个临时监护人,然后自己离开,临时监护人会照顾好孩子,确保孩子不会无人照料 。通过这些方法,可以有效地避免僵死进程的产生,保证系统的稳定运行 。
而托孤进程,也就是我们常说的孤儿进程,是指其父进程已经终止或不存在,但是该进程仍在继续运行的进程 。当一个父进程创建了一个子进程后,如果父进程先于子进程结束,那么这个子进程就会成为孤儿进程 。不过,不用担心,在 Linux 系统中,孤儿进程会被 init 进程(进程号为 1)收养 。init 进程就像是一个 “超级奶爸”,会负责监控和清理这些孤儿进程,当孤儿进程结束时,init 进程会回收其占用的资源 。所以,一般情况下,孤儿进程不会对系统造成严重的不良影响 。
(2)进程 0 和进程 1在 Linux 系统的进程家族中,进程 0 和进程 1 有着特殊的地位,它们是整个进程体系的基础和起点 。进程 0 是内核启动后创建的第一个进程,通常被称为 idle 进程或 swapper 。它主要负责 CPU 空闲时的调度工作 。当系统中没有其他可运行的进程时,进程 0 就会被调度运行,它会让 CPU 进入低功耗模式,以节省能源,直到有新的进程需要运行时才会被唤醒 。进程 0 在系统启动阶段还扮演着重要的角色,它通过kernel_init () 函数创建了进程 1 。
进程 1,也就是 init 进程,是所有用户空间进程的祖先 。它以 root 权限运行,但受到用户空间的一些限制 。init 进程的主要职责是进行系统初始化工作,它会加载初始化脚本,启动关键的系统服务,比如网络服务、日志服务、SSH 服务等 。init 进程还负责回收孤儿进程的资源,防止僵尸进程的累积 。在不同的 Linux 系统中,init 进程的实现可能会有所不同,传统的 SysVinit 是基于 Shell 脚本的启动方式,逐级执行 /etc/rc.d/rcX.d/ 中的脚本(X 为运行级别);而现代的 systemd 则采用并行启动服务的方式,通过单元文件(.service)管理依赖关系,提供更快的启动速度和更强大的状态监控功能 。
3.5exit () 与_exit () 的区别
exit () 函数和_exit () 函数都用于终止进程,但它们在功能和使用场景上有一些明显的差异 。exit () 函数是一个标准库函数,它在终止进程之前会执行一系列的清理操作 。它会调用通过 atexit 函数注册的清理函数,这些清理函数可以用于释放资源、关闭文件等;它会刷新所有输出缓冲区,确保所有数据都被写入文件 。例如,当我们使用 printf 函数输出数据时,数据可能会先被存储在缓冲区中,直到遇到换行符或缓冲区满时才会被真正写入输出设备 。如果在调用 exit () 函数之前有未刷新的缓冲区数据,exit () 函数会将这些数据写入输出设备 。exit () 函数还会关闭所有打开的文件描述符,确保文件操作的完整性 。
_exit () 函数是一个系统调用,它直接在内核层面终止进程,不会执行任何用户空间的清理操作 。它不会调用 atexit 注册的函数,也不会刷新输出缓冲区,直接将进程终止 。由于_exit () 函数不进行任何清理操作,它的执行速度比 exit () 函数更快 。在需要快速终止进程,且不关心资源清理和缓冲区数据的情况下,可以使用_exit () 函数 。例如,在子进程中调用 fork 后,如果子进程不需要执行任何额外的清理操作,可以使用_exit () 函数立即退出,以避免影响父进程的状态或输出 。
在实际编程中,我们应该根据具体的需求来选择使用 exit () 函数还是_exit () 函数 。如果需要确保程序在终止前进行资源清理和数据保存等操作,应该使用 exit () 函数;如果需要快速终止进程,且不关心这些清理操作,可以使用_exit () 函数 。
四、Linux进程案例分析
Linux的调度器类主要实现两类进程调度算法:实时调度算法和完全公平调度算法(CFS),实时调度算法SCHED_FIFO和SCHED_RR,按优先级执行,一般不会被抢占。直到实时进程执行完,才会执行普通进程。而大多数的普通进程,用的就是CFS算法。
进程调度的时机:
①进程状态转换时刻:进程终止、进程睡眠;②当前进程的”时间片”用完;③主动让出处理器,用户调用sleep()或者内核调用schedule();④从中断,系统调用或异常返回时;每个进程task_struct中都有一个struct sched_entity se成员,这就是调度器的实体结构,进程调度算法实际上就是管理所有进程的这个se。
CFS基于一个简单的理念:所有任务都应该公平的分配处理器。理想情况下,n个进程的调度系统中,每个进程获得1/n处理器时间,所有进程的vruntime也是相同的。
CFS完全抛弃了时间片的概念,而是分配一个处理器使用比来度量。每个进程一个调度周期内分配的时间(类似于传统的“时间片”)跟三个因素有关:进程总数,优先级,调度周期
4.1理解CFS的首先要理解vruntime的含义
简单说vruntime就是该进程的运行时间,但这个时间是通过优先级和系统负载等加权过的时间,而非物理时钟时间,按字面理解为虚拟运行时间,也很恰当。
每个进程的调度实体se都保存着本进程的虚拟运行时间。
而进程相关的调度方法如下:
4.2vruntime的值如何跟新?
时钟中断产生时,会依次调用tick_periodic()-> update_process_times()->scheduler_tick()
这里分析两个重要函数update_curr()和check_preempt_tick()
主要关心__update_curr()函数
关注calc_delta_fair()加权函数如何实现
若当前进程nice为0,直接返回实际运行时间,其他所有nice值的加权都是以0nice值为参考增加或减少的。
当nice!=0时,实际是按公式delta *= weight / lw来计算的weight=1024是nice0的权重,lw是当前进程的权重,该lw和nice值的换算后面介绍,上面还书的lw计算公式没弄明白,总之这个函数就是把实际运行时间加权为进程调度里的虚拟运行时间,从而更新vruntime。
更新完vruntime之后,会检查是否需要进程调度
更新完cfs_rq之后,会检查当前进程是否已经用完自己的“时间片”
当该进程运行时间超过实际分配的“时间片”,就标记调度标志resched_task(rq_of(cfs_rq)->curr);,否则本进程继续执行。中断退出,调度函数schedule()会检查此标记,以选取新的进程来抢占当前进程。
4.3如何选择下一个可执行进程
CFS选择具有最小vruntime值的进程作为下一个可执行进程,CFS用红黑树来组织调度实体,而键值就是vruntime。那么CFS只要查找选择最左叶子节点作为下一个可执行进程即可。实际上CFS缓存了最左叶子,可以直接选取left_most叶子。
上面代码跟踪到timer tick中断退出,若“ideal_runtime”已经用完,就会调用schedule()函数选中新进程并且完成切换。
如果进程状态已经不是可运行,那么会将该进程移出可运行队列,如果继续可运行put_prev_task()会依次调用put_prev_task_fair()->put_prev_entity()
__enqueue_entity(cfs_rq, prev) 将上一个进程重新插入红黑树(注意,当前运行进程是不在红黑树中的)pick_next_task()会依次调用pick_next_task_fair()
set_next_entity()函数会调用__dequeue_entity(cfs_rq, se)把选中的下一个进程即最左叶子移出红黑树。最后context_switch()完成进程的切换。
4.4何时更新rbtree
①上一个进程执行完ideal_time,还可继续执行时,会插入红黑树;②下一个进程被选中移出rbtree红黑树时;③新建进程;④进程由睡眠态被激活,变为可运行态时;⑤调整优先级时也会更新rbtree;4.5新建进程如何加入红黑树
新建进程会做一系列复杂的工作,这里我们只关心与红黑树有关部分
Linux使用fork,clone或者vfork等系统调用创建进程,最终都会到do_fork函数实现,如果没有设置CLONE_STOPPED,do_fork会执行两个与红黑树相关的函数: copy_process()和wake_up_new_task()
(1)copy_process()->sched_fork()->task_fork()计算新进程的vruntime值,加上一个“平均时间片”表示刚执行完,避免新建进程立马抢占CPU。
(2)调用wake_up_new_task函数更新时钟,激活新建的进程activate_task()会调用
将新建的进程加入rbtree;
4.6唤醒进程
调用try_to_wake_up()->activate_task()->enqueue_task_fair()->enqueue_entity()注意enqueue_entity 函数调用place_entity对进程vruntime做补偿计算,再次考察place_entity(cfs_rq, se, 0)
当initial=1时,新建进程vruntime=cfs最小vruntime值+时间片,放入红黑树最右端。
当initial=0时,表示唤醒进程,vruntime要减去一个thresh.这个thresh由调度周期sysctl_sched_latency加权得到虚拟时间,这样做可以对睡眠进程做一个补偿,唤醒时会得到一个较小的vruntime, 使它可以尽快抢占CPU(可以快速响应I/O消耗型进程)。
注意注释/* ensure we never gain time by being placed backwards. */这个设计是为了给睡眠较长时间的进程做时间补偿的,既使其可以快速抢占,又避免因太小的vruntime值而长期占用CPU。但有些进程只是短时间睡眠,这样唤醒时自身vruntime还是大于min_vruntime的,为了不让进程通过睡眠获得额外运行时间补偿,最后vruntime取计算出的补偿时间和进程本身的vruntime较大者。从这可以看出,虽然CFS不再区分I/O消耗型,CPU消耗型进程,但是CFS模型对IO消耗型天然的提供了快速的响应。
4.7改变进程优先级,如何调整rbtree
Linux中改变进程优先级会调用底层的set_user_nice()
set_user_nice把进程从红黑树取出,调整优先级(nice值对应权重),再重新加入红黑树
set_load_weight()函数是设置nice值对应的权重
数组prio_to_weight[]是将nice值(-20~19)转化为以nici 0(1024)值为基准的加权值,根据内核注释每一个nice差值,权重相差10%,即在负载一定的条件下,每增加或减少一个nice值,获得的CPU时间相应增加或减少10%
上面calc_delta_mine()函数用到这个数组加权值,这个转化过程还没弄明白,有明白的朋友,指点一二,不胜感激
最后,说下对CFS “完全公平” 的理解:
①不再区分进程类型,所有进程公平对待
②对I/O消耗型进程,仍然会提供快速响应(对睡眠进程做时间补偿)
③优先级高的进程,获得CPU时间更多(vruntime增长的更慢)
可见CFS的完全公平,并不是说所有进程绝对的平等,占用CPU时间完全相同,而是体现在vruntime数值上,所有进程都用虚拟时间来度量,总是让vruntime最小的进程抢占,这样看起来是完全公平的,但实际上vruntime的更新,增长速度,不同进程是不尽一样的。CFS利用这么个简单的vruntime机制,实现了以往需要相当复杂算法实现的进度调度需求,高明!