Linux 进程状态
上章我们讲到进程的基本概念,本章我们来讲解进程的状态,将从所有操作系统的进程状态共性和Linux特有的状态讲解。
一.进程状态
1.概念导入
每一个活着的人,都有自己的状态,而我们也因不同的状态做着不同的事。操作系统中的进程也是如此,操作系统会根据进程的不同状态进行处理,状态也决定了进程要做什么动作。上回我们讲到,Linux中的PCB的名字叫做task_struct,而进程的状态由task_struct中的一个int类型数组维护,不同的数值代表着不同的状态。
1.我们先对课本上对进程状态的描述进行提炼。
2.具体讲解
进程能运行,本质在于cpu在维护一个调度队列,选取PCB,再根据PCB中的内存指针找到代码和数据

这里我们遗留一个大大的问题:
PCB只有一份,却可以同时隶属于多种数据结构,这是怎么做到的?
2.运行态与阻塞态
运行:只要进程在调度队列或者占用cpu资源运行时,就算做运行状态
阻塞:有什么现象?用scanf或者cin做例子。代码一跑起来就是一个进程,当他遇到这两个函数时程序就会停下来,停下来干什么?等待用户输入(等待键盘文件就绪)。所以阻塞,就是等待设备或某种资源就绪。
更详细的说,管理硬件设备,就是先描述再组织。操作系统可以维护设备队列和运行队列等等数据结构。
每一个设备,还可以有自己的等待队列
假如当前的调度队列中执行程序,执行到scanf,需要等待键盘输入,于是OS转而去查找device队列查看特殊硬件键盘的状态,假如这时的键盘为不活跃状态,就会把当前这个task从调度队列中移动到设备队列的wait queue
此时的task不在调度队列,就不会被cpu调度,就处于阻塞状态。也就是说,阻塞就是把PCB从调度队列链接到其他的队列中。
那么此时键盘被按下时,当前的task能知道吗?其实不知道。
键盘按下,它的状态改变,操作系统一定会第一时间知道,所以此时OS会查看对应设备结点的device结构体,并将状态修改为活跃,查看等待队列不为空,所以OS就把该task重新链接到调度队列。注意此时数据还未从键盘读取到程序中,进入调度队列,task被调度时就会重新执行scanf从键盘中读取数据。
也就是说,从阻塞回到运行,就是找到PCB,并将他链接回运行队列。
那么我们能得出一条原理:
进程状态的变化,表现之一就是在不同的队列中流动。
在键盘中,可以有很多进程想用键盘,就会链接到键盘device的等待队列中。
3.挂起态
挂起态是用来处理一种极端的情况的。当内存资源不足时,操作系统会将一些进程的代码和数据调出内存,转而调到磁盘的swap分区暂存。这就是课本中提到的阻塞挂起。
还有更极端的情况:如果内存资源严重不足,甚至可能把正在运行队列末端的task代码和数据换入swap分区。这就是课本中提到的运行挂起。
4.理解内核链表
在这里我们将回答我们上面的问题:
PCB只有一份,却可以同时隶属于多种数据结构,这是怎么做到的?
我们常见的链表设计可能是这样的:每个节点都有指向下一个和指向前一个的指针next和prev,并且这些指针都是指向一整个Node结构的(struct Node*)。
而内核链表的设计却大有乾坤:一个task_struct结点内包含一个list_head成员,而每个list_head都指向下一个task_struct结点的list_head,而不是整个task_struct结点。
所以要遍历进程,就用一个struct list_head*遍历即可。
但是这样访问到的也只是list_head这一个属性,那怎么访问一个task_struct的其他属性呢?
结构体的整体地址,和第一个成员的地址,在数值上是相等的。一个结构体内的所有成员地址,应该是逐步递增的。也就是说,我们目前知道的是一个task_struct内list_head的地址。所以要访问任意一个task_struct内部的其他成员,就需要:
1.得到links相对于结构体初始位置的偏移量
&((struct task_struct*)0->links)
2.得到task_struct的起始地址
next指针减去这个偏移量,再强转为task_struct*类型
(struct task_struct*) (next - &((struct task_struct*)0->links))
这样我们就可以通过对这个结果解引用得到task_struct所有的成员了。
那么上面的问题就得到解决:可以在一个task_struct定义一个结构的list_head,就可以定义很多个结构的list_head。
此时,当前进程可以用不同字段,不同数据结构进行链接。这样就可以让OS仅管理一个struct_list,就做到struct_list即属于运行队列,又属于全局链表,还可以在二叉树里...
这样,对于一个在运行队列的PCB,若要将它断链进入阻塞队列,也依然可以在全局的链表内找到它,就是这个原因。PCB只有一份,却可以同时隶属于多种数据结构。之前的普通类型的链表,是无法实现这种结构的。
查看Linux内核源码,发现它的做法正是如此。
二.Linux中的进程
讲完了大部分操作系统的共性,我们来看看Linux的进程状态有什么特别之处。
1.运行态和阻塞态
我们先写一个简单的程序,目的让它跑起来并查看状态。
运行起来状态如下:
用命令查看状态:
我们先查看一次
ps ajx| head;ps ajx|grep myprocess
然后再时隔一秒监控一次进程状态
while true; do
ps -C myprocess -o pid,state,%cpu,%mem,cmd --headers
echo "----------------------------------------"
sleep 1
done
为什么是阻塞(sleep)而不是运行(run)?
I/O 操作导致睡眠
代码中的
printf("hello process!\n")
是一个I/O操作当进程执行I/O操作(如向终端输出)时,它需要等待I/O设备准备就绪
在此期间,内核会将进程置为睡眠状态(S),直到I/O操作完成
缓冲机制的影响
printf
使用缓冲输出,默认情况下当输出到终端时是行缓冲每次调用
printf
后,进程可能会短暂等待缓冲区刷新
时间片调度
即使没有I/O,进程也不会100%时间处于运行状态
Linux调度器会给每个进程分配时间片,时间片用完后进程会被挂起
使用
ps
命令捕捉到的瞬间,进程可能正好处于两次运行之间的间隔
代码中若IO操作占比较大时,就会将大部分时间用于等待IO。
如果我们这里将printf的代码注释,就会显示进程处于运行态。
wujiahao@VM-12-14-ubuntu:~$ while true; dops -C myprocess -o pid,state,%cpu,%mem,cmd --headersecho "----------------------------------------"sleep 1
donePID S %CPU %MEM CMD673691 R 105 0.0 ./myprocess
----------------------------------------PID S %CPU %MEM CMD673691 R 104 0.0 ./myprocess
----------------------------------------PID S %CPU %MEM CMD673691 R 104 0.0 ./myprocess
tips:这里的状态带+,是因为在前台启动,在启动可执行程序时带上&就会在后台启动。
注意:后台的程序可以通过kill指令杀死。
2.阻塞态
由之前的讲解我们得知,我们可以用scanf函数来演示阻塞态。
我们修改代码
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h> 2 3 int main(){4 printf("我是一个进程,我的pid:%d\n",getpid());5 6 int x;7 scanf("%d",&x); 8 // while(1){9 // printf("hello process!\n");10 // }11 return 0;12 }
运行可执行程序,并查看状态。
可以看到此时进程处于阻塞态。
3.追踪(tracing stop)
追踪状态(tracing stop):被debug,由于断点进程被暂停。
暂停:不同于阻塞,他并不是因为等待资源而停止运行,而是因为某种特殊条件或者操作。是Linux特有的一种状态。操作系统觉得这个进程可能有问题,由用户决定是否继续运行。
我们修改Makefile文件,演示这种情况。
myprocess:myprocess.cgcc -o $@ $^ -g
.PHONY:clean
clean:rm -f myprocess
修改源文件
#include<stdio.h> 2 #include<sys/types.h>3 #include<unistd.h>4 int main(){5 printf("我是一个进程,我的pid:%d\n",getpid());6 7 // int x;8 // scanf("%d",&x);9 while(1){10 printf("hello process!\n");11 }12 return 0;13 }
生成可执行文件之后用cgdb打开。
打好断点之后,不输入运行,可以看到此时程序一直处于t状态。
如果将源程序直接Ctrl+Z暂停,程序的状态会显示T:暂停。
4.深度休眠(Disk sleep)
我们之前讲到的阻塞(sleep),可以说是浅度睡眠——可中断休眠。我们可以对这些进程执行kill -9指令kill掉它们,操作系统也会响应我们的操作。
而这里说的Disk sleep,是一种不可中断的休眠。我们用写磁盘数据为例讲解:
假如进程是一般的sleep状态:操作系统说,现在内存资源太紧张了,你去休息吧!进程:好吧。。但这时你的写磁盘操作已经进行了90%,你就已经跑路了。磁盘:数据还没发完呢,你怎么不说话了?剩下的10MB还传不传了?我这边也很忙,我就先去干其他活了。。
就这样:你的这些数据,会在你不可知的情况下丢失。
所以,操作系统设置了一种深度睡眠的阻塞。操作系统是无法杀掉正在进行磁盘IO的进程的。
处于这种状态的进程只能等进程自己醒来,甚至只能重启或断电才行(甚至都不行)。
我们可以构建一个块级IO观察这个现象
dd if=/dev/zero of=~/test.txt bs=4096 count=100000
这样就会创建一个每块4096比特,十万块的空间。
kill用于向进程发信号。
5.僵尸(zombie)态和结束态
僵尸态可能看字面意思难以理解,我们就举一个例子。
比如现在,你目睹了一个案发现场。此时你要做的肯定不是直接把ta搬走,而是:保护现场,拨打电话,等待专业人员采集各种信息,再搬走。
僵尸进程也是这种逻辑。我们创建一个子进程,是为了让它去完成某种工作。子进程死亡之前,父进程需要得知子进程的各种信息,否则子进程会一直处于僵尸态。而这些信息会被保存在task_struct中。
这里我们用代码示例。我们的预期是只有子进程执行这个会结束的循环,而父进程一直在执行无限循环,到时候子进程就会变成僵尸进程
#include<stdio.h>2 #include<sys/types.h>3 #include<unistd.h>4 int main(){5 pid_t id=fork();6 if(id==0)7 {8 //child9 int count = 5;10 while(count--){11 printf("我是子进程,我正在运行:%d\n",count);12 sleep(1);13 }14 }15 else{16 //father17 while(1){18 printf("我是父进程,我正在运行...\n");19 sleep(1);20 }21 } 22 // int x;23 // scanf("%d",&x);24 while(1){25 printf("hello process!\n");26 }27 return 0;28 }
此时观察进程状态
defunct:失效的。
那么父进程一直不管,不回收,不获取子进程的退出信息,那么子进程一直会处于Z状态。
长久以来,Z状态的子进程PCB一直占用内存,会造成内存泄漏。
处理僵尸进程的方法,之后会单独讲解。
6.关于内存泄漏
问:进程退出后,内存泄漏问题还在不在?
答:不存在。此时由内存泄漏问题new出来的空间会自动被系统回收
那么什么样的进程具有内存泄漏问题,会很麻烦?就是哪些常驻内存的进程
僵尸进程,也是内存泄露的一种潜在风险。未来需要用户去亲自解决这个问题,因为僵尸进程就是一种常驻内存的进程
反复的申请释放进程需要开销。由此可以引出一种节省这种消耗的结构——进程池