文章目录
- 进程和PCB
- linux与进程的相关命令
- PS
- linux下的PCB
- 进程标识符
- 父子进程
- fork
- 进程状态
- 磁盘睡眠 -- D
- 暂停和跟踪暂停 -- T和t
- 僵尸进程 -- Z
- 孤儿进程
- 进程优先级
- 进程地址空间
- 再谈fork
- 进程地址空间分布
- 虚拟地址和页表
- mm_struct
- 进程控制
- 进程终止
- 进程退出码
- 信号
- 进程等待
- 进程替换
进程和PCB
什么是进程?
课本上的定义有很多,如:进程是程序的一次执行,是加载到内存的程序,是系统进行资源分配和调度的一个独立单位。
我们不必去纠结定义,只需知道2点:如何描述进程?如何管理进程?
描述=提取进程属性,管理=对进程的属性进行管理
由此首先要引出一个概念:进程的PCB
PCB(process control block) 是什么?一句话:进程属性的集合,是一个结构体。此时进程就被拆分为2个部分:属性和数据,如下图:
linux与进程的相关命令
linux下的进程信息存储在/proc
目录下
大多数的进程信息也可以通过ps和top等用户级工具来获取
PS
ps命令用于显示当前正在运行的进程信息
a
: 显示所有用户的进程。通常情况下,ps命令仅显示与当前终端关联的进程,但使用-a选项可以显示所有用户的进程。
j
: 使用BSD风格的输出格式。这种格式下,ps命令会以进程状态、作业控制信息等形式显示进程信息。
x
: 显示与终端无关的进程。通常情况下,ps命令仅显示与当前终端关联的进程,但使用-x选项可以显示与当前终端无关的进程。
top命令用于动态显示系统中运行的进程的相关信息,包括进程的CPU利用率、内存利用率、进程ID等
linux下的PCB
在linux操作系统下的PCB:task_struct(结构体)
task_struct的内容分类:
- 标识符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- ……
重点介绍 标识符,状态,优先级
进程标识符
进程标识符,即PID:描述本进程的唯一标示符,用来区别其他进程。
这里PPID – parent PID, 即该进程的父进程的PID.
linux也提供了系统接口来获取进程的PID
返回值类型pid_t
, 本质是整型
父子进程
前文在谈到进程的描述符时谈到了父进程和子进程,怎么进程还有父子关系?
所谓父子进程,就是在一个进程的基础上创建出另一条完全独立的进程,这个就是子进程。
问题来了:
- 如何创建?
- 子进程是在父进程的基础上,那么二者的PCB、代码和数据有什么不同?
fork
linux下有一个系统调用fork,它可以创建子进程。
NAMEfork - create a child processSYNOPSIS#include <sys/types.h>#include <unistd.h>pid_t fork(void);
返回值:
失败了返回-1,并且没有子进程被创建,如果成功,父进程返回子进程的PID,子进程返回0
#include <sys/type.h>
#include <unistd.h>int main()
{pid_t id = fork();if(id == 0){//子进程}else if(id > 0){//父进程}else {//创建进程失败} }
这里就有问题了:
问题1:为什么创建子进程?
让子进程去处理其他事
问题2:为什么fork后子进程返回0?父进程返回子进程的pid
由于父只有一个,而子进程可以有很多个,因此父进程返回子进程的pid,来标识你创建好的子进程pid是多少。子进程返回0,因为子进程只有一个父亲,不需要额外标识出来。
问题3:为什么一个变量(id)有2个值?
子进程在父进程的基础上创建,这意味着父子进程具有相同的代码和数据。
那么这里的”相同“指的是
- 子进程复制一份父进程
- 子进程和父进程共享一份
实际情况是第2种:父子进程共享代码和数据
那么父子进程岂不是使用同一个变量(id)?
也不是,当子进程如果要更改数据时,会发生写时拷贝,即更改的数据拷贝一份,未更改的数据父子共享
总结:代码共享,数据写时拷贝
注:为什么要写时拷贝? 为了节省资源
问题3:父子进程谁先运行?
由各自PCB的调度信息(时间片,优先级等)+调度算法自助决定
进程状态
在操作系统理论中,进程一般有3种基本状态:运行、就绪和阻塞。
但上面的只是操作系统理论,实际的操作系统下的进程状态更复杂。
以linux为例,解释一下:运行,阻塞,挂起
在计算机操作系统中,进程可能会由于各种原因而进入阻塞挂起状态,其中包括等待输入/输出(I/O)操作完成、等待系统资源分配、等待进程间通信等。
在具体的Linux系统中,进程状态有以下几种:
R即运行状态,S即阻塞状态,这都好理解,但有3个状态很奇怪:D、T、t、X、Z
插一个小知识:我们使用ps命令查看进程会发现有些进程的状态会带个+号,如R+
这里的 +表示该进程是在前台运行
如果在运行时加上&
,可以让程序变为后台运行。此时如果想终止进程则需要kill -9 PID
磁盘睡眠 – D
进程的磁盘睡眠状态(Disk Sleep State)通常是指进程处于等待磁盘I/O操作完成的状态。这种状态通常出现在进程请求进行磁盘读取或写入操作时,但磁盘尚未完成相应的I/O操作,因此进程被阻塞,等待磁盘响应。在这种状态下,进程不会消耗CPU时间,而是被挂起,直到磁盘I/O操作完成。是阻塞挂起状态的一种形式
处于磁盘睡眠的进程,不响应操作系统的请求,直到进程完成它的I/O操作。
要想看到磁盘睡眠,需要进行高I/O操作,不容易演示。可以使用dd
命令来进行,由于dd命令的操作非常强大,但同时也非常底层,因此在使用时需要特别小心,避免造成意外的数据损坏或丢失。
暂停和跟踪暂停 – T和t
Linux操作系统的有个信号kill -19
, 可以使进程暂停。T状态即进程处于暂停状态。注意不要于S状态混淆,S状态一定是进程在等待某种资源,但T状态不一定在等待某种资源。
那T和t有什么区别呢?
Stopped(停止)状态:
进程处于停止状态通常是由于接收到了一个信号,例如SIGSTOP(Ctrl-Z产生的SIGTSTP信号)或者SIGTSTP(通常由shell的暂停命令引发)。这种状态下的进程被挂起,暂时停止执行,但可以通过发送SIGCONT信号来恢复执行。
Tracing Stop(跟踪停止)状态:
进程处于跟踪停止状态通常是由于调试器(如GDB)或者ptrace系统调用的作用。在这种状态下,进程被调试器所追踪,通常是因为调试器在进行单步执行、观察或者修改进程的内存等操作。这种状态下的进程暂时停止执行,直到调试器允许其继续执行。
一般认为T和t没什么区别。
僵尸进程 – Z
当一个进程(子进程)完成执行后,它的退出状态需要被父进程获取。如果父进程没有主动获取子进程的退出状态,那么子进程就会变成僵尸进程,相当于一个人处于生死之间。
下方代码实现:父进程一直运行,子进程执行3次后结束
结果如下:子进程的状态由S+ --> Z+, Z即处于僵尸状态
僵尸进程虽然不会直接对系统造成严重影响,但长时间存在的僵尸进程会对系统的正常运行产生一些间接的危害,包括:可能导致资源耗尽,影响进程管理,降低系统稳定性。因此需要父进程处理僵尸进程。
父进程通常需要调用类似于wait()或waitpid()的系统调用来等待子进程的退出,并获取其退出状态。
当然如果父进程也结束,系统会自动把子进程释放。
孤儿进程
僵尸进程是子进程结束,但父进程未结束。如何父进程先结束,子进程后结束呢?那么子进程便会变为孤儿进程,并被托孤给1号进程,即操作系统。
进程优先级
PRI(Priority):PRI 表示进程的静态优先级或调度优先级。俗点说就是程序被CPU执行的先后顺序,此值越小,进程的优先级别越高。
NI(Nice Value):NI 表示进程的 Nice 值,是一个表示进程调度优先级的数值。它的作用是改变PRI的值。
通过PRI和NI可以调整进程的优先级,计算公式如下:
PRI(new)=PRI(old)+nice
’
这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行所以,调整进程优先级,在Linux下,就是调整进程nice值
注意:nice 的范围在 【-20, 19】
PRI(old) 最小是80,如果原来的PRI < 80, 则会直接从80开始算:
因此:PRI 范围【60, 99】,但在计算新的PRI时,最小从80开始
举例:原来 : PRI = 60 NI = 0;
更改:令PRI = 100
结果:PRI = 99 NI = 19
那如何更改nice值呢?
在Linux系统中,nice
命令用于启动一个新的进程,并设置其优先级。而renice
命令用于修改已经运行的进程的优先级。
nice
命令的使用:
nice [OPTION] [COMMAND [ARG]...]
nice
命令通过改变进程的优先级来影响其调度。数值越大,优先级越低。默认情况下,优先级是0。
例如,运行一个命令并设置其优先级:
nice -n <优先级> <命令>
例如,将ls
命令的优先级降低为10:
nice -n 10 ls
renice
命令的使用:
renice [优先级] -p <进程ID> [<进程ID>...]
renice
命令用于修改已经运行的进程的优先级。可以指定一个或多个进程ID来修改它们的优先级。
例如,将进程ID为1234的进程的优先级设置为10:
renice 10 -p 1234
如果你想要提高某个进程的优先级,你需要具有足够的权限。通常,只有超级用户(root)才能提高进程的优先级,而普通用户只能降低自己创建的进程的优先级。
进程地址空间
再谈fork
#include <sys/types.h>
#include <unistd.h>
#include <iostream>
int g_val = 100;int main()
{pid_t id = fork();if(id == 0){g_val = 200;printf("子进程:g_val = %d, &g_val = %p\n", g_val, &g_val);}else if(id > 0){printf("父进程:g_val = %d, &g_val = %p\n", g_val, &g_val);}
}结果:
父进程:g_val = 100, &g_val = 0x55631bc26010
子进程:g_val = 200, &g_val = 0x55631bc26010
子进程更改数据后,会发生写时拷贝,因此子进程和父进程的g_val值不一样,符合预期,但是为什么发生了写时拷贝,父子进程的g_val地址还是相同?
显然这里的地址一定不是真实的地址。
要解释这个问题,要引入一个概念:进程地址空间
进程地址空间分布
c/c++常见的地址分布图,过去我们称它为程序地址分布,但实际它真正的名字是进程地址空间
不同语言的进程地址空间大致相同,下面以c++的地址分布图为例。
先验证:
#include <iostream>
using namespace std;
int g_A;
int g_B = 100;
int main()
{const char* a = "ab";static int s_A = 5;int A;int B;int C;int* m_A = new int;int* m_B = new int;int* m_C = new int;printf("字符常量:a : %p\n", a);cout << "静态变量:s_A : " << &s_A << endl;cout << "全局变量:未初始化g_A : " << &g_A << endl;cout << "全局变量:已初始化g_B : " << &g_B << endl;cout << "栈区:A : " << &A << endl;cout << "栈区:B : " << &B << endl;cout << "栈区:C : " << &C << endl;cout << "堆区:m_A : " << m_A << endl;cout << "堆区:m_B : " << m_B << endl;cout << "堆区:m_C : " << m_C << endl;
}结果
字符常量:a : 0x5649d4534009
静态变量:s_A : 0x5649d4536014
全局变量:未初始化g_A : 0x5649d4536154 未初始化地址 > 已初始化地址 符合
全局变量:已初始化g_B : 0x5649d4536010
栈区:A : 0x7ffc892fb3cc 栈区的地址是增长的, 不符合
栈区:B : 0x7ffc892fb3d0
栈区:C : 0x7ffc892fb3d4
堆区:m_A : 0x5649d5eadeb0 堆区的地址是增长的,符合
堆区:m_B : 0x5649d5eaded0
堆区:m_C : 0x5649d5eadef0
怎么栈区的地址是向上增长的呢?
这里要解释一个概念:函数栈帧,当我们调用函数时,栈区会开辟一块空间给函数使用。而函数内的局部变量是在函数栈帧中开辟空间。
栈帧的分配是向下增长的,但是栈帧内部的局部变量的地址分配是由编译器的策略来决定的,
虚拟地址和页表
每一个进程,操作系统都会分配一个进程地址空间,对于32位机器,总地址大小位4GB。每个进程都分配4GB的内存,这可能吗?不可能。
因此进程地址空间里的地址是虚拟地址,通过页表与物理地址映射。
回到fork里的问题:为什么父子进程不同的值有着相同的地址?因为这里的地址是虚拟地址。
子进程只需更改子进程页表。
mm_struct
进程控制
进程终止
当一个进程退出时,有以下3种场景:
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
对于场景1,我们无需关心。
对于场景2,结果不正确,因此我们需要查明为什么不正确。通过什么来查明? 进程的退出码。即main函数的返回值。
对于场景3,代码异常终止,此时还需要关心进程的退出码吗?不用。退出码是结果是否正确的标志。进程是否异常的标志是信号。比如ctrl + c 终止进程,就是向进程发送SIGINT信号
进程退出码
这里要介绍2个控制进程退出的函数
_exit函数#include <unistd.h>
void _exit(int status);参数:status 定义了进程的终止状态,父进程通过wait来获取该值
说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值
是255。exit函数#include <unistd.h>
void exit(int status);exit最后也会调用exit, 但在调用exit之前,还做了其他工作:
1. 执行用户通过 atexit或on_exit定义的清理函数。
2. 关闭所有打开的流,所有的缓存数据均被写入
3. 调用_exit
我们平时控制进程退出是使用return。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数。回值当做 exit的参数。