进程
基本概念
程序:使用编程语言编写的代码经过编译和链接处理,得到的计算机可以理解和执行的指令。
进程:进程(Process)是程序的动态执行实例,包含程序运行时的所有状态信息(如 CPU 寄存器、内存地址、文件描述符等)。是操作系统进行资源分配和调度的基本单位,在内核看来,进程是一个个实体,内核需要在他们之间共享各种计算机资源。例如内存资源,内核会在开始为进程分配一定大小的内存,并统筹该进程和整个系统对内存的需求,对这一分配进行跳转。程序终止时,内核会释放所有资源,供其他进程使用。
程序是静态的:是存储在磁盘上的指令和数据的集合(如/bin/ls)。
进程是动态的:程序被加载到内存后,由内核调度执行,会经历创建、运行、终止等生命周期。
即使只有一个CPU也支持并发。例:当一个程序在读取磁盘文件时,硬盘读写速度是很慢的,如果CPU一直等到硬盘返回数据,CPU利用率就会很低。所以,当进程从硬盘读取数据时,CPU不需要阻塞等待数据的返回,而去执行其他进程。当硬盘数据返回时,给CPU发送中断,CPU就可以回来继续执行了。
CPU可以通过交替执行程序管理多个进程,CPU会在进程间快速切换,使每个程序运行几十或几百毫秒。然而,在任意一个瞬间,CPU只能运行一个进程,如果把时间定位1秒内的话,他可能运行多个进程。这样就会让我们产生并行的错觉,实际上是并发
核心特征:
- 动态性:进程随程序执行而创建,随执行结束而消亡,状态会动态变化(如运行→睡眠)。
- 独立性:每个进程拥有独立的地址空间(默认不共享内存),通过内核隔离资源。
- 并发性:多个进程可通过 CPU 调度 “同时” 运行(单核 CPU 通过时间片切换实现,多核 CPU 可物理并行)。
进程状态
当一个进程开始运行时,他会经历下面这几种状态:
- 运行态:运行态指的就是进程实际占用CPU的时间片运行时
- 就绪态:就绪态指的是可运行,但因为其他进程正在运行而处于就绪状态
- 阻塞态:该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时即使给它CPU控制权,它也无法运行。
状态切换:
- 运行状态到阻塞态:当进程遇到某个事件需要等待时会进入阻塞态
- 阻塞态到就绪态:当进程要等待的事件完成时会从阻塞态变到就绪态
- 就绪态到运行态:处于就绪态的进程被操作系统的调度程序选中后,就分配 CPU 开始运行
- 运行态到就绪态:进程运行过中,分配它的时间片用完后,操作系统会将其变为就绪态,接着从就绪态中选择一个运行
程序调度指的是,决定哪个进程优先被运行和运行多长,这是很重要的一点。已经设计出多各算法来尝试平衡系统整体效率与各个流程之间的竞争需求。
进程的组成
进程在内存中由多个段(Segment)组成,各段分工明确,共同支撑进程运行:
段名称 | 作用 | 特点 |
---|---|---|
代码段(Text) | 存储程序的可执行指令(如函数体)。 | 只读(防止被意外修改),可被多个进程共享(如多个ls进程共享同一段代码)。 |
数据段(Data) | 存储已初始化的全局变量和静态变量(如int a = 10;)。 | 可读可写,进程私有(每个进程的修改不影响其他进程)。 |
BSS 段 | 存储未初始化的全局变量和静态变量(如int b;)。 | 内核会在进程加载时将其初始化为 0,大小在编译时确定。 |
堆(Heap) | 存储动态分配的内存(如malloc()/new申请的内存)。 | 由程序员手动管理(需显式释放),从低地址向高地址增长(动态扩展)。 |
栈(Stack) | 存储函数调用信息(返回地址、参数)、局部变量、临时数据。 | 由编译器自动管理(函数调用时分配,返回时释放),从高地址向低地址增长。 |
进程标识符
Linux 内核通过唯一的标识符(ID)管理进程,核心 ID 包括:
基础 ID:
- PID(Process ID):进程的唯一标识,由内核在进程创建时分配(范围通常为30000以下,不同系统可能不同)。
- 特殊 PID:PID=0是内核调度进程(swapper),PID=1是初始化进程(现代系统为systemd,传统为init),是所有进程的 “祖先”。
- PPID(Parent PID):当前进程的父进程 ID(除PID=1外,所有进程均有父进程)。
扩展 ID(用于进程组管理)
- PGID(Process Group ID):进程组的标识,一个进程组包含多个关联进程(如管道中的多个命令),便于批量发送信号(如kill -TERM -PGID终止整个组)。
- SID(Session ID):会话的标识,一个会话包含多个进程组,通常与终端关联(如终端关闭时,会话内进程会收到SIGHUP信号)。
进程控制块PCB
每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux 内核的进程控制块是 task_struct 结构体。其中成员有很多,以下是重点部分:
- 进程 id,每个进程有唯一的 id,用 pid_t 类型表示,其实就是一个非负整数
- 进程的状态,有就绪、运行、挂起状态
- 描述虚拟地址空间的信息
- 文件描述符表,包含很多指向 file 结构体的指针
- 进程切换时需要保存和恢复的一些 CPU 寄存器
- 描述控制终端的信息
- 当前工作目录
- umask 掩码
- 和信号相关的信息
- 用户 id 和组 id
- 会话(Session)和进程组
- 进程可以使用的资源上限(Resource Limit)
进程的生命周期
创建(创建子进程)
创建进程的方式:
- 系统初始化:启动操作系统时会创建若干个进程
- 用户请求创建:例如双击图标启动程序
- 系统调用创建:一个运行的进程可以发出系统调用创建新的进程帮助其完成工作
父进程通过系统调用创建子进程,核心步骤:
- 父进程调用fork():内核复制父进程的task_struct(PCB),分配新 PID,子进程继承父进程的资源(如文件描述符、内存空间)。
- 特性:成功后fork()返回两次 —— 父进程返回子进程 PID,子进程返回 0(便于区分父子进程)。失败返回-1,子进程不被创建
fork() 函数会创建一个子进程,父进程的内容会复制到子进程的进程空间中,包括父进程的数据段和堆栈段,并且和父进程共享代码段,所以成功后父子进程都停留在了进程创建函数(fork)上,因此,fork()函数在父子进程中都会返回,两个返回值不同。
- 优化:采用写时复制(COW,Copy-On-Write):子进程初始共享父进程内存,仅当父子进程修改数据时,内核才复制该内存页(减少冗余复制)。
父子进程间遵循读时共享写时复制(copy-on-write)的原则。现在的 Linux 内核在 fork()函数时往往在创建子进程时并不立即复制父进程的数据段和堆栈段,而是当子进程修改这些数据内容时复制操作才会发生,内核才会给子进程分配进程空间,将父进程的内容复制过来,然后继续后面的操作,这样的实现更加合理,对于那些只是为了复制自身完成一些工作的进程来说,这样做的效率会更高。这也是现代操作系统的一个重要的概念——“写时复制”的一个重要体现。
- 子进程加载新程序(可选):子进程可通过execve()系列函数(execl/execv等)加载新程序,替换自身的代码段、数据段等(PID 不变,实现 “运行新程序”)。
示例:Shell 执行ls命令时,先fork()创建子进程,再通过execve()加载/bin/ls程序。
注意事项:
- 父子进程的局部变量,全局变量,堆区空间不是共享的,父子进程打印变量的地址是相同的(该地址是虚拟地址空间),但是他们指向的物理空间是不同的
- 父子进程间文件共享,执行fork()子进程会获得父进程所有文件描述符的副本,这些副本的创建类似于dup(),同一文件描述符在父子进程中对应的是相同的文件
exec函数族
fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种 exec 函数以执行另一个程序。当执行调用一种 exec 函数时,通过该调用,进程能以全新程序来替换当前运行的程序。将当前进程的代码段和数据段换为所要加载的程序的代码段和数据段,然后让进程从新的一段指令开始执行,但进程 ID 不变,换核不换壳。
其实有六种以 exec 开头的函数,统称 exec 函数:
#include<unistd.h>
int execl(const char*pathname,const char*arg,...);
int execlp(const char*file,const char*arg,...)
int execle(const char*pathname,const char*arg,...,char *const envp[]);
int execv(const char*pathname,char* const argv[],...);
int execvp(const char*file,char* const argv[],...);
int execvpe(const char*file,char* const argv[],...);
他们功能都一样,只是使用方式略有不同
execl函数
作用:加载一个进程,通过路径+程序名来加载
函数参数:
pathname:可执行文件路径
arg:可执行程序的参数,对应main()函数的第二个参数(argv),格式相同,以NULL结束
函数返回值;
- 成功:无返回
- 失败:-1
execlp函数
作用:加载一个进程,借助PATH环境变量
函数参数:
file:可执行文件的文件名,系统会在环境变量PATH的母鹿列表中寻找可执行文件
arg:可执行程序的参数
函数返回值
成功:无返回
失败:-1
运行
运行(调度)
进程的运行由内核调度器(Scheduler)管理,目标是公平分配 CPU 资源并保证响应速度。
- 调度策略:
- 普通进程:采用CFS(完全公平调度器),基于 “虚拟运行时间” 分配 CPU(优先级高的进程虚拟时间增长慢,获得更多 CPU)。
- 实时进程:采用SCHED_FIFO(先进先出)或SCHED_RR(时间片轮转),优先级高于普通进程(用于对延迟敏感的场景,如工业控制)。
- 优先级:通过nice值(范围-20~19)调整,nice值越小,优先级越高(默认 0)。
终止
终止(退出)
进程终止的原因包括:
- 正常终止:
- 程序执行完main函数(return)。
- 调用exit()(会刷新缓冲区)或_exit()(直接终止,不刷新缓冲区)。
- 异常终止:
- 收到致命信号(如SIGSEGV段错误、SIGKILL强制终止)。
- 硬件错误(如 CPU 异常)。
进程的退出状态,0表示正常退出,非0值表示因异常退出,保存在全局变量?中,?中,?中,?保存的是最近一次运行的进程的返回值,返回值有以下3中情况:
- 程序中的main函数运行结束后,$?中保存main()函数的返回值
- 程序运行中调用exit函数结束运行,$?中保存exit函数的参数
- 程序异常退出$?中保存异常出错的错误号
资源回收
资源回收
一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的 PCB 还保留着,内核在其中保留了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用 wait 或 waitpid 获取这些信息,然后彻底删除这个进程。我们知道一个进程的退出状态可以在 Shell 中用特殊变量 $? 查看,因为 Shell 是它的父进程,当它终止时 Shell 调用 wait 或 waitpid 得到它的退出状态同时底清除掉这个进程。
wait函数
- 父进程调用wait():阻塞等待任意子进程终止,回收其资源。
- 功能:
- 阻塞等待子进程退出
- 回收子进程残留资源
- 获取子进程结束状态
- 头文件
- #include<sys/types.h>
- #include<sys/wait.h>
- 函数返回值:
- 成功:返回清理掉的子进程ID;
- 失败:返回-1(没有子进程)
当进程终止时,操作系统的隐式回收机制会:
- 关闭所有文件描述符
- 释放用户空间,分配的内存。内核的PCB仍存在。其中保存该进程的退出状态
(正常终止->退出值;异常终止->终止信号)
可使用wait函数传出参数status来保存进程的退出状态。借助宏函数来进一步判断进程终止的具体原因。宏函数可分为如下三组:
waitpid函数
- 父进程调用waitpid():可指定等待的子进程(通过 PID),支持非阻塞模式。
作用同wait,但可指定进程id为pid的进程清理,可以不阻塞
头文件
- #include<sys/types.h>
- #include<sys/wait.h>
函数原型: pid_t waitpid(pid_t pid,int *status,int options);
参数 pid
pid>0:回收指定ID的子进程
pid=-1:回收任意子进程(相当于wait)
pid=0:回收和当前调用进程(父进程)一个组的任意子进程
pid<-1:设置负的进程组ID,回收指定进程组内的任意子进程
函数返回值
成功:返回清理掉的子进程ID
失败:返回-1(无子进程)
参数3为WNOHANG。且子进程正在运行,返回0
注意:一次wait或者waitpid调用只能清理一个子进程,清理多个子进程应使用循环
孤儿进程和僵尸进程
孤儿进程:父进程先于子进程结束,则子进程成为孤儿进程,变成孤儿进程后会有一个专门用于回收的init进程成为它的父进程,称init进程为领养孤儿进程
僵尸进程:进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸进程。
特别注意:
僵尸进程是不能使用kill命令清除掉的,因为kill命令只是用来终止进程的,而僵尸进程已经终止。可以杀死它的父进程,让init进程变成它的父进程,init进程可以回收它