一、引言
- 进程、子进程和僵进程
- 子进程的创建以及父子进程关系
- fork,exec,system,wait,waitpid系统调用
- 如何避免“僵尸”进程?
二、进程概念
1、进程与程序
之前我们学了文件,文件都是存放在磁盘,里面的程序运行起来就会占用内存空间(如我们电脑卡了换大的内存条),当然,也会去占用CPU、网络、电源等资源
程序是一个简单的实体,说白了,程序是存放在磁盘文件中的可执行文件,某些操作系统用任务表示正被执行的程序。
进程是计算机操作系统分配资源的基本单位(进程是程序的一次执行),但是一个程序不仅仅只有一个进程,可以有多个(如打开任务管理器可以看到windows上开启一个火绒应用程序有多个进程)
2、进程与进程ID
操作系统会为每一个进程分配一个唯一的整型ID,作为进程的标识(pid)(一定是非负整数),计算机系统就利用这个进程ID来管理进程,等价于下图中windows上的任务管理器中的pid
每一次电脑开机之后进程pid是唯一的,关机重启后每个程序的pid会改变,进程pid是随机分配的,一台计算机的pid最多有65535个(进程启动pid不一定按顺序)
进程除了自身的ID外,还有父进程ID,所有进程的祖先进程是同一个进程,它叫做init进程(就像新冠,必须有一个人先得才会传染别人),ID为 1,且不会被终止(杀不死),类似ubuntu系统中的0号进程systemd (如下图)
查看进程树:pstree
查看进程命令:ps -aux
杀死进程:kill -9 pid
3、获取进程标识
#include <sys/types.h>
#include <unistd.h>uid_t geteuid(void); 返回:调用进程的有效用户ID
gid_t getgid(void); 返回:调用进程的实际组ID
gid_t getegid(void); 返回:调用进程的有效组IDpid_t getpid(void); 返回:调用进程的进程ID
pid_t getppid(void); 返回:调用进程的父进程ID
uid_t getuid(void); 返回:调用进程的实际用户ID
4、linux下的进程结构
linux系统是一个多进程的系统,进程之间具有并行性、互不干扰的特点。
linux中进程包含PCB(进程控制块)、程序以及程序所操作的数据结构集:可分为"代码段"、
“数据段”和“堆栈段”。
5、进程三种基本状态
进程在运行中不断改变运行状态,通常,一个运行进程必须具备以下三种基本状态
就绪状态(ready):当进程已分配到除CPU以外的所有必要的资源,只要获得处理机便可以立即执行,这时的进程状态称为就绪状态。
执行状态(running):当进程已获得处理机,其程序正在处理机上执行
阻塞状态(blocked):正在执行的进程,由于等待某个事件发生而无法执行时候,就放弃处理机处于阻塞状态,例如:申请缓冲区不能满足、等待信号、等待IO完成等都可能造成阻塞
1)就绪——>执行
就绪状态的进程,当进程调度程序为之分配了处理机后
2)执行——>就绪
执行状态的进程,因分配给他一个时间片已经用完而不得不让出处理机
3)执行——>阻塞
正在执行的进程因等待某种事件发生而无法继续执行
4)阻塞——>就绪
阻塞状态的进程,若其他等待的事件已经发生
6、linux中的几种进程状态
- 运行状态R(TASK_RUNNING)
- 可中断睡眠状态S(TASK_INTERRUPTIBLE)
- 不可中断睡眠状态D(TASK_UNINTERRUPTIBLE)
- 暂停状态T(TASK_STOPPED或TASK_TRACED)
- 僵死状态Z(TASK_ZOMBIE)
- 退出状态X(TASK_DEAD)
一个进程的基本流程为:新建->就绪->运行->死亡
在运行状态中可能又会出现这几种状态:僵尸-暂停-睡眠-不可中断睡眠
- 睡眠状态:进程睡一会自己醒来回到就绪态
- 暂停状态:进程手动开始会回到就绪态
- 僵死状态:进程走完了所有逻辑,但是内存没有清空
二、进程创建
引入:两种在linux中运行工程的方法
- 在bin文件中X64中的 Debug 中执行.out 文件(./*.out——*为文件名)
- 利用g++命令:g++ main.cpp -o main,会生成main(可执行文件),执行main:./main
注意:如果VS中代码有修改,要让ubuntu那边有响应的话,不要直接点击运行,而是点击重新生成解决方案,会把整个工程重新编译生成,替换旧版本。然后再执行命令
1、fork系统调用
fork函数用于从已存在的进程中创建一个新进程,新进程称为子进程,原进程称为父进程
头文件:#include <unistd.h>
pid_t fork(void);
返回值:子进程中为0,父进程中为子进程I D,出错为-1
注意:
1、fork调用一次返回两次,两次返回的区别是子进程返回值为0,父进程返回的是子进程的进程ID
因为每个进程都会有对应的PCB,代码段,数据段和堆栈段等,fork ()创建的子进程自然也有代码段,是通过拷贝一份父进程代码段(即整个cpp文件的内容,但是子进程不会在fork()一次),于是就会有两个pid,输出(调用一次fork,两次返回)
结果如下:
从上图来看, 一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的的调度算法
2、使用fork函数得到的子进程从父进程继承了整个进程的地址空间。包括进程上下文、进程堆栈、内存信息、打开的文件描述符、进程优先级等
4、父子进程的代码段中用到的变量(全局变量)地址相同,但却不能共享,因为多个进程互不干扰,所以进程里无法进行数据共享(即若定义一个变量,父进程和子进程都使用,虽然数据不同,但地址相同)
5、fork系统调用之后,父子进程交替执行。如果父进程先退出,子进程还没退出,那么子进程的父进程就会变成init进程,因为任何一个进程都必须有父进程。如果子进程先退出,父进程没有退出,那么子进程必须等到父进程捕获到了下一个子进程的退出状态才会真正的结束,否则这个时候子进程就会称为僵进程。
- 进程拖孤:父进程先于子进程死亡,子进程变成孤儿进程,被托管给系统进行管理,会重新找了个父亲子进程还在走,就算父进程死亡,这个进程还没结束
- 僵尸进程:子进程先于父进程走完逻辑,进入僵死状态,不做任何业务,资源不会立即回收,子进程变成僵尸进程(占用内存)
- 僵死状态占用内存,我们需要避免僵死状态的出现,可以在子进程结束后添加语句exit(0)来释放内存
进程彻底结束的含义:所有进程包括所有子进程都结束了并释放内存才能算结束
6、经典创建进程错误:理所当然的认为开多个进程可以直接在主进程(父进程)多次连续fork,或者是用循环多次进行fork
产生的问题:子进程还会走fork函数(上图中的后面三个fork),导致子进程再开子进程(套娃)
解决方法:通过if...else避免。如果想让子进程再开子进程,把fork放到if中,如果想让父进程再开子进程,把fork放到else中,即在父亲的执行范围内再开子进程
子进程与父进程的区别:
- 子进程设置的锁,子进程不能继承
- 各自的进程ID和父进程的ID不同
- 子进程的未决告警被清除;
- 子进程的未决信号集设置为空集
示例代码
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
using namespace std;
int number = 0;
int main()
{
int pid = 0;
int ppid = 0;
pid = getpid();//获取当前main函数的启动进程ID
//cout << "ppid " << ppid << endl;
/*调用fork函数,其返回值为pid*/
pid = fork();//一次调用,两次返回
//cout << "pid " << pid << endl;
/*通过ret的值来判断fork函数的返回情况,首先进行出错处理*/
if(pid == -1)
{
perror("fork error");
exit;
}
/*返回值=0代表子进程*/
else if (pid == 0)//子进程运行范围
{
while (1)
{
number++;
cout << number << "地址" << &number << endl;
/*cout << "子进程运行范围 pid =" << pid << "当前进程id = " << getpid()
<< "当前进程的父进程 id =" << getppid()<< endl;*/
sleep(3);
}
}
else {//父进程运行范围
while (1)
{
number++;
cout << number << "地址"<< & number << endl;
/*cout << "父进程运行范围 pid =" << pid << "当前进程id = " << getpid()
<< "当前进程的父进程 id =" << getppid() << endl;*/
sleep(3);
}
}
return 0;
}
2、exec族
用fork创建的子进程后执行的和父进程相同的程序(但有可能执行不同的代码分支),子进程通常会调用exec函数来执行另一个程序。调用后这个进程的用户空间代码和数据完全被新进程替换掉,从新进程的启动程序开始执行。调用exec函数不会创建新进程,所以调用后进程ID没有改变
exec名下是由多个关联函数组成的一个完整系列:
函数原型:(头文件<unistd.h> )
- int execl(const char *path, const char *arg, ...);// 需要些路径
- int execlp(const char *file, const char *arg, ...);
- int execle(const char *path, const char *arg , ..., char * const envp[]);//char * const envp[]环境变量
- int execv(const char *path, char *const argv[]);
- int execvp(const char *file, char *const argv[]);
- int execve(const char *path, char *const argv[], char *const envp[]);
参数
- path参数表示你要启动程序的名称包括路径名
- arg参数表示启动程序所带的参数
返回值:成功返回0,失败返回-1
巧记:l和v互斥 ; p与e互斥
注意:
- execl、execlp、execle的参数个数是可变的,参数以一个空指针结束
- execv、execvp的第二个参数是一个字符串数组,新程序在启动的时候会把argv数组中给定的参数传递到main中
- 以上的这些函数几乎都是用execve实现的,约定俗成,无可厚非
- 在exec族中的函数名字最后一个字母是“p”,这个函数会查找path环境变量,再去搜索新程序的可执行文件。如果可执行文件步骤path定义的路径上,就必须把包括子目录在内的绝对文件名作为一个参数传递给这些函数
- 由exec启动的新进程继承了原进程的许多东西,已经打开了的文件描述符在新进程里仍将是打开的,除非修改了它们的“exec 调用时关闭此文件”标志
示例代码
//使用文件名的方式来查找可执行文件,同时使用参数列表的方式
if(fork()==0){
/*调用execlp 函数,这里相当于调用了“ps-f”命令*/
if (execlp("ps","ps","-ef",NULL)<0)
{
perror("execlp 错误!");
exit(1);
}
}
//使用完整的文件目录来查找对应的可执行文件
if(fork()==0){
/*注意这里给出ps程序的完整路径*/
if (execl("/etc/ps","ps","-ef",NULL)<0)
{
perror("execl 错误!");
exit(1);
}
}
//将环境变量添加到新建的子进程中去
/*命令参数列表,必须以NULL结尾*/
char *envp[]={"PATH=/tmp","USER=zqw",NULL};
if(fork()==0){
/*注意这里也要指出env的完整路径 env:查看当前进程环境变量*/
if (execle("/etc/env","env",NULL,envp)<0)
{
perror("execle 错误!");
exit(1);
}
}
//通过构造指针数组的方式来传递参数,注意参数列表一定要以NULL作为结尾标识符
char*arg[]={"ls", "-a", NULL};
if(fork()==0){
if (execve("/etc/ls",arg,NULL)<0)
{
perror("execve 错误!");
exit(1);
}
}
三、exit和_exit
exit
和_exit
用于中止进程,直接使进程停止运行清除使用的内存空间,包括PCB 在内的各种数据结构。但是,这两个函数还是有区别的,exit函数在调用exit系统之前要检查文件打开情况,把文件缓冲区的内容写回到文件中去。如调用printf函数。这连个函数的调用过程如下图所示:
exit和_exit函数语法
#include <unistd.h> // _exit
#include <stdlib.h> // exit函数原型:
void _exit(int status)
void exit(int status)参数:
status: 0 代表正常结束;其他数值表示出现了错误,进程非正常结束
小试牛刀
作业将在下一篇文章中讲评
原创不易,转载请注明出处:
基于VS2019 C++的跨平台(Linux)开发(1.3)——进程管理
下面进入第二部分的学习:
基于VS2019 C++的跨平台(Linux)开发(1.3.2)——进程管理