进程
进程概念
当执行一个可执行程序时,会先把可执行程序加载到内存中。将程序加载到内存中并执行的时候,操作系统为该程序创建一个进程,进程中包含程序的代码、数据、PCB(用于描述和管理进程状态的数据结构),进程地址空间(描述进程在内存中布局的数据结构),页表(管理进程内存的重要数据结构)等。 所以进程 = 内核数据结构+程序
操作系统管理进程
操作系统创建进程时,会创建一个PCB(进程控制块)的数据结构,操作系统从此管理进程,不会去管理进程的程序,将其转换对特定数据结构的管理,先将进程状态描述起来,在将其用特定的数据结构组起来(Linux下用的是双向链表)。
- 描述起来:用struct结构体
- 组织起来:用特定的数据结构
描述进程-PCB
- 操作系统创建进程之后,会给每一个进程建立一个PCB对象
- 操作系统管理进程时会将进程的信息放在一个叫做进行控制块的数据结构中,即进行属性的集合,一般称之为PCB.
- Linux下的PCB叫做task_struct
组织进程
- 操作系统将每一个进程都进行描述,形成了一个个的进程控制块(PCB),并将这些PCB以双向链表数据结构组织起来
- 这样一来。操作系统对所有进程的控制和操作,都只和进程的PCB有关,与进程的可执行程序无关。
- 同时将进程的管理转换为对特定数据结构的增删查改。
task_strcut
- sk_strcut是PCB的一种
- 在Linux中描述进程的结构体叫做task_struct。 task_struct当中主要包含以下信息:
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器(pc): 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟总和,时间限制,记账号等。
- 其他信息
查看Linux下进程信息
通过系统文件查看
进程的信息可以通过 /proc 统文件夹查看
在Linux系统中,/proc
文件夹提供了对内核和正在运行的进程的信息的访问。
通过命令查看
ps
列出当前用户进程的简要信息
非root用户 root用户
ps aux
:显示所有进程信息这,包括用户、PID(进程ID)、CPU利用率、内存利用率等。
top命令
:以交互式的方式显示系统中运行的进程信息。它实时更新
htop命令
:它以直观和友好的方式显示系统的实时性能信息。相比于经典的 top
命令,htop
提供了更多的功能和更直观的用户界面。top的完美替代品
通过系统调用获取进程标示符
task_strut是操作系统内部数据,想要访问,必须通过系统调用。
- 进程id:pid,通过getpid()系统调用获取。
- 父进程id:ppid,通过getppid()系统调用获取。
在Linux中普通进程都有父进程,在命令行中启动的进程都是bash(命令行解释器)的子进程。
c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("PID = %d\n",getpid());
printf("PPID = %d\n",getppid());
return 0;
}
这段代码就可以查看当前进程的pid和ppid 当前进程的ppid就是bash
通过系统调用frok创建进程
fork :在Linux系统中,fork
是一个用于创建新进程的系统调用。fork
调用会创建一个与父进程几乎完全相同的子进程,这两个进程在执行时拥有相同的代码、数据。
可以使用man 2 fork
简单认识一下fork系统调用
fork
调用会返回两次:一次在父进程中,一次在子进程中。- 在父进程中,
fork
返回子进程的进程ID(PID);在子进程中,fork
返回0。 - 如果发生错误,
fork
返回-1。
c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int ret = fork();
printf("Heloo proc :pid = %d,ppid = %d,ret = %d\n",getpid(),getppid(),ret);
sleep(1);
return 0;
}
这个程序执行结果:
可以看出,这个程序会执行两次打印。
- 第一次打印的是当前进程的pid和ppid
- 第二次打印的是fork()创建的子进程的pid和ppid
- fork()创建的子进程的ppid是当前进程的pid,可以得出,当前进程和fork创建的子进程是父子关系。
- 打印的信息还有fork的返回值ret,可以看出,当前进程的fork返回值是子进程的pid,而子进程的fork返回值是0
子进程的代码和数据从哪里来?
- 每一个进程都有自己的可执行程序和PCB,fork创建的子进程也会有自己的可执行程序和PCB。(可执行程序中包含代码和数据)
- 上面的例子可以看出,子进程和父进程都可以使用printf函数进行打印,但是同一个变量,子进程和父进程的结果却不一样。
- 可以说明,子进程和父进程代码共享,数据各自开辟空间,私有一份。采用写时拷贝
fork返回值:
- 实际上fork函数会有两个返回值 (如果成功,则在父进程中返回子进程的PID,并在子进程中返回0。如果失败,父进程返回-1,不创建子进程,)
- fork之后通常要采用if分流
注意:上面代码中一个变量ret会保存两个不同的值,这和进程地址空间有关。
fork的使用
- fork之后一般要通过if分流,让父子进程做不同的事情
c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id < 0 ) return -1;
else if(id == 0)
{
//子进程
while(1)
{
printf("i am child ,pid = %d,ppid = %d\n",getpid(),getppid());
sleep(1);
}
}
else
{
//父进程
while(1)
{
printf("i am parent ,pid = %d,ppid = %d\n",getpid(),getppid());
sleep(1);
}
}
return 0;
}
让子进程循环打印自己的信息,让父进程打印自己的进程,一个程序两个死循环一直执行
运行结果 :
进程状态
操作系统创建进程时把进程的状态信息保存在进程PCB中,进程的状态主要有运行、阻塞、挂起状态
运行
一般每个CPU核心拥有一个运行队列,当进程PCB放到CPU的运行队列中,准备好随时被CPU调度时,就称之为运行状态
阻塞
c
int main()
{
int a = 0;
scanf("%d",&a);
printf("%d\n",a);
return 0;
}
- 上面这个代码执行后,只有等待用户输入后才可以继续运行。
- 这种情况,当进程在等待某种软硬件资源时,资源没有就绪时,操作系统会把进程的PCB设置为阻塞状态,将进程的PCB链入到等待资源提供的运行队列中。
- 进程状态的变迁,本质就是进程的PCB被操作系统变迁到不同的运行队列中。
挂起
- 挂起状态并不常见,挂起的前提是计算机资源比较吃紧(比如内存不足)。
- 内存吃紧时,磁盘当中有swap分区,操作系统会把内存中的数据拷贝到磁盘的swap分区(这个过程称之为换出操作),等内存资源足够时,在将swap分区中的数据拷贝到别内存中(这个过程称之为换入操作)。
- 当进程的代码和数据不在内存当中,就称之为挂起
Linux下具体的进程状态
Linux内核源码对进程状态的定义
c
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char *task_state_array[] = {
"R (running)", /* 0*/
"S (sleeping)", /* 1*/
"D (disk sleep)", /* 2*/
"T (stopped)", /* 4*/
"T (tracing stop)", /* 8*/
"Z (zombie)", /* 16*/
"X (dead)" /* 32*/
};
所谓的状态本质就是一个变量,不同的值表示不同的状态。
- 运行状态(R,Running):进程正在运行或准备运行
- 睡眠状态(S ,sleeping) :因为等待某种资源而暂时睡眠,也叫可中断睡眠(可以被中断)
- 磁盘休眠状态(D,disksleep):因为等待某种资源而暂时休眠,也叫不可中断睡眠(不可以被中断)
- 停止状态(T,stopped):进程收到信号被停止运行
- 跟踪状态(t,tracing stop):进程受到了跟踪,一般是被调试器跟踪,调式时可以看见
- 僵尸状态(Z,zombie):进程已经终止,但其父进程尚未调用
wait()
来获取其终止状态。 - 死亡状态(X,dead):进程已经死亡,父进程没调用wiat()获取终止状态
c
#include<stdio.h>
int main()
{
while(1);
return 0;
}
运行上面的程序,使用ps命令查看进程信息
STAT列就是进程状态信息列,上面进程的状态是R+,也就是运行状态。
前台进程和后台进程
- 使用ps命令查看进程信息,进程状态后面带+的是前台进程,
- 没有+的是后台进程。
- 在运行程序时最后加一个&就会变为后台进程
- 前台进程可以用ctrl+c杀掉,并且运行时不能运行其他进程。
- 后台进程不能用ctrl+C杀掉,只能使用kill命令杀掉,进程运行时还可以运行其他进程。
后台进程:
运期间还可以运行其他程序,并且不能用ctrl+c 命令杀死
kill
kill
命令用于向进程发送信号。默认情况下,kill
发送的是 SIGTERM
(终止)信号,通知进程正常退出。
kill-l
:可以查看kill命令的所有信号
kill <PID>
默认使用的是15号信号IGTERMkill -9 <PID>
强制终止进程pkill <process_name>
根据程序名终止进程
僵尸进程
- 僵尸进程是一种特殊的进程状态,当一个进程结束后,父进程没有对其进行善后处理(通过调用
wait
或waitpid
等系统调用),就会造成僵尸进程。 - 在上面利用fork创建进程时,父进程没有对子进程调用wait或者waitpid方法进行善后处理,子进程就会变为僵尸进程。
- 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
孤儿进程
- 父进程创建一个子进程,父进程先退出,子进程就会变为
孤儿进程
。 - 子进程变为孤儿进程,会被1号进程领养,1号进程就是操作系统,孤儿进程被操作吸引回收了。
- 被领养的孤儿进程会变为后台进程。 孤儿进程例子
c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
//孤儿进程demo
pid_t id = fork();
if(id < 0 )
{
perror("fork");
return -1;
}
else if(id == 0)
{
//子进程
printf("i am child process pid = %d, ppid = %d\n",getpid(),getppid());
sleep(3);//子进程等待三秒后退出
printf("child process exit\n");
}
else{
//父进程
printf("partent process ,pid = %d\n",getpid());
exit(0);//父进程直接退。
}
return 0;
上面程序运行结果如下
父进程先退出,子进程被1号进程领养。三秒后,子进程执行结束.
进程优先级
- CPU分配资源的先后顺序,就指的是进程的优先级
- 进程的优先级是通过进程调度器来管理的,它使用动态优先级调度策略来分配CPU时间。每个进程都有一个调度优先级,该优先级决定了进程在系统中被调度执行的顺序。
- 需要区分优先级和权限,优先级决定了先后的问题,而权限决定了能不能的问题
- 进程要有优先级的原因就是因为CPU资源有限
用ps -l
命令查看系统进程
- UID:代表执行者的身份
- PID:进程标示符。
- PPID:父进程标示符
- PRI:代表进程优先级,数值越小,优先级越高,越早被执行
- NI:进程的nice值
PRI和NI
PRI:
- 进程优先级就是PCB中的一个整形变量,默认的优先级是80
- 优先级可以被修改,允许修改的范围是[60,99],包括60和99,一共40个优先级
- 修改进程优先级是有范围的,如果不进行限制,不能确保有人乱修改,将自己的进程调整到最高,而常规进程很难享受到CPU资源,从而造成进程饥饿问题
NI:
- 进程的nice值。其表示进程可被执行的优先级的修正数值
- 加入nice值后,进程的PRI = PRT(old) + nice
- 调整进程的优先级就是修改进程的nice值
- nice值也是有范围的[-20,19],一共40个优先级
修改进程优先级
在 top
命令中可以交互式地修改进程的优先级。top
提供了一个交互式的界面,你可以在其中选择要调整优先级的进程,并对其进行一些操作,包括修改 Nice 值。
当前进程的PRI是默认值80
使用top命令修改优先级 1:输入top命令
2:按入r在输入要修改进程的pid
3:输入nice值([-20,19]之间)
修改之后的优先级
进程的特性
- 竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高 效完成任务,更合理竞争相关资源,便具有了优先级
- 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰
- 并行:多个进程在多个CPU下分别同时进行,称之为并行
- 并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为 并发
进程地址空间
- 进程地址空间是描述进程在内存中布局的数据结构,因为操作系统要对进程地址空间进行管理,(先用结构体描述起来,再用特定的数据结构组织起来)。
- 进程地址空间是指操作系统为每个运行的进程分配的虚拟内存空间。每个进程都有其独立的地址空间,32位下取值范围是[0,4GB]。这是操作系统提供给进程用于存储程序代码、数据、堆、栈以及其他相关信息的虚拟内存区域。
- 正文代码:也称为代码段,存放可执行代码/只读常量 不允许被修改。
- 初始化数据 && 未初始化数据:叫做数据段,存储全局变量和静态变量,包括初始化和未初始化的数据。上图将数据段分为两个区域。
- 堆区:动态分配的内存空间,用于存储程序运行时动态分配的数据。堆的大小可以在运行时动态增长或缩小。
- 共享区:进程间的共享数据或通信
- 栈区:又叫做堆栈 存放非静态局部变量/函数参数/返回值等等
- 命令行参数环境变量 :存放命令行参数和环境变量相关(命令行参数和环境变量相关可以另一篇博客Linux命令函参数&环境变量 - 掘金 (juejin.cn))
在Linux系统下进行验证:
c
#include <stdio.h>
#include <stdlib.h>
int g_unval;
int g_val = 0;
int main(int argc, char* argv[],char* envp[])
{
const char* str= "Hello World";
printf("code_address:%p\n",str);//代码段
printf("g_val_address:%p\n",&g_val);//初始化数据段(全局变量)
printf("g_unval_address:%p\n",&g_unval);//未初始化数据段
int*p = (int*) malloc(sizeof(int));
printf("heap_address:%p\n",p);//堆区
![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/606f90be65a44626a275913aa6fb39ff~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1544&h=788&s=150650&e=png&b=000006)
printf("stack_address:%p\n",&str);//栈区
for(int i = 0 ;argv[i]; ++i)
{
printf("argv_address:%p\n",argv[i]);//命令行参数
}
for (int i = 0; envp[i]; i++)
{
printf("envp_address:%p\n",envp[i]);//环境变量
}
return 0;
}
虚拟地址
c
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
}
else if(id == 0)
{
//child
g_val = 100;//子进程中修改全局变量g_val
printf("i am child process, g_val=%d,&g_val= %p\n",g_val,&g_val);
}
else
{
sleep(2);
printf("i am parent process,g_val=%d,&g_val=%p\n",g_val,&g_val);
}
return 0;
}
让子进程先执行,修改全局变量的值,再让父进程执行, 运行结果:
发现和预想的不一样,本应该子进程修改后,全局变量的值已经变为了100,而父进程打印时还是0,并没有被修改,而且同一块地址空间,存了两个不同的值。
这可以验证,在语言层面上,所有的地址都是虚拟地址,如果是真实的物理内存,就不会出现一块地址空间存放两个不同的值。物理地址用户是看不到的,由操作系统进行统一管理。
在上面的例子中,父子进行中的虚拟地址是相同的,但是变量的值是不同的。
这里由操作系统将虚拟地址映射到物理内存中,从而实现,语言层面上的一块地址存放不同的值。
内核对地址空间的描述
进程的地址空间信息被封装在 mm_struct
结构中。将其进行区域划分,
进程地址空间就类似于一把尺子,尺子的刻度由0x00000000到0xffffffff,尺子按照刻度被划分为各个区域,例如代码区、堆区、栈区等。而在结构体mm_struct当中,便记录了各个边界刻度,例如代码区的开始刻度与结束刻度。
在mm_struct结构体中,每一个字节都代表着一个虚拟地址,这些虚拟地址通过和页表与物理内存建立映射关系。
每个进程被创建时,对应的PCB,mm_struct,页表,都会随之被创建。 而操作系统可以通过进程的PCB找到其mm_struct。(PCB当中有一个结构体指针存储的是mm_struct的地址)
在用fork创建子进程时,操作系统以父进程为模板为子进程创建PCB,mm_strcut,页表等。 子进程的PCB,mm_stuct,页表和最初的父进程一样,(子进程和父进程的数据和代码是共享的),即父子进程的代码和数据通过页表映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。这样可以节省内存和提高效率。
在上面的例子中,子进程修改全局变量g_val为100,操作系统会把父进程中的g_val在物理内存中拷贝一份,在进行修改。通过页表与子进程建立映射关系,从而实现了进程之间的独立性。