进程是 Linux 操作系统的核心基石,我们日常执行的每一条命令、运行的每一个程序,本质上都是一个或多个进程在底层运转。多数开发者对进程的认知,仅停留在“启动程序就是创建进程”“关闭程序就是终止进程”的表面,却不清楚进程从诞生到消亡的完整流程,以及内核如何管理、调度这些进程,这也成为深入理解 Linux 底层的一大壁垒。
从进程的创建、运行,到等待、终止,每一个环节都有 Linux 内核的严格管控与底层机制支撑。本文将聚焦 Linux 进程管理底层原理,完整剖析进程从诞生到消亡的全生命周期,拆解 fork、exec、wait、exit 等核心系统调用的底层逻辑,揭秘内核如何分配进程资源、调度进程运行、回收进程残留资源。读懂这些底层原理,不仅能帮我们规避进程管理相关的 bug,更能深入理解 Linux 操作系统的运行本质,为底层开发、系统优化打下坚实基础。
一、Linux 进程是什么?
进程(Process)是指计算机中已运行的程序,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。进程是程序真正运行的实例,若干进程可能与同一个程序相关,且每个进程皆可以同步或异步的方式独立运行。
- 广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。
1.1 描述进程 PCB
进程:资源的封装单位,linux 用一个 PCB 来描述进程,即 task_struct, 其包含 mm,fs,files,signal…
(1)root 目录,是一个进程概念,不是系统概念
apropos chrootman chroot 2
将分区/dev/sda5 挂载到/mnt/a,调用 chroot,改变 root 目录,当前进程下的文件 b.txt 即位于当前进程的根目录。
(2)fd 也是进程级概念
(base) leon@leon-Laptop:/proc/29171$ ls fd -l
总用量 0
lrwx------ 1 leon leon 64 5 月 16 10:26 0 -> /dev/pts/19lrwx------ 1 leon leon 64 5 月 16 10:26 1 -> /dev/pts/19lrwx------ 1 leon leon 64 5 月 16 10:26 2 -> /dev/pts/19
(3)pid,系统全局概念。Linux 总的 PID 是有限的,用完 PID
: ( ) { : ∣ : & } ; : :()\{:|:\&\};::(){:∣:&};:
每个用户的 PID 也是有限的
(base) leon@leon-Laptop:/proc/29171$ cat /proc/sys/kernel/pid_max
1.2 task_ struct 内容分类
在进程执行时,任意给定一个时间,进程都可以唯一的被表征为以下元素:
- 标示符: 描述本进程的唯一标示符,⽤用来区别其他进程。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- I/O 状态信息: 包括显⽰示的 I/O 请求,分配给进程的 I/O 设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
1.3 linux 进程的组织方式
linux 里的多个进程,其实就是管理多个 task_struct,那他们是怎么组织联系的呢?
组织 task_struct 的数据结构:
用三种数据结构来管理 task_struct,以空间换时间。父进程监控子进程,linux 总是白发人送黑发人。父进程通过 wait,读取 task_struct 的退出码,得知进程死亡原因。并且清理子进程尸体。
Android/或者服务器,都会用由父进程监控子进程状态,适时重启等;
1.4 进程的状态和转换
(1)五种状态。进程在其生命周期内,由于系统中各进程之间的相互制约关系及系统的运行环境的变化,使得进程的状态也在不断地发生变化(一个进程会经历若干种不同状态)。
通常进程有以下五种状态,前三种是进程的基本状态:
- 运行状态:进程正在处理机上运行。在单处理机环境下,每一时刻最多只有一个进程处于运行状态。
- 就绪状态:进程已处于准备运行的状态,即进程获得了除处理机之外的一切所需资源,一旦得到处理机即可运行。
- 阻塞状态,又称等待状态:进程正在等待某一事件而暂停运行,如等待某资源为可用(不包括处理机)或等待输入/输出完成。即使处理机空闲,该进程也不能运行。
- 创建状态:进程正在被创建,尚未转到就绪状态。创建进程通常需要多个步骤:首先申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息;然后由系统为该进程分 配运行时所必需的资源;最后把该进程转入到就绪状态。
- 结束状态:进程正从系统中消失,这可能是进程正常结束或其他原因中断退出运行。当进程需要结束运行时,系统首先必须置该进程为结束状态,然后再进一步处理资源释放和 回收等工作。
注意区别就绪状态和等待状态:就绪状态是指进程仅缺少处理机,只要获得处理机资源就立即执行;而等待状态是指进程需要其他资源(除了处理机)或等待某一事件。之所以把处理机和其他资源划分开,是因为在分时系统的时间片轮转机制中,每个进程分到的时间片是若干毫秒。
也就是说,进程得到处理机的时间很短且非常频繁,进程在运行过程中实际上是频繁地转换到就绪状态的;而其他资源(如外设)的使用和分配或者某一事件的发生(如 I/O 操作的完成)对应的时间相对来说很长,进程转换到等待状态的次数也相对较少。这样来看,就绪状态和等待状态是进程生命周期中两个完全不同的状态,很显然需要加以区分。
(2)Linux 进程会在运行、就绪、阻塞、终止等状态间,随 CPU 调度、I/O 等待、资源申请与释放等事件自动完成状态转换。
- 就绪状态 -> 运行状态:处于就绪状态的进程被调度后,获得处理机资源(分派处理机时间片),于是进程由就绪状态转换为运行状态。
- 运行状态 -> 就绪状态:处于运行状态的进程在时间片用完后,不得不让出处理机,从而进程由运行状态转换为就绪状态。此外,在可剥夺的操作系统中,当有更高优先级的进程就 、 绪时,调度程度将正执行的进程转换为就绪状态,让更高优先级的进程执行。
- 运行状态 -> 阻塞状态:当进程请求某一资源(如外设)的使用和分配或等待某一事件的发生(如 I/O 操作的完成)时,它就从运行状态转换为阻塞状态。进程以系统调用的形式请求操作系统提供服务,这是一种特殊的、由运行用户态程序调用操作系统内核过程的形式。
- 阻塞状态 -> 就绪状态:当进程等待的事件到来时 ,如 I/O 操作结束或中断结束时,中断处理程序必须把相应进程的状态由阻塞状态转换为就绪状态。
二、Linux 进程的创建
在Linux 中,有多种方式可以创建进程,其中最常见的两种方式是:通过运行可执行程序来创建进程,以及使用系统调用接口来创建进程 。当我们在命令行中输入一个可执行程序的名称并按下回车键时,系统就会创建一个新的进程来运行这个程序。例如,当我们输入 “ls” 命令时,系统会创建一个进程来执行 “ls” 程序,该进程会读取当前目录下的文件和目录信息,并将其显示在终端上 。这种方式创建进程非常简单直接,我们在日常使用 Linux 系统时经常会用到。
另一种常见的方式是使用系统调用接口,在 Linux 中,最常用的创建进程的系统调用是 fork () 。fork () 函数就像是一个神奇的 “分身术”,它可以从一个已存在的进程(父进程)中创建出一个新的进程(子进程),这个新创建的子进程几乎是父进程的一个完全拷贝 。通过使用 fork () 函数,我们可以在程序中灵活地创建新的进程,实现多任务处理等功能。 除此之外,还有一些其他的系统调用函数,如 vfork () 和 clone (),它们也可以用于创建进程,不过它们的使用场景和功能略有不同 。
2.1 fork () 函数
fork 函数的原型非常简洁:pid_t fork(void); 。这个函数就像是一个神奇的开关,当它被调用时,会在操作系统中引发一系列奇妙的变化。它会创建一个新的进程,这个新进程就是子进程,而调用 fork 的进程则是父进程。
从实现原理上看,fork 函数会复制父进程的几乎所有资源,包括虚拟地址空间、堆栈、打开的文件描述符等。在虚拟地址空间方面,父子进程各自拥有自己独立的虚拟地址空间,但它们共享代码段(因为代码段通常是只读的,不需要为每个进程单独复制一份)。这就好比父子俩住在各自的房子里(虚拟地址空间),但他们共享同一个图书馆(代码段) 。
在早期的 Unix 系统中,fork 创建子进程时会直接复制父进程的整个地址空间,这会导致大量的内存拷贝操作,效率非常低下。后来引入了写时拷贝(Copy-On-Write,COW)技术,大大提高了 fork 的效率。写时拷贝技术的原理是,在 fork 创建子进程时,并不立即复制父进程的地址空间,而是让父子进程共享相同的物理内存页面。只有当其中一个进程试图修改共享的内存页面时,系统才会为修改的页面创建一个副本,分别分配给父子进程。这就好比父子俩一开始共享同一本书(物理内存页面),当其中一个人想要在书上做笔记(修改内存页面)时,才会复制一本新的书给他 。
fork () 函数是 Linux 系统中创建进程的核心函数,它的作用是从一个已存在的进程中创建一个新的进程 。这个新创建的进程被称为子进程,而原来的进程则被称为父进程 。fork () 函数的使用非常简单,只需要在程序中调用 fork () 函数即可。例如:
#include <stdio.h>#include <unistd.h>int main() { pid_t pid; pid = fork(); if (pid == 0) { // 子进程执行的代码 printf("这是子进程,我的 PID 是:%d\n", getpid()); } else if (pid > 0) { // 父进程执行的代码 printf("这是父进程,我的 PID 是:%d,我创建的子进程的 PID 是:%d\n", getpid(), pid); } else { // fork()函数调用失败 perror("fork"); return 1; } return 0;}
在这个例子中,我们调用 fork () 函数创建了一个子进程。fork () 函数返回后,会有两个进程在执行,一个是父进程,一个是子进程 。父进程和子进程从 fork () 函数返回后,会根据 fork () 函数的返回值来区分自己是父进程还是子进程 。如果返回值为 0,则表示当前进程是子进程;如果返回值大于 0,则表示当前进程是父进程,返回值就是子进程的 PID;如果返回值小于 0,则表示 fork () 函数调用失败 。
fork () 函数在创建子进程时,会在内核中进行一系列复杂的操作 。内核会为子进程分配一个新的进程控制块(PCB),这个 PCB 就像是子进程的 “身份证”,里面记录了子进程的各种信息,如进程 ID、状态、优先级、内存映射等 。同时,内核还会为子进程分配独立的内存空间,包括代码段、数据段、堆栈段等 。不过,在 Linux 系统中,为了提高效率,子进程并不会立即复制父进程的所有内存内容,而是采用了一种写时复制(Copy - on - Write,COW)的技术 。也就是说,在子进程创建初期,子进程和父进程共享相同的内存页面,只有当子进程或父进程对某个内存页面进行写操作时,系统才会为写操作的进程复制一份该内存页面的副本,从而保证两个进程的内存独立性 。
子进程创建后,它和父进程之间的关系就像是父子关系一样 。子进程会继承父进程的许多属性和资源,如打开的文件描述符、信号处理方式、当前工作目录等 。不过,子进程也有一些自己独有的属性,如进程 ID、父进程 ID 等 。子进程的父进程 ID 就是创建它的父进程的进程 ID 。通过这种父子关系,系统可以方便地管理和调度进程 。 例如,父进程可以通过 wait () 函数等待子进程结束,并获取子进程的退出状态;子进程也可以通过 exec () 函数族来执行一个新的程序,从而替换自己的代码和数据 。
在 fork 创建子进程的过程中,写时拷贝(Copy-On-Write,COW)技术发挥着至关重要的作用,它是一种高效的内存管理策略,旨在优化内存使用和提升性能。
写时拷贝的原理基于这样一个事实:在大多数情况下,子进程在创建后的一段时间内,并不会立即对从父进程继承的数据进行修改。传统的进程创建方式是在创建子进程时,将父进程的所有数据都完整地复制一份给子进程,这无疑会消耗大量的时间和内存资源,尤其是当父进程的数据量较大时。而写时拷贝技术则巧妙地避免了这种不必要的复制操作。当 fork 函数创建子进程时,子进程并不会立即获得父进程数据的完整副本,而是与父进程共享相同的物理内存页面。内核通过巧妙的页表映射机制,使得父子进程在逻辑上都认为自己拥有独立的数据空间,但实际上这些数据在物理内存中是共享的。只有当父子进程中的某一方试图对共享的数据进行写入操作时,写时拷贝机制才会被触发。
此时,操作系统会为执行写入操作的进程分配新的物理内存页面,并将需要修改的数据从共享页面复制到新的页面中,然后更新页表映射,使得该进程的虚拟地址指向新的物理页面。这样,其他未进行写入操作的进程仍然可以继续共享原来的物理页面,从而最大限度地减少了内存的使用和复制操作的开销。
为了更直观地理解写时拷贝的工作过程,我们可以将其类比为一个多人共享文件的场景。假设有多个用户需要查看同一个文档,一开始,大家都可以直接读取同一个文件副本,这样既节省了存储空间,又提高了读取效率。当其中一个用户需要对文档进行修改时,系统会为这个用户创建一个独立的文件副本,让他在这个副本上进行修改,而其他用户仍然可以继续使用原来的文件副本。这样,既保证了数据的一致性,又避免了不必要的文件复制操作。在 Linux 进程管理中,写时拷贝技术就像是这个智能的文件共享系统,有效地提高了内存的利用率和进程创建的效率 。通过写时拷贝,Linux 系统在进程创建时可以快速地完成子进程的初始化,同时减少了内存的占用,使得系统能够更高效地运行多个进程。这种技术在现代操作系统中被广泛应用,是实现高效进程管理的关键技术之一。
虽然 fork 函数为 Linux 进程创建提供了强大的功能,但在实际使用中,fork 调用并非总是成功的,它可能会因为各种原因而失败。了解这些失败原因对于编写健壮的程序至关重要。
系统中进程数量过多是导致 fork 调用失败的一个常见原因。在 Linux 系统中,系统资源是有限的,内核需要为每个进程分配一定的资源,如内存、文件描述符、进程控制块(PCB)等。当系统中已经存在大量的进程,且这些进程占用了大量的系统资源时,内核可能无法为新的子进程分配足够的资源,从而导致 fork 调用失败。这就好比一个仓库,里面的货物(资源)有限,当已经存放了太多的货物时,就没有足够的空间来存放新的货物(创建新进程)了。
实际用户的进程数超过资源限制也会使 fork 调用失败。每个用户在系统中都有一定的进程资源限制,这是为了防止单个用户过度占用系统资源,影响其他用户和系统的正常运行。这个限制可以通过系统参数进行配置,例如 ulimit -u 命令可以查看和设置用户的最大进程数。当一个用户创建的进程数量达到或超过了这个限制时,再调用 fork 函数就会失败。这就像是一个游戏,每个玩家都有一定的游戏资源配额,当某个玩家用完了自己的配额,就无法再进行某些操作(创建新进程)了。
此外,内存不足也可能导致 fork 失败。在创建子进程时,内核需要为子进程分配内存,包括用户空间内存和内核空间内存。如果系统内存不足,无法满足子进程的内存需求,fork 调用就会失败。这种情况类似于电脑内存不足时,无法打开新的应用程序一样。当 fork 调用失败时,我们可以通过检查 errno 变量来获取具体的错误信息,从而采取相应的措施,如释放一些资源、调整系统参数或者提示用户进行相应的操作。
2.2 vfork () 函数
vfork () 函数也是 Linux 系统中用于创建进程的函数,它和 fork () 函数非常相似,但也有一些重要的区别 。vfork () 函数创建的子进程与父进程共享数据段,而不是像 fork () 函数那样子进程拷贝父进程的数据段 。这意味着在子进程调用 exec () 或 exit () 之前,子进程和父进程的数据是共享的,子进程对数据的修改会直接影响到父进程 。vfork () 函数保证子进程先运行,在子进程调用 exec () 或 exit () 之后,父进程才可能被调度运行 。这与 fork () 函数不同,fork () 函数创建的父子进程的执行次序是不确定的 。
vfork () 函数的使用场景相对较少,主要用于当子进程需要立即执行 exec () 函数族中的某个函数,替换自身的代码和数据时 。因为在这种情况下,子进程不需要自己独立的数据段,共享父进程的数据段可以节省内存和时间开销 。不过,由于 vfork () 函数中子进程和父进程共享数据段,且子进程先运行,如果在子进程调用 exec () 或 exit () 之前,子进程依赖于父进程的进一步动作,就可能会导致死锁 。所以在使用 vfork () 函数时需要特别小心,确保子进程能够及时调用 exec () 或 exit () 。
2.3 clone () 函数
clone () 函数是 Linux 系统中另一个用于创建进程的系统调用,它比 fork () 和 vfork () 函数更加灵活和强大 。clone () 函数可以创建一个新的进程,并且可以指定新进程与调用进程之间共享的资源,如文件描述符、内存空间、信号处理等 。这使得 clone () 函数不仅可以用于创建普通的进程,还可以用于创建线程 。在 Linux 系统中,线程实际上就是一种特殊的进程,它们共享同一个进程的地址空间和其他资源 。clone () 函数的原型如下:
intclone(int (*fn)(void *), void *child_stack, int flags, void *arg);
其中,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 进程的运行
通过 fork 创建的子进程,和父进程的代码段、数据段完全一样——也就是说,子进程会执行和父进程相同的代码(从 fork 之后开始)。但实际开发中,我们创建子进程,往往是为了让它执行另一个程序(比如父进程是 shell,子进程执行 ls、pwd 等命令),这时候就需要 exec 系统调用。
3.1 exec 函数族是什么?
exec 函数族并非单一的函数,而是包含了多个功能相似但参数形式略有不同的函数,主要有 execl、execlp、execle、execv、execvp 和 execve 。这些函数的主要作用是在当前进程中执行一个新的程序,它们会用新程序的代码和数据完全替换掉当前进程的代码段、数据段、堆和栈等,使得进程从新程序的入口点(通常是 main 函数)开始执行。需要注意的是,调用 exec 函数族并不会创建新的进程,进程的 PID 保持不变,就好像是给进程换上了一套全新的 “装备”,但还是原来那个 “人” 。
以 execl 函数为例,它的函数原型为 int execl(const char *path, const char *arg, ...);,其中 path 参数指定了要执行的可执行文件的路径,可以是绝对路径或相对路径;arg 参数则是传递给新程序的命令行参数列表,以可变参数的形式传递,并且最后一个参数必须是 NULL,用于标识参数列表的结束。比如,我们要在当前进程中执行 ls -l 命令,可以使用以下代码:
#include <stdio.h>#include <unistd.h>intmain(){ // 使用 execl 函数执行 ls -l 命令 execl("/bin/ls", "ls", "-l", NULL); // 如果 execl 调用失败,会执行到这里 perror("execl failed"); return 0;}
在这段代码中,/bin/ls 是 ls 命令的绝对路径,第一个"ls"是程序名,"-l"是 ls 命令的参数,NULL 表示参数列表的结束。如果 execl 函数调用成功,当前进程就会开始执行 ls -l 命令,不再执行 execl 函数后面的代码;如果调用失败,execl 函数会返回 - 1,并设置 errno 变量,perror 函数会输出错误信息。
3.2 exec 底层实现逻辑
exec 的底层执行步骤,本质上是“清空旧程序,加载新程序”:
- 内核销毁当前进程的代码段、数据段、堆、栈(保留 PID、文件描述符等);
- 读取指定的可执行文件(比如 /bin/ls),将文件中的代码段、数据段加载到进程的内存空间;
- 初始化进程的程序计数器(PC),指向新程序的入口地址(main 函数的地址);
这里有个关键细节:如果 exec 调用成功,它不会返回(因为当前进程的代码段已经被替换,原来的 exec 之后的代码已经不存在了);只有当 exec 调用失败(比如指定的可执行文件不存在),才会返回 -1,继续执行原来的代码。
3.3 进程调度:内核如何决定“谁先运行”?
创建进程(fork)、替换程序(exec)后,进程就进入了“就绪态”,等待内核调度器(Scheduler)分配 CPU 时间片,才能进入“运行态”。
Linux 内核的调度器,核心目标是“公平且高效”——既要保证每个进程都能获得 CPU 资源,又要提升系统的整体吞吐量。它的底层依赖「调度算法」,比如 Linux 2.6 之后的 CFS(完全公平调度器),核心逻辑是:给每个进程分配一个“时间片”(默认是 10ms),调度器按照进程的优先级,轮流给进程分配 CPU 时间片;当进程的时间片用完,调度器会暂停该进程,将其放回就绪队列,再调度下一个进程。
简单说,进程的运行,本质上是“调度器不断切换进程占用 CPU”的过程——我们感觉多个进程在同时运行,其实是调度器切换得太快(毫秒级),给我们的错觉。
四、Linux 进程的等待
当子进程执行完任务后,会终止运行,但此时子进程的资源并不会立即被内核回收——如果父进程不主动处理,子进程会变成“僵尸进程”(Zombie Process),占用 PID 等系统资源,长期积累会导致系统资源耗尽。而 wait() 系列系统调用(wait、waitpid 等),就是父进程用来“等待子进程终止,并回收子进程资源”的核心接口。
4.1 wait 函数的作用与原理
在 Linux 进程的生命周期中,进程等待是一个至关重要的环节,而 wait 函数则是实现这一环节的关键工具。当子进程完成其任务并准备退出时,wait 函数就开始发挥作用,它主要用于父进程等待子进程结束,在这个过程中,父进程会被阻塞,暂时停止执行,就像一位耐心等待孩子完成任务的家长,直到它的某个子进程退出 。
wait 函数的这种阻塞机制具有重要意义。一方面,它确保了父进程不会在子进程还未完成任务时就继续执行后续代码,从而避免了可能出现的数据不一致或逻辑错误。比如,在一个数据处理程序中,子进程负责读取和处理数据,父进程需要等待子进程处理完成后,才能对处理后的数据进行进一步的汇总和分析。如果没有 wait 函数的阻塞机制,父进程可能会在子进程还未完成数据处理时就尝试读取未处理完的数据,导致结果错误。
另一方面,wait 函数在子进程退出后,会回收子进程占用的系统资源,包括释放子进程的内存空间、关闭其打开的文件描述符等,有效地避免了资源泄漏和僵尸进程的产生。僵尸进程是指子进程已经退出,但父进程尚未回收其资源的进程,它们会占用系统资源,如果大量出现,可能会导致系统性能下降。通过 wait 函数,父进程可以及时回收子进程的资源,确保系统的稳定运行 。
wait 函数还能够获取子进程的退出状态,这为父进程了解子进程的执行情况提供了重要信息。子进程的退出状态可以反映出它在执行过程中是否遇到错误、任务是否成功完成等。父进程可以根据这些信息来决定后续的操作,比如,如果子进程正常退出,父进程可以继续执行后续的任务;如果子进程异常退出,父进程可以进行错误处理或重新启动子进程。wait 函数通过一个整型指针参数 status 来返回子进程的退出状态,这个状态值是一个整数值,其中不同的二进制位记录了子进程退出的详细信息 。
为了方便解析这些信息,Linux 系统提供了一系列宏定义,例如 WIFEXITED(status)用于判断子进程是否正常退出,如果正常退出则返回非零值;WEXITSTATUS(status)用于获取子进程正常退出时的返回值,当 WIFEXITED(status)为非零值时,通过这个宏可以提取子进程的退出状态码。通过这些宏,父进程可以轻松地获取子进程的退出状态,从而更好地管理和协调子进程的执行。
4.2 waitpid 函数的特性与使用
waitpid 函数作为 wait 函数的扩展,具有更为灵活和强大的功能,在 Linux 进程管理中发挥着重要作用。它的函数原型为 pid_t waitpid(pid_t pid, int *status, int options);,其中 pid 参数用于指定等待的子进程的 PID,这使得 waitpid 函数可以有针对性地等待特定的子进程结束,而不像 wait 函数那样只能等待任意一个子进程。当 pid > 0 时,waitpid 会等待进程 ID 与 pid 相等的子进程;当 pid == -1 时,它的功能与 wait 函数相同,会等待任意一个子进程 。
waitpid 函数的 options 参数为其带来了更多的灵活性,它支持多种等待方式,其中最常用的选项是 WNOHANG。当 options 设置为 WNOHANG 时,如果指定的子进程没有结束,waitpid 函数不会阻塞父进程,而是立即返回 0,这为父进程提供了一种非阻塞等待的方式,使其可以在等待子进程的同时继续执行其他任务,提高了程序的并发处理能力。
比如,在一个网络服务器程序中,父进程可能需要同时处理多个子进程的任务,并且在等待某些子进程结束的过程中,还需要继续接受新的网络连接和处理其他请求。使用 WNOHANG 选项,父进程可以定期检查子进程的状态,而不会被阻塞,从而保证了服务器的高效运行 。
为了更直观地展示 waitpid 函数的使用方法,我们来看一个简单的示例代码:
#include <stdio.h>#include <unistd.h>#include <sys/types.h>#include <sys/wait.h>#include <stdlib.h>intmain(){ pid_t pid, ret_pid; int status; // 创建子进程 pid = fork(); if (pid == -1) { perror("fork"); return 1; } else if (pid == 0) { // 子进程 sleep(3); // 模拟子进程执行任务 printf("Child process is exiting...\n"); exit(10); // 子进程以退出码 10 结束 } else { // 父进程 do { // 使用 WNOHANG 选项进行非阻塞等待 ret_pid = waitpid(pid, &status, WNOHANG); if (ret_pid == 0) { // 子进程还未结束,父进程可以继续执行其他任务 printf("Child process is still running, parent can do other things...\n"); sleep(1); } } while (ret_pid == 0); if (ret_pid == pid) { // 子进程正常结束 if (WIFEXITED(status)) { printf("Child process exited normally, exit code: %d\n", WEXITSTATUS(status)); } else { printf("Child process exited abnormally\n"); } } else if (ret_pid == -1) { // waitpid 调用失败 perror("waitpid"); } } return 0;}
在这段代码中,首先使用 fork 函数创建了一个子进程。子进程通过 sleep 函数模拟执行任务,然后以退出码 10 结束。父进程在创建子进程后,使用 waitpid 函数并设置 WNOHANG 选项进行非阻塞等待。在等待过程中,如果 waitpid 返回 0,说明子进程还未结束,父进程会打印相应信息并继续执行其他任务(这里通过 sleep 函数模拟)。
当子进程结束后,waitpid 会返回子进程的 PID,父进程通过检查 status 参数来判断子进程是否正常结束,并获取其退出码 。通过这个示例,我们可以清晰地看到 waitpid 函数的使用方法和非阻塞等待的效果,它为 Linux 进程管理提供了更加灵活和高效的方式。
4.3 僵尸进程的产生与解决
如果父进程没有调用 wait()/waitpid(),子进程终止后,会变成僵尸进程(状态为 Z)——此时子进程的 PID 被占用,内核无法回收其 task_struct。
举个例子:如果父进程一直运行,却不处理子进程的终止,子进程就会一直是僵尸进程;如果父进程先于子进程终止,子进程会被 init 进程(PID=1)接管,init 进程会自动调用 wait() 回收子进程资源,不会产生僵尸进程。
解决僵尸进程的核心方法:父进程必须调用 wait()/waitpid() 等待子进程终止,或者使用信号(SIGCHLD)机制,在子进程终止时触发信号处理函数,回收资源。
实战案例:用 wait 回收子进程
#include <stdio.h>#include <unistd.h>#include <sys/wait.h>#include <stdlib.h>intmain(){ pid_t pid = fork(); if (pid == -1) { perror("fork failed"); return 1; } else if (pid == 0) { // 子进程 printf("子进程(PID:%d)开始执行,执行完退出\n", getpid()); sleep(2); // 模拟子进程执行任务 exit(0); // 子进程正常终止 } else { // 父进程 int status; printf("父进程等待子进程终止...\n"); wait(&status); // 阻塞等待子进程终止 if (WIFEXITED(status)) { // 判断子进程是否正常终止 printf("子进程正常终止,退出状态码:%d\n", WEXITSTATUS(status)); } printf("父进程回收子进程资源,执行完毕\n"); } return 0;}
运行结果:
这个案例中,父进程通过 wait() 等待子进程终止,回收子进程资源,避免了僵尸进程的产生——这是实际开发中必须注意的点。
五、Linux 进程的消亡
进程的终止,分为“正常终止”和“异常终止”,但无论哪种方式,最终都会调用 exit() 系统调用(异常终止会由内核自动调用),完成进程的最终清理工作。
5.1 进程退出的场景与方式
进程终止意味着进程生命周期的结束,它标志着进程不再执行任何指令,操作系统会回收进程占用的所有资源,将其从系统中移除 。正常终止通常是进程完成了它被设计要执行的任务后,主动请求操作系统终止运行。比如,当我们运行一个计算 1 到 100 之和的程序,程序计算完成并输出结果后,就会正常终止 。此时,进程的退出状态通常为 0,表示成功退出 。
而异常终止则是指进程在运行过程中遇到了无法处理的错误或被外部信号强制终止 。例如,程序试图访问一个不存在的文件,并且没有合适的错误处理机制,可能会因为文件读取错误而崩溃终止;或者进程接收到某些信号,如 SIGINT(通常由 Ctrl+C 触发)、SIGKILL(无法被捕获或忽略)等,也会导致进程异常终止 。异常终止时,进程的退出状态通常为非零值,具体值取决于错误的类型或信号的编号 。
(1)正常终止的方式。在 Linux 中,进程正常终止有几种常见的方式 。一种是在 main 函数内执行 return 语句,return 语句的返回值会作为进程的退出码 。例如,在下面的代码中,return 0 表示进程正常结束:
#include <stdio.h>intmain(){ printf("程序执行中...\n"); return 0;}
另一种方式是调用 exit 函数,exit 函数是一个标准库函数,定义在<stdlib.h>头文件中 。它用于正常或异常地终止程序,并执行一些清理操作 。在调用 exit 时,程序会执行以下操作:调用所有已注册的 atexit 函数,这些函数可以用于释放资源、关闭文件等;刷新所有输出缓冲区,确保所有数据都被写入;关闭所有打开的文件描述符 。例如:
#include <stdio.h>#include <stdlib.h>voidcleanup(){ printf("执行清理函数...\n");}intmain(){ // 注册清理函数 atexit(cleanup); printf("测试缓冲区行为"); exit(0);}
还有一种方式是调用_exit 或_Exit 函数,它们是系统调用,定义在<unistd.h>头文件中 。_exit 和_Exit 函数用于立即终止程序,不执行任何清理操作 。这意味着它们不会调用通过 atexit 注册的函数,也不会刷新输出缓冲区 。例如:
#include <stdio.h>#include <unistd.h>intmain(){ printf("使用_exit...\n"); printf("这是缓冲区中的内容。"); _exit(0);}
(2)异常终止的原因。进程异常终止通常是由程序错误、资源问题或信号等原因导致的 。程序错误是导致进程异常终止的常见原因之一,例如段错误(Segmentation Fault),当程序试图访问它没有权限访问的内存地址,如空指针引用或者越界访问数组时,就会发生段错误 。以下是一个段错误的示例代码:
#include <stdio.h>intmain(){ int *ptr = NULL; *ptr = 100; // 空指针解引用,会导致段错误 return 0;}
除零错误也是一种常见的程序错误,当程序尝试除以零时,就会引发除零错误 。例如:
#include <stdio.h>intmain() { int a = 10; int b = 0; int c = a / b; // 除零操作,会导致程序异常终止 return 0;}
资源问题也可能导致进程异常终止 。当进程使用的资源,如内存、文件描述符等,超过了系统设定的限制时,就会出现资源不足的情况 。例如,当进程申请的内存空间超过了系统可用内存时,就会导致内存耗尽,进程可能会被操作系统终止 。信号也是导致进程异常终止的一个重要原因 。在 Linux 系统中,有许多不同类型的信号,其中一些信号是致命的,会导致进程立即终止 。
例如,SIGSEGV 信号表示段错误,当进程发生段错误时,操作系统会向该进程发送 SIGSEGV 信号,导致进程异常终止 ;SIGABRT 信号表示程序异常终止,通常是由 abort 函数调用或其他严重错误引起的 。还有一些非致命信号,如 SIGINT(通常由 Ctrl+C 触发)用于中断进程,SIGHUP 用于通知进程挂起 。这些信号可以被进程捕获并处理,如果进程没有处理这些信号,它们也可能导致进程异常终止 。
5.2 exit 与_exit 函数的区别
在 Linux 系统中,exit 函数和_exit 函数都用于终止进程,但它们之间存在一些关键的区别,这些区别在实际编程中需要特别注意 。
exit 函数是标准 C 库中的函数,它在终止进程之前会进行一系列的清理工作。当调用 exit 函数时,它首先会执行用户通过 atexit 或 on_exit 函数注册的清理函数,这些清理函数可以用于释放程序运行过程中分配的资源、关闭打开的文件、保存程序状态等操作。
然后,exit 函数会关闭所有打开的流,将缓冲区中的数据写入文件,确保数据的完整性 。例如,在一个写入文件的程序中,数据可能会先写入缓冲区,如果在数据还未写入文件时进程就终止了,可能会导致数据丢失。而使用 exit 函数可以保证缓冲区中的数据被写入文件,避免数据丢失。最后,exit 函数会调用_exit 函数来真正终止进程。
相比之下,_exit 函数是一个系统调用,它的作用更为直接和底层。_exit 函数直接使进程停止运行,清除其使用的内存空间,并销毁其在内核中的各种数据结构。它不会执行用户定义的清理函数,也不会刷新缓冲区,直接关闭所有打开的文件描述符并终止进程 。
由于_exit 函数没有额外的清理操作,所以它的执行速度比 exit 函数更快,适用于那些对执行速度要求较高,且不需要进行复杂清理工作的场景,比如一些守护进程在启动子进程后,子进程如果只需要简单地执行一些任务然后快速退出,就可以使用_exit 函数 。
为了更直观地理解两者的区别,我们来看一个简单的示例代码:
#include <stdio.h>#include <stdlib.h>#include <unistd.h>voidmy_cleanup(){ printf("This is a cleanup function.\n");}intmain(){ atexit(my_cleanup); printf("Before exit or _exit\n"); printf("This is a buffered output. "); // 使用 exit 函数 //exit(0); // 使用_exit 函数 _exit(0); return 0;}
在这段代码中,我们定义了一个清理函数 my_cleanup,并使用 atexit 函数将其注册。然后在 main 函数中,先输出一些信息,其中包含一个缓冲区输出。如果我们使用 exit 函数,程序会在终止前执行清理函数,并将缓冲区中的数据输出;而如果使用_exit 函数,程序会直接终止,不会执行清理函数,缓冲区中的数据也不会输出 。通过这个示例,我们可以清晰地看到 exit 函数和_exit 函数在功能和行为上的差异,在实际编程中,我们需要根据具体的需求选择合适的函数来终止进程。
六、内核视角:资源分配与调度
6.1 进程资源的分配机制
在 Linux 进程管理中,内核如同一位严谨的管家,负责为每个进程分配所需的资源,确保进程能够顺利运行。其中,内存分配是进程资源分配的重要环节,它直接影响着进程的运行效率和系统的整体性能 。
Linux 内核采用分页机制来管理内存,将内存划分为固定大小的页面,通常为 4KB。在进程创建时,内核会为其分配虚拟地址空间,这个虚拟地址空间由多个虚拟页面组成。虚拟地址空间为进程提供了独立的内存视图,使得每个进程都认为自己拥有整个内存空间,从而实现了进程之间的内存隔离。例如,当我们运行一个 C 语言程序时,程序中的变量、函数等都被映射到虚拟地址空间中,进程在访问这些内存位置时,使用的是虚拟地址 。
在实际的内存分配过程中,内核会根据进程的需求,将虚拟页面映射到物理内存页面上。这个映射过程通过页表来实现,页表是一种数据结构,它记录了虚拟页面与物理页面之间的对应关系。当进程访问虚拟地址时,内核会通过页表查找对应的物理地址,从而实现对物理内存的访问。如果所需的物理页面不在内存中,就会发生缺页中断,内核会从磁盘中读取相应的页面到内存中,并更新页表 。
除了内存,文件描述符也是进程重要的资源之一,它是 Linux 系统中用于标识进程打开的 I/O 资源的非负整数,是 Unix/Linux “一切皆文件” 哲学的核心体现。当进程打开一个文件或设备时,内核会为其分配一个文件描述符,通过这个文件描述符,进程可以对文件或设备进行读写、控制等操作。每个进程都有一个文件描述符表,用于记录其打开的文件描述符和对应的文件信息。文件描述符表是一个数组,数组的下标就是文件描述符,数组元素则指向相应的文件结构体,该结构体包含了文件的元数据和操作函数指针等信息 。
在进程创建时,内核会默认打开三个文件描述符,分别是标准输入(stdin,文件描述符为 0)、标准输出(stdout,文件描述符为 1)和标准错误输出(stderr,文件描述符为 2),它们为进程与外界的交互提供了基础通道。当进程需要打开新的文件时,内核会在文件描述符表中查找一个未被使用的最小下标,将其作为新的文件描述符分配给进程,并创建相应的文件结构体,将其指针存入文件描述符表中 。
6.2 进程调度的策略与算法
Linux 内核支持多种进程调度策略,以满足不同类型进程的需求,确保系统的高效运行和任务的及时响应。这些调度策略大致可分为实时调度策略和普通调度策略 。
实时调度策略主要用于对时间要求严格的实时进程,这类进程需要在规定的时间内完成任务,否则可能会导致严重的后果。实时调度策略包括先进先出调度(SCHED_FIFO)和轮流调度(SCHED_RR) 。SCHED_FIFO 是一种非抢占式的实时调度策略,它按照先进先出的顺序调度进程,一旦一个进程获得 CPU,它会一直运行,直到它主动放弃 CPU 或者被更高优先级的实时进程抢占 。例如,在工业控制领域,一些实时监控进程需要持续读取传感器数据,SCHED_FIFO 策略可以保证这些进程在获取 CPU 后能够不间断地运行,确保数据采集的及时性。
SCHED_RR 则是一种抢占式的实时调度策略,它为每个进程分配一个时间片,当进程的时间片用完后,它会被放回就绪队列的末尾,等待下一次调度。如果有更高优先级的实时进程进入就绪队列,它可以立即抢占当前正在运行的进程 。这种策略在一些对时间响应要求较高且需要公平性的实时场景中非常有用,比如多媒体播放,既要保证音频和视频的流畅播放,又要公平地分配 CPU 时间给其他实时任务。
普通调度策略适用于大多数非实时进程,它们对时间的要求相对不那么严格。普通调度策略中最常用的是完全公平调度算法(Completely Fair Scheduler,CFS) 。CFS 的核心思想是为每个进程维护一个虚拟运行时间(vruntime),并根据 vruntime 来调度进程。虚拟运行时间是根据进程的实际运行时间和权重计算得出的,权重反映了进程的优先级。进程的权重越高,其虚拟运行时间增长越慢,也就越容易被调度运行。例如,一个优先级较高的进程在相同的实际运行时间内,其虚拟运行时间的增长会比优先级较低的进程慢,这样在调度时,它就会更频繁地获得 CPU 资源 。
CFS 使用红黑树来管理可运行的进程,红黑树是一种自平衡的二叉搜索树,它能够保证在插入、删除和查找操作时的时间复杂度为 O (logN)。在 CFS 中,红黑树的节点是进程的调度实体(sched_entity),每个调度实体包含了进程的虚拟运行时间等信息。调度器在选择下一个运行的进程时,会从红黑树中选择虚拟运行时间最小的节点,即选择最 “饥饿” 的进程运行,从而实现了对所有进程的公平调度 。通过这种方式,CFS 有效地避免了某些进程长时间占用 CPU,而其他进程得不到运行机会的情况,保证了系统的公平性和整体性能 。
在一个同时运行多个普通进程的系统中,CFS 能够合理地分配 CPU 时间,使得每个进程都能得到公平的运行机会,无论是进行文本编辑的进程,还是进行数据处理的进程,都能在 CFS 的调度下有序地运行。