Bootstrap

Linux进程控制

1 进程创建

1.1 fork()创建子进程

  • fork创建子进程,操作系统做了什么?

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

  • 子进程执行那些代码语句
int main(){
        printf("Before: pid is %d\n", getpid());
        pid_t pid = fork();
        printf("After:pid is %d, fork return %d\n", getpid(), pid);
        sleep(1);
        return 0;
}

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

  • 子进程未执行fork()前语句,原因如下:

首先,父子进程共享的代码是所有的,不是只共享fork()后的代码。但是在创建子进程时,父进程的上下文数据也拷贝给了子进程,此时,父进程已经执行完成fork()了,因此子进程运行时,读取上下文数据后,默认从fork()后的代码开始执行

  • fork常规用法(两个)
  1. 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
  2. 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数(进程替换)
  • fork调用失败原因
  1. 系统中有太多的进程
  2. 实际用户的进程数超过了限制

1.2 写时拷贝

  • 父子进程是共享代码,独立数据

理论上,进程具有独立性,拥有自己的内核结构,自己的代码和数据。但在一般情况下,子进程没有加载过程,即子进程没有自己的代码和数据,因此子进程只能“使用”父进程的代码和数据。
但是在一般情况下,代码是不可写的,因此父子共享可行。而数据可能被修改,因此必须有各自的数据。

  • 数据独立实现手段

为了节省空间,不会对数据进行全部拷贝,只拷贝将来会被父子进程写入的数据,但是由于无法提取预知哪些数据要分离,因此,操作系统采用写时拷贝技术,将父子进程的数据进行分离。
写时拷贝(使用时再分配)使得父子进程完成彻底分离,保证了进程的独立性,是一种延时申请技术,能提高整机内存的使用率


2 进程终止

  • 进程终止时,操作系统释放进程申请的相关内核数据结构和对于的数据和代码;本质是释放系统资源
  • 进程终止的常见方式
    a.代码运行结束,结果正确
    b.代码运行结束,结果错误
    c.代码未运行结束,程序崩溃

2.1 退出码

main()的返回值是该进程的退出码,是返回给上级进程用来判断该进程的执行结果。0表示运行结果正确,非0表示运行结果错误,不同数字可标识不同的错误原因,从而方便在运行结果错误时,定位错误原因。
程序崩溃时,退出码无意义,因为退出码对应的return语句没有被执行

strerror 打印相应退出码对应的错误

#include <stdio.h>
#include <string.h>
int main(){
        int number;
        for( number = 0 ; number < 20; number++){
                printf("%d: %s\n",number, strerror(number));
        }
        return 0;
}

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

echo $? 可查看最近一个进程执行完毕的退出码
在这里插入图片描述

2.2 _exit()和exit()

  • 代码如何终止进程
  1. return,在main()里表示进程退出,执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做exit的参数
  2. exit(),与return区别是,在任何地方调用,都直接终止整个进程
  3. _exit()
  1. _exit()
#include <unistd.h>
void _exit(int status);

status 定义了进程的终止状态,父进程通过wait来获取该值;虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值是255

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

调用exit(),最终仍会调用_exit(),不同之处在于exit()会执行用户通过 atexit或on_exit定义的清理函数,关闭所有打开的流,所有的缓存数据均被写入;通俗点讲,exit()会冲刷缓冲区,然后才结束,而_exit()则是直接结束

  • 观察如下代码,感受_exit()和exit()区别
int main(){
        printf("hello");
        exit(0);
}
int main(){
        printf("hello");
        _exit(0);
}
int main(){
        printf("hello\n");
        _exit(0);
}

运行结果分别为
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

exit()会打印缓冲区数据再结束,因此第一个代码会打印"hello"
_Exit()不会打印缓冲区的数据,直接结束,因此第二个代码不会打印
printf输出的数据不是直接输出到屏幕上,而是保存在缓冲区,后加\n,\n相当于刷新缓存的作用,因此,第三个"hello\n"会直接输出到屏幕上

  • 总结
  1. exit()是C/C++接口,exit()会刷新缓冲区,缓冲区是由C标准库维护的;_exit()是操作系统提供的接口
  2. 库函数 exit 可以在任意位置调用,用于退出进程, 并且退出前会刷新文件缓冲区中的数据到文件中
  3. 系统调用 _exit 可以在任意位置调用,用于退出进程,但是退出时直接释放所有资源,并不会刷新缓冲区

3 进程等待

  • 为什么要进程等待:

父进程通过进程等待,回收子进程资源,获取子进程退出信息(退出码),根据退出码判断子进程运行结果

3.1 如何等待

  • 通过wait()和waitpid()进行等待
  1. wait()
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int *status);

pid_t ret = wait(NULL);
返回值:成功则返回被等待进程的pid,失败则返回-1;
参数:status存放子进程退出状态,如果不关心子进程退出状态则填NULL

  1. waitpid()
