十、Linux多进程编程
目录:
- 十、Linux多进程编程
- 一、进程(process)相关概念
- 1.进程简介
- 2.进程的状态
- 3.并发
- 二、虚拟内存(重要)
- 1.虚拟内存的含义
- 2.虚拟内存和物理内存映射关系
- 三、PCB进程控制块
- 四、fork()进程控制
- 1.函数原型
- pid_t fork(void);
- 2.函数原理图
- 3.进程共享
- 4.getpid和getppid函数获取进程PID
- pid_t getpid(void);
- \ pid_t getppid(void);
- 5.getuid、geteuid、getgid和getegid函数
- uid_t getuid(void);
- \ uid_t geteuid(void);
- gid_t getgid(void);
- \ gid_t getegid(void);
- 五、exec函数族
- 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[]);
- 六、回收子进程
- 1.孤儿进程、僵尸进程
- 2.wait()函数
- pid_t wait(int *status);
- 3.子进程退出值、异常退出原因信息的获取,暨 `int *status` 相关宏函数的使用
- 4.waitpid()函数
- pid_t waitpid(pid_t pid, int *status, int options);
一、进程(process)相关概念
1.进程简介
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础
程序:程序是死的,是指编译好的二进制文件,是一段段二进制机器码,放在磁盘上,不占用系统资源
进程:进程是活的,是在计算机上运行起来的程序,需要占用内存、CPU等系统资源
./main.c
ps aux
2.进程的状态
进程主要有四个状态:就绪、运行、挂起、停止,其中在挂起状态中,进程一般在缺少某种资源或等待外设响应时,会主动让出CPU,让其他进程先执行
3.并发
在单核操作系统中,一个时间段中可以有多个进程都处于已启动运行到运行完毕之间的状态,它们之间互相穿插着运行,但一个时间点瞬间,只能有一个进程在运行,进程之间通过时钟中断来进行进程切换
二、虚拟内存(重要)
1.虚拟内存的含义
🔺虚拟内存是一种计算机系统内存管理技术,它使得应用程序认为它拥有连续可用的内存,即一个连续完整的地址空间,但实际上,它通常是被分隔成多个物理内存碎片,即计算机将碎片的物理内存映射成连续的虚拟内存来使用
🔺对虚拟内存的定义是基于对地址空间的重定义的,即把地址空间定义为 连续的虚拟内存地址,以借此 “欺骗” 程序,使它们以为自己正在使用一大块连续的地址
🔺虚拟内存的大小一般为物理内存大小的1.5~3倍左右,但并不是说虚拟内存可以超过物理内存的大小来使用,而是说一个进程中的数据(堆、栈等)可以存放在这片虚拟地址的不同地址位置的范围,一个进程最终只占用了这片虚拟地址的很小一部分地址,是不可能达到4G这么大的,而用到的那部分内存区域,会借助PCB中的页指针,通过页表映射到物理内存,这些才是真正使用的物理内存大小。
🔺为什么在32位操作系统中,虚拟内存的大小是4G呢?这是因为在32位操作系统中,指针的大小为 4 个字节,它表示 232 范围,因此寻址能力为 0x0000 0000 ~ 0xFFFF FFFF,因此虚拟内存的范围为4GB;在64位操作系统中,指针的大小为 8 个字节,它表示 264 范围的寻址能力,寻址能力为 0x0000 0000 0000 0000 ~ 0xFFFF FFFF FFFF FFFF,因此虚拟内存的大小更大
如图所示:
1️⃣由于内核是管理计算机硬件的程序,只有一份,是各进程共享的,因此无论什么进程,内核的虚拟内存都映射到物理内存的同一位置,而这里可以存在多个PCB进程控制块,来存放不同的进程信息
2️⃣其他虚拟内存映射到物理内存的不同位置,因而每个进程都可以使用同样的虚拟内存地址而不冲突,因为它们的物理地址实际上是不同的,比如:进程A的 0x12345678 虚拟地址和进程B的的 0x12345678 虚拟地址映射的物理地址是不同的的
🔴正是因为1️⃣,才使得进程间得以进行通信,因为各进程的内核区映射到同一物理地址上
🔴正是因为2️⃣,才使得各进程相互独立,即使虚拟地址相同,进程之间也不会发生冲突
2.虚拟内存和物理内存映射关系
一个程序在运行时,需要放在物理内存上才能进行运算,其中需要通过专用的硬件内存管理单元 MMU(memory management unit) 来翻译成对应的内存物理地址,然后CPU在内存地址的位置上取到数据返回
简而言之,就是虚拟内存映射到物理内存当中,CPU就会使用物理内存进行运算,MMU 在当中就负责帮助完成虚拟内存到物理内存的映射
三、PCB进程控制块
虽然各进程共享一个内核,映射到同一物理地址,但内核中可以存在多个PCB进程块(结构体),分别存放不同进程的信息
PCB进程控制块包含的信息:
- 进程ID,
pid_t
类型 - 进程的状态:就绪、运行、挂起、停止等状态
- 进程切换时需要保存和恢复的一些CPU寄存器
- 描述虚拟地址空间的信息
- 描述控制终端的信息
- 当前工作目录(current working directory)
- umask 掩码
- 文件描述符表,包含很多指向 file 结构体的指针
- 与信号相关的信息
- 用户 id 和组 id
- 会话(Session)和进程组
- 进程可以使用的资源上限(resource limit)
四、fork()进程控制
1.函数原型
包含头文件:
#include <unistd.h>
#include <fcntl.h>
pid_t fork(void);
创建一个子进程
返回值
父进程返回子进程的PID,子进程返回 0,失败时返回 -1、errno
例如:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main(int argc, char *argv[])
{
pid_t pid = fork();
if(pid == -1)
{
perror("fork error");
exit(1);
}else if(pid == 0)
{
printf("this is a child process, return value = %d\n", pid);
}else
{
printf("this is a parent process, return value = %d\n", pid);
}
printf("end of process\n");
return 0;
}
2.函数原理图
在调用 fork()
创建子进程后,父进程实际上是原进程,而子进程会拷贝父进程中的代码、数据等资源,由于在 fork()
前的代码已经被父进程执行了,因此子进程不会再重新执行,子进程会保留父进程中的变量数据继续执行 fork()
后的代码
父进程中的 fork()
返回子进程的PID,而子进程中的 fork()
返回 0,后续代码中通过对PID值的判断,可以使父进程和子进程做不同的事
注:子进程并不是把 0~3G 的用户空间拷贝一份而映射到不同的物理地址,父子进程之间遵循 读时共享写时复制 的原则
🔺其意思是,对于 全局变量、变量和常量等 这些数据而言,当子进程只是 读(取值) 的话,那么父子进程就会共享这些数据,如果需要 写(幅值、改值) 时,子进程就会拷贝一份该数据,独立出来,该数据不再共享
3.进程共享
🅾父进程在 fork()
之后,父子进程有哪些相同和相异之处呢?
在刚 fork()
之后:
🔺 相同点: 全局变量、.data、.text、堆栈、环境变量、用户ID、宿主目录、进程工作目录、信号处理方式…
🔺 不同点: 进程ID、fork()
返回值、父进程ID、进程运行时间、闹钟(定时器)、未决信号集
🔺 父子进程共享: 1.文件描述符、2.mmap映射区
进程不会共享数据空间,线程又叫轻量级轻量级进程,线程会共享数据空间
4.getpid和getppid函数获取进程PID
包含头文件:
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
返回当前进程的PID
返回值
该函数总会执行成功,并返回当前进程的PIDpid_t getppid(void);
返回当前进程的父进程的PID
返回值
该函数总会执行成功,并返回当前进程的父进程的PID
例如:创建N个子进程
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#define N 3
int main(int argc, char *argv[])
{
int n;
pid_t pid;
for(n=0;n<N;n++)
{
pid = fork();
if(pid == -1)
{
perror("fork error");
exit(1);
}else if(pid == 0)
{
printf("n = %d, parent pid = %d, pid = %d\n", n, getpid(), getppid());
break;
}
}
return 0;
}
为了避免子进程重复创建子进程,我们需要在创建子进程后,使用 break;
使子进程退出循环
通过结果,我们发现子进程并不是按顺序来创建的,这是因为Linux在解析代码的时候,将这些 fork()
视为同时创建,因此哪个子进程先执行就取决于哪个进程先抢占CPU此外,还可能出现下面这种情况,不打印用户信息:root@ubuntu:~/Desktop/Linux系统编程#
这是由于父进程先于子进程结束,父进程主函数结束时,会向Terminal打印用户信息:root@ubuntu:~/Desktop/Linux系统编程#
,而子进程才执行,因此会出现不打印用户信息的情况,可以通过 sleep(2);
来使父进程最后结束
5.getuid、geteuid、getgid和getegid函数
包含头文件:
#include <unistd.h>
#include <sys/types.h>
uid_t getuid(void);
返回调用进程的真实用户ID
返回值
该函数总会执行成功,并返回调用进程的真实用户IDuid_t geteuid(void);
返回调用进程的有效用户ID
返回值
该函数总会执行成功,并返回调用进程的有效用户IDgid_t getgid(void);
返回调用进程的真实组ID
返回值
该函数总会执行成功,并返回调用进程的真实组IDgid_t getegid(void);
返回调用进程的有效组ID
返回值
该函数总会执行成功,并返回调用进程的有效组ID
五、exec函数族
exec函数族:可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段,在执行完后,原调用进程的内容除了进程号外,其它全部被新程序的内容替换了
简单来说,就是在调用exec函数族成功后,可以执行exec函数族所指定的可执行文件,代替原进程的任务,进程ID不变,原进程任务作废
✅exec函数族位于man手册第 3 卷
🔺在Linux中使用exec函数族主要有以下情况:
- 当进程认为自己不能再为系统和用户做出任何贡献时,可以调用任意exec函数族让自己重生
- 如果一个进程想执行另外一个程序,那么它就可以调用fork函数新建一个进程,然后调用任何一个exec函数使子进程重生
包含头文件:
#include <unistd.h>
exec函数族共有6个,这些函数皆以 “exec” 为开头:
exec[…]字母 | 含义 |
l (list) | 命令行参数列表 |
p (path) | 借助PATH环境变量来加载一个进程(如:ls、cp、cat、date等命令的可执行文件) |
v (vector) | 相当于将命令行参数封装到一个字符串数组中,如: |
e (environment) | 使用环境变量数组,不适用进程原有的环境变量,设置新加载程序运行的环境变量 |
🔺重点掌握 l 和 p
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[]);
调用这些函数后,执行所指定的可执行程序,代替原进程的任务
const char *path
可执行文件路径const char *file
借助环境变量,可直接写文件名,自动在环境变量中寻找const char *arg
可执行文件的一些char *argv[]
选项,从argv[0]
开始,在结束处需要加NULL
表示结束char *const argv[]
相当于将命令行参数封装到一个字符串数组中,如:char *argv[] = {"ls", "-lh", NULL};
char *const envp[]
环境变量数组返回值
因为执行成功后就不会再回到原代码了,因此成功没有返回值,如果失败则返回 -1、errno
例如:fork()
创建子进程,子进程通过 execlp()
执行终端命令 ls
在 execlp("ls", "ls", "-lh", NULL);
中,第一个 "ls"
是环境变量,第二个 "ls"
是 argv[0]
终端命令;"-lh"
可分开输入
实际上,不通过环境变量,只使用 execl()
也可以执行终端命令 ls,已知 ls 的可执行文件路径在 /bin/ls 下,则:execl("/bin/ls", "ls", "-lh", NULL);
,比较麻烦,因此对于终端命令,直接使用execlp最佳
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main(int argc, char *argv[])
{
pid_t pid = fork();
if(pid == -1)
{
perror("fork error");
exit(1);
}else if(pid == 0)
{
execlp("ls", "ls", "-lh", NULL);
perror("execlp error");
exit(1);
}else
{
sleep(1);
printf("I'm parent process, PID = %d\n", getpid());
}
return 0;
}
由于exec执行成功后不再回到原处,因此上述代码中 perror()
只有在exec失败后才会执行
例如:将终端命令 ps aux
打印到用户指定文件
分析:需要使用 open()
、execlp()
、dup2()
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main(int argc, char *argv[])
{
int fd = open(argv[1], O_WRONLY | O_CREAT | O_TRUNC, 0644);
int ret = dup2(fd, STDOUT_FILENO);
if(ret == -1)
{
perror("dup2 error");
exit(1);
}
pid_t pid = fork();
if(pid == -1)
{
perror("fork error");
exit(1);
}else if(pid == 0)
{
execlp("ps", "ps", "aux", NULL);
perror("execlp error");
exit(1);
}else
{
sleep(1);
close(fd);
}
return 0;
}
了解:实际上,只有 execve 是真正的系统调用,其他5个函数最终都调用 execve,是库函数,所以 execve 在 man 手册第 2 卷,其它函数在 man 手册第 3 卷
六、回收子进程
1.孤儿进程、僵尸进程
🔺父进程结束时,会统一回收其子进程的资源
孤儿进程:父进程先于子进终止,则子进程沦为 “孤儿进程”,会被 init 进程领养,由 init 代替父进程回收子进程资源
僵尸进程:进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(zombie)进程,如:子进程结束了,但父进程还没结束,还没对子进程资源进行回收,则子进程变为僵尸进程
2.wait()函数
🔺一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的 PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存进程的退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个
🔺子进程终止时,父进程有义务回收这个PCB
当进程终止时,该进程的父进程可以调用 wait()
或 waitpid()
获取这些信息,然后彻底清除掉这个进程。我们知道一个进程的退出状态可以在 Shell 中用特殊变量 $?
查看,因为 Shell 是它的父进程,当它终止时 Shell 调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程
✅ wait()
函数的功能有三个:
- 阻塞等待子进程退出
- 回收子进程的残留资源,包括PCB
- 获取子进程结束状态或异常退出原因
包含头文件:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
阻塞式回收任一(谁先结束就先回收谁)子进程退出资源
int *status
传出参数,子进程结束状态或异常退出原因,这是个整型数,这些信息需要通过宏函数来获取返回值
成功时返回所回收的子进程的ID,失败返回 -1、errno
例如:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
int status; // 记录子进程退出状态或异常退出原因
pid_t pid = fork();
if(pid == -1)
{
perror("fork error");
exit(1);
}else if(pid == 0)
{
printf("I'm child process, PID = %d\n", getpid());
}else
{
pid_t wpid = wait(&status);
if(wpid == -1)
{
perror("wait error");
exit(1);
}
printf("wait the child process of PID = %d, status = %d\n", wpid, status);
}
return 0;
}
3.子进程退出值、异常退出原因信息的获取,暨 int *status
相关宏函数的使用
通过 man 2 wait
可以查看这些宏函数
常用宏函数:
| 作用 |
WIFEXITED(status) | 进程是否正常退出?是返回 1,否返回 0 |
WEXITSTATUS(status) | 若进程正常退出,返回进程退出值 return 值,只有当 |
| 作用 |
WIFSIGNALED(status) | 进程是否由信号终止?是返回 1,否返回 0 |
WTERMSIG(status) | 若进程由信号终止,返回使进程终止的信号编号,只有当 |
✅ 注意:程序所有的异常终止情况,其根本原因都是由信号所致,其中 Ctrl + c 实际上就是向进程发送了一个终止信号
🔺因此,有一般流程:
例如:子进程 return 55;
,检验 WEXITSTATUS()
是否返回 55(退出状态)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
int status;
pid_t pid = fork();
if(pid == -1)
{
perror("fork error");
exit(1);
}else if(pid == 0)
{
printf("I'm child process, PID = %d\n", getpid());
sleep(10);
return 55;
}else
{
pid_t wpid = wait(&status);
if(wpid == -1)
{
perror("wait error");
exit(1);
}
// 如果为真,说明子进程正常退出
if(WIFEXITED(status))
{
printf("the child of PID = %d terminated normally, status = %d\n", wpid, WEXITSTATUS(status));
}
// 如果为真,说明进程由信号终止
if(WIFSIGNALED(status))
{
printf("the child of PID = %d terminated by a signal, signal number = %d\n", wpid, WTERMSIG(status));
}
}
return 0;
}
1️⃣若不进行干预,则子进程正常退出,退出值为 55:
2️⃣若在 sleep(10);
的过程中,我们通过另一个Terminal输入 kill -9 [PID]
,终结子进程,则异常退出:
若不关心子进程结束原因,则直接:
int wpid = wait(NULL);
4.waitpid()函数
包含头文件:
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
阻塞式回收指定子进程退出资源
pid_t pid
指定回收的子进程的ID;若为 -1,则和wait()
一样,回收任一子进程退出资源;int *status
传出参数,子进程结束状态或异常退出原因,这是个整型数,这些信息需要通过宏函数来获取int options
通常用来设置非阻塞:WNOHANG
,不设置时写 0返回值
返回所回收的子进程的ID;若参数3 指定了WNOHANG
并且尚未有子进程结束时(即还没有子进程需要被回收时)返回 0;当没有进程需要被回收或失败返回 -1、errno
pid_t pid | 含义 |
pid > 0 | 回收指定 pid 的子进程 |
pid = 0 | 回收和当前调用 waitpid 一个组的所有子进程 |
pid < -1 | 取绝对值,回收该绝对值所对应的进程组内的任一子进程 |
pid = -1 | 回收任意子进程(相当于 wait) |
进程组:当父进程创建子进程时,子进程会和父进程同属于一个进程组,且该组的组长为父进程,进程组ID为父进程的PID
例如:使用for循环创建5个子进程,并通过 waitpid()
指定阻塞回收第3个子进程时
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
#define N 5
int main(int argc, char *argv[])
{
int i;
pid_t pid;
for(i=0;i<N;i++)
{
pid = fork();
if(pid == -1)
{
perror("fork error");
exit(1);
}else if(pid == 0)
{
printf("I'm child No.%d, PID = %d\n", i+1, getpid());
break;
}
if(i == 2)
{
pid_t wpid = waitpid(pid, NULL, 0);
printf("waitpid a child, PID = %d\n", wpid);
}
}
// 父进程
if(i == 5)
{
sleep(1);
}
return 0;
}
例如:使用for循环创建多个子进程,每个子进程分别执行 i 秒,使用 waitpid()
非阻塞式回收所有子进程
while((wpid = waitpid(-1, NULL, WNOHANG)) != -1){...}
中,当 wpid == -1
时,说明已经没有子进程需要被回收
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
#define N 5
int main(int argc, char *argv[])
{
int i;
pid_t pid,wpid;
for(i=0;i<N;i++)
{
pid = fork();
if(pid == -1)
{
perror("fork error");
exit(1);
}else if(pid == 0)
{
sleep(i); // 每个子进程都执行一段时间
printf("I'm child No.%d, PID = %d\n", i+1, getpid());
break;
}
}
// 父进程
if(i == 5)
{
// 当 waitpid 返回 -1 时,说明没有子进程需要被回收
while((wpid = waitpid(-1, NULL, WNOHANG)) != -1)
{
if(wpid > 0)
{
printf("waitpid child PID = %d\n", wpid);
}else if(wpid == 0) // 如果尚未有子进程结束,则先 sleep,相当于阻塞
{
sleep(1);
}
}
}
return 0;
}