Linux进程和信号超详细分析
Linux进程和信号超详细分析
进程是一个非常复杂的概念,涉及的内容也非常非常多。在这一小节所列出内容,已经是我极度简化后的内容了,应尽可能理解,这些理论在使用操作系统和程序开发方面都非常重要。更多关于进程的内容需阅读操作系统方面的书籍。
进程的基本概念
进程和程序的区别
程序是二进制文件,是静态存放在磁盘上的,它占用磁盘空间,但不占用系统运行资源(即cpu和内存)。
进程是用户执行程序或者程序被调用后运行的结果,可以认为进程是程序的一个运行实例。进程是动态的,会申请和使用系统资源,并与操作系统内核进行交互。在后文中,不少状态统计工具的结果中显示的是system类的状态,其实system状态就是内核状态。
多任务和cpu时间片
现在几乎所有的操作系统都能”同时”运行多个进程,也就是多任务或者说是并行执行。但实际上这是人类的错觉,一颗物理cpu(一核、一线程)在同一时刻只能运行一个进程,只有多颗物理cpu才能真正意义上实现多任务。
人类会产生错觉,以为操作系统能并行做几件事情,这是通过在极短时间内进行进程间切换实现的,因为时间极短,前一刻执行的是进程A,下一刻切换到进程B,不断的在多个进程间进行切换,使得人类以为在同时处理多件事情。
不过,cpu如何选择下一个要执行的进程是非常复杂的。在Linux上,决定下一个要运行的进程是通过”调度类”(调度程序)来实现的。程序何时运行,由进程的优先级决定,但要注意,优先级值越低,优先级就越高,就越快被调度类选中。除此之外,优先级还影响分配给进程的时间片长短。在Linux中,改变进程的nice值,可以影响某类进程的优先级值。
有些进程比较重要,要让其尽快完成,有些进程则比较次要,早点或晚点完成不会有太大影响,所以操作系统要能够知道哪些进程比较重要,哪些进程比较次要。比较重要的进程,应该多给它分配一些cpu的执行时间,让其尽快完成。下图是cpu时间片的概念。
由此可以知道,所有的进程都有机会运行,但重要的进程总是会获得更多的cpu时间,这种方式是”抢占式多任务处理”:内核可以强制在时间片耗尽的情况下收回cpu使用权,并将cpu交给调度类选中的进程,此外,在某些情况下也可以直接抢占当前运行的进程。随着时间的流逝,分配给进程的时间也会被逐渐消耗,当分配时间消耗完毕时,内核收回此进程的控制权,并让下一个进程运行。但因为前面的进程还没有完成,在未来某个时候调度类还是会选中它,所以内核应该将每个进程临时停止时的运行时环境(寄存器中的内容和页表)保存下来(保存位置为内核占用的内存),这称为保护现场,在下次进程恢复运行时,将原来的运行时环境加载到cpu上,这称为恢复现场,这样cpu可以在当初的运行时环境下继续执行。
调度类选中了下一个要执行的进程后,要进行底层的任务切换,也就是上下文切换,这一过程需要和cpu进行紧密的交互。进程切换不应太频繁,也不应太慢。切换太频繁将导致cpu闲置在保护和恢复现场的时间过长,保护和恢复现场对人类或者进程来说是没有生产力的(因为它没有在执行程序,而是在保护或恢复环境)。切换太慢将导致进程调度切换慢,很可能下一个进程要等待很久才轮到它被调度执行(如果你发出一个ls命令要等半天,这显然是不允许的)。
至此,也就知道了cpu的衡量单位是时间,就像内存的衡量单位是空间大小一样。进程占用的cpu时间长,说明cpu运行在它身上的时间就长。注意,cpu的百分比值不是其工作强度或频率高低,而是”进程占用cpu时间/cpu总时间”,这个衡量概念一定不要搞错。
父子进程及创建进程的方式
根据执行程序的用户UID以及其他标准,会为每一个进程分配一个唯一的PID。
父子进程的概念,简单来说,在某进程(父进程)的环境下执行或调用程序,这个程序触发的进程就是子进程,而进程的PPID表示的是该进程的父进程的PID。由此也知道了,子进程总是由父进程创建。
在Linux,父子进程以树型结构的方式存在,父进程创建出来的多个子进程之间称为兄弟进程。CentOS 6上,init进程是所有进程的父进程,CentOS 7上则为systemd。
Linux上创建子进程的方式有三种:一种是fork出来的进程,一种是exec出来的进程,一种是clone出来的进程。
(1).fork是复制进程,它会复制当前进程的副本,以适当的方式将这些资源交给子进程。所以子进程掌握的资源和父进程是一样的,包括内存中的内容,所以也包括环境变量和变量。但父子进程是完全独立的,它们是一个程序的两个实例。
(2).exec是加载另一个应用程序,替代当前运行的进程。也就是说在不创建新进程的情况下加载一个新程序。exec还有一个动作,在被调用程序的代码执行完毕后,立即退出该进程。所以为了保证进程安全,若要形成新的且独立的子进程,都会先fork一份当前进程,然后在fork出来的子进程上调用exec来加载新程序替代该子进程。例如在bash下执行cp命令,会先fork出一个子bash进程(是当前bash进程的副本),然后再exec加载cp程序覆盖子bash进程变成cp进程。但要注意,fork进程时会复制所有内存页,但使用exec加载新程序时会初始化地址空间,意味着复制动作完全是多余的操作,当然,有了写时复制技术不用过多考虑这个问题。
(3).clone用于实现线程。clone的工作原理和fork相同,但clone出来的新进程不独立于父进程,它只会和父进程共享某些资源,在clone进程的时候,可以指定要共享的是哪些资源。
题外知识:如何创建一个子进程?
每次fork一个进程的时候,都会向内核发出一次
fork()
系统调用,它会为当前进程(即父进程)和创建出来的新进程(子进程)分别返回PID值:对子进程的返回值为0,对父进程的返回值是子进程的pid。所以,可以使用下面的shell伪代码来描述运行一个ls命令时的过程:
1
2
3
4
5
6 fpid=`fork()`
if [ $fpid = 0 ]{
exec(ls) || echo "Can't exec ls"
exit
}
wait($fpid)假设上面是在shell脚本中执行ls命令,那么fork的是shell脚本进程。fork后,父进程将继续执行,且if语句判断失败,于是执行wait;而子进程执行时将检测到fpid=0,于是执行exec(ls),当ls执行结束,子进程因为exec的原因将退出。于是父进程的wait等待完成,继续执行后面的代码。
如果在这个shell脚本中某个位置,执行exec命令(exec命令调用的其实就是exec家族函数),shell脚本进程直接切换到exec命令上,执行完exec命令,就表示进程终止,于是exec命令后面的所有命令都不会再执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 +--------+
| pid=7 |
| ppid=4 |
| bash |
+--------+
|
| calls fork
V
+--------+ +--------+
| pid=7 | forks | pid=22 |
| ppid=4 | ----------> | ppid=7 |
| bash | | bash |
+--------+ +--------+
| |
| waits for pid 22 | calls exec to run ls
| V
| +--------+
| | pid=22 |
| | ppid=7 |
| | ls |
V +--------+
+--------+ |
| pid=7 | | exits
| ppid=4 | <---------------+
| bash |
+--------+
|
| continues
V
进程是有所有者的,也就是它的发起者,如果某个用户并非进程发起者、非父进程发起者、非root用户,那么它无法杀死进程。且杀死父进程(非终端进程),会导致子进程变成孤儿进程,孤儿进程的父进程总是init/systemd。
进程的状态
进程并非总是处于运行(Running)中,至少cpu没运行在它身上时它就是非运行的。进程有几种状态,不同的状态之间可以实现状态切换。
下图是非常经典的进程状态描述图,个人感觉右图更加易于理解。
运行态:进程正在运行,也即是cpu正在它身上。
就绪(等待)态:进程可以运行,已经处于等待队列中,也就是说调度类下次可能会选中它。
睡眠(阻塞)态:进程睡眠了,不可运行。
各状态之间的转换方式为:(也许可能不太好理解,可以结合稍后的例子)
新状态->就绪态
:当等待队列允许接纳新进程时,内核便把新进程移入等待队列。就绪态->运行态
:调度类选中等待队列中的某个进程,该进程进入运行态。运行态->睡眠态
:正在运行的进程因需要等待某事件(如IO等待、信号等待等)的出现而无法继续执行,进入睡眠态。睡眠态->就绪态
:进程所等待的事件发生了,进程就从睡眠态排入等待队列,等待下次被选中执行。运行态->就绪态
:正在执行的进程因时间片用完而被暂停执行;或者在抢占式调度方式中,高优先级进程强制抢占了正在执行的低优先级进程。运行态->终止态
:一个进程已完成或发生某种特殊事件,进程将变为终止状态。对于命令来说,一般都会返回退出状态码。
注意上面的图中,没有就绪-->睡眠
和睡眠-->运行
的状态切换。这很容易理解。对于就绪-->睡眠
,就绪状态中的进程等待被调度,它还没有被CPU执行,所以不可能判定为进入睡眠状态,况且就绪态本就表示可运行,而进入睡眠态表示暂时不可运行,这是冲突的;而对于睡眠-->运行
,因为调度类只会从就绪队列中挑出下一次要运行的进程,所以睡眠态接收到事件完成的信号后必须先进入就绪态等待被调度执行。
再说说运行态-->睡眠态
。从运行态到睡眠态一般是等待某事件的出现,例如等待信号通知,等待IO完成。信号通知很容易理解,而对于IO等待,程序要运行起来,cpu就要执行该程序的指令,同时还需要输入数据,可能是变量数据、键盘输入数据、网络数据或磁盘文件中的数据,除了在内存中的变量数据外,获取其它数据的速度相对cpu来说都是极慢极慢的。如果cpu在需要数据的那一刻却得不到数据,cpu就只能闲置下来,这肯定是不应该的,因为cpu是极其珍贵的资源,所以内核应该让正在运行且需要数据的进程暂时进入睡眠,等它的数据都准备好了再回到等待队列等待被调度类选中。这就是IO等待。
上面的图中少了一种进程的特殊状态——僵尸态。僵尸态进程表示的是进程已经转为终止态,它已经完成了它的使命并消逝了,但是内核还没有来得及将它在进程列表中的项删除,也就是说内核没给它料理后事,这就造成了一个进程是死的也是活着的假象,说它死了是因为它不再消耗额外资源(但可能会占用未释放的资源),调度类也不可能选中它并让它运行,说它活着是因为在进程列表中还存在对应的表项,可以被捕捉到。僵尸态进程并不一定会占用多少资源(除非fork出来的大量进程都占用了未释放的资源且成了僵尸进程),正常情况下的大多数僵尸进程仅在进程列表中占用一点点的内存,大多数僵尸进程的出现都是因为进程正常终止(包括kill -9),但父进程没有确认该进程已经终止,内核也不知道该进程已经终止了。僵尸进程更具体说明见后文。
另外,睡眠态是一个非常宽泛的概念,分为可中断睡眠和不可中断睡眠。可中断睡眠是允许接收外界信号和内核信号而被唤醒的睡眠,绝大多数睡眠都是可中断睡眠,能ps或top捕捉到的睡眠也几乎总是可中断睡眠;不可中断睡眠只能由内核发起信号来唤醒,外界无法通过信号来唤醒,主要表现在和硬件交互的时候。例如cat
查看一个文件时,从硬盘上加载数据到内存中,在和硬件交互的那一小段时间一定是不可中断的,否则在加载数据的时候突然被人为发送的信号手动唤醒,而被唤醒时和硬件交互的过程又还没完成,所以即使唤醒了也没法将cpu交给它运行,所以cat一个文件的时候不可能只显示一部分内容。而且,不可中断睡眠若能被人为唤醒,更严重的后果是硬件崩溃。由此可知,不可中断睡眠是为了保护某些重要进程,也是为了让cpu不被浪费。
其实只要发现进程存在,且非僵尸态进程,还不占用cpu资源,那么它就是睡眠的。包括后文中出现的暂停态、追踪态,它们也都是睡眠态。
举例分析进程状态转换过程
进程间状态的转换情况可能很复杂,这里举一个例子,尽可能详细地描述它们。
以在bash下执行cp命令为例。在当前bash环境下,处于可运行状态(即就绪态)时,当执行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此时就成了僵尸进程。
进程结构和子shell
前台进程:一般命令(如cp命令)在执行时都会fork子进程来执行,在子进程执行过程中,父进程会进入睡眠,这类是前台进程。前台进程执行时,其父进程睡眠,因为cpu只有一颗,即使是多颗cpu,也会因为执行流(进程等待)的原因而只能执行一个进程,要想实现真正的多任务,应该使用进程内多线程实现多个执行流。
后台进程:若在执行命令时,在命令的结尾加上符号&
,它会进入后台。将命令放入后台,会立即返回父进程,并返回该后台进程的的jobid和pid,所以后台进程的父进程不会进入睡眠。当后台进程出错,或者执行完成,总之后台进程终止时,父进程会收到信号。所以,通过在命令后加上&
,再在&
后给定另一个要执行的命令,可以实现”伪并行”执行的方式,例如cp /etc/fstab /tmp & cat /etc/fstab
。
bash内置命令:bash内置命令是非常特殊的,父进程不会创建子进程来执行这些命令,而是直接在当前bash进程中执行。但如果将内置命令放在管道后,则此内置命令将和管道左边的进程同属于一个进程组,所以仍然会创建子进程。
说到这了,应该解释下子shell,这个特殊的子进程。
一般fork出来的子进程,内容和父进程是一样的,包括变量,例如执行cp命令时也能获取到父进程的变量。但是cp命令是在哪里执行的呢?在子shell中。执行cp命令敲入回车后,当前的bash进程fork出一个子bash,然后子bash通过exec加载cp程序替代子bash。请不要在此纠结子bash和子shell,如果搞不清它们的关系,就当它是同一种东西好了。
那是否可以理解为所有命令、脚本其运行环境都是在子shell中呢?显然,上面所说的bash内置命令不是在子shell中运行的。其他的所有方式,都是在子shell中完成,只不过方式不尽相同。
分为几种情况(只列出几种比较能说明问题的例子,还有其它很多种会进入子shell的情况):
(1).执行bash内置命令:bash内置命令是非常特殊的,父进程不会创建子进程来执行这些命令,而是直接在当前bash进程中执行。但如果将内置命令放在管道后,则此内置命令将和管道左边的进程同属于一个进程组,所以仍然会创建子进程,但却不一定是子shell。请先阅读完下面的几种情况再来考虑此项。
(2).执行bash命令:显然它会进入子shell环境,它的绝大多数环境都是新配置的,因为会加载一些环境配置文件。事实上fork出来的bash子进程内容完全继承父shell,但因重新加载了环境配置项,所以子shell没有继承普通变量,更准确的说是覆盖了从父shell中继承的变量。不妨试试在
/etc/bashrc
文件中定义一个变量,再在父shell中export名称相同值却不同的环境变量,然后到子shell中看看该变量的值为何?其实执行bash命令,既可以认为进入了子shell,也可以认为没有进入子shell。在执行bash命令后从变量
$BASH_SUBSHELL
的值为0可以认为它没有进入子shell。但从执行bash命令后进入了新的shell环境来看,它有其父bash进程,且$BASHPID
值和父shell不同,所以它算是进入了子shell。执行bash命令更应该被认为是进入了一个完全独立的、全新的shell环境,而不应该认为是进入了片面的子shell环境。
(3).执行shell脚本:因为脚本中第一行总是
#!/bin/bash
或者直接bash xyz.sh
,所以这和上面的执行bash进入子shell其实是一回事,都是使用bash命令进入子shell。只不过此时的bash命令和情况(2)中直接执行bash命令所隐含的选项不一样,所以继承和加载的shell环境也不一样。事实也确实如此,shell脚本只会继承父shell的一项属性:父进程所存储的各命令的路径。另外,执行shell脚本有一个动作:命令执行完毕后自动退出子shell。
(4).执行非bash内置命令:例如执行cp命令、grep命令等,它们直接fork一份bash进程,然后使用exec加载程序替代该子bash。此类子进程会继承所有父bash的环境。但严格地说,这已经不是子shell,因为exec加载的程序已经把子bash进程替换掉了,这意为着丢失了很多bash环境。
(5).非内置命令的命令替换:当命令行中包含了命令替换部分时,将开启一个子shell先执行这部分内容,再将执行结果返回给当前命令。因为这次的子shell不是通过bash命令进入的子shell,所以它会继承父shell的所有变量内容。这也就解释了
$(echo $$)
中$$
的结果是当前bash的pid号,而不是子shell的pid号,因为它不是使用bash命令进入的子shell。(6).使用括号
()
组合一系列命令:例如(ls;date;echo haha)
,独立的括号将会开启一个子shell来执行括号内的命令。这种情况等同于情况(5)。
最后需要说明的是,子shell的环境设置不会粘滞到父shell环境,也就是说子shell的变量等不会影响父shell。
还有两种特殊的脚本调用方式:exec和source。
exec:exec是加载程序替换当前进程,所以它不开启子shell,而是直接在当前shell中执行命令或脚本,执行完exec后直接退出exec所在的shell。这就解释了为何bash下执行cp命令时,cp执行完毕后会自动退出cp所在的子shell。
source:source一般用来加载环境配置类或库函数类脚本。它不会开启子shell,直接在当前shell加载脚本且执行脚本后不退出当前shell,所以脚本会继承当前已有的变量,且脚本执行完毕后加载的环境变量会粘滞给当前shell,在当前shell生效。
job任务
大部分进程都能将其放入后台,这时它就是一个后台任务,所以常称为job,每个开启的shell会维护一个job table,后台中的每个job都在job table中对应一个Job项。
手动将命令或脚本放入后台运行的方式是在命令行后加上&
符号。例如:
1 | [root@server2 ~]# cp /etc/fstab /tmp/ & |
将进程放入后台后,会立即返回其父进程,一般对于手动放入后台的进程都是在bash下进行的,所以立即返回bash环境。在返回父进程的同时,还会返回给父进程其jobid和pid。未来要引用jobid,都应该在jobid前加上百分号%
,其中%%
表示当前job,例如kill -9 %1
表示杀掉jobid为1的后台进程,如果不加百分号,完了,把Init进程给杀了(但该进程特殊,不会受影响)。
通过jobs命令可以查看后台job信息。
1 | jobs [-lrs] [jobid] |
通过&
放入后台的任务,在后台中仍会处于运行中。当然,对于那种交互式如vim类的命令,将转入暂停运行状态。
1 | [root@server2 ~]# sleep 10 & |
一定要注意,此处看到的是running和ps或top显示的R状态,它们并不总是表示正在运行,处于等待队列的进程也属于running。它们都属于task_running。
另一种手动加入后台的方式是按下CTRL+Z键,这可以将正在运行中的进程加入到后台,但这样加入后台的进程会在后台暂停运行。
1 | [root@server2 ~]# sleep 10 |
从jobs信息也看到了在每个jobid的后面有个+
号,此外还有-
或者不带符号的情况。
1 | [root@server2 ~]# sleep 30&vim /etc/my.cnf&sleep 50& |
操作方式也很简单,直接在命令后加上jobid即可(即[fg|bg] [%jobid]
),不给定jobid时操作的将是当前任务,即带有+
的任务项。
1 | [root@server2 ~]# sleep 20 |
disown命令可以从job table中直接移除一个job,仅仅只是移出job table,并非是结束任务。而且移出job table后,作业将脱离shell管理,不再依赖于终端,当终端断开会立即挂在init/systemd进程之下。所以,disown命令提供了让进程脱离终端的另一种方式。
1 | disown [-ar] [-h] [%jobid ...] |
如果不给定任何选项,该shell中所有的job都会被移除,移除是disown的默认操作,如果也没给定jobid,而且也没给定-a
或-r
,则表示只针对当前任务即带有+
号的任务项。例如:
1 | [root@server2 ~]# sleep 30 & sleep 40 & |
终端和进程的关系
使用pstree命令查看下当前的进程,不难发现在某个终端执行的进程其父进程或上几个级别的父进程总是会是终端的连接程序。
例如下面筛选出了两个终端下的父子进程关系,第一个行是tty终端(即直接在虚拟机中)中执行的进程情况,第二行和第三行是ssh连接到Linux上执行的进程。
1 | [root@server2 ~]# pstree -c | grep bash |
正常情况下杀死父进程会导致子进程变为孤儿进程,即其PPID改变,但是杀掉终端这种特殊的进程,会导致该终端上的所有进程都被杀掉。这在很多执行长时间任务的时候是很不方便的。比如要下班了,但是你连接的终端上还在执行数据库备份脚本,这可能会花掉很长时间,如果直接退出终端,备份就终止了。所以应该保证一种安全的退出方法。
一般的方法也是最简单的方法是使用nohup命令带上要执行的命令或脚本放入后台,这样任务就脱离了终端的关联。当终端退出时,该任务将自动挂到init(或systemd)进程下执行。
另一种方法是使用screen这个工具,该工具可以模拟多个物理终端,虽然模拟后screen进程仍然挂在其所在的终端上的,但同nohup一样,当其所在终端退出后将自动挂到init/systemd进程下继续存在,只要screen进程仍存在,其所模拟的物理终端就会一直存在,这样就保证了模拟终端中的进程继续执行。它的实现方式其实和nohup差不多,只不过它花样更多,管理方式也更多。一般对于简单的后台持续运行进程,使用nohup足以。
另外,在子shell中的后台进程在终端被关闭时也会脱离终端,因此不会受shell和终端的控制。例如shell脚本中的后台进程,再如(sleep 10 &)
。
可能你已经发现了,很多进程是和终端无关的,也就是不依赖于终端,这类进程一般是内核类进程/线程以及daemon类进程,若它们也依赖于终端,则终端一被终止,这类进程也立即被终止,这是绝对不允许的。
信号
信号在操作系统中控制着进程的绝大多数动作,信号可以让进程知道某个事件发生了,也指示着进程下一步要做出什么动作。信号的来源可以是硬件信号(如按下键盘或其他硬件故障),也可以是软件信号(如kill信号,还有内核发送的信号)。不过,很多可以感受到的信号都是从进程所在的控制终端发送出去的。
需知道的信号
Linux中支持非常多种信号,它们都以SIG字符串开头,SIG字符串后的才是真正的信号名称,信号还有对应的数值,其实数值才是操作系统真正认识的信号。但由于不少信号在不同架构的计算机上数值不同(例如CTRL+Z发送的SIGSTP信号就有三种值18,20,24),所以在不确定信号数值是否唯一的时候,最好指定其字符名称。
以下是需要了解的信号。
1 | Signal Value Comment |
除了这些信号外,还需要知道一个特殊信号:代码为0的信号。此信号为EXIT信号,表示直接退出。如果kill发送的信号是0(即kill -0
)则表示不做任何处理直接退出,但执行错误检查:当检查发现给定的pid进程存在,则返回0,否则返回1。也就是说,0信号可以用来检测进程是否存在,可以代替ps aux | grep proc_name
。(man kill中的原文为:If sig is 0, then no signal is sent, but error checking is still performed。而 man bash 的trap小节中有如下描述:If a sigspec is EXIT (0),这说明0信号就是EXIT信号)
以上所列的信号中,只有SIGKILL和SIGSTOP这两个信号是不可被捕捉且不可被忽略的信号,其他所有信号都可以通过trap或其他编程手段捕捉到或忽略掉。
此外,经常看到有些服务程序(如httpd/nginx)的启动脚本中使用WINCH和USR1这两个信号,发送这两个信号时它们分别表示graceful stop和graceful restart。所谓的graceful,译为优雅,不过使用这两个字去描述这种环境实在有点不伦不类。它对于后台服务程序而言,传达了几个意思:
这要看服务程序对信号的具体实现。
再来说说,为什么后台服务程序可以使用这两个信号。以httpd的为例,在其头文件mpm_common.h中有如下几行代码:
1 | /* Signal used to gracefully restart */ |
这说明注册了对应信号的处理函数,它们分别表示将接收到信号时,执行对应的GRACEFUL函数。
注意,SIGWINCH是窗口程序的尺寸改变时发送改信号,如vim的窗口改变了就会发送该信号。但是对于后台服务程序,它们根本就没有窗口,所以WINCH信号对它们来说是没有任何作用的。因此,大概是约定俗成的,大家都喜欢用它来作为后台服务程序的GRACEFUL信号。但注意,WINCH信号对前台程序可能是有影响的,不要乱发这种信号。同理,USR1和USR2也是一样的,如果源代码中明确为这两个信号注册了对应函数,那么发送这两个信号就可以实现对应的功能,反之,如果没有注册,则这两个信号对进程来说是错误信号。
更多更详细的信号理解或说明,可以参考wiki的两篇文章:
jobs控制机制:https://en.wikipedia.org/wiki/Job_control_(Unix)
信号说明:https://en.wikipedia.org/wiki/Unix_signal
SIGHUP
(1).当控制终端退出时,会向该终端中的进程发送sighup信号,因此该终端上行的shell进程、其他普通进程以及任务都会收到sighup而导致进程终止。
多种方式可以改变因终端中断发送sighup而导致子进程也被结束的行为,这里仅介绍比较常见的三种:
但不管是何种实现方式,终端退出后未被终止的进程将只能挂靠在init/systemd下。
(2).对于daemon类的程序(即服务性进程),这类程序不依赖于终端(它们的父进程都是init或systemd),它们收到sighup信号时会重读配置文件并重新打开日志文件,使得服务程序可以不用重启就可以加载配置文件。
僵尸进程和SIGCHLD
一个编程完善的程序,在子进程终止、退出的时候,内核会发送SIGCHLD信号给父进程,父进程收到信号就会对该子进程进行善后(接收子进程的退出状态、释放未关闭的资源),同时内核也会进行一些善后操作(比如清理进程表项、关闭打开的文件等)。
在子进程死亡的那一刹那,子进程的状态就是僵尸进程,但因为发出了SIGCHLD信号给父进程,父进程只要收到该信号,子进程就会被清理也就不再是僵尸进程。所以正常情况下,所有终止的进程都会有一小段时间处于僵尸态,只不过这种僵尸进程存在时间极短,几乎是不可被ps或top这类的程序捕捉到的。
如果在特殊情况下,子进程终止了,但父进程没收到SIGCHLD信号,没收到这信号的原因可能是多种的,不管如何,此时子进程已经成了永存的僵尸,能轻易的被ps或top捕捉到。僵尸不倒霉,人类就要倒霉,但是僵尸爸爸并不知道它儿子已经变成了僵尸,因为有僵尸爸爸的掩护,僵尸道长即内核见不到小僵尸,所以也没法收尸。悲催的是,人类能力不足,直接发送信号(如kill)给僵尸进程是无效的,因为僵尸进程本就是终结了的进程,它收不到信号,只有内核从进程列表中将僵尸进程表项移除才算完成收尸。
要解决掉永存的僵尸有几种方法:
(1).杀死僵尸进程的父进程。没有了僵尸爸爸的掩护,小僵尸就暴露给了僵尸道长的直系弟子init/systemd,init/systemd会定期清理它下面的各种僵尸进程。所以这种方法有点不讲道理,僵尸爸爸是正常的啊,不过如果僵尸爸爸下面有很多僵尸儿子,这僵尸爸爸肯定是有问题的,比如编程不完善,杀掉是应该的。
(2).手动发送SIGCHLD信号给僵尸进程的父进程。僵尸道长找不到僵尸,但被僵尸祸害的人类能发现僵尸,所以人类主动通知僵尸爸爸,让僵尸爸爸知道自己的儿子死而不僵,然后通知内核来收尸。
当然,第二种手动发送SIGCHLD信号的方法要求父进程能收到信号,而SIGCHLD信号默认是被忽略的,所以应该显式地在程序中加上获取信号的代码。也就是人类主动通知僵尸爸爸的时候,默认僵尸爸爸是不搭理人类的,所以要强制让僵尸爸爸收到通知。不过一般daemon类的程序在编程上都是很完善的,发送SIGCHLD总是会收到,不用担心。
手动发送信号(kill命令)
使用kill命令可以手动发送信号给指定的进程。
1 | kill [-s signal] pid... |
使用kill -l
可以列出Linux中支持的信号,有64种之多,但绝大多数非编程人员都用不上。
使用-s
或-signal
都可以发送信号,不给定发送的信号时,默认为TREM信号,即kill -15
。
1 | shell> kill -9 pid1 pid2... |
pkill
pkill和pgrep命令是同族命令,都是先通过给定的匹配模式搜索到指定的进程,然后发送信号(pkill)或列出匹配的进程(pgrep),pgrep就不介绍了。
pkill能够指定模式匹配,所以可以使用进程名来删除,想要删除指定pid的进程,反而还要使用-s
选项来指定。默认发送的信号是SIGTERM即数值为15的信号。
1 | pkill [-signal] [-v] [-P ppid,...] [-s pid,...][-U uid,...] [-t term,...] [pattern] |
在CentOS 7上,还有两个好用的新功能
1 | -F, --pidfile file:匹配进程时,读取进程的pid文件从中获取进程的pid值。 |
例如:
1 | [root@xuexi ~]# ps x | grep ssh[d] |
现在想匹配/usr/sbin/sshd
。
1 | [root@xuexi ~]# pgrep bin/sshd |
可以看到第一个什么也不返回。因为不加-f
选项时,pgrep只能匹配进程名,而进程名指的是sshd,而非/usr/sbin/sshd
,所以匹配失败。加上-f
后,就能匹配成功。所以,当pgrep或pkill匹配不到进程时,考虑加上-f
选项。
再例如踢出终端:
1 | shell> pkill -t pts/0 |
killall
killall主要用于杀死一批进程,例如杀死整个进程组。其强大之处还体现在可以通过指定文件来搜索哪个进程打开了该文件,然后对该进程发送信号,在这一点上,fuser和lsof命令也一样能实现。
1 | killall [-r,--regexp] [-s,--signal signal] [-u,--user user] [-v,--verbose] [-w,--wait] [-I,--ignore-case] [--] name ... |
fuser和lsof
fuser可以查看文件或目录所属进程的pid,即由此知道该文件或目录被哪个进程使用。例如,umount的时候提示the device busy可以判断出来哪个进程在使用。而lsof则反过来,它是通过进程来查看进程打开了哪些文件,但要注意的是,一切皆文件,包括普通文件、目录、链接文件、块设备、字符设备、套接字文件、管道文件,所以lsof出来的结果可能会非常多。
fuser
1 | fuser [-ki] [-signal] file/dir |
在不加选项时,显示结果中文件或目录的pid后会带上一个修饰符:
1 | c:在当前目录下 |
例如:
1 | [root@xuexi ~]# fuser /usr/sbin/crond |
表示/usr/sbin/crond
被1425这个进程打开了,后面的修饰符e表示该文件是一个可执行文件。
1 | [root@xuexi ~]# ps aux | grep 142[5] |
lsof
例如:
输出信息中各列意义:
1 | COMMAND:进程的名称 |
一些基本用法:
1 | lsof /path/to/somefile:显示打开指定文件的所有进程之列表;建议配合grep使用 |
大概-i
是使用最多的了,而-i
中使用最多的又是服务名或端口了。
1 | [root@www ~]# lsof -i :22 |