pid_t waitpid(pid_t pid, int *status, int options);

pid_t ret = waitpid(pid, NULL, 0)||pid_t ret = waitpid(pid, &status, 0);
返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在
参数:
pid = -1:等待任一个子进程; pid > 0:等待其进程ID与pid相等的子进程
option 默认为0,表示阻塞等待;WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
status 输出型参数 不是按照整数来整体使用的,是按照比特位的方式,将32位比特位进行划分

正常终止,次第八位表示子进程退出的退出码
异常退出,本质是操作系统杀掉进程,通过发送信号的方式,最低7位表示进程收到的信号
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)

  • wait/waitpid的本质是读取子进程的task_struct结构,使用系统调用让操作系统将进程内核数据结构对象的数据传给wait/waitpid

3.2 阻塞等待和非阻塞等待

  1. 进程阻塞等待
  • 阻塞意味着进程的PCB被放入等待队列中,并将进程状态由R改为S状态;子进程退出后,父进程的PCB从等待队列中拿回,进入就绪队列等待CPU调度
  • 实例
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
int main(){
        // 创建子进程
	pid_t id = fork();
	if(id < 0 ){
		perror("fork");
		exit(1);	// 标识进程运行完毕且结果错误
	}
        // 子进程运行代码
	else if(id == 0){
		int cnt = 5;
		while(cnt--){
			printf("cnt: %d, 我是子进程, pid: %d, ppid:%d\n", cnt, getpid(), getppid());
			sleep(1);
		}
		exit(222);      // 设置子进程退出码
	}
        // 父进程运行代码
        else{
                int status = 0;
                // 子进程退出后,父进程才执行waitpid
                // 保持进程退出顺序性,子进程退出,父进程才能退出,让父进程进行更多收尾工作
                pid_t ret = waitpid(id, &status, 0);
                // 默认在阻塞状态下等待子进程退出
                // id > 0: 等待指定进程;id = -1:等待任意一个子进程退出(与wait()等价) 
                if(ret > 0){
                        // 宏处理
                        if(WIFEXITED(status)){
                                printf("子进程执行完成,正常退出,退出码: %d\n", WEXITSTATUS(status));
                        }
                        else{
                                printf("子进程异常退出: %d\n", WIFEXITED(status));
                        }		
                }
	}
	return 0;
}

waitpid(pid, NULL, 0)wait(NULL) 等效,均为父进程阻塞等待子进程退出

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

  1. 进程非阻塞等待
  • 非阻塞等待意味着父进程在子进程退出前,能够运行其他任务,而不是只等待子进程
    父进程在等待子进程返回结果,情况有如下:
  1. 等待成功,子进程退出
  2. 等待成功,子进程还未退出
  3. 等待失败
  • 实例
#include <iostream>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <vector>

typedef void (*handler_t)(); //函数指针类型
std::vector<handler_t> handlers; //函数指针数组

void fun_one(){
    printf("这是一个临时任务1\n");
}
void fun_two(){
    printf("这是一个临时任务2\n");
}
// 设置对应的方法回调
// 以后想让父进程执行任何方法的时候,只要向Load里面注册,就可以让父进程执行对应的方法喽!
void Load(){
    handlers.push_back(fun_one);
    handlers.push_back(fun_two);
}
int main(){
	pid_t id = fork();
	if(id < 0 ){
		perror("fork");
		exit(1);	// 标识进程运行完毕且结果错误
	}
	else if(id == 0){
		int cnt = 5;
		while(cnt--){
			printf("cnt: %d, 我是子进程, pid: %d, ppid:%d\n", cnt, getpid(), getppid());
			sleep(1);
		}
		exit(222);
	}
	else{
		int quit = 0;
	        while(!quit){
                    int status = 0;
                    pid_t res = waitpid(-1, &status, WNOHANG); //以非阻塞方式等待
                    if(res > 0){
                        //等待成功 && 子进程退出
                        printf("等待子进程退出成功, 退出码: %d\n", WEXITSTATUS(status));
                        quit = 1;
                    }
                    else if( res == 0 ) {
                        //等待成功 && 但子进程并未退出
                        printf("子进程还在运行中,暂时还没有退出,父进程可以在等一等, 处理一下其他事情??\n");
                        if(handlers.empty()) {
				Load();
			}
                        for(auto func: handlers) {
                            //执行处理其他任务
                            func();
                        }
                    }
                    else{
                        //等待失败
                        printf("wait失败!\n");
                        quit = 1;
                   }
                    sleep(1);
                }	
	}
	return 0;
}

waitpid(-1, &status, WNOHANG) WNOHANG表示父进程非阻塞等待

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


