本文主要介绍进程的相关知识
认识冯诺依曼体系结构
几乎所有的计算机都是遵守冯诺依曼体系的,计算机是由一个个硬件组成的,配套着操作系统就可以发挥出其功能。
冯诺依曼体系将计算机的硬件分成了主要的三大块
- 1.输入输出设备:输入设备有键盘鼠标,写字板,网卡等;输出设备有显示器 打印机等
- 2.cup(中央处理器,由运算器和控制器组成),主要完成一些算数运算和逻辑运算。
- 3.存储器,也就是内存,内存的存在是为了适配外设和CPU速度不均的问题。
为什么要有内存这个东西呢?
从技术角度分析
cpu的运算速度 > 寄存器的速度 > 缓存的速度 > 内存的速度 > 外设的速度(磁盘等)
因为数据大多都是需要cpu计算的 但是输入输出设备的速度和cpu的速度完全不是一个级别的,cpu速度远远快于外设速度,那么需要计算的数据直接交给cpu处理的话就会因为外设的速度慢而使得cpu的效率发挥不出来,所以cpu都是不和外设直接打交道的,都是通过内存来交流的,因为内存的速度相比外设快,外设可以将数据加载到内存,然后让cpu找内存拿数据,处理完后返回给内存,再回到外设,这样设计就可以达到视频外设和cpu速度不均的问题,提高效率。
看到这里我们可能会有一个疑惑,cpu和外设速度相差大就让外设加载数据到内存,那么外设加载的速度还是很慢啊,加载到内存也还是会很慢啊,那cpu还是会需要等待内存给它提供来自外设的数据,效率不还是没有提升吗?
这里举例说明吧,我们计算机是有预装载的机制的,就像我们开机的时候,需要等上个几十秒,这就是计算机在把操作系统加载到内存,有了操作系统计算机才可以运行起来,这个等待的过程就是预装载,刚开机会觉得电脑有些卡,但是过了一段时间就很顺畅,这就是因为计算机在加载数据到内存里;还有就是我们在写一个程序的时候会编译运行,运行之前这个程序一定是要被加载到内存的,但是并不是我们什么时候运行就什么时候加载,而是计算机会提前把我们写的代码加载到内存中,这就是预装载,可以是的数据提前被加载到内存,提高效率。(预装载是体系结构规定的)
操作系统的基本概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
- 内核(进程管理,内存管理,文件管理,驱动管理)
- 其他程序(例如函数库, shell程序等等)
操作系统的作用是什么
操作系统生来就是为了管理软硬件资源的,是一个搞管理的软件
系统调用和库函数相关概念
- 操作系统可以向用户提供服务,但是操作系统不会把自己完全地暴露给用户,而是向用户提供一些接口,供用户开发使用,这些接口就叫做系统调用。
- 系统调用在使用上功能比较基础,对用户的要求相对较高,所以开发者可以将系统调用适度封装,形成库,有了库,就很有利于上层用户或开发者进行二次开发了。库函数也就是对系统调用的封装的产物。
思考:c/c++代码可以在windows和linux不同的系统下跑起来的,但是不同的系统的系统接口(系统调用)是不同的,那在两个系统上跑的都是一样的代码(c、c++代码),代码的执行结果为什么是一样的呢?
这就要说到c/c++的库了,库会帮我们分辨不同的平台,进而使得我在linux下就是调用的linux的系统调用,在Windows下就是调用的windows的系统调用,是类似多态的原理的,调用printf语句,在linux下就会调linux的对应的系统调用将数据打印到显示器,反之亦然。同一个对象执行相同动作,表现出不同的结果,这就是多态。
进程基本概念
进程的概念有两个层面上的解释:
教科书上的解释:进程是程序的一个执行实例,是正在执行的程序。
内核上的观点:担当分配系统资源(cpu 时间,内存)的实体。
描述进程进程控制块(PCB)
进程的信息会被放到一个叫进程控制块的数据结构中,可以理解成它是进程属性的集合。linux中的PCB实际上是一个叫做task_struct的结构体
task_struct 结构体
task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息
task_struct中的内容
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
- I/ O状态信息: 包括显示的I/O请求,分配给进程的I/ O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息
//截取源码部分
struct task_struct {
volatile long state;//状态 /* -1 unrunnable, 0 runnable, >0 stopped */
void *stack;//栈
atomic_t usage;
unsigned int flags; /* per process flags, defined below */
unsigned int ptrace;
int lock_depth; /* BKL lock depth */
/* task state */
int exit_state;
int exit_code, exit_signal;
int pdeath_signal; /* The signal sent when the parent dies */
/* ??? */
unsigned int personality;
unsigned did_exec:1;
unsigned in_execve:1; /* Tell the LSMs that the process is doing an
* execve */
unsigned in_iowait:1;
/* Revert to default priority/policy when forking */
unsigned sched_reset_on_fork:1;
pid_t pid;//标识符
pid_t tgid;
//...
};
进程是如何被操作系统管理起来的
六字真言:“先描述,再组织”
操作系统上跑的是一个个程序,也就是进程,那么操作系统就是要管理这些进程,怎么管理呢?
进程有它的对应的属性(下面会有介绍),因为linux操作系统是用c语言写的,所以进程的属性是用结构体来描述的(先描述),这些结构体会被一个双链表链接起来,所以对进程的管理也就演变成了对数据结构的管理。
先描述
将进程用一个数据结构描述,linux中用task_struct 结构体来描述,将进程的所有属性都包含在这个结构体中(里面会有指针指向进程的代码和数据)。一个进程的组成就是其task_struct 结构体和其对应的代码和数据。
再组织
将一个个进程的数据结构组织在一起,linux中使用双链表将这些结构体一个个连起来,完成对进程数据结构的组织。
描述好,组织好,才好管理
管理进程就是变成了对数据结构的管理,管理好这些数据结构就可以管理好进程!!!
进程在内存中的样子:
如何将进程运行起来:
进程的查看
介绍几种方式:
一、ps命令查看
ps axj | grep 文件名 | grep -v grep
将proc程序跑起来,形成一个进程
用上面的命令进行查看进程!
二、通过/proc系统文件夹进行查看
这里的查看某个进程是需要明确该进程的pid才可以进行查看的,因为在/proc文件夹中大多都是存的进程的pid,通过pid来标识进程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pHAqbk2Z-1677761095396)(C:\Users\华哥\AppData\Roaming\Typora\typora-user-images\image-20221009002430289.png)]
三、通过top命令查看
获取进程标识符
getpid()和getppid()
man 3 getpid
getpid()是获取进程本身的标识符 pid
getppid()是获取当前进程的父进程的 pid
代码演示:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
while(1)
{
printf("我是一个进程!\n,我的pid:%d ppid:%d\n",getpid(),getppid());
sleep(1);
}
return 0;
}
进程的创建
fork()创建进程
注意的点:
- fork()是有两个返回值的 你没听错!如果创建成功会返回子进程的pid给父进程,会给子进程返回给0;如果创建失败就会返回-1。
为什么对父进程就是返回子进程的pid,对创建出的子进程就是返回的0呢?
因为一个父进程可以拥有多个子进程,所以需要父进程需要去标识每个子进程,那么就需要知道子进程的pid,故调用fork()函数创建子进程,fork()会给父进程返回创建出来的子进程的pid;对于子进程,它只有一个父进程,并且一个进程被创建出来就是知道自己和其父亲的pid的,所以fork()对创建出来的进程的返回值为0即可;当创建失败就返回-1;
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id=fork();
while(1)
{
if(id>0)
{
printf("我是父进程! 我的pid:%d ppid:%d \n",getpid(),getppid());
}
else if(id==0)
{
printf("我是子进程! 我的pid:%d ppid:%d \n",getpid(),getppid());
}
else
{
printf("创建失败!\n");
}
sleep(1);//循环一次休眠一秒
}
return 0;
}
可以看出运行的结果是交替打印父子进程的信息 因为这里调用fork()创建出了子进程,那么就会有两个进程,从而会使得程序有两个执行流,父子进程都会执行各自的执行流,所以在循环的作用下就会出现交替打印父子进程信息的结果,这也是父子进程执行各自执行流的结果。
-
父子进程代码是共享的,数据会各自开辟空间 私有一份(会有写时拷贝,也就是当子进程要改变父进程中的数据就会独立开辟一块空间,建立自己的数据,使得自己不会影响到父进程的数据,实现进程的独立性)
-
fork()一般都会用if else 语句实行分流操作 使得子进程去完成具体的某项工作 而不是和父进程执行一样的操作
思考:上面的代码中的pid_t id =fork() 为什么一个id可以接收不同的返回值呢?
进程的状态
操作系统原理中的进程状态
进程运行和进程终止
进程运行就是进程在运行队列中准备就绪,等待cpu执行其代码的状态;进程终止就是进程通过return或exit或遇到异常退出,随时等待操作系统回收其资源的状态。
进程阻塞
什么是进程阻塞?
一个进程不仅仅会申请cpu的资源,还可能会申请其他设备的资源,如磁盘网卡显示器等,当一个进程在等待队列中准备就绪,当cup资源就绪时,开始执行该进程的代码,如果该进程的代码涉及到申请其他资源的时候(例如访问磁盘资源,涉及文件相关操作),如果此时的某个设备资源正在别其他进程使用,使得该进程申请资源时资源不就绪,那么该进程的task_struct 就会被操作系统从运行队列中移除,然后放到操作系统中描述对应设备资源的数据结构中(一般是一个等待队列里),我们称进程等待某个资源就绪的状态为阻塞状态。
进程挂起
什么是进程挂起?
当内存中的进程太多(其数据和代码都会一并加载到内存中),就会使得内存的空间不足,那么操作系统就会将短期内不会被调度的进程的代码和数据置换回磁盘中,以此来缓解内存空间不足。代码和数据被置换回磁盘的进程就会被操作系统挂起!!!(此操作一般会涉及磁盘的高频访问)
进程
为了表示进程运行的情况,linux中的进程都具有其对应的状态。
linux内核源代码:
/*
* 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)运行状态
- R是运行状态,但是并不意味着它一定在运行,它表明进程要么就是在运行中,要么就是在运行队列中
演示:
#include<stdio.h>
#include<unistd.h>
int main()
{
while(1)
{
//啥也不干 就一直死循环 就会一直在运行队列里 就是R状态
}
return 0;
}
S (sleeping)睡眠状态
- 意味着进程在等待某种资源,一般不是cpu资源(这里的睡眠有时候也叫做可中断睡眠,可被操作系统杀死)
#include<stdio.h>
#include<unistd.h>
int main()
{
while(1)
{
printf("i am a process\n");
sleep(1);
}
return 0;
}
D (disk sleep) 深度睡眠状态
- 磁盘休眠状态,也叫不可中断睡眠状态,在这个状态的进程通常会等待IO的结束,操作系统是无法杀掉D状态的进程的
- 这里可以用dd命令自己去试试。
- D状态对应操作系统层面上的阻塞状态,只不过一般是进程等待磁盘资源时才是D,等待其他资源一般是S
T (stopped) 暂停状态
- 可以发送SIGSTOP信号(19)给进程来停止(T)进程。这个被暂停的进程可以通过发送SIGCONT信号(18)让进程继续运行
窗口1:
窗口2:
监视窗口:
上面的操作涉及进程信号知识,后面的博客会讲解,这里只需知道19 号信号可以使进程暂停即可!
Z (zombie) 僵尸状态
- 僵尸状态是一个比较特殊的状态,当进程退出并且父进程(使用wait()系统调用等待子进程)没有读取到子进程退出的返回代码时就会产生僵尸进程
- 僵尸进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
- 只要子进程退出,父进程还在运行,但是父进程没有读取子进程状态,子进程就会进入Z状态
演示在下面!
X (dead) 死亡状态
- X状态只是一个返回状态,是不会在任务列表看到此状态的
僵尸进程
#include<stdio.h>
#include<unistd.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
pid_t id= fork();
if(id>0)
{
//parent
printf("i am parent\n");
sleep(15);//休眠15s但是不退出
exit(0);
}
else if(id==0)
{
//child
int n=10;//10s后退出 如果父进程未读取其退出代码 该进程会变成僵尸进程 成Z状态
while(n--)
{
printf("i am child\n");
sleep(1);
}
printf("子进程退出!\n");
exit(0);
}
else
{
printf("creat fail!\n");
}
return 0;
}
程序运行:
脚本监控进程状态:
//shell脚本
while :;do ps axj | head -1 && ps axj | grep myproc | grep -v grep;sleep 1;echo "#######################################################" ;done
僵尸进程的危害
- 进程的退出状态必须要被维持下去,因为它要告诉它的父进程,父进程交给它的任务它完成的怎么样了,但是如果父进程一直不读取,那么子进程就会一直处于Z状态!
- 维护退出状态本身就是维护进程的相关数据结构,所以操作系统需要维护进程的task_struct (pcb进程控制块),僵尸进程的退出信息一直不被读取,那么操作系统就需要一直维护它的PCB!
- 维护僵尸进程的PCB会造成内存资源的浪费,存在内存泄漏!!!
孤儿进程
什么是孤儿进程?
顾名思义,就是其父亲不要它了,这个进程成了孤儿。也就是一个进程的父进程提前退出,但是其子进程还没有退出,那么该子进程就会变成失去父亲的孤儿进程(孤儿进程会被1号进程收养,回收资源)
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t id= fork();
if(id==0)
{
//child
int n=10;
while(n--)
{
sleep(1);
printf("i am child! pid:%d i am running!\n",getpid());
}
}
else if(id>0)
{
int m=5;
//parent
while(m--)
{
printf("i am parent! pid:%d i am running!\n",getpid());
sleep(1);
}
exit(0);
}
else{
//error
printf("fork fail!\n");
}
return 0;
}
监控:
进程优先级
因为计算的各种资源是有限的,但是进程却很多,那么进程之间就会有竞争,类比现实生活中的排队现象,我们都是站成一条长队,排在前面的就优先,同样的对应进程间也有需要进行排队使用资源,于是就有了有进程优先级。
概念
- cpu资源分配的先后顺序就是指进程的优先权
- 优先权高的进程有优先执行的权利,配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
- 还可以把进程运行到指定的cpu上,这样就可以把不重要的进程安排到某个cpu,大大改善系统整体性能。
如何查看进程优先级
使用top命令就可以看到
PR(process priority)和NI (nice)是什么?
上图中的PR就是进程优先级。
NI是进程优先级的修正数值,可以通过改变进程的NI值对进程优先级进行适度的调节!
注意点
- PR越小,进程的优先级越高,代表进程越快被执行!
- NI值范围是[-20,19]的 一共40个级别
优先级公式 : P R θ ( n e w ) = P R θ ( o l d ) + N I ( N I ϵ [ − 20 , 19 ] ) 优先级公式:PR_\theta(new)=PR_\theta(old)+NI \space\space\space (NI\epsilon[-20,19]) 优先级公式:PRθ(new)=PRθ(old)+NI (NIϵ[−20,19])
如何改变(调节)进程优先级
更据上面的公式可以知道,进程优先级的调节是通过修改对应进程的NI值来实现的。
//步骤
1、输入top命令
2、再依次输入 r + 修改的进程pid + 回车 + 对应的nice值
前后对比:
环境变量
概念:
- 环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但
是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
常见环境变量
- 1、PATH :指定命令的搜索路径
- 2、HOME: 指定用户的主工作目录(即用户登录到linux系统中时,默认的目录)
- 3、 SHELL:当前Shell,它的值通常是/bin/bash
环境变量表
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。
如何查看环境变量
//执行下面指令即可
1.查看所有环境变量 env命令会将所有的环境变量都显示出来
env
2.查看具体名称的环境变量
echo $环境变量名
如何获取环境变量
三种方式
通过main函数的参数获取
我们平常看到的main函数大多是不带参数的,但实际上它是有参数的,main函数可以带三个参数!!!
//第一个参数是执行main函数对应的进程的指令的个数,第二个是执行其对应进程的指令字符串,第三个是其对应进程的环境变量(字符指针数组,里面存的是一个个的环境变量字符串)
int main(int argc,char* argv[],char* env[])
{
printf("%d\n",argc);
for(int i=0;i<argc;i++)
printf("%s\n",argv[i]);
for(int i=0;i<argc;i++)
printf("%s\n",env[i]);
return 0;
}
通过第三方变量environ获取
- libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用的时候,要用extern声明。
#include <stdio.h>
int main(int argc, char *argv[])
{
//可以通过environ找到存储环境变量的指针数组,进而打印里面的环境变量(一个个字符串)
extern char **environ;
int i = 0;
for(; environ[i]; i++){
printf("%s\n", environ[i]);
}
return 0;
}
通过系统调用获取
getenv()获取环境变量
char getenv(const char name)**
参数name为所需要获取的环境变量的名称,如果该环境变量在环境表中存在那么就会返回所要获取的环境变量(字符串),反之如果不存在就会返回空指针!
putenv()设置环境变量
#include<stdio.h>
#include<stdlib.h
int main(int argc,char* argv[],char* env[])
{
extern char** environ;
int i;
printf("putenv之前环境变量表:\n");
for(i=0;environ[i];i++)
printf("%s\n",environ[i]);
putenv("MYPATH = 66666666666666666666666");
printf("putenv之后环境变量表:\n")
for(i=0;environ[i];i++)
printf("%s\n",environ[i]);
return 0;
}
注:由于putenv会存在安全问题(野指针),可以使用setenv()来代替putenv() !
环境变量的全局属性
在命令行中我们可以定义两种变量,一种是本地变量,一种是环境变量。
本地变量
本地变量不具有全局属性,不会被子进程继承!!!
因为我们自己写的程序运行起来形成的进程,都是有bash创建的子进程,那么我们在命令行中定义本地变量,再到我们自己写的程序中去找在bash中定义的本地变量如果找得到说明本地变量是可以被子进程继承的,否则不可以!!!
注意:这里要清楚的理解我们写的代码形成的进程都是由bash创建的子进程!!!
#include<stdio.h>
#include<stdlib.h>
int main()
{
printf("%s\n",getenv("LocalVal"));
return 0;
}
环境变量的定义
我们只需要使用关键字export就可以将一个本地变量导出为环境变量!!!
export LocalVal=1234567//那么LocalVal就会被导进bash的环境表中了 注:=两边不要加空格!!!
#include<stdio.h>
#include<stdlib.h>
int main()
{
printf("%s\n",getenv("MyVal"));
return 0;
}
进程地址空间
程序地址空间
我们在学语言的时候经常听到什么全局变量是存在静态区,常量字符串是存在常量区,定义的普通变量是存在栈上的,malloc出来的数据是存在堆上的,这里的堆,栈,常量区,静态区都是程序地址空间的叫法!
但是一个程序运行起来就是一个进程,那么原来的程序地址空间的叫法是不准确的,应该叫做进程地址空间。
进程地址空间
什么是进程地址空间呢?
所谓的进程地址空间是操作系统通过软件的方式,给进程提供一个软件视角(进程地址空间),认为自己是独占着整个系统的所有资源(内存),每个进程运行起来的时候,操作系统都会先给其创建一个进程地址空间(mm_struct),将进程所需要的空间先规划好,当其真正需要使用的时候再给它分配,这样就避免了进程一运行操作系统就需要立马给其分配内存,提高了内存的使用效率。
如何管理地址空间呢?
还是六字真言:“先描述,再组织”
既然进程地址空间是一个个的内核数据结构mm_struct 那么管理好mm_struct 就可以管理好进程地址空间!
mm_struct()结构组成
这里是大概的简略的组成(其真实组成远不止这么点东西,这里只介绍其区域划分),其实源码是有一个vm_area_struct 结构体来完成各个区域的划分的(了解即可,无需深究!)
//笼统理解为下面结构
struct mm_struct
{
//各个区域的划分
unsigned int code_start;
unsigned int code_end;
unsigned int stack_start;
unsigned int stack_end;
unsigned int init_data_start;
unsigned int init_data_end;
unsigned int uninit_data_start;
unsigned int uninit_data_end;
...
}
注意:这里的进程地址空间并不是直接对应的物理地址,而是虚拟地址,物理地址和虚拟地址是通过页表转换的!
为什么需要有虚拟地址这个东西呢?
-
出于安全考虑,进程虽然认为只有其自己独占整个内存,但是这是操作系统为其画的大饼,实则是多个进程在共用一块内存,那么就会有多个进程会用到同一块物理空间的可能,如果每个进程都是直接访问的物理内存就会容易出现野指针的问题,例如原本多个进程共用的空间,其中一个进程终止了对该空间释放,那么就会使得原来与其共用一块内存的进程访问该空间时出现野指针,所以直接访问物理地址是具有安全隐患的!!!
-
通过添加一层软件层,完成有效的对进程操作内存的风险管理(权限管理),本质是为了保护物理内存各个进程的数据安全
-
将内存申请和内存使用在时间上解耦。通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存操作和OS进行内存管理进行软件层面上的分离。
感兴趣的可以去写代码验证上面的排列。
感知地址空间的存在
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int a=10;
int main()
{
pid_t id=fork();
if(id>0)
{
//parent
int n=10;
while(n--)
{
printf("我是父进程 pid:%d ppid:%d a:%d &a:%p\n",getpid(),getppid(),a,&a);
sleep(1);
}
}
else if(id==0)
{
//child
int n=10;
while(n--)
{
if(n==5)
{
printf("我是子进程 我修改了a 为 200!\n");
a=200;
}
printf("我是子进程 pid:%d ppid:%d a:%d &a:%p\n",getpid(),getppid(),a,&a);
sleep(1);
}
}
else
{
perror("fork error!");
}
return 0;
}