【Linux】Linux进程控制

一. 进程创建

1. fork函数初识

在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。

#include <unistd.h>
pid_t fork(void);
返回值:子进程中返回0,父进程返回子进程id,出错返回-1

fork的实现是在内核空间
进程调用fork,当控制转移到内核中的fork代码后,内核做:

  • 分配新的内存块和内核数据结构给子进程
  • 将父进程部分数据结构内容拷贝至子进程
  • 添加子进程到系统进程列表当中
  • fork返回,开始调度器调度

在这里插入图片描述
当一个进程调用fork之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以开始它们自己的旅程,看如下程序:

int main()
{
	pid_t pid;
	printf("Before: pid is %d\n", getpid());
	if ( (pid=fork()) == -1 )
	{
		perror("fork()")exit(1);
	}
	printf("After:pid is %d, fork return %d\n", getpid(), pid);
	sleep(1);
	
	return 0;
}
运行结果:
[root@localhost linux]# ./a.out
Before: pid is 43676
After:pid is 43676, fork return 43677
After:pid is 43677, fork return 0

这里看到了三行输出,一行before,两行after。进程43676先打印before消息,然后它有打印after。另一个after消息有43677打印的。注意到进程43677没有打印before,为什么呢?如下图所示:
在这里插入图片描述
所以,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。
注意,fork之后,谁先执行完全由调度器决定。

2.fork函数返回值

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
  • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数

如何理解fork()函数有两个返回值呢?
fork函数的大致执行过程如下:

pit_t fork()
{
	1.创建子进程的PCB
	2.赋值
	3.创建子进程的地址空间
	4.复制
	5.创建并设置页表
	6.子进程放入进程list
	……
	return pid;
}

当一个函数准备return的时候,核心代码都已经执行完了。当fork()函数准备return的时候,子进程已经被创建好了,并且可能已经在OS的运行队列中,准备被调度了。所以父进程和子进程各自执行return,因此会有2个返回值。

如何理解fork()返回后,给父进程返回子进程的pid,给子进程返回0?
一个父进程可能有多个子进程,而一个子进程只有一个父进程。因此,对父进程来说,子进程是需要被标识的,因为父进程创建子进程的目的是让其执行任务的,父进程只有知道了子进程的PID才能很好的对该子进程指派任务;而子进程不用区分它的父进程,因为只有一个,所以返回0。

如何理解同一个id值,怎么可能会保存两个不同的值,让if和else if同时执行?

pid_t id=fork();

我们是用同一个id来接收fork()函数的返回值,地址也是一样的,但是id的内容却不一样。这是因为返回的本质就是写入!所以,谁先返回,谁就先写入id,因为进程具有独立性,会采取写时拷贝,存储两个不同的值

写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
在这里插入图片描述

3. fork常规用法

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子 进程来处理请求。
  • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数

4. fork调用失败的原因

  • 系统中有太多的进程
  • 实际用户的进程数超过了限制

二. 进程终止

1. 进程退出场景

  1. 代码运行完毕,结果正确
  2. 代码运行完毕,结果不正确 (退出码这个时候起效果)
  3. 代码异常终止(退出码无意义)

2. 进程退出码

2.1 main函数的返回值的意义

为什么我们都在main函数的最后都要 return 0,而不返回其他数?
这是因为main函数是间接性被操作系统所调用的,那么当main函数调用结束后就应该给操作系统返回相应的退出信息,而这个所谓的退出信息就是以退出码的形式作为main函数的返回值返回,return 后面的值代表进程退出的时候,对应的退出码。标定进程执行的结果是否正确。如果不关心进程退出码,return 0就行。

2.2 查看进程退出码

echo $?

$?:永远记录最近一个进程在命令行中执行完毕时对应的退出码

2.3 进程退出码的分类

  1. 代码运行完毕,结果正确—return 0;
  2. 代码运行完毕,结果不正确 —return !0; 退出码在这个时候起效果,可以通过退出码查看具体错误原因,例如内存空间不足、非法访问以及栈溢出等等
  3. 代码异常终止—退出码无意义

C语言当中的strerror函数可以通过错误码,获取该错误码在C语言当中对应的错误信息:
在这里插入图片描述

  1 #include <stdio.h>
  2 #include <string.h>
  3 
  4 int main()
  5 {
  6     for(int i = 0; i < 150; i++)
  7     {
  8         printf("%d: %s\n", i, strerror(i));
  9     }
 10 
 11     return 0;                                                                                                                                                          
 12 }                                                                                                                           

运行代码后我们就可以看到每个错误码所表示的错误信息:
在这里插入图片描述
当我们在命令行上使用ls,pwd等命令时,也是在运行一个程序,我们也可以查看其对应的退出码。
可以看到,当这些这些命令成功执行后,其退出码也是0。
在这里插入图片描述

3. 进程常见退出方法

3.1 正常终止(可以通过 echo $? 查看进程退出码)

1.从main返回:

main函数内的return返回代表进程退出非main函数return代表函数返回

return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数。

2. 调用_exit函数

#include <unistd.h>
void _exit(int status);
参数:status 定义了进程的终止状态,父进程通过wait来获取该值

