一、进程概念
1. 狭义定义
在生活中,当我们打开某个软件的时候,其实就是将这个程序运行起来,这些程序运行起来都需要被加载到内存中去,而这每一个运行起来的程序就是一个进程,所以说进程是正在运行的程序的实例。
2. 广义定义
进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动,它是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
3. PCB
在操作系统中,进程的描述信息被放在一个数据结构中——进程控制块,它就好比是一个进程属性的集合。而在Linux操作系统中,描述进程信息的PCB是一个结构体——task_struct,系统在管理进程的时候会先用 task_struct 将进程信息描述起来,然后再使用双向链表将这些结构体组织起来进行管理。
4. 进程描述信息
- 标识符(PID):描述本进程的唯一标识符,用来区别其他进程
- 状态:描述进程的状态,进程有多个状态,比如运行、睡眠、停止等
- 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表等
- 记账信息:包括处理器的时间总和,时间限制,记账号等
- 优先级:相对于其他进程的优先级
- 程序计数器:程序中即将被执行的下一条指令的地址
- 上下文数据:进程执行时处理器的寄存器中的数据
5. 查看进程信息
(1)通过 /proc 系统文件查看
如果我们要获取PID为1这个进程的信息,那么就通过 /proc/1 这个系统文件来查看
(2)通过 ps 命令查看
- ps -ef :查看当前操作系统所有进程的信息
- ps aux : 查看详细信息
- ps -ef | head -n 1 && ps -ef | grep test :显示信息首行并筛选出关于test的进程信息
(3)通过 top 任务管理器查看
(4)通过 getpid() 在代码中获取进程PID
二、进程创建
fork() :通过复制调用进程创建新进程
说明:
(1)调用进程称为父进程,创建出来的新进程称为子进程
(2)对于父进程来说返回值是子进程的PID,对于子进程来说返回值是0,我们可以通过返回值来判断父子进程,从而进行代码分流
(3)父子进程共用同一个代码段,但是它们的数据并不共用
实例:
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t pid = fork(); //创建一个新进程,通过复制调用进程
if(pid < 0)
{
printf("fork error\n");
return -1;
}
else if(pid == 0) //子进程,返回值是0
{
printf("This is a child process. [PID:%d] [pid=%d]\n", getpid(), pid);
}
else //父进程,返回值是子进程的pid
{
printf("This is a parent process. [PID:%d] [pid=%d]\n", getpid(), pid);
}
while(1)
{}
return 0;
}
结果:
三、进程状态
一个进程从创建到销毁的整个生命周期可以划分为一组状态,这些状态刻画了整个进程,可以说,进程状态即体现一个进程的生命状态。
1. 三态模型
为了便于管理进程,一般来说,按进程在运行过程中的不同情况至少要定义三种不同的进程状态:
基本状态 | 含义 |
---|---|
就绪态 | 进程已经具备运行条件,等待系统分配处理器以便运行 |
运行态 | 进程占用CPU,并在CPU上运行 |
等待态 / 阻塞态 / 睡眠态 | 正在执行的进程,由于等待某个事件发生而暂时无法运行。引起进程阻塞的事件可有多种,例如,等待I/O完成、申请缓冲区不能满足、等待信号等。 |
通常,每个进程在运行过程中,任意时刻当且仅当处于上述三种状态之一。同时,在一个进程运行过程中,它的状态将会发生改变,这将引起状态之间进行转换。状态转换关系主要有以下几种:
状态转换 | 原因 |
---|---|
就绪态 -> 运行态 | CPU空闲时被调度选中一个就绪进程运行 |
运行态 -> 就绪态 | 运行时间片到,或出现有更高优先权进程 |
运行态 -> 等待态 | 正在运行的进程因发生某等待事件而无法继续运行(如发生了I/O请求) |
等待态 -> 就绪态 | 进程所等待的事件已经发生,则进入就绪队列(如I/O完成) |
图解:
注意: 以下两种状态转换关系是不可能发生的
状态转换(不可发生) | 原因 |
---|---|
就绪态 -> 等待态 | 就绪进程没有占用CPU,也就是说,它根本没有运行,那么它的状态就不会改变,更谈不上进入等待态 |
等待态 -> 运行态 | 操作系统在进行调度时不会从阻塞队列进行挑选,而是从就绪队列中选取。等待进程被唤醒后首先要进入就绪队列,这样才会被系统调度选中,从而进入运行状态,即使给等待进程分配CPU,也无法执行 |
2. 其他状态
在一些系统中,又增加了一些新的状态,如可运行状态、可中断睡眠状态、不可中断睡眠状态、停止状态、追踪状态、僵尸状态、死亡状态等。
(1)可运行状态(R)
运行态和就绪态的合并,表示进程正在运行或者在就绪队列中准备运行,在 Linux 中使用 TASK_RUNNING 宏表示可运行状态。
一般而言,在同一个时刻可能有多个进程处于可运行状态,那么这些进程的task_struct结构就都会被放入对应CPU的就绪队列中,等待CPU来调度,当CPU空闲的时候就会从就绪队列中调度某一个进程的task_struct描述信息结构,这个过程就是进程调度 。
(2)可中断睡眠状态(S)
进程处于睡眠状态,并且可以被其他进程信号或时钟中断唤醒,在 Linux 中使用 TASK_INTERRUPTIBLE 宏表示此状态。
进程处于这个状态是因为等待某个事件发生,而被挂起,暂时无法运行。这些进程的 task_struct 结构会被放入对应事件的等待队列中,当这些事件发生时(由外部中断触发或由其他进程触发),那么对应等待队列中的一个或多个进程将被唤醒,所以该状态也可被称为浅度睡眠状态 。
通常,在进程列表中,大多数的进程都是处于这个状态,因为CPU就那么几个,而进程可以达到上百个,也可能更多,如果不是大多数的进程处于睡眠状态,那CPU又怎能响应的过来!
(3)不可中断睡眠状态(D)
和 TASK_INTERRUPTIBLE 状态类似,都是进程处于睡眠状态,但是区别是该状态是不可中断的,这指的是进程不能响应异步信号,即不能被其他进程信号或时钟中断唤醒,所以该状态也可被称为深度睡眠状态 ,在 Linux 中使用 TASK_UNINTERRUPTIBLE 宏表示此状态。
TASK_UNINTERRUPTIBLE 状态存在的意义就在于,内核的某些处理流程是不能被打断的。如果响应异步信号,程序的执行流程中就会被插入一段用于处理异步信号的流程(这个插入的流程可能只存在于内核态,也可能延伸到用户态),于是原有的流程就被中断了。
在进程对某些硬件进行操作时(比如进程调用read系统调用对某个设备文件进行读操作,而read系统调用最终执行到对应设备驱动的代码,并与对应的物理设备进行交互),可能需要使用 TASK_UNINTERRUPTIBLE 状态对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。
一般情况下,TASK_UNINTERRUPTIBLE 状态总是非常短暂的,通过 ps 命令基本上不可能捕捉到,但是Linux系统中也存在容易捕捉的 TASK_UNINTERRUPTIBLE 状态,比如执行vfork系统调用后,父进程将进入 TASK_UNINTERRUPTIBLE 状态,直到子进程调用 exit 或 exec函数
(4)停止状态(T)
表示进程停止运行接受某种处理,在 Linux 中使用 TASK_STOPPED 宏表示此状态。
一般向进程发送一个 SIGSTOP 信号,它就会因响应该信号而进入 TASK_STOPPED 状态(除非该进程本身处于不可中断睡眠状态而不响应该信号)。若要解除该状态,可以向进程发送一个 SIGCONT 信号,可以让其从 TASK_STOPPED 状态恢复到 TASK_RUNNING 状态。
(5)追踪状态(t)
表示进程正在被跟踪,这指的是进程暂停下来,等待跟踪它的进程对它进行操作。比如在 gdb 调试中对被跟踪的进程下一个断点,进程在断点处停下来的时候就处于追踪状态。在 Linux 中使用 TASK_TRACED 宏表示此状态。
对于进程本身来说,TASK_STOPPED 和 TASK_TRACED 状态很类似,都是表示进程暂停下来,而追踪状态相当于在停止状态之上多了一层保护,处于追踪状态的进程不能响应 SIGCONT 信号而被唤醒,只能等到调试进程通过 ptrace 系统调用执行 PTRACE_CONT、PTRACE_DETACH 等操作,或调试进程退出,被调试的进程才能恢复可运行状态。
(6)僵尸状态(Z)
表示进程已经结束,在退出的过程中,除了 task_struct 描述信息结构以外,进程占有的所有资源将被回收。在Linux 中使用 EXIT_ZOMBIE 宏表示此状态。
(7)死亡状态(X)
表示进程在退出的时候,将会释放它的所有占用资源,不会保留它的 task_struct 描述信息结构。在Linux 中使用 EXIT_DEAD 宏表示此状态。
处于该状态的进程会立即彻底释放它所占用的资源,所以 EXIT_DEAD 状态是非常短暂的,几乎不可能通过 ps 命令捕捉到。
四、进程优先级
1. 概念
CPU给多个进程分配资源的先后顺序 ,这就是进程的优先级(priority)。进程的优先级越高,那它的优先执行权利就越高。
2. 为什么需要进程优先级
在多任务操作系统中,CPU需要处理许许多多的进程,而在这众多的进程中,有些很重要,还有些在当前看来不是很重要。为了提高性能,我们可以让CPU去优先处理那些重要的进程,而剩下的那些可以放在后面再处理,所以这就需要引进进程的优先级,以便区分这些进程。此外,进程的优先级也要能让我们人为控制,这样我们就可以更加灵活地去控制这些进程的执行顺序,进一步提高系统性能。
3. 如何修改进程优先级
在这里,我们首先要知道两个概念:PRI 和 NI ,通过查看系统进程(在Linux中使用 ps -l 命令),我们会发现在进程的信息中有这两个值,如下图所示:
那么这两个值代表什么呢?
其实,我们看看PRI ,这不就是 priority 的缩写吗!显然,这就是进程的优先级,它的值越小,那么进程的优先级就越高。而 NI 代表的是这个进程的nice值,它表示进程可被执行的优先级的修正数值,也就是说,我们可以通过修改这个值来间接修改进程的优先级,需要说明的是,这个值的取值范围是-20~19,一共40个级别。
于是,在加入了nice值后,我们的PRI将会变成:PRI(new) = PRI(old) + NI。这样,当NI为负数的时候,PRI就会越小,进程的优先级也就越高,也就越早被运行。所以,调整进程优先级,在Linux下就是调整nice值。
此外,我们还需要注意一点,进程的nice值并不是进程的优先级,它们两个不是同一个概念,只能说进程的nice值可以影响进程优先级。
那么如何修改nice值呢?
通过下面这份代码来进行演示:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("PID:%d\n", getpid()); //获取进程PID
while(1)
{
sleep(1);
}
return 0;
}
提前说明:以下三种方法都需要root权限,否则权限不允许!
(1)nice 命令
说明:在进程启动前指定nice值
实例:
之后,我们来观察进程的 PRI 和 NI 值,如下:
(2)renice 命令
说明:在进程启动后修改nice值
实例:
之后,我们再来观察进程的 PRI 和 NI 值,如下:
(3)top 任务管理器
实例:
之后,我们再来观察进程的 PRI 和 NI 值,如下:
五、进程地址空间
1. 虚拟地址
首先,我们在这里分析一下下面这份代码:
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
int num = 0; // 全局变量num
int main()
{
pid_t pid = fork();
if(pid < 0)
{
perror("fork error");
return 0;
}
else if(pid == 0) // 子进程
{
num = 10;
printf("child process [PID:%d] num=%d &num=%p\n", getpid(), num, &num);
}
else // 父进程
{
sleep(3);
printf("parent process[PID:%d] num= %d &num=%p\n", getpid(), num, &num);
}
return 0;
}
运行结果:
通过运行结果我们发现:父子进程的num变量,值不同,但地址却是相同的,这是值得我们深思的!
其实,通过上面的例子,我们首先能得出以下结论:
- 变量值不同,说明父子进程输出的变量不是同一个变量
- 变量地址相同,说明这个地址一定不是物理地址。在Linux下,这种地址叫做虚拟地址
其实,对于我们用户来说,看到的所有地址都是虚拟地址,而物理地址是看不到的,它由操作系统统一管理。原因是,在物理内存上面,任何的区域和位置都是可读可写的,假如进程直接访问的是物理内存,那么系统就会存在很大的安全隐患,所以为了提高安全性,我们用户只能通过虚拟地址来间接访问物理内存。而这种间接访问是需要操作系统帮忙的,操作系统必须将虚拟地址转化成物理地址,这样我们才能访问到物理内存。
那么操作系统是如何进行转化的呢?
2. 页表
(1)概念
页表就是用于将虚拟地址转换为物理地址的转换关系表。访问虚拟地址时,操作系统会通过页表中的映射关系来找到对应的实际物理地址,从而进行访问。
(2)图解
从上面的图中,我们就可以看出同一个变量,地址相同,其实是虚拟地址相同;值不同,其实是被映射到了不同的物理地址。