4 进程替换

  • 进程替换是通过调用一种exec函数,将该进程的用户空间代码和数据被新程序替换
  • 进程替换的目的是,通过fork创建子进程后,将子进程进行进程替换,从而让子进程执行其他程序
  • 需要注意的是:

1.程序替换是在当前进程pcb并不退出的情况下,替换当前进程正在运行的程序为新的程序(加载另一个程序在内存中,更新页表信息,初始化虚拟地址空间),因此进程替换的过程不会创建新进程,调用exec前后该进程的id并未改变
2. 并且当前进程在运行完替换后的程序后就会退出,并不会继续运行原先的程序,即原程序exec函数的后续代码失效

4.1 进程替换的使用场景

  1. 不创建子进程,原进程进行进程替换
int main(){
        printf("当前进程开始运行!\n");
        execl("/usr/bin/ls", "ls", "-l", "-a", "-i", NULL);
        printf("当前进程运行结束!\n");
        return 0;
}

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

进程替换完成,调用打印当前文件夹的目录信息命令,但并不打印 “当前进程运行结束!” 这条语句
原因在于,进程替换会将当前进程的所有代码和数据全部替换,一旦调用完成,execl函数的后续所有代码都不会执行
因此,execl()不需要进行返回值判定,因为一旦替换完成,后续所有代码不会执行,故无返回值。只有当替换失败,返回-1

  1. 创建子进程,让子进程进行进程替换
  • 创建子进程目的:为了不影响父进程,使用进程替换,替换子进程的代码和数据,执行新进程,不影响父进程的代码和数据;让父进程聚焦在读取和解析数据上,指派子进程执行代码的功能
  • 未进行进程替换、加载新程序之前,父子进程代码共享,数据写时拷贝;当子进程加载新程序后,代码也写时拷贝,父子进程代码分离
int main(){
        pid_t id = fork();
        if(id == 0){
                // 子进程
                printf("子进程开始运行,pid:%d\n", getpid());
                sleep(2);
                execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
                exit(1);
        }
        else{
                // 父进程
                printf("父进程开始运行,pid:%d\n", getpid());
                int status = 0;
                pid_t id = waitpid(-1, &status, 0);     // 阻塞等待
                if(id > 0){
                        printf("wait success, exit code: %d\n", WEXITSTATUS(status));
                }
        }
        return 0;
}

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

4.2 其他exec函数使用

  1. int execl(const char *path, const char *arg, ...);
  2. int execv(const char *path, char *const argv[]);
char* const _argv[NUM] = {"ls", "-a", "-l", NULL};
execv("/usr/bin/ls", _argv);

l——list 意味着execl的参数采用列表; v——vector 表示execv的参数采用数组

  1. int execlp(const char *file, const char *arg, ...);
execlp("ls", "ls", "-a", "-l", NULL);

p —— 在环境变量PATH中进行查找,无需执行程序的路径,有p自动搜索环境变量PATH,无需手动写路径

  1. int execvp(const char *file, char *const argv[]);
execvp("ls", _argv);

vp —— 参数采用数组,无需手动写路径

  1. int execle(const char *path, const char *arg, ..., char * const envp[]);
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
execle("ls", "ls", "-a", "-l", NULL, envp);

le——参数采用列表自己维护环境变量

  1. int execve(const char *file, char *const argv[], char *const envp[]);
execve("/bin/ls", _argv, envp);
  • 上述1-5是系统提供的基本封装,6execve才是系统调用,其它五个函数最终都调用execve

在这里插入图片描述

  1. 进程替换自己写的C、C++二进制程序

mycmd.c

#include <stdio.h>
#include <stdlib.h>
int main(int argc, char* argv[]){
	if(argc != 2){
		printf("can not execute!\n");
		exit(1);
	}
	printf("获得环境变量 : %s\n", getenv("env17"));
	if(strcmp(argv[1], "-a") == 0){
		printf("hello a!\n");
	}
	else if(strcmp(argv[1], "-b") == 0){
		printf("hello b!\n");
	}
	else{
		printf("hello NULL!\n");
	}
	return 0;
}

main函数部分