说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值是255。

使用_exit函数退出进程的方法我们并不经常使用,_exit函数也可以在代码中的任何地方退出进程,但是_exit函数会直接终止进程,并不会在退出进程前会做任何收尾工作,不会刷新缓冲区
执行下面的代码:

#include <stdio.h>
#include <unistd.h>

 int main()
{
	printf("hello world!");                                                                                                                                            
	sleep(2);
	_exit(1);
}

运行结果:
在这里插入图片描述
打印的字符串没有带\n,数据会保留在缓冲区当中,由于_exit终止进程时不会刷新缓冲区,因此运行该程序没有输出结果

3. 调用exit函数

#include <unistd.h>
void exit(int status);

exit最后也会调用_exit, 但在调用_exit之前,还做了其他工作:

  1. 执行用户通过 atexit或on_exit定义的清理函数。
  2. 关闭所有打开的流,所有的缓存数据均被写入
  3. 调用_exit

在这里插入图片描述
exit结束进程时,会主动刷新缓冲区
实例:

#include <stdio.h>
#include <unistd.h>

 int main()
{
	printf("hello world!");                                                                                                                                            
	sleep(2);
	exit(1);
}

运行结果:
在这里插入图片描述

_exit系统调用exit库函数

异常退出

  • ctrl + c,信号终止
  • 代码中发生除以0空指针的使用等也会引发程序异常终止
  • 给进程发kill信号也会导致进程异常退出

三. 进程等待

1. 进程等待必要性

  • 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
  • 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
  • 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
  • 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息,可以解决僵尸进程问题
  • 进程退出的时候会将自己的退出结果写入到PCB中,父进程可以通过status从子进程的PCB中获取子进程的退出信息

2. 进程等待的方法

2.1 wait方法

#include<sys/types.h>
#include<sys/wait.h> pid_t wait(int*status);
返回值: 成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关心则可以设置成为NULL;否则,操作系统会通过该参数,将子进程的退出信息反馈给父进程。稍后再介绍。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
	pid_t id = fork();
	if(id < 0)
	{
	  perror("fork");
	  exit(-1);//表示进程运行完毕,结果不正确
	}
	if(id == 0)
	{
		//子进程
		int cnt = 5;
		while(cnt)
		{
			printf("cnt: %d, 我是子进程:%d, 父进程:%d\n",cnt--, getpid(), getppid());
			sleep(1);
		}
		exit(0); //进程退出
	}
	//父进程
	sleep(5);
	pid_t ret = wait(NULL);//阻塞式的等待!
    if(ret > 0)
    {
      printf("等待子进程成功,ret:%d\n",ret);
    }
    sleep(5);
    
	return 0;
}

使用以下监控脚本对进程进行实时监控:

while :; do ps ajx | head -1 && ps ajx | grep mytest | grep -v grep;echo "######################";sleep 1;done

可以看到,当子进程退出后,父进程读取了子进程的退出信息,回收了子进程资源,子进程也就不会变成僵尸进程了:
在这里插入图片描述

2.2 waitpid方法

pid_ t waitpid(pid_t pid, int *status, int options);
返回值
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数
pid
Pid=-1,等待任一个子进程。与wait等效。
Pid>0.等待其进程ID与pid相等的子进程。
status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。默认为0,表示阻塞等待

  • 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
  • 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
  • 如果不存在该子进程,则立即出错返回。

在这里插入图片描述

  • 非阻塞等待有什么好处?
    不会占用父进程的全部精力,父进程可以通过轮询来非阻塞等待一个子进程,并且可以在轮询期间做其他的事情

2.3 获取子进程status

  1. wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
  2. 如果传递NULL,表示不关心子进程的退出状态信息。
  3. 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
  4. status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16位):
    在这里插入图片描述
    退出状态用于判断结果是否正确,终止信号用于判断进程是否正常退出

由此我们可以通过此来对status进行位操作来获取异常信号和退出码

exit_code = (status >> 8) & 0xFF; //退出码
exit_signal = status & 0x7F;      //退出信号
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
    pid_t id=fork();
    if(id==0)
    {
        //子进程
        int cnt=3;
        while(cnt--)
        {
            printf("子进程:%d  父进程:%d  %d\n",getpid(),getppid(),cnt);
            sleep(1);
        }
        exit(10);//子进程退出
    }
    int status = 0; //不是被整体使用的,有自己的位图结构
    pid_t ret =waitpid(id, &status, 0);
    if(ret > 0) 
    {
        //父进程
        printf("等待成功,返回值子进程的PID:%d,终止信号:%d,子进程退出码:%d\n",ret,(status&0X7F),((status>>8)&0XFF));
    }
    sleep(5);
    return 0;
}

运行结果:
在这里插入图片描述

四. 进程程序替换

1. 替换原理

创建子进程的目的:

  1. 想让子进程执行父进程代码的一部分,本质是执行父进程对应的磁盘代码的一部分
  2. 想让子进程执行一个全新的程序,本质是让子进程想办法,加载磁盘上指定的程序,执行新程序的代码和数据。这就是进程的程序替换

