Linux进程 --- 2
大家好啊,时隔多日,很高兴我们又再次见面了。这篇文章将接着介绍有关进程的相关内容。这篇文章将重点讲解一下有关fork讲解和进程状态的部分,相信大家会有所收获的。
let's go!
目录
编辑
前言
一、查看进程
二、fork初识
三、进程状态
1.进程状态,运行、阻塞、挂起编辑
1.1运行
1.2阻塞
1.3挂起
2.具体如何维护Linux状态
2.1 R和S状态
2.2 D状态 --- 深度休眠
2.3 T和t状态
2.4 X状态
2.5 Z状态 --- 僵尸状态
四、进程组织结构反思
结语
前言
我们在前面简单讲解了一下什么是进程,大家没有看前面的内容可以先去看一看,接下来的内容将接着前面开始讲解。难度稍大,大家可以多加思考!!
一、查看进程
在前文当中有讲到进程的查看,但是只是简单提了一下,现在我们来实际演示看看。
进程信息可以通过/proc系统文件夹查看
大多数进程信息也同样可以使用top和ps这些用户级工具来获取
我们通过cd /proc的指令到达了proc文件夹里面了
其中有很多数字(进程pid),它们就对应着一个个的进程,比如我们想要获取PID为1的进程信息,你需要查看 /proc/1 这个文件夹。
除了通过/proc系统文件夹来查看进程,我们也可以通过ps的指令来查看对应的进程
也可以通过查询man手册来查看:man ps
也可以通过系统调用来获取进程的标识符
进程id(PID)
父进程id(PPID)
通过getpid()和getppid()函数来获取当前进程的PID和PPID
我们自己来简单写一个无限循环的代码,然后再来查看它
我们将对应的代码通过gcc编译成为了proc这个可执行程序
开始运行一下它:
开始无限循环了,也打印出了对应的pid和ppid,我们通过ps去查看一下吧:
可以发现,对应的2928就可以找到对应的./proc
二、fork初识
通过系统调用创建进程fork
man fork,通过man手册可以查看具体的一个详细的介绍
fork这个函数可以帮助我们创建一个子进程~
它会给父进程返回子进程的pid,给子进程返回0
大家先这样简单理解一下,看以下代码:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int main()
{printf("begin: 我是一个进程,pid: %d, ppid: %d\n", getpid(), getppid());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);}}else{// error}return 0;
}
我们将它编译出来,并通过监视窗口来查看一下
监视窗口指令:
(proc为我生成的可执行文件名)
while :; do ps ajx | head -1; ps ajx | grep proc | grep -v grep; echo "------------------------------------------------------"; sleep 1; done;
我们会发现以下这些问题:
1.为什么fork要给子进程返回0给父进程返回子进程pid呢?
2.对于fork这个函数是如何做到返回两次的呢?
3.对于一个变量而言是如何拥有不同的值呢?
4.fork函数是如何做到的呢?做了什么呢?
1. 要理解这些问题,我们首先要来理解一下fork干了什么事情??
在没有fork之前,只有一个进程。
进程 = 内核数据结构 + 代码和数据
操作系统的内核数据结构表示的就是PCB
创建一个进程就要为它创建一个对应的PCB
CPU去调度对应的进程:
创建子进程,就是系统中多了一个进程罢了
以父进程为模版创建出对应的子进程,然后将其中的如pid和ppid这样的数据进行重新的修改一下。这样我们有了子进程的一个内核数据结构了,那么对应的代码和数据呢?
子进程创建的时候是没有自己的代码和数据的,只能去访问父进程的代码和数据。
这就是为什么fork之后代码和数据是父子进程共享的了
所以CPU在调度子进程的时候的代码便是父进程的代码,代码是不可以被修改的!
为什么要创建子进程呢?那是因为我们想要让父子进程执行不同的代码块,干不同的事情。这就让fork具有了不同的返回值!
2.一个函数如何做到返回两次呢?
fork本质是一个函数,它在操作系统内肯定是有着自己独特的实现的。
通过fork的工作在返回前就已经创建好了子进程,最后返回的return语句就由父子进程共享了,那么当返回的时候就可以实现返回两次。
3.子进程和父进程的代码是同一份,但是子进程如何看待对应的数据呢?
对于id的值有不同的情况怎么分析呢?
在任何的平台下面,进程在运行的时候,是具有独立性的,一个进程不会影响到其他的进程。
父子进程那么就不应该享有一份数据!!
那么理论上子进程就要想办法将数据也给去拷贝一份,但是正常情况下,这会出现很多没有必要的拷贝。
这就有了父子进程之间数据层面写实拷贝。当需要修改数据的时候,就会去进行拷贝数据到新空间去使用就不会影响父进程的数据。
那么在fork最后return的时候是写入吗?
肯定是的,子进程写入的时候就会发生写实拷贝。这样就使得id的值出现了不同。
还有一个问题:父子进程创建出来了,谁先运行,谁后运行呢?
谁先运行由调度器决定!用户是无法决定的
在一个电脑上,大部分就一个CPU,但是具有许多的进程,每个进程都想要被优先执行,抢占这个CPU。我们的调度器就可以帮助我们正确的,合适的去调度我们的这些进程。
最后一个问题:子进程的ppid是父进程,那么父进程的ppid是谁呢?
我们通过ps查看一下:
我们也就可以想到bash肯定也是通过创建fork这样的方式创建出子进程去执行,然后bash自己去等待去执行其他的指令。
三、进程状态
R (running) --- 运行状态:并不代表进程一定在运行,它表明进程要么在运行中要么在运行队列里。
S (sleeping) --- 睡眠状态:意味着进程在等待事件的完成(这里睡眠有时候也叫做可中断睡眠(interruptible sleep))。
D (disk sleep) --- 磁盘休眠状态:有时候也叫不可中断睡眠状态 (uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
T (stopped) --- 停止状态:可以通过发送SIGSTOP信号给进程来停止(T)进程。这个被暂停的进程可以被通过发送SIGCONT信号让进程继续运行。
t (tracing stop) ---
X (dead) --- 死亡状态:这个状态只是一个返回状态,不会在任务列表中看到它
Z (zomble) --- 僵尸状态
1.进程状态,运行、阻塞、挂起

由上图我们可以看到在很多地方都有着自己的一个说法。但是大概的逻辑都没什么太大的问题,可以看看上面的模型。
1.1运行
我们前面讲过一个个的进程有自己的一个task_struct,我们让每个进程的内核数据结构去CPU中的运行队列中排队,就代表了这个进程正在运行状态
当CPU去找自己的运行队列的时候,调度器(一种函数),将运行队列作为一种参数传入进去。调度器就能够找到所有排队的进程。所有处于运行队列的进程就处于运行状态,也就是R状态。
在这个运行队列中的进程代表着这个进程已经准备好了,随时都可以被调度。
一个进程只要把自己放到CPU上开始运行了,是不是一直要执行完毕,才把自己放下来?
并不是,如果是的话,当有一个进程进入死循环,那么其他进程就无法被调度了。必须要等到前面进程执行完毕才能执行自己的话就是非常的有问题。
为了防止这种情况,对于每一个进程都有一个叫做时间片的概念。
这个t就可以帮助我们避免这个进程被一直进行。当运行的时间超过t就会让该进程下来,重新排队。
这样,就可以在一个时间段之内,所有的进程代码都会被执行!
这个情况在计算机中就叫做并发执行
在计算机中就会充满大量的把进程从CPU上放上去,拿下来的动作!
这个就叫做进程切换
1.2阻塞
前面讲的那种通过运行队列对于进程的管理,本质上讲就是操作系统对于软件的管理。
那么对于外设这种硬件资源该如何去管理呢?
不管这些硬件是什么,我们通过一个结构来描述,然后再管理
当有一个进程要我们进行键盘输入的时候,出现了一直不输入的情况。
那么当前的这个进程就要想办法让自己去等待键盘资源。
我们此时将这个进程链入到键盘的这个队列当中即可,如果还有想要键盘资源的就都链在这个队列的后面排队去:
当然对于其他的一些硬件如果需要也会有对应的链接
对于这些排在后面的这个队列就叫做等待队列:waitqueue
我们就将这些处于在等待某种特定资源的进程,所处于的状态就叫做阻塞状态!!
当等待的资源就绪后,就直接将对应的这个进程拿走,放入运行队列之中。
1.3挂起
在前面的阻塞中,在阻塞的进程中,都会有自己的数据。这些每个进程都会消耗占有一部分资源,然而在等待的过程中这些资源也没有用到,导致操作系统内存严重不足了。为了保障操作系统能够正常使用的情况下,还在阻塞的这些进程的资源就被拿去使用了。
实在没有办法了,操作系统就将原有的数据给交给磁盘这种外设当中去存着。就只让对应的PCB在队列中排队,当下次执行到对应的进程的时候就将写入到外设的数据拿回来。
这个过程就叫做内存数据的换出换入
这个进程将自己的PCB去排队的了,就代表这个进程处于了挂起状态
2.具体如何维护Linux状态
2.1 R和S状态
看如下代码:
#include <stdio.h>
#include <unistd.h>int main()
{while(1){printf("hello everone!\n");sleep(1);}return 0;
}
我们将它运行起来,然后去查看一下对应进程的信息:
可以感受到一个死循环,对应的进程应该是正在运行的啊,但是为什么显示的却是S呢?不应该是R吗?
在将代码改一下:
#include <stdio.h>
#include <unistd.h>int main()
{while(1);return 0;
}
此时这个进程就显示的是R了。
那究竟为什么有了前面的打印代码就是S,没了就是R了呢?
主要是,有了打印,它会等待显示器资源。所以不要用自己的感觉来以为进程在运行。它有很大的概率是在阻塞等待中某种资源当中
当没有IO的时候,它的进程状态就变成了R状态
所以,有时候,有些进程就需要等待一下资源,为S阻塞等待状态中。
R状态是存在的,S状态是比较常见的
因为操作系统是非常快的,运行只是一瞬间的事情,所以我们查的时候,大部分时间都只会查到正在等待的S状态,所以S状态是很常见的。
再看这个代码:
我们通过输入,将进程给阻塞起来:
#include <stdio.h>
#include <unistd.h>int main()
{int a = 0;printf("Please Enter# ");scanf("%d", &a);printf("echo : %d\n", a);return 0;
}
S状态就是我们前面讲的阻塞状态。。。
一直在等待我们的键盘输入。
显然可以想到我们的bash进程就是经常处于S状态:
2.2 D状态 --- 深度休眠
S 浅度睡眠 --- 可以被唤醒,响应外部的一些变化
D 深度睡眠 --- 不响应操作系统的任何请求
可以这样理解D状态:
如果有一个进程要向磁盘去写入大量的数据,但是向磁盘写入的运行速度是很慢的,可能需要很久的时间才能够给进程答复。如果在这个时间段,操作系统出现了很严重的问题,如内存极度的不足,操作系统就要开始想方设法的杀死一些进程,腾出空间去使用。
如果此时操作系统将这个向磁盘写入数据的进程杀掉,磁盘写入的时候就会将数据丢失了。这将会导致很严重的问题。但是不管对于进程、磁盘、操作系统这几方来讲,都是没有错的,所以为了避免这样的错误发生,就需要一个深度睡眠的状态D去标识这个进程,避免被杀掉导致问题
当该进程完成了自己的任务后,就将自己的状态改回正常的状态。
对于这个D状态的来讲,它不响应操作系统的任何请求......
对于D状态是处于高度的IO时会出现的,一般还是很难见到的。
2.3 T和t状态
对于这两个状态,差别不大。这里讲解一下T状态
stopped暂停状态
我们先认识两个信号:18 19号信号
它们可以帮助我们进行将进程给继续和暂停的作用:
我们继续来运行一下这个代码看看现象:
#include <stdio.h>
#include <unistd.h>int main()
{while(1){printf("hello everone!\n");sleep(1);}return 0;
}
可以看到这个进程就一直在运行了
此时给它发送一个19号信号:
可以发现该进程已经被我们暂停了,此时它的一个进程状态为:
此时它的进程状态就为T,已经被暂停了
自然通过18号信号可以将它恢复:
但是我们发现,它已经变成了一个后台的进程了,它的一个+号没有了
对于这种后台进程,使用简单的ctrl + c是杀不掉的,只能使用9号信号将它杀死:
好像感觉S和T状态没什么区别?那么S和T状态的区别是什么呢?
S和T都可以是操作系统层面上的阻塞状态,S状态肯定是在等待某种资源的就绪,但是T状态有可能是在等待某种资源的就绪,也有可能是单纯的被控制了,让这个进程之间就暂停了。
为什么要暂停一个进程了,有什么用?
我们使用gdb去调试一个代码的时候,打上断点,运行到那里的时候,就会出现t状态,表示被暂停了:
刚开始处于S状态,我们打上断点去运行一下:
t也是暂停状态
2.4 X状态
死亡状态:这个状态只是一个返回状态,不会在任务列表中看到它
2.5 Z状态 --- 僵尸状态
Z(zomble) 僵尸状态
对于一个进程它在死亡的时候不会立即进入死亡状态,它会先进入一个僵尸状态Z
Z状态是什么意思?为什么要维护这个Z状态呢?
当一个进程死亡了,先将自己的信息和死亡原因给维护一段时间,这个时候的进程状态就处于僵尸状态。将这些信息维护起来给父进程知道,方便父进程知道原因。
看以下代码,我们来看看僵尸进程 :
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int main()
{pid_t id = fork();if (id == 0){// 子进程int cnt = 5;while(cnt--){printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());sleep(1);}exit(0); // 直接退出}else{// 父进程while(1){printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid());sleep(1);}}return 0;
}
运行后,子进程循环结束按道理已经死亡了,我们去观察一下:
我们发现子进程并没有X状态,而是Z状态僵尸了
进程一般退出的时候,如果父进程没有主动回收子进程的信息,那么子进程就将保持着僵尸状态,让自己一直处于Z状态。进程的相关资源尤其是task_struct结构体不能被释放掉!
如果一直保持僵尸,此时就造成了内存泄漏的问题了!
至于怎么回收子进程,博主将在后续的文章里面揭示!
那通过上面的演示,可能就有人会想着如果父进程先退出了会怎么样呢???我们一样来写一段代码观察观察:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int main()
{pid_t id = fork();if (id == 0){// 子进程while(1){printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());sleep(1);}}else{// 父进程int cnt = 5;while(cnt--){printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid());sleep(1);}exit(0);}return 0;
}
打开监控观察如下:
while :; do ps ajx | head -1 && ps ajx | grep proc | grep -v grep; echo "--------------------------------------------------------------------------------------------"; sleep 1; done;
我们可以看到当父进程退出了,子进程还在的情况,子进程将被1号进程给接管。
那么1号进程是谁呢?它就是当前的操作系统本身!(根据操作系统版本的不一样,有叫systemd也有叫init)
父子进程,父进程先退出,子进程的父进程会被改成1号进程(操作系统)
父进程是1号进程 ------ 孤儿进程
该进程被系统领养!
为什么要被领养呢?
因为孤儿进程未来也要退出,也要被释放!
四、进程组织结构反思
在Linux中,可以发现其实进程链入的结构有可能是双向链表中,也可能是在树当中,它不是单一的某种结果能够维护好这一大堆的进程的。那么对于进程来讲是如何做到的呢?
我们进程的task_struct中会含有一个link的对象,通过这个来连接其他的进程。
比如这个双链表的结构,我们通过一个start指针就可以去遍历其他的节点(进程)了
那么我们怎么去计算出每个进程的其他的数据的呢?其他的字段呢?
&((task_struct*)0->link)): 通过0号地址转换成对应的task_struct*类型,然后去调用link,再取地址就可以计算出link在task_struct里面的一个偏移量是多少。
我们是通过start来遍历的,所以start是在内存中link真实的一个起始位置,我们通过:
start - &((task_struct*)0->link)) 计算出真实地址处对应task_struct的起始的地址
然后将这个地址再转换成task_struct类型,就可以实现遍历其他的数据字段的功能了。
有了这个当我们的进程的类型不一样的时候,也是可以连接进入这个数据结构当中的,只要通过计算就可以去获取内容了!!
如果当结构变成了left ,right这样的树也是可以正确的使用的,也就可以让不同的进程链入不同的数据结构了!!
结语
很高兴大家能够有所收获!有所疑问可以私信博主,或者留言评论!
大家多多关注 + 收藏 + 点赞,谢谢大家!