上一篇进程概念的文章,仔细讲解了进程的创建及其组成,进程 = 内核数据结构 + 代码和数据。 一个系统中往往由多个进程共同执行,而不是只执行一个进程。比如现在,我既打开网页,又打开qq,又打开网易云音乐、又打开PDF阅读器,后台还有一些我看不见的进程,这些进程在操作系统内被CPU调度的时候,并不是一直运行,而是一个进程被CPU执行一段时间,然后让另一个进程被CPU执行一段时间,这样不断切换,每一个进程在固定时间段内都有所推进。
但是为什么我们感觉上是进程一直在执行呢?是因为CPU运行的速率和我们人的感官能力差别太大,从我们的角度看来,所有的进程是同时在运行的。
既然进程是可以被CPU调度的,那么 CPU 调度进程的依据是什么呢?其实这取决于进程的状态。
例如,阻塞 就是进程因为等待某种条件就绪,而导致的一种不推进的状态——即进程卡住了。阻塞一定是在等待某种资源,当具体的资源被别人使用完之后,再被自己使用,这就是进程阻塞的原因。
举一个现实生活中的例子,取银行存钱,窗口工作人员告诉我必须要先要填一张表,于是我去先把表填了,而工作人员就先处理其他人的业务。这里的工作人员就相当于CPU,办理业务的人就相当于一个进程(“我”也是一个进程),进程要完成的任务就是存钱,其代码也是存钱的逻辑,而 这张表 就是存钱进程所必须的资源。 在这张表就绪之前,“我” 所处的状态就是阻塞状态,被CPU调度了也没用 。
但是,在现实中等待就是找个位置坐下来,可是对于操作系统,如何去理解进程等待资源就绪呢?诸如磁盘、网卡、显卡等等设备,其实也是被操作系统“管理”的,也是先描述、再组织。
如下图所示,设备被操作系统“先描述,再组织”,一个个描述设备的 pcb 结构体被连起来成了链表。左边的 struct dev 代表的就是描述设备的结构体,里面有一个指针 queue ,指向该设备的等待队列。例如,现在某个进程正在使用网卡这个设备,但是进程A也要使用,设备又只有一个,所以网卡的pcb 里面的 queue 指针就会指向进程A 的pcb,这就代表着 A 在等待使用网卡,A处于阻塞状态。(如果有多个进程请求使用网卡,就按照顺序链接在该队列的尾部)
所以,将进程的 pcb 链接在某个设备的等待队列的尾部,就说明该进程在等待某种资源!既然该进程的 pcb 被链入到了某个资源的等待队列,那么就无法调度它,该进程就属于阻塞状态!!!
如下,CPU原本在调度某个进程A,该进程现在需要网卡设备,但是网卡设备被另一个进程占用了,所以 A 进程的 pcb(task_struct) 只能挂在网卡设备的等待队列中,此时进程A处于阻塞状态!
但是,如果内存里面的进程以及其他的数据过多,会使得内存空间不够用,这时处于阻塞状态的进程 A,其 pcb、代码和数据都是用不到的,白白占用内存空间,CPU就会把进程的代码和数据放到磁盘中,此时进程A就处于挂起状态
!!
如下图所示,挂起状态的进程的 pcb依然在内存中,只是代码和数据被放到磁盘。
上面所说的进程状态,是适用于所有的操作系统,对于 Windows,MacOS,Linux 等等都适用,但是具体到某一个操作系统,那么除了上面所说的,还有其他的一些状态,下面就以 Linux 为例。
先看一段Linux 的内核源码。
/*
* 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 */
};
如下是对源码中各个符号的状态的解释。
进程的这些状态是存储在 task_struct 里面的,如下,用一个 status 来标识其状态。
struct task_struct
{int status;…………
}
进程处于“R”状态的时候,一定是在CPU上运行吗?其实不是,进程处于“R”状态的时候,其实是进程的 pcb 在运行队列里面,CPU在调度的时候,只需要去运行队列里挑选指定的进程即可。
S状态是休眠状态,意味着进程在等待某种资源。
编译并运行下列代码,观察进程状态,如下图。
1 #include 2 3 int main() 4 { 5 6 while(1) 7 { 8 int a; 9 scanf("%d",&a); 10 printf("%d\n",a); 11 } 12 return 0; 13 }
如下,通过 COMMAND 可以看出 PID 为13272的就是测试代码的进程,查看其状态,是 “S+”,该进程在等待键盘资源。
当我们从键盘中输入数字并回车,键盘设备就绪了,该进程就会被CPU调度到运行队列,就会执行代码,将数据放到变量a里面,再打印,我们就会看到打印出的数据!
S状态又被称作可中断休眠,是指处于该状态下的进程,可以通过 Ctrl+c 强制退出。
D状态又被称作不可中断休眠。这种状态正常情况下很少会遇到。
例如,现在进程A要向磁盘写100Mb的数据,写完之后才可以在CPU上跑。将数据写到磁盘是比较慢的,这段时间内,进程A无疑会处于休眠状态,假设是处于S状态。当磁盘还在拷贝数据的时候,假设操作系统发现内存严重不足,那么它就会杀掉某些进程,此时进程A由于在休眠状态,所以被杀死。过了一会,磁盘数据保存失败,但是磁盘向进程A反馈的时候,发现进程A不见了。
那么这些数据该怎么办呢?丢弃吧,万一是100Mb很重要的信息呢?比如银行转账信息。保存吧?,由于进程A已经没了,即使保存了,进程也找不到了。
从操作系统、进程、磁盘的角度来看,三者都没有错。但是这件事就是做错了,所以,D 状态应运而生,处于D状态的进程,操作系统不可以强制杀死。
T状态是用户来控制的,可以通过 kill 指令来实现。
使用下列代码测试:
1 #include2 #include 3 4 int main()5 {6 int cnt=0;7 while(1)8 {9 printf("我在运行!%d\n",cnt++);10 sleep(1);11 }12 return 0;13 }
如下,使用 kill -19 PID ,该进程就成了T状态,这是由 “我” 来控制的,而非进程要等待某种资源。
当然,也可以使该进程继续运行,使用 18 号信号即可。如下图。
但是,此时却发现一个问题,kill -18 PID 之后,该进程的状态就变成了 “S” ,而不是一开始的 “S+”,并且无法通过 Ctrl+c 退出进程,进程一直在打印消息。
这其实是因为,“S+” 状态的进程,是在前台运行,“S” 状态的进程,是在后台运行。前台运行的可以用 Ctrl+c 终止,但是后台运行的进程却不可以,虽然不会影响我们使用命令行,但是总是不舒服的。
这里可以使用 kill -9 PID ,杀死后台进程。
该状态是追踪时的暂停,其实就相当于我们在VS环境下编写代码打断点,然后程序运行会在断点处停下,此时进程就处于 t 状态。
Z 状态又被称作僵尸状态,是 Linux 下非常重要的一种状态。在这里和 X 状态一起分析。
首先,我们要明白为什么要创建一个进程?因为——我们要让进程帮我们办事情。那么对于这件事情的结果,我们就有两种态度——1.我关心结果。2.我不关心结果。这里主要是关心结果的情况。
用C语言写代码的时候,有的函数会设置返回值,例如 main() 函数。这个返回值其实就是进程的退出码。如下,执行 test ,实际上是 bash 创建了一个子进程来执行。可以通过 echo $? 拿到上一个进程的退出码,也就是 test 的退出码,发现是4。由此知道,result 的最终结果不是 100。
那么,我们就可以根据退出码来判断一个进程执行的结果是否正确。
1 #include 2 #include 3 4 int main() 5 {6 int a=10,b=20;7 int ret=a*b;8 if(ret == 100) return 0;9 else return 4; 10 }
但是,当一个进程退出的时候,其所有信息都被操作系统清除,该进程的父进程也就无法获取它的退出码。
所以,为了让父进程得到退出码,一般而言,进程退出的时候,不会彻底退出,而是维持 Z 状态,也叫做僵尸状态,这是为了方便父进程 / 操作系统 去读取该进程的退出码。
那么为什么子进程不直接把退出码返回给父进程,再退出呢?这是因为退出码也是数据,而进程具有独立性,子进程无法把数据返回给父进程,会发生写时拷贝。
我们可以通过运行下列代码,查看僵尸状态。
1 #include 2 #include 3 4 int main() 5 { 6 pid_t ret = fork(); 7 if(ret == 0) 8 { 9 //子进程 10 while(1) 11 { 12 printf("我是子进程, 我的pid是: %d, 我的父进程是: %d \n", getpid(), getppid()); 13 sleep(1); 14 } 15 } 16 else if(ret > 0) 17 { 18 //父进程 19 while(1) 20 { 21 printf("我是父进程, 我的pid是: %d, 我的父进程是: %d \n", getpid(), getppid()); 22 sleep(1); 23 } 24 } 25 return 0; 26 }
如下,kill 子进程之后,子进程确实进入了僵尸状态。
但是,维持僵尸状态也是要消耗内存的,如果一直维持僵尸状态,就会造成内存泄漏。
僵尸进程是 子进程退出,父进程要获取一些信息。但是如果一个进程的父进程退出了,那么该进程会发生什么变化呢?
我们可以通过执行下面的代码查看。
1 #include2 #include3 4 int main() 5 { 6 pid_t ret = fork(); 7 if(ret == 0) 8 { 9 //子进程 10 while(1) 11 { 12 printf("我是子进程, 我的pid是: %d, 我的父进程是: %d \n", getpid(), getppid());13 sleep(1); 14 } 15 } 16 else if(ret > 0) 17 { 18 //父进程 19 int cnt=5; 20 while(1) 21 { 22 printf("我是父进程, 我的pid是: %d, 我的父进程是: %d \n", getpid(), getppid()); 23 sleep(1); 24 if(--cnt) break;25 }26 }27 return 0;28 }
如下图,我们可以看到:1、当父进程推出之后,父进程并没有进入僵尸状态,而是直接退出了。2、父进程退出之后,子进程的父进程就变成了 1 号进程,并且子进程由 “S+” 变成了 “S”,即从前台进程变成了后台进程。
对于 1 ,很好解释,因为在僵尸状态里面的代码其实是错误的,没有 wait ,只是为了帮我们更好地看到僵尸进程这个现象。而父进程的父进程,即bash,在父进程退出之后,自动回收了该进程,所以我们没有看到僵尸状态。
对于2,子进程被 1 号进程“领养”,让1 号进程成为新的父进程(1号进程实际上就是操作系统)。这种被 1 号进程“领养”的进程,就是孤儿进程
。
那么,为什么子进程会被1 号进程领养呢?反过来想,如果子进程A不被领养,那么后续A退出就没有进程回收进程A了,进程A就会一直处于僵尸状态,消耗内存资源。