char* const _env[NUM] = {(char*)"env17=111222", NULL};
execle(./mycmd, "mycmd", "-a", NULL, _env);

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

  1. 执行其他语言的程序
  • exec* 功能其实是加载器的底层接口
        execlp("python" , "test.py", NULL);
        execlp("bash", "test.sh", NULL);
        execlp("./test.py" test.py" NULL);

5 简易Shell

  • 基础
  1. 首先shell是常驻内存的进程,因此需要while(1),保证不退出
  2. 其次需要打印提示信息,如[yjp@localhost myshell]#
  3. 然后需要从键盘获取用户输入,使用fgets函数
  4. 解析用户输入,保存用户命令参数,使用strtok函数对用户输入进行拆分,并存放到g_argv数组
  5. fork函数创建子进程,使用execvp函数进行进程替换,父进程阻塞等待
int main(){
        // 0.shell是常驻内存的进程,即不退出
        while(1){
                // 1.打印提示信息
                printf("[yjp@localhost myshell]# ");
                fflush(stdout);	// 刷新缓冲区
                memset(cmd_line, '\0', sizeof cmd_line);
                
                // 2.获取用户输入,即各种指令
                if(fgets(cmd_line, sizeof cmd_line, stdin) == NULL){
                        continue;
                }
                // 清理'\n'
                cmd_line[strlen(cmd_line) - 1] = '\0';
                // 3.命令行字符串解析
                g_argv[0] = strtok(cmd_line, SEP);	// 首次使用需要传入原始字符串,返回SEP前的字串,并且指针指向子串的下一个位置
                int index = 1;
                while(g_argv[index++] = strtok(NULL, SEP));	// 第二次使用如果仍需使用原始字符串,传NULL,并且指针指向首次使用后,返回的子串的下一个位置
                // 4. fork()
                pid_t id = fork();
                if(id == 0){
                        // 子进程
                        printf("子进程执行以下功能\n");
                        execvp(g_argv[0], g_argv);
                        exit(1);
                }
                int status = 0;
                pid_t ret = waitpid(id, &status, 0);
                if(ret > 0){
                        printf("exit code: %d\n", WEXITSTATUS(status));
                }
        }
        return 0;
}
  • ls和ll命令
    在Shell输入whilch lswhilch ll发现ls与’ls --color=auto’等同、ll与’ls -l --color=auto’等同,因此可对这两个命令进行特殊修改

在这里插入图片描述

if(strcmp(g_argv[0], "ls") == 0){
        g_argv[index++] = "--color=auto";
}
if(strcmp(g_argv[0], "ll") == 0){
        g_argv[0] = "ls";
        g_argv[index++] = "-l";
        g_argv[index++] = "--color=auto";
}
  • 内置命令cd
    实际使用时发现cd命令失效,原因是cd是内置命令,内置命令和shell是为一体的,是shell的一部分,不需要单独去读取某个文件,系统启动后,就执行在内存中了,内置命令不会产生子进程去执行,因此需要让父进程自己执行cd命令
if(strcmp(g_argv[0], "cd") == 0 ){
        // 父进程cd 而不是子进程cd
        if(g_argv[1] != NULL){
                chdir(g_argv[1]);       // 改变当前工作目录到指定的路径g_argv[1]
        }
        continue;
}
  • 综上,总体代码如下
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

#define NUM 1024
#define SIZE 31
#define SEP " "

char cmd_line[NUM];
char* g_argv[SIZE];

// shell运行原理 -- 子进程执行命令,父进程等待和解析命令
int main(){
        // 0.shell是常驻内存的进程,即不退出
        while(1){
                // 1.打印提示信息
                // [yjp@localhost myshell]#
                printf("[yjp@localhost myshell]# ");
                fflush(stdout);	// 刷新缓冲区
                memset(cmd_line, '\0', sizeof cmd_line);
                
                // 2.获取用户输入,即各种指令
                if(fgets(cmd_line, sizeof cmd_line, stdin) == NULL){
                        continue;
                }
                // 清理'\n'
                cmd_line[strlen(cmd_line) - 1] = '\0';
                // 3.命令行字符串解析
                g_argv[0] = strtok(cmd_line, SEP);	// 首次使用需要传入原始字符串,返回SEP前的字串,并且指针指向子串的下一个位置
                int index = 1;
                if(strcmp(g_argv[0], "ls") == 0){
                        g_argv[index++] = "--color=auto";
                }
                if(strcmp(g_argv[0], "ll") == 0){
                        g_argv[0] = "ls";
                        g_argv[index++] = "-l";
                        g_argv[index++] = "--color=auto";
                }
                while(g_argv[index++] = strtok(NULL, SEP));	// 第二次使用如果仍需使用原始字符串,传NULL,并且指针指向首次使用后,返回的子串的下一个位置
                // 4. TODP 内置命令 让父进程自己执行的命令,称为内置命令,有些命令一定要父进程执行,如cd命令
                if(strcmp(g_argv[0], "cd") == 0 ){
                        // 父进程cd 而不是子进程cd
                        if(g_argv[1] != NULL){
                                chdir(g_argv[1]);       // 改变当前工作目录到指定的路径g_argv[1]
                        }
                        continue;
                }
                // 5. fork()
                pid_t id = fork();
                if(id == 0){
                        // 子进程
                        printf("子进程执行以下功能\n");
                        execvp(g_argv[0], g_argv);
                        exit(1);
                }
                int status = 0;
                pid_t ret = waitpid(id, &status, 0);
                if(ret > 0){
                        printf("exit code: %d\n", WEXITSTATUS(status));
                }
        }
        return 0;
}
;