每天学五分钟 Liunx 0100 | 服务篇:进程状态


多任务和 CPU 时间片

前面说了 Liunx 是多用户多任务的,所谓的多任务就是多个进程“同时”执行。比如,同时开多个软件(进程),对于用户来说好像每个软件(进程)都在工作,但是实际上,单核 CPU 做不到多个进程同时执行,只有多个 CPU 才能真正实现多任务执行。之所以会产生多个任务同时执行的错觉是因为 CPU 的运行速度太快了,远远超过了肉眼能够感知的速度。
CPU 处理进程是通过系统的调度类程序实现的,调度类根据进程的优先级调度进程,给进程分配时间片,优先级越高的进程越容易被调度,同时分配的时间片也越多。时间片的示意图如下:
 
 
如图所示,每个进程都会被调度到,同时,时间片分配越多的进程, CPU 会处理更长时间。当当前处理的进程时间片结束的时候,内核会将进程临时停止时的运行环境,即寄存器中的内容和页表保存到内存中(保护现场)。同时,收回 CPU 的使用权,将 CPU 交给调度类选中的进程继续处理下个进程,在下个进程运行时,将下个进程原来的运行时环境加载到 CPU (恢复现场),这样 CPU 就可以在当初处理该进程的运行时环境继续工作了。
基于上面描述可以知道,如果调度类调度进程太过频繁的话会使得 CPU 陷入保护现场和恢复现场的时间过长,而这部分时间 CPU 是没有工作的,这样不好。当然,调度类调度的太慢的话,会使得其它进程一直处于等待状态,这样也不好。
 
 

进程状态

 
 
事实上进程不是只有运行和等待两种状态,它有多种状态,如上图所示,分别是:
运行态:进程正在运行,CPU 正在处理它;
就绪态:进程可以被运行,已经在等待队列中,调度类可能会调度到它;
阻塞(睡眠)态:进程睡眠了,不可被运行,通常是等待某事件如 I/O 请求等;
 
各个进程的状态可以相互转换,转换形式有:
  • 新状态->就绪态:当等待队列允许接纳新进程时,内核便把新进程加入等待队列;
  • 就绪态->运行态:调度类选中等待队列中的进程,该进程进入运行态;
  • 运行态->阻塞态:正在运行的进程因需要等待某事件(如 I/O 请求、信号等待等)的出现而无法执行,进入阻塞态;
  • 阻塞态->就绪态:进程所等待的事件发生,从阻塞态进入等待队列,等待下次被调度执行;
  • 运行态->就绪态:正在执行的进程因时间片用完而被暂停执行;或者在抢占式调度方式中,高优先级进程强制抢占了正在执行的低优先级进程;
  • 运行态->终止态:一个进程已完成或发生某种特殊事件,进程将变为终止状态。对于命令来说,一般都会返回退出状态码。
 
上图中少了终止态和僵尸态,终止态即是进程完成或由于特殊事件使得进程终止的状态,称为终止态。僵尸态是进程结束了进入终止状态,但是内核没有发现该进程已经终止从而没把它从进程表中删除,处于僵尸态的进程称为僵尸进程,它会一直占用进程号和少量资源。<僵尸进程的详细信息可看这里>。
其中,阻塞(睡眠)态又分为可中断睡眠和不可中断睡眠。可中断睡眠是允许接收外界信号和内核信号而被唤醒的睡眠,绝大多数睡眠都是可中断睡眠,能被 ps 或 top 捕捉到的睡眠几乎都是可中断睡眠。不可中断睡眠只能由内核发起信号唤醒,外界无法通过信号来唤醒,主要表现在和硬件交互的时候。例如, cat 一个文件时,从硬盘上加载数据到内存,在和硬件交互的那一小段时间一定是不可中断的,否则如果进程被唤醒,加载数据中断,cat 出来的文件会只显示一部分,严重者,甚至会造成硬件的奔溃。
 

父子进程和 BASH

上一节工作管理说到,工作可以被放到后台执行,相应的,进程也可分为前台进程和后台进程:
前台进程:一般命令(非内置命令)在执行时都会 fork(复制) 子进程来执行命令,在子进程执行过程中,父进程会进入睡眠,这类进程是前台进程。
后台进程:在命令的结尾加上 & 号,会将命令放入后台,并返回该后台进程的的 jobid 和 pid,同时父进程进入运行态,当后台进程终止时,父进程会收到信号。所以,通过在命令后加上 "&" 号可以实现 “伪并行”的工作方式。
 
前台进程执行时,如果输入 bash 内置命令,父进程将不会创建子进程来执行这些命令,而是直接在当前 bash 进程中执行。但如果将内置命令放在管道后,则此内置命令将和管道左边的进程同属于一个进程组,所以仍然会创建子进程。
shell 中可通过 enable 和 type 命令查看命令是否是内置命令:
[test@lianhua ~]$ type echo
echo is a shell builtin
[test@lianhua ~]$ enable | grep echo
enable echo
[test@lianhua ~]$ enable cp
-bash: enable: cp: not a shell builtin
 
 
Liunx 中创建子进程的方式有三种,分别是:
1. fork 复制进程:fork 会复制当前进程的副本,产生一个新的子进程,父子进程是完全独立的两个进程,他们掌握的资源(环境变量和普通变量)是一样的。
2. exec:exec 方式不会产生子进程,它会加载新的程序从而取代当前进程,当前进程的变量是被初始化了。exec 加载的程序执行完毕后会退出当前 exec 所在的 shell。
3. clone:用来实现 Liunx 中的线程。
 
