一、进程的概述
1.什么是进程
进程:即进行中的程序,可执行文件从开始运行到结束运行这段过程就叫进程。
2.程序和进程的区别
-
程序:存储在磁盘上、占磁盘空间、静态的。如:我们编写的C语言代码就是程序,存储在我们电脑磁盘上;
-
进程:运行在系统上、占内存空间,动态的,包括进程的创建、调度、消亡。如:我们的代码经过编译生成了可执行文件,然后将可执行文件运行,这个运行中的程序就是进程。
3.并发和并行的区别
- 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行,并行是真正做到了同时进行;
- 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,利用了人眼的暂留现象(余晖效应),因为切换太快,人眼感觉不出来,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行(分时复用)。
4.PCB 进程控制块
4.1 PCB 的概述
PCB:进程控制块,程序运行时,内核为每个进程分配一个 PCB(进程控制块),维护进程相关的信息。Linux 内核的进程控制块是 task_struct 结构体,这个结构体里面存放着运行维护进程需要的所有资源。
- task_struct 结构体:这个结构体里面的内容很多,但很多是涉及到系统内核的一些操作,我们不需要全部了解,后续学习可能用到和需要掌握的内容主要如下:
- 进程 id:系统会为每个进程分配唯一的 id,在 C 语言中用
pid_t
类型表示,其本质是一个非负整数, 进程有就绪、运行、挂起、停止等状态; - 进程切换时需要保存和恢复的一些 CPU 寄存器,因为我们上面讲到了分时复用,进程间快速切换,当前这个进程暂停了以后,下次要接着当前运行,就得保存当前的工作状态;
- 描述虚拟地址空间的信息,描述控制终端的信息,当前工作目录(CurrentWorking Directory),umask 掩码;
- 文件描述符表,包含很多指向 file 结构体的指针;
- 和信号相关的信息,用户 id 和组 id,会话(Session)和进程组,进程可以使用的资源上限(Resource Limit)等。
- 进程 id:系统会为每个进程分配唯一的 id,在 C 语言中用
4.2进程的状态
上面提到了进程包括就绪、运行、挂起、停止等状态,这里就详细介绍一下。
4.2.1进程状态三层模型
-
三层模型包括:
- 等待态:进程还不具备被 CPU 调度的条件,进程正在等待 CPU 能调用的条件成立;
- 就绪态:进程被调度的条件成立,等待 CPU 调度;
- 执行态:进程的正在被 CPU 执行。
-
三层模型示意图
这里还有一个就绪态和执行态之间的一个循环切换,这里就是我们前面提到的分时复用,不同进程来回切换,每个进程只允许执行很短的事件,一个时间片到以后就把 CPU 让出来给其它进程用,该进程就变为就绪态等待被再次调用,如此循环。
4.2.2进程状态五层模型
相比于三层模型,多了僵尸态和停止态,等待态也分为了两种情况,其示意图如下:
- 五层模型介绍:
- 可中断等待态(TASK_INTERRUPTIBLE) :进程被 CPU 调度的条件还不成立,但不一定要条件成立才能被唤醒,也会因为接收到信号而提前被唤醒;
- 不可中断等待态(TASK_UNINTERRUPTIBLE):和可中断相比,这个必须等到条件满足才能被唤醒,不能通过信号提前唤醒;
- 就绪态(TASK_RUNNABLE): 表示己经准备就绪,正等待被调度;
- 执行态(TASK_RUNNING) : 进程正在被 CPU 执行 ;
- 僵尸态(TASK_ZOMBIE):表示该进程已经结束了,但是其父进程还没有调用 wait 或 waitpid 来释放该进程资源(PCB资源);
- 停止态(TASK_STOPPED):进程停止执行,当进程接收到 SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU 等信号的时候会进入停止态。此外,在调试期间接收到任何信号,都会使进程进入这种状态。当接收到 SIGCONT 信号,会重新回到执行态。
4.2.3查看进程状态
通过 ps -aux
命令查看进程状态。
- 命令演示
edu@edu:~$ ps -aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.1 0.1 119968 6004 ? Ss 15:43 0:01 /sbin/init splash
root 2 0.0 0.0 0 0 ? S 15:43 0:00 [kthreadd]
root 3 0.0 0.0 0 0 ? S 15:43 0:00 [ksoftirqd/0]
root 5 0.0 0.0 0 0 ? S< 15:43 0:00 [kworker/0:0H]
root 7 0.0 0.0 0 0 ? S 15:43 0:00 [rcu_sched]
root 8 0.0 0.0 0 0 ? S 15:43 0:00 [rcu_bh]
root 9 0.0 0.0 0 0 ? S 15:43 0:00 [migration/0]
root 10 0.0 0.0 0 0 ? S 15:43 0:00 [watchdog/0]
root 11 0.0 0.0 0 0 ? S 15:43 0:00 [watchdog/1]
root 12 0.0 0.0 0 0 ? S 15:43 0:00 [migration/1]
......
- 上面的 STAT 就是其状态信息列,参数意义如下:
D 不可中断 Uninterruptible(usually IO)
R 正在运行,或在队列中的进程
S 处于休眠状态(大写S)
T 停止或被追踪
Z 僵尸进程
W 进入内存交换(从内核 2.6 开始无效)
X 死掉的进程
< 高优先级
N 低优先级
s 包含子进程(小写s)
+ 位于前台的进程组(即与终端设备有交互的进程)
- ps 命令常用于查看进程相关的信息,其选项如下:
-a 显示终端上的所有进程,包括其他用户的进程
-u 显示进程的详细状态
-x 显示没有控制终端的进程
-w 显示加宽,以便显示更多的信息
-r 只显示正在运行的进程
pstree 树状显示进程关系
啥也不加,显示的是当前进程
二、进程号
1.进程号概述
每个进程都由一个唯一的进程号来标识,其类型为 pid_t。进程号总是唯一的,但进程号可以重用,即当一个进程终止后,其进程号就可以再次被其它使用。
- 常用进程号分为:
- PID:当前进程号;
- PPID:当前进程的父进程号;
- PGID:进程组 ID。
2.获取进程号
2.1获取当前进程号
- 函数介绍
#include <sys/types.h> // 包含的头文件
#include <unistd.h>
pid_t getpid(void);
功能:获取本进程号(PID)
参数:
无
返回值:
本进程的进程号(PID)
2.2获取当前进程父进程号
- 函数介绍
#include <sys/types.h>
#include <unistd.h>
pid_t getppid(void);
功能:获取调用此函数的进程的父进程号(PPID)
参数:
无
返回值:
调用此函数的进程的父进程号(PPID)
2.3获取进程组号
- 函数介绍
#include <sys/types.h>
#include <unistd.h>
pid_t getpgid(pid_t pid);
功能:获取进程组号(PGID)
参数:
pid:0或指定进程号
返回值:
参数为 0 时返回当前进程组号,否则返回参数指定的进程的进程组号
- 代码演示
void test01()
{
printf("当前进程号:%d\n", getpid());
printf("当前进程父进程号:%d\n", getppid());
printf("当前进程组号:%d\n", getpgid(0));
// 用于阻塞,防止进程退出
getchar();
}
- 运行结果
当前进程号:4618
当前进程父进程号:2791
当前进程组号:4618
- 通过 ps 命令查看当前进程相关进程号
edu@edu:~$ ps -ajx | grep a.out
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
2791 4618 4618 2791 pts/21 4618 S+ 1000 0:00 ./a.out
可以看到,几个进程号是对应的。
查看父进程号对应的哪个进程:
edu@edu:~$ ps -A | grep 2791
2791 pts/21 00:00:00 bash
可以看到,父进程号对应的进程是 bash 解析器,因此,每个进程都不能独立启动,都必须通过一个父进程间接启动,父进程还有父进程,就这样一层层有秩序地管理进程(创建进程和回收进程资源),一直到最顶层的 1 号进程。
- 可以通过 pstree 命令查看进程间的创建关系
systemd─┬─ManagementAgent───6*[{ManagementAgent}]
├─ModemManager─┬─{gdbus}
│ └─{gmain}
├─NetworkManager─┬─dhclient
│ ├─dnsmasq
│ ├─{gdbus}
│ └─{gmain}
├─VGAuthService
├─accounts-daemon─┬─{gdbus}
│ └─{gmain}
... ...
三、创建子进程
1.子进程引入
- 为什么我们要创建子进程,看下面的例子:
void test02()
{
while (1)
{
printf("---------------------------1\n");
sleep(1);
}
while (1)
{
printf("---------------------------2\n");
sleep(1);
}
}
- 运行结果
---------------------------1
---------------------------1
---------------------------1
---------------------------1
---------------------------1
---------------------------1
... ...
- 说明:可以看到,当程序执行时,永远都只能执行第一个循环,第二个循环被第一个阻塞掉了,就无法执行到,而如果我们想要同时执行两个循环,就需要用到子进程。
2.fork 创建进程
2.1fork 语法
进程是系统进行资源分配的基本单位。
子进程:系统允许一个进程创建新进程,这个新进程即为子进程,子进程还可以创建新的子进程,形成进程树结构模型。
- fork 函数介绍
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
功能:用于从一个已存在的进程中创建一个新进程,新进程称为子进程,原进程称为父进程
参数:
无
返回值:
成功:在子进程中返回 0,父进程中返回子进程 ID。pid_t,为整型。
失败:返回-1。
失败的两个主要原因是:
1)当前的进程数已经达到了系统规定的上限,这时 errno 的值被设置为 EAGAIN。
2)系统内存不足,这时 errno 的值被设置为 ENOMEM。
- 注意:父进程和子进程都会在 fork 之后运行,创建子进程后,会另外开辟一个空间,将父进程的资源拷贝一份给子进程。
2.2创建一个子进程
- 代码演示
void test03()
{
pid_t pid = fork();
if (pid > 0) // 父进程执行的代码
{
printf("父进程ID:%d\n", getpid());
getchar(); // 阻塞,防止父进程退出
}
else if (pid == 0) // 子进程执行的代码
{
printf("子进程ID:%d\n", getpid());
getchar(); // 阻塞,防止子进程退出
}
}
- 运行结果
父进程ID:6826
子进程ID:6827
- 命令查看进程号
edu@edu:~$ ps -ajx | grep a.out
2791 6826 6826 2791 pts/21 6826 S+ 1000 0:00 ./a.out
6826 6827 6826 2791 pts/21 6826 S+ 1000 0:00 ./a.out
- 当我们输入字符解堵塞
// 明明是一个父进程一个子进程,但是只输入了一个字符就退出了
// 难道父子进程都退出了马,通过命令查看进程状态
edu@edu:~$ ps -ajx | grep a.out
1 6827 6826 2791 pts/21 2791 S 1000 0:00 ./a.out
-
说明:
- 可以看到还有一个 a.out 进程在运行,对应进程号,可以看到是之前的子进程,说明刚刚只是父进程退出了,然后子进程没了父进程,就没有父进程为其回收资源了,为了防止无法回收子进程资源,系统会通过1号进程来接手,这样的进程叫做孤儿进程;
- 我们上面创建父子进程,然后分别执行了各自的代码,这里有一个误区,就是会误以为,
if (pid > 0)
条件成立里面的部分是父进程,else if (pid == 0)
条件成立里面的是子进程。其实创建子进程的时候,会将父进程的整个资源,包括这里的所有代码都拷贝一份到子进程,所有这里所有的代码在父子进程中都存在,只是从逻辑角度将其划分为父进程执行的代码和子进程执行的代码。
-
上面同时执行两个 while 循环的代码的实现
void test04()
{
pid_t pid = fork();
if (pid > 0)
{
while (1)
{
printf("---------------------------1\n");
sleep(1);
}
}
else if (pid == 0)
{
while (1)
{
printf("---------------------------2\n");
sleep(1);
}
}
}
- 运行结果
edu@edu:~/study/my_code$ ./a.out
---------------------------1
---------------------------2
---------------------------2
---------------------------1
---------------------------1
---------------------------2
---------------------------1
---------------------------2
... ...
3.父进程和子进程的关系
3.1父子进程的关系
使用 fork 函数创建的子进程是父进程的一个复制品,父进程的空间地址的内容拷贝了一份给子进程空间。
地址空间中包括:进程上下文、进程堆栈、打开的文件描述符、信号控制设定、进程优先级、进程组号等。
子进程所独有的只有它的进程号,计时器等。因此,使用 fork函数的代价是很大的。
但是为了尽可能减少空间的消耗,并不是将父进程的资源完完全全拷贝给子进程,对于一些数据在写时是独立的,读时是共享。
3.2 写时独立读时共享
- 代码演示:读时共享
void test05()
{
int num = 10;
pid_t pid = fork();
if (pid > 0)
{
while (1)
{
printf("父进程:num = %d,num_id = %p\n", num, &num);
sleep(1);
}
}
else if (pid == 0)
{
while (1)
{
printf("子进程:num = %d,num_id = %p\n", num, &num);
sleep(1);
}
}
}
- 运行结果
父进程:num = 10,num_id = 0x7ffcfcff2ee0
子进程:num = 10,num_id = 0x7ffcfcff2ee0
父进程:num = 10,num_id = 0x7ffcfcff2ee0
子进程:num = 10,num_id = 0x7ffcfcff2ee0
父进程:num = 10,num_id = 0x7ffcfcff2ee0
子进程:num = 10,num_id = 0x7ffcfcff2ee0
父进程:num = 10,num_id = 0x7ffcfcff2ee0
子进程:num = 10,num_id = 0x7ffcfcff2ee0
... ...
- 说明:上面是通过父子进程分别读取 num 变量的数据,同时打印 num 变量数据的地址,发现打印的数据和地址都一样,验证了上面所说的读时共享。也就是创建子进程的时候,只是将变量名拷贝了过去,但是通过变量名访问数据的时候还是访问的同一个内存地址。
- 代码演示:写时独立
void test06()
{
int num = 10;
pid_t pid = fork();
if (pid > 0)
{
while (1)
{
printf("父进程:num = %d,num_id = %p\n", num, &num);
sleep(1);
}
}
else if (pid == 0)
{
while (1)
{
num++;
printf("子进程:num = %d,num_id = %p\n", num, &num);
sleep(1);
}
}
}
- 运行结果
父进程:num = 10,num_id = 0x7ffc89a37c30
子进程:num = 11,num_id = 0x7ffc89a37c30
父进程:num = 10,num_id = 0x7ffc89a37c30
子进程:num = 12,num_id = 0x7ffc89a37c30
父进程:num = 10,num_id = 0x7ffc89a37c30
子进程:num = 13,num_id = 0x7ffc89a37c30
父进程:num = 10,num_id = 0x7ffc89a37c30
... ...
- 说明:可以看到,父子进程打印的两个 num 的值不一样了,说明子进程修改 num 变量了以后,num 在父子进程中已经是独立的两份了。但是这里看到的 num 的地址还是一样的,那是因为进程里面的内存地址是虚拟地址,并不是实际的物理地址,虽然父进程与子进程中变量虚拟地址是一样的,但是映射到不同的物理地址就得到了不同的数值。
3.3 printf 换行与不换行
- 代码演示
void test07()
{
printf("hello world\n"); // 加换行
printf("hello friend"); // 不加换行
pid_t pid = fork();
if (pid > 0)
{
}
else if (pid == 0)
{
}
}
- 运行结果
hello world
hello friendhello friendedu@edu:~/study/my_code$
- 说明
- 现象:可以看到,加了换行符的字符串打印了一遍,但没加换行符的字符串打印了两遍;
- 加换行符打印一遍,是因为我们知道 printf 函数是一个库函数,库函数有缓冲区,要将数据显示在终端设备上,需要将输出到缓冲区的数据刷新到终端,换行就是其中的刷新方式之一。在创建子进程之前,字符串就已经刷新到终端了,又因为父子进程是从 fork 之后执行的,因此对有换行的这个打印不会有任何影响,直接打印一次就完事了;
- 但是不加换行符,没有行刷新、满刷新和强制刷新,就只剩下进程结束刷新了,又因为在进程结束前先创建了子进程,子进程会拷贝父进程资源,连同缓冲区一起拷贝了,因此父子进程结束,会分别将字符串刷新到终端设备,就出现了两个 hello friend;
- 如果在其下面添加一个
fflush(stdout)
强制刷新,就只会打印一次。
3.4库函数 write 输出
- 代码演示
void test08()
{
write(1, "hello world", 11); // 加换行
printf("hello friend"); // 不加换行
pid_t pid = fork();
if (pid > 0)
{
}
else if (pid == 0)
{
}
}
- 运行结果
hello worldhello friendhello friendedu@edu:~/study/my_code$
- 说明:可以看到,如果使用库函数输出,即使不加换行也只会输出一次,因为库函数是直接操作内核资源,可以直接将数据输出到终端设备,根本不需要什么缓冲区,因此也就不存在库函数的缓冲区拷贝和结束刷新。
3.5 exit 和 _exit
- 代码演示1
void test09()
{
printf("hello friend");
pid_t pid = fork();
if (pid > 0)
{
exit(-1);
}
else if (pid == 0)
{
_exit(-1);
}
}
- 运行结果
hello friendedu@edu:~/study/my_code$
- 代码演示2
void test09()
{
printf("hello friend");
pid_t pid = fork();
if (pid > 0)
{
_exit(-1);
}
else if (pid == 0)
{
_exit(-1);
}
}
- 运行结果
edu@edu:~/study/my_code$ // 啥也没有
- 代码演示3
void test09()
{
printf("hello friend");
pid_t pid = fork();
if (pid > 0)
{
exit(-1);
}
else if (pid == 0)
{
exit(-1);
}
}
- 运行结果
hello friendhello friendedu@edu:~/study/my_code$
- 说明:
- 上面演示的三种情况,可以发现,通过
_exit(-1)
退出进程的时候,不打印,通过exit(-1)
会打印; - 因为
_exit(-1)
是系统调用,作用是退出进程,不会刷新缓冲区; exit(-1)
是库函数,作用是退出进程,会刷新缓冲区。
- 上面演示的三种情况,可以发现,通过
4.父子进程运行顺序
- 代码演示
void test10()
{
pid_t pid = fork();
if (pid > 0)
{
printf("父进程运行了\n");
}
else if (pid == 0)
{
printf("子进程运行了\n");
}
}
- 运行结果
edu@edu:~/study/my_code$ ./a.out
父进程运行了
子进程运行了
edu@edu:~/study/my_code$ ./a.out
父进程运行了
子进程运行了
- 说明:
- 上面运行的结果可以看出,我们多次调用,都是父进程先执行,子进程后执行,那是因为这里只有父进程先执行了才能调用 fork 创建子进程,因此这里演示肯定是父进程先执行,不然哪来的子进程;
- 但是我们站在原理的角度出发,父子进程是分别独立的进程,它们之间谁先运行要看谁先抢占到 CPU 资源,因此谁先运行是不确定的。