本文目录
一.进程的概念
说到进程,必须要先说程序。
程序:是指编译好的二进制文件,在磁盘上,不占用系统资源(cpu、内存、打开的文件、设备、锁…)
进程:正在执行的程序,每个程序运行起来就会产生进程。也可理解为cpu未完成的工作,在内存中执行,占用系统的资源。
举一个例子:
程序 → 剧本(纸) 进程 → 戏(舞台、演员、灯光、道具…)
同一个剧本可以在多个舞台同时上演。同样,同一个程序也可以加载为不同的进程(彼此之间互不影响)
注意:进程不是可执行程序,它们之间无任何联系。
二.进程的管理
进程的管理由操作系统内核来负责,分为描述进程和组织进程两部分
1.描述进程
进程信息都被放在一个叫进程控制块的数据结构中。这个控制块叫PCB进程控制块,是一个很庞大的结构体(task_struct),一个进程对应一个task_struct。
task_struct主要包含:
1.进程标识符pid:进程的身份标识,同一台计算机上两个不同的进程不可能有相同的pid
2.内存指针:告诉进程代码和数据在哪个部分。
3.进程状态(后面详细介绍)
4.优先级:数字,表示这个进程是先被调度执行还是后被调度执行。
5.上下文数据(寄存器)。保存上下文:CPU寄存器的内容保存到内存中。恢复上下文:内存中的寄存器恢复到CPU中。
6.记账信息:每个进程在CPU上执行了多久统计数据。
1.1父进程和子进程
进程可以创建进程,子进程由父进程创建。
父进程:PPID
子进程:PID
1.创建子进程:使用fork()函数:一次调用会有两次返回值,父进程返回子进程的pid,子进程会返回0,所以下面增加一个ret来区分打印父进程和子进程。
注意:当ret<0时,可能是内存不够,或者是进程太多,达到了上限。
2.执行过程
父子进程都是紧接着fork继续执行。
3.执行先后顺序
不确定,取决于操作系统的调度器。
4.创建代码
5.运行结果
进程的调度
进程的调度:让少量的CPU能够满足大量的进程同时执行的需求
并行:两个CPU分别执行两个进程。
并发:一个CPU分别执行两个进程。
1.2进程的状态
进程中常见状态:
R–就绪状态,进程在就绪队列中,就会处于这个状态。
S–睡眠状态,暂时轮不到。
D–深度睡眠状态,一般发生在密集地进行I/O操作的时候,吐coredump文件
T–暂停stop
t–跟踪trace(使用gdb调试时)
X–进程已经结束,只在Linux源码总存在
Z–僵尸进程,和父进程子进程相关联
1.3僵尸进程
我将刚刚的process里的代码做一点修改:在父进程和子进程的创建中加上两个sleep,让父进程一直存在,而子进程循环5次sleep后结束,那么最后打印的进程结果按理由来说应该是没有子进程的。
但是打印的结果是这样:
子进程还是存在,Z就是代表僵尸进程,后缀<defunct>代表失效进程。
我还是用来使用kill命令+进程pid来强制删除进程,但是未果,说明僵尸进程就已经是一个死掉的进程,任何的kill等杀死命令对它已经没有作用。
僵尸进程的成因:子进程结束以后,父进程没有回收子进程的资源。
僵尸进程危害:造成内存泄漏。
治理僵尸进程:kill僵尸进程的父进程,kill掉父进程后,子进程就成了孤儿进程,孤儿进程会被1号进程收养,从而释放资源。更科学处理:进程等待(后面详细介绍)
1.4孤儿进程
再来稍微修改一下代码,让父进程先结束,从而让子进程变成孤儿进程。
可以从打印结果看出,子进程确实被1号进程所收养。
孤儿不是一种进程的状态,指的是父进程先结束了,但是子进程还在,子进程的父亲就变成了1号进程(init)。
1.5进程的查看
在Linux下,我们使用下面这些指令来查看当前想看的进程。
ps aux //进程数量太多,不便于查看
ps aux | less //其中|是管道,把前一个命令的输出作为第二个命令的输入,less可以实现上下翻页
ps aux | grep + 文件名 //查看匹配的进程,常用
其中ps相当于任务管理器,能够查看系统上都有哪些进程
我先使用ps aux | less来查看一下进程,下图是使用该条指令所显示的一页的进程内容,我们先来了解一下这里给出的进程信息里的一些比较重要的概念。
USER:代表进程的创建者,图中是root,表明root是这些进程的创建者。
PID:进程的身份标识,两个不同的进程不可能有相同的PID,每次结果可能不一样
%CPU和%MEM:代表该进程占用了多少CPU资源和内存资源
STAT:绝大部分进程都处于休眠状态
看了这么多root创建的进程,那么怎么看自己代码的进程?一段代码如果运行结束,那么该进程就会结束,所以,想要看到进程,要使得该进程在运行时查看。
所以我写了一个死循环,注意一定要加上unistd.h的头文件。执行该代码后,在通过上述指令显示进程后再筛选你当前代码的进程信息即可。
第三条指令才是我们常用的查看进程的指令:**ps aux | grep + 文件名 **
这里以我的为例,可以看到,前面数字11946就是PID
在这里又有一个问题,由于我在这里看的up主的教学视频上面在死循环里面加了一个sleep,但是我在好奇为什么代码里要加上一个sleep?当我去掉sleep后重新编译运行,查看进程后,进程的信息变成了这样:
这里我们可以看到,有一个进程的CPU占用率由0变成了98.6,原因一下子明朗,sleep的代表进程休眠,所以不会占用CPU。当sleep不存在时,CPU占用率就不为0了,注意CPU利用率可以超过100。
1.6 进程的优先级
通过top来查看,数字越小,优先级越高。
NI:优先级的修正值
进程真正的优先级:PR+NI
环境变量
以键值对的角度来看,环境变量是一个键值对结构,键是变量名,值是变量内容。通过env来查看系统上所有的环境变量。
env $ +{环境变量名}:查看某个环境变量。
在这些环境变量中,每个:之间都代表一个目录。
我们只需要重点研究这些路径
PATH路径:shell中敲下的指令,去哪些目录中去查找对应的可执行程序。
HOME:家目录。
SHELL:当前的shell,默认为/bin/bash。
PATH路径
1.使用env $ 指令,可以将PATH路径筛选出来查看。
2.使用export指令,修改PATH的值,在后面拼接上:+一串路径。对于PATH修改一般只是进行追加,不会把原来的路径去掉。(重启终端时,PATH会恢复如初)
3.永久改变PATH:修改~/.bashrc,使用指令vim ~/.bashrc,在该文件里加上这行即可。
保存退出后,登出以后再登录,就可以看到修改效果了。
4.window上面的环境变量
在CSDN上特别火的程序员桌面装逼神器就是修改了环境变量,桌面上干干净净没有一个快捷方式。把你要访问的路径添加到环境变量里,取一个名字便于快速访问,最后直接在运行窗口理敲如这个快捷名字就可以打开需要访问的程序。
通过代码获取环境变量
最后打印结果:可以显示所有的环境变量
程序的地址空间
关于著名的地址空间分布图,这里就不再过多介绍,引荐一片博文:
栈有多大?堆有多大?
栈的大小可配置,可变,默认是8兆。堆可以非常大。
1.通过ulimit -a查看当前大小
2. 输入ulimit -s 1024(改成10兆)
- 如果是大对象,必须在堆上分配
- 如果是小对象,并且需要频繁的创建和销毁,则推荐在栈上分配内存,效率会更高。
2.组织进程
双向链表进程组织,链表中的每个节点就是一个task_struct。
三.进程的创建
1.fork函数
2.1运行规则:
1.会把父进程的PCB拷贝一份,稍加修改,成为子进程的PCB
2.会把父进程的虚拟地址空间拷贝一份,作为子进程的地址空间(这里拷贝用的是写时拷贝,父子进程共用同一份代码,各有一份数据,由于大部分的内存空间可能被拷贝,创建进程开销仍然比较高)
3.fork返回会在父子进程中分别返回(父进程中返回子进程的pid,子进程返回0,失败时返回-1)
4.父子进程执行顺序没有先后,全靠调度器来实现
2.2关于fork的经典问题:
1.问:下面这段代码会打印几个=?
答案是8个,关键是要掌握fork函数的特性。在第一次进入循环,i=0的时候,一个进程变成fork为两个进程,打印两个=,第二次循环时,i=1,之前的两个进程否要fork一个新的进程,此时就有4个进程,四个进程都要输出一个=,一共就是8个进程。
2.现在来修改一下代码:加上一行fflush(stdout)。问:现在的打印结果是什么?
答案是6个。因为第一次循环时,两个进程中的=会放在缓冲区,缓冲区一刷新,=会直接打印到显示器上。第二次循环时,四个进程中的=又都会存放在缓冲区中,缓冲区再刷新,=又直接被显示器打印,所以2+4等于6个。
2.3 fork调用失败原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
四.进程终止
进程退出场景
- 代码运行完毕,结果正确。
1.从main函数返回,返回值叫做进程的退出码,退出码为0–运行结果是否正确,退出码非0–结果不正确。$?–bash中的一个特殊变量,表示上个命令对应进程的退出码。
2.调用exit,它是一个库函数,如exit(1),1代表进程退出码,(encho $? 查看上一个进程的退出码)可以使进程直接退出。
3._exit:进程退出(系统调用)//atexit程序的退出函数
- 代码运行完毕,结果不正确。
- 代码异常终止。
五.进程等待
父进程对子进程进行进程等待,等待是为了读取子进程的运行结果(当子进程再父进程前面时,先执行子进程)。解决僵尸问题。
- wait方法
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status)
//int*status是输出型参数,是系统自动分配给用户的,不是用户自己传递的。status可以获取子进程的状态
我们来写这样一段代码,运用一下wait,观察它的特性。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
pid_t ret=fork();
if(ret>0)
{
//father
printf("father %d\n",getpid());
int status=0;
pid_t result=wait(&status);//status是一个输出型变量
printf("result %d\n",result);
}
else if(ret==0)
{
//child
int count=3;
while(count>0)
{
printf("child %d\n",getpid());
sleep(1);
count--;
}
exit(0);
}
else
{
//error
perror("fork");
}
return 0;
}
运行结果:
可以发现,明明result的值是在子进程之前获得的,但是wait的返回值result是在最后面才打印的,所以显然wait函数返回是在子进程结束后才有的,wait是一个不同寻常的函数。
特点:
参数是输出型参数 //表示退出码+正常或异常退出
wait的返回值是子进程的pid
wait是一个阻塞式的函数,直到子进程结束,wait函数才会返回
为了充分理解参数是输出型参数这个特点,我在父进程的打印里加上了status,看看status这个参数有什么特点。
结果是status值为0,为什么呢?我首先猜想,因为我加在子进程中加上了exit(0),那么status是不是代表了exit的退出码?
那么现在我把exit(0)改成exit(1),来验证一下我的猜想,后来发现status的值打印出来是256,然后我就懵逼了,怎么又变成256了?
通过造访多篇相关博客,我才恍然大悟,原来status不能当做简单的整型变量来看待,
得当做位图来看待,如图
-
正常退出
这种情况下,wait作为一个16位的int数据,从8到15位(第一个字节)保存的是进程的退出码(范围为0-255),0到7位(最低位)就是0。 -
被信号kill掉
最高位是core dump文件,剩下的位表示信号。
通过kill -l来查看所有的信号,可以发现一共有62个信号。
所以,这个输出型参数的含义就是指status实际上表示退出码+正常或异常退出
最低位字节为0–表示正常终止,最低位字节非0----表示异常终止
那么我们现在可以在代码中运用status来获取退出码,判断进程是正常结束还是异常退出了。
//father
printf("father %d\n",getpid());
int status=0;
//阻塞式函数
pid_t result=wait(&status);//status是一个输出型变量
printf("result %d status %d\n",result,status);
if(status & 0xff)//获取最低4位
{
printf("异常终止,信号为 %d\n",status & 0x7f);//取低7位
}
else{
printf("正常退出,退出码为%d\n",(status>>8) & 0xff);//前8位
}
}
else if(ret==0)
{
//child
int count=3;
while(count>0)
{
printf("child %d\n",getpid());
sleep(1);
count--;
}
exit(3);
}
else{
//error
perror("fork");
}
return 0;
}
正常退出结果:
异常退出结果(把子进程的count改大,让子进程持续时间变长,复制会话,敲kill -9 +子进程pid):
此时在原来窗口就可以看到这样的输出:信号9对应上面信号表的SIGKILL,专门用来强制杀掉信号。
使用wait时应该注意什么?我们再来看一段代码,fork两次进程,只wait回收一次。
在另一个窗口执行ps aux | grep waittest,可以看到进程的状态,很明显有一个子进程成为了僵尸进程。因为这个子进程没有被wait回收。
那么如果我写3个wait呢?会出现什么?
结果是最后一个wait会输出-1.
wait需要的注意事项:
1.wait的调用次数必须和子进程个数一致。wait调用次数过太少会导致僵尸进程。wait调用次数过多,多出来的wait就会调用出错。所以wait数目要和fork的数目匹配。
2.如果有多个子进程,任何一个子进程结束都会触发wait的返回。
那么如果每个进程wait都要等的话,效率未免太低,这个时候就有了waitpid。waitpid能等待某个子进程的退出,行为和wait非常相似。
- waitpid
pid_t waitpid(pid_t pid,int*status,int options)
//pid_t pid:需要等待的进程pid. Pid=-1,等待任意一个子进程。与wait等效。 Pid>0.等待其进程ID与pid相等的子进程。
//options: 默认为0,此时的waitpid为阻塞式。WNOHANG是一个宏,加上之后,waitpid就变成了非阻塞。
那么我将刚刚的wait改成waipid,并且我让waipid第一个等待的进程持续时间比第二个等待的进程长,来看看waipid会不会一直在阻塞等待第一个进程。
运行结果:
可以证明:waitpid会一直阻塞等待它相等的那个进程。
前面提到,WNOHANG是一个宏,拆解为 W NO HANG,即不阻塞模式。如果该子进程没有结束,则返回0;如果该子进程已经结束,则返回该pid;加上之后,waitpid就变成了非阻塞,那么我们来看看它的用法,看看等待了多少次。
结果:可以看出,等待次数还是比较多。
所以,对于waitpid来说:
- 优点:能够灵活的控制代码,充分利用等待时间去做其他事情,
- 缺点:会使得代码写起来很复杂。
六.进程的程序替换
fork创建出的子进程,和父进程共用同一套代码,而事实上我们更需要的是创建出的子进程能够执行一份单独的代码。当进程调用一种exec函数时:
1.该进程的用户空间代码和数据完全被新程序(从一个可执行文件中来)替换,从新程序的启动例程开始执行。
2.原有的堆和栈中的数据全都不要了,根据新代码的执行过程重新构件堆和栈的内容。
3.而进程中的程序替换不会创建新进程,也不会销毁进程
类似于双击exe执行一个程序的过程(操作系统的加载器模块)
替换函数
int execl(const char *path, const char *arg, ...);//path必须是完成的路径,arg代表命令行参数
int execlp(const char *file, const char *arg, ...);//p表示自动从path的目录中查找可执行程序,相当于直接敲ls命令
int execle(const char *path, const char *arg, ...,char *const envp[]);//
int execv(const char *path, char *const argv[]);
int execvp(const char*path, char *const argv[];
int execve(const char *path, char *const argv[], char *const envp[]
- execl
输出运行结果后,输入ls /查看是否正确,通过发现确实替换成了ls /可执行程序。
但是我们不禁疑惑,为什么after里面的内容没有显示?这就是一旦程序替换成功,替换函数后面的代码就会被替换成可执行程对应的代码。这些代码都不会再执行。解决方法:借助fork函数将数据复制一份,让子进程进行替换,父进程没有影响,可以继续执行后面的程序。
运行结果成功:
- execlp
它与execl不同之处在于,execl中的path必须是一个完整的路径(绝对路径或者相对路径),但execlp中只要是一个可执行程序名即可。p表示自动从path的目录中查找可执行程序,相当于直接敲ls命令。只需要将刚刚路径中的路径直接写成ls就ok
execlp("ls","ls","/",NULL);
运行结果与刚刚execl的效果是一样的。
- execle
用户进行程序替换的时候手动指定环境变量
execlp("ls", "ls", "-l", "/etc", (char *)0);
- execv
char *argv[] = {"ls", "-l",NULL};//数组必须以NULL结尾
execv("/bin/ls", argv);
- execvp
char *argv[] = {"ls", "-l", NULL};
execvp("ls", argv);
关于进程的总结暂时先到这里