可以看出,一般稳妥的执行程序的方式是先 fork 出一个子进程,然后在此子进程上执行 exec 命令,exec 执行完之后退出当前 shell 环境,返回状态码给父进程,同时切换到父进程。这样的方式可以保证父进程以及父进程下其它子进程的安全。
 
 
这里面有个特殊的进程就是 bash, bash 是通过开启 shell 启起来的。那么,在创建子 bash 进程时,何时启动子 shell,子 shell 的环境变量和普通变量是怎么来的,这些问题就变得很需要讨论了。
命令 echo $BASHPID 可打印当前 bash 的 PID,通过它我们可以查看当前 bash 进程是不是在子 shell 下,比如:
[test@lianhua ~]$ echo $BASHPID
557442
[test@lianhua ~]$ bash
[test@lianhua ~]$ echo $BASHPID
782463
可以看出执行 bash 命令进入了子 shell ,在子 shell 中的 bash PID 是 782463。
 
常用的在 shell 中是否进入子 shell 可分为以下几种:
1. 执行 bash 内置命令:前面略有提及,执行 bash 内置命令是在当前 bash 进程中执行命令,并不会产生子 shell:
[test@lianhua ~]$ type let
let is a shell builtin
[test@lianhua ~]$ echo $BASHPID
557442
[test@lianhua ~]$ let a=$BASHPID
[test@lianhua ~]$ echo $a
557442
 
但是如果内置命令后加管道时,管道会把内置命令加入到同一进程组中,从而创建子 shell:
[test@lianhua ~]$ type cd
cd is a shell builtin
[test@lianhua ~]$ echo $BASHPID
557442
[test@lianhua ~]$ cd | expr $BASHPID
872810
 
2. 执行 bash 非内置命令:执行 bash 非内置命令会先 fork 出子 bash 进程,子 bash 进程是父 bash 进程的副本,具有父 bash 进程一样的变量,然后通过 exec 加载非内置命令,此时子 bash 的环境被初始化了,这个环境也称为单独的环境,执行完之后 exec 退出子 shell:
[test@lianhua ~]$ echo $BASHPID
557442
[test@lianhua hxia]$ type cat
cat is /usr/bin/cat
[test@lianhua hxia]$ cat / &
[1] 907235
 
3.执行 bash 命令:执行 bash 会进入子 shell,产生子 bash 进程,子 bash 进程会重新加载环境配置项(fork bash 时完全继承父 shell 的环境信息),覆盖从父 shell 中继承来的变量(普通变量):
[test@lianhua ~]$ env | grep HOME
HOME=/home/test
[test@lianhua ~]$ a=lianhua
[test@lianhua ~]$ env | grep lianhua
[test@lianhua ~]$ echo $a
lianhua
[test@lianhua ~]$ echo $BASHPID
992329
[test@lianhua ~]$ bash
[test@lianhua ~]$ env | grep HOME
HOME=/home/test
[test@lianhua ~]$ echo $a
 
[test@lianhua ~]$ echo $BASHPID
1004303
 
4. 执行 shell 脚本:shell 脚本中第一行总是 "#!/bin/bash" 或者执行 shell 脚本时使用 bash ***.sh 执行,这和 bash 进入子 shell 时一样的,都是通过 bash 命令进入子 shell,不过和直接执行 shell 脚本不一样的是,执行 shell 脚本产生的子 shell 只会继承父 shell 的父进程存储命令路径这一项属性。
 
 
进程状态举例
结合前面的知识,这里以在 shell 下执行 cp 为例进一步解释进程间状态转换流程:
当执行 cp 命令时,首先 fork 出一个 bash 子进程,然后在子 bash 上 exec 加载 cp 程序,cp 子进程进入等待队列,由于是在命令行下敲的命令,所以优先级较高,调度类很快选中它。在 cp 这个子进程执行过程中,父进程 bash 进入睡眠状态(不仅是因为cpu只有一颗的情况下一次只能执行一个进程,还因为进程等待),并等待被唤醒,此刻 bash 无法和人类交互。当 cp 命令执行完毕,它将自己的退出状态码告知父进程此次复制是成功还是失败,然后 cp 进程自己消逝掉,父进程 bash 被唤醒再次进入等待队列,并且此时 bash 已经获得了 cp 退出状态码。根据状态码这个"信号",父进程 bash 知道了子进程已经终止,然后通知给内核,内核收到通知后将进程列表中的 cp 进程项删除。至此,整个 cp 进程正常完成。
假如 cp 这个子进程复制的是一个大文件,一个 cpu 时间片无法完成复制,那么在一个 cpu 时间片消耗尽的时候它将进入等待队列。
假如 cp 这个子进程复制文件时,目标位置已经有了同名文件,那么默认会询问是否覆盖,发出询问时它等待 yes 或 no 的信号,所以它进入了睡眠状态(可中断睡眠),当在键盘上敲入 yes 或 no 信号给 cp 的时候,cp 收到信号,从睡眠态转入就绪态,等待调度类选中它完成 cp 进程。
在 cp 复制时,它需要和磁盘交互,在和硬件交互的短暂过程中,cp 将处于不可中断睡眠。
假如 cp 进程结束了,但是结束的过程中出现了某种意外导致 bash 这个父进程不知道它已经结束了(此例中是不可能出现这种情况的),那么 bash 就不会通知内核回收进程列表中的 cp 表项,cp 就成了僵尸进程。
 
 
 
[声明]
文章中大部分内容来源于骏马金龙老师的博文,详细了解可看他的博客: 
 
 
 
(完)
posted @ 2020-05-03 18:24  lubanseven  阅读(187)  评论(0编辑  收藏  举报