进程概念
1、冯诺依曼体系结构
简单来说,计算机中是由一个个硬件构成
- 输入单元:键盘、鼠标、写字板等
- 中央处理器(CPU):含有运算器和控制器等
- 输出单元:显示器,打印机等
对于冯诺依曼一些结构,有以下几点注意:
- 存储器指的是内存
- 不考虑缓存情况,这里的CPU能且只能对内存进行读写, 不能访问外设
- 外设(输入设备或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取
- 总结就是:所有设备只能直接和内存打交道
2、进程
2.1基本概念
程序的一个执行实例,正在执行的程序等。担当分配系统资源(CPU时间,内存)的实体。
2.2描述进程-PCB
进程信息被放在进程控制块(一个数据结构),叫做PCB,Linux操作系统下的PCB是task_struct
进程 = 内核数据结构(PCB) + 程序段 + 数据段
task_struct内容分类
- 标识符:描述本进程的唯一标识符,用来区别其他进程
- 状态:任务状态,推出代码,推出信号
- 优先级:相对于其他进程的优先级
- 程序技术器:程序中即将被执行的下一条指令的地址
- 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据:进程执行时处理器的寄存器中的数据
- io状态信息:包括显示器的io请求,分配给进程的io设备和被进程使用的文件列表
- 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记帐号等
2.3组织进程
可以在内核源代码里找到,所有运行系统里的进程都以task_struct链表的形式存在内核里
2.4查看进程
如:要获取PID为1的进程信息,你需要查看 /proc/1 这个文件夹。
大多数进程信息同样可以使用top和ps这些用户级工具来获取
2.5通过系统调用获取进程标识符
- 进程id(PID)
- 父进程id(PPID)
获取进程识别码(getpid函数与getppid函数)
函数原型:
pid_t getpid(void) pid_t getppid(void)
其中返回值类型pid_t是一种有符号整型,也可以使用整形int类型变量来接收
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("pid: %d\n", getpid());
printf("ppid: %d\n", getppid());
return 0;
}
2.6通过系统调用创建进程-fork初识
- 使用man手册运行man fork认识fork函数
- fork有两个返回值
- 父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)
fork の 头文件与返回值
头文件:
unistd.h
函数原型:
pid_t fork(void);
父进程中,fork返回新创建子进程的进程ID
子进程中,fork返回0
fork函数的调用逻辑和底层逻辑
在上文介绍PCB的时候有提到过,进程由内核数据结构和代码、数据两部分组成。因此每个进程都会有自己的PCB即task_struct
结构体。当调用了fork函数后,系统创建子进程,即创建一个属于子进程的task_struct
,将父进程的大部分属性拷贝过去(不在内的如pid、ppid),由于父子进程属于同一个程序,他们的代码是共用的,但是两个进程同时访问一个变量的时候会出现冲突问题,因此子进程会将它将要访问的数据做一份额外的拷贝,也就是子进程访问拷贝出来的数据,然后父子进程就有了属于各自的数据,对变量的操作也是独立的。
fork函数创建子进程过程
- 创建子进程PCB
- 填充PCB对应的内容属性
- 让子进程和赴京城指向同样的代码
- 父子进程都是有独立的task_struct,已经可以被CPU调度运行了
问:为什么fork函数调用完后会返回两个值,这和寻常的函数不是不一样么?
在fork函数中,创建子进程的步骤完成后,在return返回之前,父子进程已经可以被CPU调度运行了,也就是说,在return前fork函数执行了父子两个进程,return是作为父子进程的共有程序,他们都会各自返回一个值,因此整体看fork函数会返回两个值,分别属于调用fork函数中父子进程的返回值。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int ret = fork();
printf("hello proc : %d!, ret: %d\n", getpid(), ret);
sleep(1);
return 0;
}
由于父子进程的代码是一样的,因此如果需要使得父子进程执行不一样的代码,可以使用if加上返回值的条件限定来进行父子进程分流
#include <stdio.h> #include <sys/types.h> #include <unistd.h> int main() { int ret = fork(); if(ret < 0){ perror("fork"); return 1; } else if(ret == 0){ //child printf("I am child : %d!, ret: %d\n", getpid(), ret); }else{ //father printf("I am father : %d!, ret: %d\n", getpid(), ret); } return 0; }
3、进程状态
在程序运行的时候,如果遇到一个scanf等语句,进程会暂停知道输入相应的数据,才继续运行,由此可见进程需要有不同的状态(例如运行、阻塞、挂起等),不然进程无法按照预期正常执行。
3.1状态
R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里
S睡眠状态(sleeping): 可中断睡眠状态,意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠 (interruptible sleep))
**D磁盘休眠状态(Disk sleep):**不可中断睡眠状态,在这个状态的 进程通常会等待IO的结束。
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可 以通过发送 SIGCONT 信号让进程继续运行。
**X死亡状态(dead):**这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
z僵尸状态(zombie): 进程结束运行后大部分资源被回收,但进程描述符仍保留,直到父进程获取其退出状态。处于该状态的进程已死亡却占据一定系统资源,会在任务列表里显示为
Z
,过多僵尸进程会造成系统资源浪费。
运行队列
进程需要执行的时候,会被加入到运行队列中,并由调度器对队列进行调度,在CPU中执行运行的进程,无论是在运行中的还是在运行队列中的进程都是在R运行状态。示意图如下:
3.2进程状态查看命令
3.2.1 ps命令
用于查看当前系统中的进程状态。
- 语法:
ps [选项]
- 常用选项:
- -a:显示所有与终端相关的进程,包括其他用户的进程。
- -u:以用户格式显示进程信息,包括用户名、启动时间等。
- -x:显示所有进程,包括没有控制终端的进程。
- -ef:显示所有进程的详细信息,包括进程ID(PID)、父进程ID(PPID)等。
- -j: 会以作业格式显示进程信息,这种格式输出的内容比默认格式更丰富,会额外展示一些进程的上下文信息,常见的有:
- PPID:父进程 ID,用于表明该进程是由哪个进程创建的。
- PGID:进程组 ID,它将相关的进程组织在一起形成一个进程组。
- SID:会话 ID,代表进程所属的会话,有助于对进程进行更宏观的管理和分类。
例如,想要查看常用的指令可以使用:
ps ajx | head -1; ps axj | grep test1
来查看test1可执行程序的进程相关信息,如下:
当执行
ps axj | grep test1
时,你可能会看到输出结果中包含一个grep
进程,这是因为grep
命令本身也是一个进程,并且它在执行搜索时,ps axj
的输出中也包含了grep test1
这个命令行字符串,所以grep
会把自身这个进程也匹配出来并显示在结果中。
3.2.2 top命令
动态地显示系统中各个进程的资源占用情况,如CPU使用率、内存使用率等。
- 语法:
top [选项]
- 常用选项:
- -d:指定更新间隔时间,单位为秒。例如,
top -d 5
表示每5秒更新一次显示内容。 - -b:以批处理模式运行,可用于将输出重定向到文件。
- -n:指定显示的次数。例如,
top -n 3
表示只显示3次更新后的结果。
- -d:指定更新间隔时间,单位为秒。例如,
在top
命令的交互界面中,还可以使用一些按键进行操作,如按M
键可以按照内存使用量对进程进行排序,按P
键可以按照CPU使用率进行排序等。
3.2.3 htop命令
是top
命令的增强版,提供了更友好的交互式界面,支持鼠标操作,并且可以更直观地显示进程树等信息。
- 语法:
htop
直接在终端输入htop
即可启动该命令,使用方法与top
类似,但界面更加丰富和易于操作。
3.2.4 pidof命令
用于查找指定名称的进程的PID。
- 语法:
pidof [进程名称]
例如,要查找名为nginx
的进程的PID,可以使用命令:pidof nginx
。
3.2.5pgrep命令
根据进程名称或其他条件查找进程的PID。
- 语法:
pgrep [选项] [进程名称]
- 常用选项:
- -l:显示进程名称和PID。
- -u:指定用户,只查找该用户的进程。
例如,要查找用户ubuntu
下名为python
的进程的PID,并显示进程名称和PID,可以使用命令:pgrep -lu ubuntu python
。
- ps -l 列出与本次登录有关的进程信息;
- ps -aux 查询内存中进程信息;
- ps -aux | grep + 程序名字 查询该程序进程的详细信息;
- top 查看内存中进程的动态信息;
- kill -9 + pid 杀死进程。
举例如下图:
其中,在使用ps -l命令时,注意到几个信息,有下:
- UID : 代表执行者的身份
- PID : 代表这个进程的代号
- **PPID :**代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
- **PRI :**代表这个进程可被执行的优先级,其值越小越早被执行
- **NI :**代表这个进程的nice值
3.2.6 /proc文件系统:
Linux的
/proc
文件系统包含了大量关于系统和进程的信息。每个进程都有一个以其PID命名的目录,如
/proc/1234
,其中包含了该进程的详细信息。可以查看
/proc/[PID]/status
文件来获取进程的状态信息。
例如执行
ls /proc/45311 -dl
:
/proc/45311
是目标路径,其中/proc
是系统中用于反映进程运行状态的虚拟文件系统,45311
代表特定进程的 ID,此路径指向该进程对应的目录;-dl
是选项组合,-d
使ls
仅列出目录本身而非其内部内容,-l
让ls
以长格式输出详细信息
3.3僵尸进程(Z状态)
- 僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用) 没有读取到子进程退出的返回代码时就会产生僵死(尸)进程
- 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码
- 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
下面是一个僵尸进程例子:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main(){
pid_t ret = fork();
if(ret == 0){
printf("child process exit\n");
exit(0);
}
else{
while(1){
}
}
return 0;
可以复制一个当前会话便于观察进程信息,下图一为上面代码运行效果,下图二为运行中的进程信息,可以看到由于子进程代码中有exit(0)而提前退出,而父进程一直等待子进程的反馈未果,因而子进程处于z状态。想要结束程序可以使用Ctrl + c 退出或使用kill命令。
进程一般退出的时候,如果父进程没有主动回收子进程信息,子进程会一直让自己处于Z状态,进程的相关资源尤其是
task_struct
结构体不能被释放
僵尸进程的危害
- 进程的退出状态必须被维持下去。父进程如果一直不读取,那子进程就一直处于Z状态
- 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话 说,Z状态一直不退出,PCB一直都要维护
- 那一个父进程创建了很多子进程,就是不回收,会造成内存资源的浪费。因为数据结构 对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间的,不会受会造成内存泄漏
3.4孤儿进程
- 父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
- 父进程先退出,子进程就称之为“孤儿进程”
- 孤儿进程被1号systemd进程”领养“,当然要有systemd进程回收。
下面是一个孤儿进程的例子。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main(){
pid_t ret = fork();
if(ret == 0){
for(int i=0;i<60;i++){
printf("chile process %d \n",getpid());
sleep(1);
}
}
else{
for(int i=0;i<8;i++){
printf("father process %d\n",getpid());
sleep(1);
}
exit(0);
}
return 0;
}
可以看到父进程提前退出,子进程继续执行,如果观察进程信息会发现子进程在父进程提前退出后它的PPID变成了1。使用ps ajx | grep systemd
会发现PID是1,即1号进程就是操作系统本身。我们把这种子进程称为孤儿进程。
为什么孤儿进程的PPID会变成1?
因为子进程将来需要被释放,原来的父进程提前退出,因此子进程被系统进程”领养“,在结束后进程后释放掉子进程。
4、进程优先级
4.1基本概念
- cpu资源分配的先后顺序,就是进程的优先权
- 优先权高的进程有优先执行权,配置进程优先权对多任务环境的linux很有用,可以改善系统性能
- 还可以把进程运行到指定的CPU上,把不重要的进程安排到某个CPU,可以大大改善系统整体性能
4.2查看系统进程
4.2.1 ps -l
在使用ps -l命令时,注意到几个信息,有下:
- UID : 代表执行者的身份
- PID : 代表这个进程的代号
- **PPID :**代表这个进程是由哪个进程发展衍生而来的,即父进程的代号
- PRI :代表这个进程可被执行的优先级,其值越小越早被执行
- **NI :**代表这个进程的nice值,nice值:进程优先级的修正数据(可以用来改)
4.2.2 PRI & NI
- PRI,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高
- NI就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值
- PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
- 这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
- 所以,调整进程优先级,在Linux下,就是调整进程nice值
- nice其取值范围是**-20至19**,一共40个级别。
- 需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进 程的优先级变化。
- 可以理解nice值是进程优先级的修正修正数据
4.3用top命令更改已存在进程的nice:
- top
- 进入top后按 “r“ -> 输入进程PID -> 输入nice值
5、环境变量
5.1常见环境变量
- PATH : 指定命令的搜索路径
- HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
- SHELL : 当前Shell,它的值通常是/bin/bash。
5.2查看环境变量
环境变量相关命令
echo $NAME
显示某个环境变量的值,其中NAME是环境变量名称- export: 设置一个新的环境变量
- env: 显示所有环境变量
- unset: 清除环境变量
- set: 显示本地定义的shell变量和环境变量
5.3测试PATH
- 举一个简单的例子
#include<stdio.h>
int main()
{
int i;
for(i=0;i<5;i++){
printf("I am a process\n");
}
return 0;
}
我们将他编译为叫process的可执行程序,当需要执行这个程序的时候我们应该使用./process
来执行,直接输入process会显示”command not found“。但是在执行命令的时候比如touch命令、ls命令等,我们只需要输入命令名字即可,如果我们想让process这个程序像命令一样执行,即输入process就能执行,那么可以将程序所在路径加入到环境变量PATH当中
配置环境变量
PATH=$PATH:/root/workspace/Linux
将当前程序所在的路径加入到环境变量PATH当中PATH=/root/workspace/Linux
将当前程序所在的路径覆盖至环境变量PATH当中,相当于把PATH当中全部覆盖掉,然后ls等指令就会失效了。
执行完后,我们就可以直接输入process来执行程序,不需要带上路径了,甚至用mv将process改名后也能正常运行。使用which process也能找到~/root/workspace/Linux。
5.4代码中获取环境变量
getnev函数
函数声明:
char *getenv(const char *name)
其中name是需要获取的环境变量名使用举例:
#include<stdio.h> #include<stdlib.h> int main() { printf("PATH:%s\n",getenv("PATH")); return 0; }
6、进程地址空间
6.1程序地址空间
地址空间一共有如下的几个区域,从下到上地址逐渐增加,其中栈区的空间是从上往下使用,即从高地址往低地址增长;堆区的空间是从下往上使用,即从低地址往高地址增长,需要注意的是,在不同位操作系统下或者不同编译器下,内存的分配规则都可能是不同的,这里以linux为例,也是最经典的一种。
我们平时敲代码使用程序地址空间的时候,当我们定义一个局部变量,它的空间就是在栈区上开辟的,有临时性;当我们使用malloc申请空间的时候,是在堆区开辟的空间;当我们定义一个全局变量的时候,它的空间就是在全局变量中开辟的,其中也分为未初始化全局变量和已初始化全局变量。在32位系统下的寻址空间是4GB
为了直观地体现出地址分配的规则,我们使用一些例子来做演示:
#include<stdio.h>
#include<stdlib.h>
int val1 = 10;
int val2;
int main() {
//以下均为存储在各区地址空间中的实例
printf("代码区: %p\n", main);
const char* str = "helllo linux";
printf("字符常量区: %p\n", str);
printf("已初始化全局变量区: %p\n", &val1);
printf("未初始化全局变量区: %p\n", &val2);
char* a = (char*)malloc(sizeof(char));
printf("堆区: %p\n", a);
printf("栈区: %p\n", &str);
return 0;
}
运行结果如下图所示:
通过运行结果会发现打印出来的地址从代码区到栈区依次递增。
6.2进程地址空间
当我们使用fork()函数生成一个子进程的时候,子进程会对将要访问的父进程的内容进行写时拷贝,但是会发现子进程和父进程对于同一个全局变量进行访问更改等操作的时候,这个变量的地址是不变的,也就是说同一个地址可能会有两个值,因为这里的地址并不是物理地址,而是虚拟地址(我们平时写程序用到的地址相关的内容一般都是虚拟地址)。如果是物理地址,这是绝对不可能的,可以配合下面案例理解:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int val = 0;
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ //child,子进程肯定先跑完,也就是子进程先修改,完成之后,父进程再读取
val=100;
printf("child: %d : %p\n", val, &val);
}
else{ //parent
sleep(3);
printf("parent: %d : %p\n", val, &val);
}
sleep(1);
return 0;
}
运行结果如图
会发现前文所说的现象,同一个变量,子进程对其将要访问的变量进行写时拷贝,但是父子进程中的val确是同一个地址,因此这里的地址是虚拟地址而非物理地址。他们地址上的逻辑应该对应下图(简化):
- 当父进程创建出来,系统创建了父进程的PCB和父进程的进程地址空间,PCB指向进程地址空间
- 这里创建的进程地址空间是虚拟地址,虚拟地址和物理内存是通过页表来映射的
- 当访问某个地址时,页表通过映射关系,查找到物理地址,并读取存在当中的数据
- 当父进程创建子进程的时候,系统也根据父进程为模板创建子进程对应的PCB和进程地址空间
- 由于子进程时以父进程为模板创建的,因此他们页表是一样的,因此子进程和父进程能够共享代码
- 对于同一个全局变量,当子进程需要对其进行写入等操作时,由于父子进程的虚拟地址对应同一块物理地址,为保证独立性,系统会在物理内存中额外开辟一块空间
- 至此,父子进程各自页表中对于此全局变量的虚拟地址是相同的,但是对应的物理地址是不同的。