用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变
在这里插入图片描述

  • 进程程序替换之后,该进程对应的PCB、进程地址空间以及页表等数据结构都没有发生改变,只是将指定进程的代码和数据加载到物理内存当中,覆盖自己原来的代码和数据
  • 进程程序替换不会影响父进程。虚拟地址空间和页表保证了进程的独立性,一旦执行流想替换代码和数据,就会发生写时拷贝,此后父子进程的代码和数据也就分离了,因此子进程进行程序替换后不会影响父进程的代码和数据
  • 程序替换的本质,就是将指定程序的代码和数据加载到指定的位置,覆盖原先的程序的代码和数据
  • 虚拟地址 + 页表保证了进程的独立性,一旦有执行流想替换代码或数据,发生写时拷贝

2. 替换方法

2.1 替换函数

有六种以exec开头的函数,统称exec函数:

#include <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[]);
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[]);

2.2 函数解释

  • 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。因为原程序的代码已经被覆盖,返回也没有意义
  • 如果调用出错则返回-1
  • 所以exec函数只有出错的返回值而没有成功的返回值

2.3 命名解释

这些函数原型看起来很容易混,但只要掌握了规律就很好记。

  • l(list) : 表示参数采用列表 。将执行参数一个一个地进行传递,需以NULL结束
  • v(vector) : 参数用数组 。可以将所有的执行参数放入数组中,进行统一传递,而不使用可变参数方案
  • p(path) : 有p自动搜索环境变量PATH 。不同告诉程序的路径,告诉程序的名称,自动在环境变量PATH中进行可执行程序的查找
  • e(env) : 表示自定义环境变量

在这里插入图片描述

  • 即使函数名称中不带e的exec家族的函数,子进程照样能拿到默认的环境变量environ,通过地址空间的方式让子进程拿到。

exec调用举例如下:

#include <unistd.h>
int main()
{
	char *const argv[] = {"ps", "-ef", NULL};
	char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
	
	execl("/bin/ps", "ps", "-ef", NULL);
	
	// 带p的,可以使用环境变量PATH,无需写全路径
	execlp("ps", "ps", "-ef", NULL);
	
	// 带e的,需要自己组装环境变量
	execle("ps", "ps", "-ef", NULL, envp);
	execv("/bin/ps", argv);
	
	// 带p的,可以使用环境变量PATH,无需写全路径
	execvp("ps", argv);
	
	// 带e的,需要自己组装环境变量
	execve("/bin/ps", argv, envp);
	
	exit(0);
}

事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册 第2节,其它函数在man手册第3节。这些函数之间的关系如下图所示。
下图exec函数族 一个完整的例子:
在这里插入图片描述

五. 简易shell的实现

我们可以综合前面的知识,做一个简易的shell
考虑下面这个与shell典型的互动:

[root@localhost epoll]# ls
client.cpp readme.md server.cpp utility.h
[root@localhost epoll]# ps
PID TTY TIME CMD
3451 pts/0 00:00:00 bash
3514 pts/0 00:00:00 ps

用下图的时间轴来表示事件的发生次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左向右移动。shell从用户读入字符串"ls"。shell建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结束。
在这里插入图片描述
然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序 并等待这个进程结束。所以要写一个shell,需要循环以下过程:

  1. 获取命令行
  2. 解析命令行
  3. 建立一个子进程(fork)
  4. 替换子进程(execvp)
  5. 父进程等待子进程退出(wait)

根据这些思路,和我们前面的学的技术,就可以自己来实现一个shell了。
实现代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#define MAX_CMD 1024
char command[MAX_CMD];
int do_face()
{
	memset(command, 0x00, MAX_CMD);
	printf("minishell$ ");
	fflush(stdout);
	if (scanf("%[^\n]%*c", command) == 0) {
		getchar();
		return -1;
	}
	return 0;
}
char** do_parse(char* buff)
{
	int argc = 0;
	static char* argv[32];
	char* ptr = buff;
	while (*ptr != '\0') {
		if (!isspace(*ptr)) {
			argv[argc++] = ptr;
			while ((!isspace(*ptr)) && (*ptr) != '\0') {
				ptr++;
			}
		}
		else {
			while (isspace(*ptr)) {
				*ptr = '\0';
				ptr++;
			}
		}
	}
	argv[argc] = NULL;
	return argv;
}
int do_exec(char* buff)
{
	char** argv = { NULL };
	int pid = fork();
	if (pid == 0) {
		argv = do_parse(buff);
		if (argv[0] == NULL) {
			exit(-1);
		}
		execvp(argv[0], argv);
	}
	else {
		waitpid(pid, NULL, 0);
	}
	return 0;
}

int main(int argc, char* argv[])
{
	while (1) {
		if (do_face() < 0)
			continue;
		do_exec(command);
	}
	return 0;
}

在继续学习新知识前,我们来思考函数和进程之间的相似性
exec/exit就像call/return
一个C程序有很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过call/return系统进行通信。这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux鼓励将这种应用于程序之内的模式扩展到程序之间。如下图:
在这里插入图片描述
一个C程序可以fork/exec另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过exit(n)来返回值。调用它的进程可以通过wait(&ret)来获取exit的返回值。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值