在上一期Linux篇我们讲到了进程的概念,这期我们来继续深入:
目录
在Windows环境下,我们可以双击.exe后缀的图标,这时系统自动会创建一个进程去运行它。在Linux环境下使用./指令也是如此。那么我们如何在进程运行时,从代码中再创建进程呢?
一、fork
1.1 fork是什么
在Linux中我们使用fork函数来创建进程,我们使用man指令来看一下:
我们可以看到使用fork函数可以创建一个子进程,创建成功给父进程返回子进程pid的值,给子进程返回0值,创建失败返回-1值(使用时别忘了包上头文件unistd.h)
1.2 fork的使用
我们现在来改写一下,process.c文件:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("AAAAAAA\n");
printf("BBBBBBB\n");
return 0;
}
这个程序我们再熟悉不过了,编译运行一下:
现在我们来对其做一些些小改动:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("AAAAAAA\n");
fork();
printf("BBBBBBB\n");
return 0;
}
我们添加了一个fork函数,现在我们再来重新编译运行一下:
咦,怎么打印了两次BBBBBBB?
我们使用看一下打印时对应的进程:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("AAAAAAA pid:%d ppid:%d\n",getpid(),getppid());
fork();
printf("BBBBBBB pid:%d ppid:%d\n",getpid(),getppid());
return 0;
}
运行结果:
我们可以看到,在我们使用fork之后,程序运行时重新创建了一个子进程,然后分别由父进程和子进程分别打印了B(在这里看起来是父进程先打印的,但是在实际中父子进程是同时进行的,谁先打印是随机的)
现在我们来研究一下fork函数的返回值:
该返回值是pid_t类型的,我们现在用一个变量接受一下看看:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("AAAAAAA pid:%d ppid:%d\n",getpid(),getppid());
pid_t ret = fork();
printf("BBBBBBB pid:%d ppid:%d ret:%d &ret:%p\n",getpid(),getppid(),ret,&ret);
return 0;
}
运行结果如下:
我们可以看到确实如此,父进程的ret的值就是子进程的pid,子进程的ret的值就是0
不过奇怪的是,这个ret变量明明地址都是一样的,为什么在父子进程中数值却不一样呢?而且一个fork函数是怎么做到返回两个值的??
这个问题我们先埋个伏笔,在后面的环境变量中会详细讲解~
1.3 进程的分流
既然我们知道父子进程fork函数的返回值不一样,那我们就可以利用返回值来进行进程的控制:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<assert.h>
int main()
{
pid_t ret = fork();
assert(ret!=-1);
if(ret==0)
{
//子进程
while(1)
{
printf("我是子进程,pid:%d ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
else if(ret>0)
{
//父进程
while(1)
{
printf("我是父进程,pid:%d ppid:%d\n",getpid(),getppid());
sleep(2);
}
}
else{}
return 0;
}
如果返回值ret等于0,我们就让其进入子进程的循环,如果ret大于0,我们就让其进入父进程的循环
运行结果:
我们可以看到父进程和子进程是同时进行的,同时进入两个循环,但子进程打印速度是父进程的两倍,就形成了以上结果。
通过以上试验,我们可以得出以下结论:
fork之后,执行流会变成2个执行流
fork之后,谁先运行由调度器决定
fork之后,fork之后的代码共享,通常我们通过if 和else if来进行执行流分流
1.4 进程的独立性
我们现在来举个例子,例如我现在打开任务管理器,可以看到以下进程,当我关闭kugo这个进程时并不会影响到其他进程,同样当我关闭其他进程时也不会影响到kugo这个进程的运行:
由此我们可以发现进程之间是相互独立的,相互并不影响。
接下来我们拿父子进程验证一下:
当我们终止子进程后,父进程并没有受影响,所以父子进程之间也是相互独立的
拿既然是独立的,我们在子进程或父进程中修改它们公有的数据,那修改后另一个进程中的数据也会发生变化呢?
我们来试试看:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<assert.h>
int main()
{
int a=99;
pid_t ret = fork();
assert(ret!=-1);
if(ret==0)
{
//子进程
a=100;
while(1)
{
printf("我是子进程,pid:%d ppid:%d a=%d &a=%p\n",getpid(),getppid(),a,&a);
sleep(1);
}
}
else if(ret>0)
{
//父进程
while(1)
{
printf("我是父进程,pid:%d ppid:%d a=%d &a=%p\n",getpid(),getppid(),a,&a);
sleep(1);
}
}
else{}
return 0;
}
我们看到上面该段代码,我们在fork之前创建了一个变量a,并且在子进程中修改了这个变量,现在我们来看会不会对父进程造成什么影响:
我们可以很清清楚楚的看到,子进程的a值被更改了,但是父进程a值并没有发生改变!
可是在父子进程中a的地址都一样啊,怎么值就不一样呢?
这是因为:当有一个执行流尝试修改数据的时候,操作系统会自动给我们当前进程触发写时拷贝(copy-on-write, COW)的机制
什么是写时拷贝?就是等到修改数据时才真正分配内存空间,这是对程序性能的优化,可以延迟甚至是避免内存拷贝,当然目的就是避免不必要的内存拷贝。
在这里父子进程的数据没有发生变化之前是共用同一块空间的,在子进程修改a值时会重新开辟一块独立的空间来存储修改后的值:
对于空间地址没有发生改变的问题,这是因为系统给我们看的是虚拟地址而不是物理地址,我们在后面再继续探讨。
由此我们可以得出一个结论:进程之间的代码与数据都是独立的!
1.5 fork的返回值问题
为什么我们在代码中可以看到只调用一次fork函数却给父子进程分别返回了两个不同的值呢?
这时在fork函数内部是创建完子进程再返回值的,创建完子进程之后,后续的代码父子进程都是共享的,这时return指令就会被运行两次,分别给父子进程返回不一样的值。
pid_t ret = fork();
但是这个ret变量怎么会接收两个返回值呢?这就可以用上面写时拷贝的机制来解释了,当父进程修改ret值时会重新开辟空间,这样子就导致了一个ret两个值~
二、进程状态
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。(在 Linux内核里,进程有时候也叫做任务)
下面是进程状态在kernel源代码里定义:
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
我们可以看到进程的状态可以分为以下几种:
R运行状态(running):并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
S睡眠状态(sleeping):意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep) 。
D磁盘休眠状态(Disk sleep):有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
Z僵尸状态(Zombies):是一个比较特殊的状态,当父进程没有读取到子进程退出的返回代码时就会产生僵死(尸)进程,僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
在正式解读上面进程状态之前我们先搞清楚两个基本状态概念:阻塞和挂起
2.1 阻塞
阻塞:进程因为等待某种条件就绪,而导致的一种不推进的状态(即进程卡住了)。
为什么阻塞?
因为进程要通过等待的方式,等具体的资源被别人是用完成之后,再被自己使用。例如:我们在下载软件时,这时网络中断了,CPU会将该下载进程的PCB放入相对应的操作系统所管理的硬件队列中(在这个例子里将放入管理网卡这个硬件设备的队列中)等待网络资源的分配。在该下载进程没有拿到网络资源之前是不会再进入到CPU中运行的,最终会看到进程不继续推进了(在我们电脑上就表现为下载的进度条卡住了)。
所以我们可以得出:阻塞一定进程是在等待某种资源!简单来说阻塞就是进程等待某种资源就绪尔不被调度的过程。
2.2 挂起
当进程处于阻塞状态时,操作系统可能会因为优化内存空间,将阻塞进程在内存中保存的代码和数据转移到磁盘(如此一来可以释放一部分内存空间供其他正在被调度的进程使用),当该进程要被CPU调度时再将其磁盘中的代码和数据拷贝到内存中运行。
上面阻塞进程的代码和数据移存在磁盘中的过程被称为挂起。所以挂起是一种特殊的阻塞。
2.3 R(运行状态)
当一个进程在运行时,一定会被CPU调度吗?
这是说不准的,在Linux环境下如果进程的状态是R,那这个进程一定是在操作系统所管理的运行队列下的。运行队列是专门存储可以被CPU调度的进程的队列,CPU从中选择需要的进程去运行:
所以说当一个进程在运行时,并不一定会在被CPU调度,但是它一定是在运行队列中的。
现在我们来见一见进程的R状态:
#include<stdio.h>
int main()
{
while(1)
{
printf("我在运行嘛?\n");
}
return 0;
}
我们用上述代码生成一个可运行程序来执行一下,并且查看一下其进程状态:
我们可以该进程在疯狂打印对屏幕进行输出,但是??
S+是啥玩意,这里来解释一下:+号我们先不管,S表示的意是该进程处于休眠状态。
该进程不是一直在运行打印吗,怎么会在休眠状态???
别急,我们屏蔽一下printf函数试试看:
#include<stdio.h>
int main()
{
while(1)
{
//printf("我在运行嘛?\n");
}
return 0;
}
接下来运行一下试试看:
咦?奇怪这次怎么就是R+了呢?
这时由于该代码屏蔽了printf函数,进程在运行时只剩下了while语句,该语句一直在计算,所以导致该进程是一直要存在CUP里进行计算的,最终毫无疑问肯定是R状态。那加上printf函数为什么就是S状态了呢?这是因为该进程在执行printf函数时需要向显示器打印,在打印的过程中需要等待显示器资源,此时这个进程就不在运行队列中了,但是我们要明白等待显示器资源的时间是远远大于CPU运行while语句的时间的(CPU速度非常快),导致我们查看该进程状态时可能99.99%的时间都是在等待显示器资源,所以就显示出S+状态了。
2.4 S(可中断休眠状态)
S状态实际是进程处于阻塞状态,但是处于该状态的进程可被中断。
我们来演示一遍下面的代码:
#include<stdio.h>
int main()
{
int a;
while(1)
{
scanf("%d",&a);
printf("%d\n",a);
}
return 0;
}
该进程在运行时在等待键盘资源的输入,所以处于S状态,但是该进程在等待资源时我们可以直接将其中断:
2.5 D(不可中断休眠状态)
该状态我们目前还无法演示,处于D状态的进程也是在阻塞状态中,但是操作系统无法将其中断!
下面我们来举个例子:在一个进程要向磁盘写入数据时,需要等待磁盘资源(也就是等待磁盘将数据写入完毕),但是如果该进程将自己设置为S可中断休眠状态的话,操作系统发现内存不足的情况下是可以中断处于S状态的进程的,一旦该进程被中断,如果磁盘写入数据发生错误,磁盘无法向进程反应错误,会造成重要数据的丢失。所以该类进程设置为S状态是不合理的,于是就有了D状态,让操作系统无法中断该进程,保证数据的安全性。
但是需要注意的是,D状态应该是短时间就处理完的状态,如果计算机有进程长时间处于D状态,这就说明磁盘压力很大,计算机处于宕机的边缘。因为操作系统无法中断该进程,导致不能正常关机,如果强行中断电源会造成严重后果。
2.6 T(停止状态)
处于该状态下的进程也是一种阻塞状态,T表示因为某种原因该进程暂停了
我们用下面代码模拟一下:
#include<stdio.h>
#include<unistd.h>
int main()
{
int count=0;
while(1)
{
printf("%d\n",count++);
sleep(1);
}
return 0;
}
我们刚开始运行可以看到该进程是S+的状态
现在我们使用kill指令控制一下该进程
2.6.1 kill指令
kill可以对相对应的进程做出一系列控制
使用方法为:
kill -(想要对进程进行操作的编号) 进程的PID
kill后面可以有64种对进程的操作,分别对应不同的编号:
在这里我们使用19号操作对该进程进行暂停:
我们可以看到该的进程被暂停了,来查看一下该进程状态:
确实为T,如果我们想让其继续运行可以使用kill的18号指令:
但是我们可以看到一个小问题,这时该进程的状态怎么就变成S了呢,+去哪里了?
而且我现在怎么Ctrl+c都终止不了该进程了:
这是因为带有+状态的进程是在前台运行的,没有+的进程是在后台运行的,前台的进程可以用Ctrl+c来终止,但是后台的进程就不行了,得用kill的9号指令来终止:
2.6.2 killall指令
我们在杀掉进程时除了使用kill -9指令以外还可以使用killall指令,使用方法为:
killall 进程的名称
例如:
2.7 t(追踪暂停状态)
t状态和T状态本质是一样的,但是t状态我们可以在调试代码时遇到,比如我们使用gdb进行打断点处理时,进程遇到断点停下来,此时去查看其状态就是t。
2.8 X(死亡状态)
当一个进程真正被回收释放后,会返回一个状态X,我们很难在任务列表里看到这个状态。
2.9 Z(僵尸状态)
当一个子进程所以的代码运行完毕结束时并不会真正的释放该进程的所以数据,该数据需要被保存直到父进程(或者操作系统)读取完毕回收后才会真正被释放成为X状态。在进程运行完毕之后,成为X状态之前这个过程我们称之为僵尸状态Z。
就好比警察办案,对受害人并不是第一时间处理(这时候这个资源还被占着),而是收集附近足够的证据后,才开始清理现场(清理资源)。
我们用下面该段代码来演示一下:
#include<stdio.h>
#include<unistd.h>
int main()
{
pid_t id=fork();
if(id==0)
{
while(1){}
}
else if(id>0)
{
while(1){}
}
return 0;
}
运行起来我们可以看到两个父子进程:
现在我们来杀掉子进程:
杀掉子进程之后操作系统和父进程都没有回收子进程,所以该进程处于一个僵尸状态。
三、Linux进程状态和操作系统进程状态的关系
在操作系统这门课中,进程状态是这样被定义的:
其概念具体在Linux环境下就体现为:
R包含了新建、就绪、运行状态
Z和X都属于终止状态
S/D/T/t都是阻塞状态的表现
所以Linux是操作系统的具体化,显然光抽象的理解操作系统的概念是不足以让我们使用好某种具体的操作系统,结合实操才能真正理解其原理~
四、孤儿进程
在讲述孤儿进程概念之前,我们先来见一见:
先编写一下运行代码:
#include<stdio.h>
#include<unistd.h>
int main()
{
pid_t id=fork();
if(id==0)
{
while(1)
{
printf("我是子进程,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
else if(id>0)
{
while(1)
{
printf("我是父进程,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
return 0;
}
我们运行该代码并查看该进程的状态:
现在我们来直接杀掉父进程,并且查看一下进程状态:
我们可以看到,当我们直接将父进程杀掉之后,子进程的ppid直接变成了1,而父进程也直接不见了,并没有出现僵尸状态。这是为什么呢?
在这里父进程被杀掉之后,子进程缺少父进程的管理,所以导致其直接被操作系统领养了,操作系统的pid就是1,当子进程结束后会由操作系统来回收。
而父进程直接消失了是因为该父进程的父进程bash,由于bash的回收机制父进程直接被释放所以没有产生僵尸进程,
从上述操作中我们可以得到孤儿进程的定义:父进程先退出,子进程就称之为“孤儿进程”
本期内容到这就结束了,如有纰漏还请各位大佬在评论区指出呀~
下期再见~