进程概念
进程其实是程序的动态概念。平时我们用c/c++写的程序,在没有运行之前都是叫做程序不能叫做进程,只有运行起来以后才叫做进程。同时进程是资源的分配单位,而线程是cpu调度单位。在linux内核进程的资源管理是通过pcb(进程控制块)来控制的,对应数据结构task_struct,一个进程涉及到的信息有:
- pid
- 进程状态
- 当前工作目录
- umask
- 文件描述符表
- 与信号相关的信息
- 用于id、组id
- 控制终端、session、进程组
- 环境变量 等等。每个进程都是独占进程地址空间的。
如何创建进程
在linux创建进程很简单,是通过系统调用fork来实现的。大致代码片段为:
pid_t pid = fork()
if (pid < 0) {
/// errror
} else if (pid == 0) {
/// 是子进程
} else {
/// 父进程。返回的pid表示子进程的id
}
fork是一次调用,在用户空间会返回两次,一次是返回的父进程,一次返回到子进程,没有绝对的先后顺序,取决于内核的调度算法。fork之后,子进程会继承父进程的pcb(当然pid是不同的),子进程的进程空间代码段、数据段都完全拷贝父进程,只是执行了不同的代码分支而已。
示例代码为:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(void) {
int n = 0;
char *msg = NULL;
pid_t pid = fork();
if (pid < 0) {
perror("failed fork");
abort();
} else if (pid == 0) {
// child
n = 5;
msg = "this is the child process";
} else {
// parent
n = 3;
msg = "this is the parent process";
}
for (int i = 0; i < n; i++) {
printf("%s\n", msg);
sleep(1);
}
return 0;
}
shell执行命令过程分析
用了上边的基础之后,对于理解shell是如何执行命令就很好理解了。大概是这样:shell等待用户的输入,当截取到用户的命令之后,会先判断是不是shell的内部命令还是外部命令。如果是内部命令的话,就相当于调用一个函数;如果是外部命令的话,会先调用fork创建一个子进程,在进程里边通过exec函数簇来执行命令,父进程通过wait、waitpid等待子进程的结束,而shell进程用同样的方式等待父进程的结束。
exec函数簇
主要是指如下一坨函数:
这些函数是有明显的规律的:
l:表示将命令行参数散开,最终以NULL结尾。p:表示程序可以不用写全路径,可以通过环境变量找到。不带p的则要提供绝对路径或者相对路径。e:表示带有环境变量,如果传入这些环境变量,那么exec执行的程序拿到的环境变量是以该参数指定的为准,当然这些环境变量组成的字符指针数组也是以NULL结尾的。v:表示将命令行参数合并成一个字符指针数组一并传入,也是以NULL结尾。 最终都会调到execve这个函数。
关于环境变量的要点:
- 环境变量所用到的libc接口
#include <stdio.h>
#include <stdlib.h>
void print_env() {
extern char **environ;
fprintf(stdout, "************\n");
for (int i = 0; environ[i] != NULL; i++) {
fprintf(stdout, "%s\n", environ[i]);
}
}
int main(void) {
/// 打印从shell进程继承过来的环境变量
print_env();
/// 添加新的环境变量
setenv("MAX", "10", 1);
/// 打印所有环境变量时会包含新增MAX
print_env();
/// 去掉新增MAX
unsetenv("MAX");
/// 打印所有环境变量是不包含MAX
print_env();
return 0;
}
- 环境变量最终是以
exec传递的数组为准 当前我们通过execve来执行某条命令时,如果我们自己指定环境变量字符指针数组传过去的话,最终命令拿到的就是我们传过去的那一部分;如果传NULL,那么命令就拿不到任何环境变量;所以我们在使用execl之类的函数时最终调用execve时会将所有的环境变量都传递过去。
wait/waitpid
进程终止时虽然文件都会关闭、用户空间的内存都会释放,但是在内核中的PCB还保留着的呢。要想彻底的清理掉PCB,那么父进程就需要调用wait或waitpid来进行释放。
这里记录一下他们的用法:
wait只能等待第一个子进程返回。而且会一直阻塞等待。
waitpid可以等待指定的子进程返回。也可以通过options指定WNOHANG来表示不阻塞该函数立即返回0。两个函数都可以通过传出参数status来获取退出状态。
WIFEXITED:用于判断是否为正常终止。进一步通过WEXITSTATUS获取状态码WIFSIGNALED:用于判断是不是因为信号异常终止。进一步通过WTERMSIG获取导致异常终止的信号编码。
这里有两点需要注意:
- 如果一个进程终止时,它的父进程没有来得及调用
wait、waitpid。那此时该进程就是僵尸进程。简单来说就是子进程终止了,父进程还在但是没有调用wait、waitpid。
示例代码为:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("failed fork");
abort();
} else if (pid == 0) {
fprintf(stdout, "child process\n");
exit(1);
}
for (;;);
return 0;
}
通过ps -u就可以看出
可以看出pid为35776的子进程的status为
Z+说明是僵尸进程。解决僵尸进程的方法也很简单,就是将父进程kill就可以,因为会被1号进程回收。
- 如果一个进程终止时,父进程早就终止了,那么该进程会被init(systemd进程),也就是1号接管。可以先看看如下的代码实例:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("failed fork");
abort();
} else if (pid == 0) {
fprintf(stdout, "child process\n");
sleep(20);
exit(1);
}
return 0;
}
也就是父进程先退出,子进程还在跑,这个子进程会被1号进程接管。
运行之后我们能看到a.out进行的父进程为1号进程了。
进程树
其实进程也是一种树的关系,用户进程的祖宗是1号进程。通过ps -ef可以看到
CMD一栏中如果没有[]表示用户进程,有[]表示内核进程。用户进程的祖宗是1号进程,内核进程的祖宗为2号进程。TTY那一列为?表示后台进程,也就是一些守护进程。
进程间通信
进程间通信最直观的方式肯定得通过一块公共的区域来实现的,比如通过文件或者内核缓冲区。目前就学习到一种方式,那就是管道,通过系统调用pipe来实现。比如实例代码为:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void) {
int fds[2] = {0};
pipe(fds); // fds[0]:表示读端。fds[1]:表示写端
pid_t pid = fork();
if (pid < 0) {
perror("failed fork");
abort();
} else if (pid == 0) {
// child
close(fds[1]);
sleep(1);
char buff[10] = {0};
read(fds[0], buff, sizeof(buff));
fprintf(stdout, "reviced:%s\n", buff);
exit(0);
} else {
// parent
close(fds[0]);
write(fds[1], "hello", 5);
}
wait(NULL);
return 0;
}
通过pipe来实现进程间通信,具有明显的两个缺点:
- 一个pipe只能实现单向,如果要实现双向,就得两个pipe。
- pipe通信是基于两个fd,而fd的传递方式是通过fork,所以得依赖继承关系来传递的。