【linux系统】进程

发布于:2024-10-12 ⋅ 阅读:(7) ⋅ 点赞:(0)


进程和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的内容分类:

  1. 标识符: 描述本进程的唯一标示符,用来区别其他进程。
  2. 状态: 任务状态,退出代码,退出信号等。
  3. 优先级: 相对于其他进程的优先级。
  4. 程序计数器: 程序中即将被执行的下一条指令的地址。
  5. 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
  6. 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
  7. I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
  8. 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
  9. ……

重点介绍 标识符,状态,优先级

进程标识符

进程标识符,即PID:描述本进程的唯一标示符,用来区别其他进程。
在这里插入图片描述
这里PPID – parent PID, 即该进程的父进程的PID.

linux也提供了系统接口来获取进程的PID
在这里插入图片描述
返回值类型pid_t, 本质是整型
在这里插入图片描述

父子进程

前文在谈到进程的描述符时谈到了父进程和子进程,怎么进程还有父子关系?

所谓父子进程,就是在一个进程的基础上创建出另一条完全独立的进程,这个就是子进程。
问题来了:

  1. 如何创建?
  2. 子进程是在父进程的基础上,那么二者的PCB、代码和数据有什么不同?

fork

linux下有一个系统调用fork,它可以创建子进程。

NAME
       fork - create a child process

SYNOPSIS
       #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个值?
子进程在父进程的基础上创建,这意味着父子进程具有相同的代码和数据。
那么这里的”相同“指的是

  1. 子进程复制一份父进程
  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. 代码运行完毕,结果不正确
  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的参数

信号

进程等待

进程替换