Linux的进程相互之间有一定的关系。比如说,在Linux进程基础中,我们看到,每个进程都有父进程,而所有的进程以init进程为根,形成一个树状结构。我们在这里讲解进程组和会话,以便以更加丰富的方式了管理进程。
进程组 (process group)
每个进程都会属于一个进程组(process group),每个进程组中可以包含多个进程。进程组会有一个进程组领导进程 (process group leader),领导进程的PID (PID见Linux进程基础)成为进程组的ID (process group ID, PGID),以识别进程组。
$ps -o pid,pgid,ppid,comm | cat
PID为进程自身的ID,PGID为进程所在的进程组的ID, PPID为进程的父进程ID。从上面的结果,我们可以推测出如下关系:
图中箭头表示父进程通过fork和exec机制产生子进程。ps和cat都是bash的子进程。进程组的领导进程的PID成为进程组ID。领导进程可以先终结。此时进程组依然存在,并持有相同的PGID,直到进程组中最后一个进程终结。
我们将一些进程归为进程组的一个重要原因是我们可以将信号发送给一个进程组。进程组中的所有进程都会收到该信号。我们会在下一部分深入讨论这一点。
我们知道,要在当前进程中生成一个子进程,一般需要调用fork这个系统调用,fork这个函数的特别之处在于一次调用,两次返回,一次返回到父进程中,一次返回到子进程中,我们可以通过返回值来判断其返回点:
子进程的终结(termination)
当子进程终结时,它会通知父进程,并清空自己所占据的内存,并在内核里留下自己的退出信息(exit code,如果顺利运行,为0;如果有错误或异常状况,为>0的整数)。在这个信息里,会解释该进程为什么退出。父进程在得知子进程终结时,有责任对该子进程使用wait系统调用。这个wait函数能从内核中取出子进程的退出信息,并清空该信息在内核中所占据的空间。但是,如果父进程早于子进程终结,子进程就会成为一个孤儿(orphand)进程。孤儿进程会被过继给init进程,init进程也就成了该进程的父进程。init进程负责该子进程终结时调用wait函数。
linux上进程有5种状态:
1. 运行(正在运行或在运行队列中等待)
2. 中断(休眠中, 受阻, 在等待某个条件的形成或接受到信号)
3. 不可中断(收到信号不唤醒和不可运行, 进程必须等待直到有中断发生)
4. 僵死(进程已终止, 但进程描述符存在, 直到父进程调用wait4()系统调用后释放)
5. 停止(进程收到SIGSTOP, SIGSTP, SIGTIN, SIGTOU信号后停止运行运行)
ps工具标识进程的5种状态码:
D 不可中断 uninterruptible sleep (usually IO)
R 运行 runnable (on run queue)
S 中断 sleeping
T 停止 traced or stopped
Z 僵死 a defunct ("zombie") process
id为0的进程通常是调度进程,常常被称为交换进程(swapper),该进程为内核的一部分,他并不执行任何磁盘上的程序,因此也被称为系统进程。
id为1 init进程,在自举过程结束后由内核调用。
id为2 页守护进程
对于父进程已经终止的所有进程,他们的父进程都改变为init。
在一个进程终止时,内核逐个检查所有活动的进程,以判断他是否是正要终止进程的子进程,如果是,则将该进程id更改为1,这种方法保证了每个进程都有一个父进程。
如果子进程在父进程终止之前终止,父进程如何能做相应检验得到子进程的终止状态呢?对此的回答是:内核为每个子进程保存了一定量的信息,所以当终止进程的父进程调用wait或waitpid时,可以得到这些信息。
僵死(尸)进程:一个已经终止,但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息,释放它仍占有的资源)的进程
一个由init进程领养的进程终止时会发生什么?他会不会变为一个僵死进程?否!因为init被编写为无论何时只要有一个子进程终止,init就会调用一个wait函数取得其终止状态。这样就防止了系统中有很多僵死进程。
进程退出意味着进程生命期的结束,系统资源被回收,进程从操作系统环境中销毁。进程异常退出是进程在运行过程中被意外终止,从而导致进程本来应该继续执行的任务无法完成。
首先我们来看导致进程异常退出的这两类情况:
●第一类:向进程发送信号导致进程异常退出;
●第二类:代码错误导致进程运行时异常退出。
第一类:向进程发送信号导致进程异常退出
信号:
UNIX 系统中的信号是系统响应某些状况而产生的事件,是进程间通信的一种方式。信号可以由一个进程发送给另外进程,也可以由核发送给进程。
信号处理程序:
信号处理程序是进程在接收到信号后,系统对信号的响应。根据具体信号的涵义,相应的默认信号处理程序会采取不同的信号处理方式:
●终止进程运行,并且产生 core dump 文件。
●终止进程运行。
●忽略信号,进程继续执行。
●暂停进程运行。
●如果进程已被暂停,重新调度进程继续执行。
前两种方式会导致进程异常退出,是本文讨论的范围。实际上,大多数默认信号处理程序都会终止进程的运行。
在进程接收到信号后,如果进程已经绑定自定义的信号处理程序,进程会在用户态执行自定义的信号处理程序;反之,内核会执行默认信号程序终止进程运行,导致进程异常退出。
图1.默认信号处理程序终止进程运行
所以,通过向进程发送信号可以触发默认信号处理程序,默认信号处理程序终止进程运行。在 UNIX 环境中我们有三种方式将信号发送给目标进程,导致进程异常退出。
方式一:调用函数 kill() 发送信号
我们可以调用函数 kill(pid_t pid, int sig) 向进程 ID 为 pid 的进程发送信号 sig。这个函数的原型是:
1
2
3
|
#include
#include
int kill(pid_t pid, int sig);
|
调用函数 kill() 后,进程进入内核态向目标进程发送指定信号;目标进程在接收到信号后,默认信号处理程序被调用,进程异常退出。
清单 1. 调用 kill() 函数发送信号
1
2
3
4
5
6
7
8
9
10
11
12
|
/* sendSignal.c, send the signal ‘ SIGSEGV ’ to specific process*/
#include
#include
int main(int argc, char* argv[])
{
char* pid = argv[1];
int PID = atoi(pid);
kill(PID, SIGSEGV);
return 0;
}
|
上面的代码片段演示了如何调用 kill() 函数向指定进程发送 SIGSEGV 信号。编译并且运行程序:
1
2
3
4
5
6
7
8
|
[root@machine ~]# gcc -o sendSignal sendSignal.c
[root@machine ~]# top &
[1] 22055
[root@machine ~]# ./sendSignal 22055
[1]+ Stopped top
[root@machine ~]# fg %1
top
Segmentation fault (core dumped)
|
上面的操作中,我们在后台运行 top,进程 ID 是 22055,然后运行 sendSignal 向它发送 SIGSEGV 信号,导致 top 进程异常退出,产生 core dump 文件。
方式二:运行 kill 命令发送信号
用户可以在命令模式下运行 kill 命令向目标进程发送信号,格式为:
kill SIG*** PID
在运行 kill 命令发送信号后,目标进程会异常退出。这也是系统管理员终结某个进程的最常用方法,类似于在 Windows 平台通过任务管理器杀死某个进程。
在实现上,kill 命令也是调用 kill 系统调用函数来发送信号。所以本质上,方式一和方式二是一样的。
操作演示如下:
1
2
3
4
5
6
7
|
[root@machine ~]# top &
[1] 22810
[root@machine ~]#
kill -SIGSEGV 22810
[1]+ Stopped top
[root@machine ~]# fg %1
top
Segmentation fault (core dumped)
|
方式三:在终端使用键盘发送信号
用户还可以在终端用键盘输入特定的字符(比如 control-C 或 control-\)向前台进程发送信号,终止前台进程运行。常见的中断字符组合是,使用 control-C 发送 SIGINT 信号,使用 control-\ 发送 SIGQUIT 信号,使用 control-z 发送 SIGTSTP 信号。
在实现上,当用户输入中断字符组合时,比如 control-C,终端驱动程序响应键盘输入,并且识别 control-C 是信号 SIGINT 的产生符号,然后向前台进程发送 SIGINT 信号。当前台进程再次被调用时就会接收到 SIGINT 信号。
使用键盘中断组合符号发送信号演示如下:
[root@machine ~]# ./loop.sh ( 注释:运行一个前台进程,任务是每秒钟打印一次字符串 )
i’m looping …
i’m looping …
i’m looping … ( 注释:此时,用户输入 control-C)
[root@machine ~]# ( 注释:接收到信号后,进程退出 )
对这类情况的思考
这类情况导致的进程异常退出,并不是软件编程错误所导致,而是进程外部的异步信号所致。但是我们可以在代码编写中做的更好,通过调用 signal 函数绑定信号处理程序来应对信号的到来,以提高软件的健壮性。
signal 函数的原型:
1
2
|
#include
void (*signal(int sig, void (*func)(int)))(int);
|
signal 函数将信号 sig 和自定义信号处理程序绑定,即当进程收到信号 sig 时自定义函数 func 被调用。如果我们希望软件在运行时屏蔽某个信号,插入下面的代码,以达到屏蔽信号 SIGINT 的效果:
(void)signal(SIGINT, SIG_IGN);
执行这一行代码后,当进程收到信号 SIGINT 后,进程就不会异常退出,而是会忽视这个信号继续运行。
更重要的场景是,进程在运行过程中可能会创建一些临时文件,我们希望进程在清理这些文件后再退出,避免遗留垃圾文件,这种情况下我们也可以调用 signal 函数实现,自定义一个信号处理程序来清理临时文件,当外部发送信号要求进程终止运行时,这个自定义信号处理程序被调用做清理工作。代码清单 2 是具体实现。
清单 2. 调用 signal 函数绑定自定义信号处理程序
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
|
/* bindSignal.c */
#include
#include
#include
void cleanTask(int sig) {
printf( "Got the signal, deleting the tmp file\n" );
if( access( "/tmp/temp.lock", F_OK ) != -1 ) {
if( remove( "/tmp/temp.lock" ) != 0 )
perror( "Error deleting file" );
else
printf( "File successfully deleted\n" );
}
printf( "Process existing...\n" );
exit(0);
}
int main() {
(void) signal( SIGINT, cleanTask );
FILE* tmp = fopen ( "/tmp/temp.lock", "w" );
while(1) {
printf( "Process running happily\n" );
sleep(1);
}
if( tmp )
remove( "/tmp/temp.lock" );
}
|
运行程序:
1
2
3
4
5
6
7
|
[root@machine ~]# ./bindSignal
Process running happily
Process running happily
Process running happily ( 注释:此时,用户输入 control-C)
Got the signal, deleting the tmp file ( 注释:接收到信号后,cleanTask 被调用 )
File successfully deleted ( 注释:cleanTask 删除临时文件 )
Process existing... ( 注释:进程退出 )
|
第二类:编程错误导致进程运行时异常退出
相比于第一类情况,第二类情况在软件开发过程中是常客,是编程错误,进程运行过程中非法操作引起的。
操作系统和计算机硬件为应用程序的运行提供了硬件平台和软件支持,为应用程序提供了平台虚拟化,使进程运行在自己的进程空间。在进程看来,它自身独占整台系统,任何其它进程都无法干预,也无法进入它的进程空间。
但是操作系统和计算机硬件又约束每个进程的行为,使进程运行在用户态空间,控制权限,确保进程不会破坏系统资源,不会干涉进入其它进程的空间,确保进程合法访问内存。当进程尝试突破禁区做非法操作时,系统会立刻觉察,并且终止进程运行。
所以,第二类情况导致的进程异常退出,起源于进程自身的编程错误,错误的编码执行非法操作,操作系统和硬件制止它的非法操作,并且让进程异常退出。
在实现上,操作系统和计算机硬件通过异常和异常处理函数来阻止进程做非法操作。
异常和异常处理函数
当进程执行非法操作时,计算机会抛出处理器异常,系统执行异常处理函数以响应处理器异常,异常处理函数往往会终止进程运行。
广义的异常包括软中断 (soft interrupts) 和外设中断 (I/O interrupts) 。外设中断是系统外围设备发送给处理器的中断,它通知处理器 I/O 操作的状态,这种异常是外设的异步异常,与具体进程无关,所以它们不会造成进程的异常退出。本文讨论的异常是指 soft interrupts,是进程非法操作所导致的处理器异常,这类异常是进程执行非法操作所产生的同步异常,比如内存保护异常,除 0 异常,缺页异常等等。
处理器异常有很多种,系统为每个异常分配异常号,每个异常有相对应的异常处理函数。以 x86 处理器为例,除 0 操作产生 DEE 异常 (Divide Error Exception),异常号是 0;内存非法访问产生 GPF 异常 (General Protection Fault),异常号是 13,而缺页 (page fault) 异常的异常号是 14。当异常出现时,处理器挂起当前进程,读取异常号,然后执行相应的异常处理函数。如果异常是可修复,比如内存缺页异常,异常处理函数会修复系统错误状态,清除异常,然后重新执行一遍被中断的指令,进程继续运行;如果异常无法修复,比如内存非法访问或者除 0 操作,异常处理函数会终止进程运行。
图 2. 异常处理函数终止进程运行
实例以及分析
实例一:内存非法访问
这类问题中最常见的就是内存非法访问。内存非法访问在 UNIX 平台即 segmentation fault,在 Windows 平台这类错误称为 Access violation。
内存非法访问是指:进程在运行时尝试访问尚未分配(即,没有将物理内存映射进入进程虚拟内存空间)的内存,或者进程尝试向只读内存区域写入数据。当进程执行内存非法访问操作时,内存管理单元 MMU 会产生内存保护异常 GPF(General Protection Fault),异常号是 13。系统会立刻暂停进程的非法操作,并且跳转到 GPF 的异常处理程序,终止进程运行。
这种编程错误在编译阶段编译器不会报错,是运行时出现的错误。清单 3 是内存非法访问的一个简单实例,进程在执行第 5 行代码时执行非法内存访问,异常处理函数终止进程运行。
清单 3. 内存非法访问实例 demoSegfault.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
#include
int main()
{
char* str = "hello";
str[0] = 'H';
return 0;
}
编译并运行:
[root@machine ~]# gcc demoSegfault.c -o demoSegfault
[root@machine ~]# ./demoSegfault
Segmentation fault (core dumped)
[root@machine ~]# gdb demoSegfault core.24065
( 已省略不相干文本 )
Core was generated by `./demoSegfault'.
Program terminated with signal 11, Segmentation fault.
|
分析:实例中,字符串 str 是存储在内存只读区的字符串常量,而第 5 行代码尝试更改只读区的字符,所以这是内存非法操作。
进程从开始执行到异常退出经历如下几步:
1、进程执行第 5 行代码,尝试修改只读内存区的字符;
2、内存管理单元 MMU 检查到这是非法内存操作,产生保护内存异常 GPF,异常号 13;
3、处理器立刻暂停进程运行,跳转到 GPF 的异常处理函数,异常处理函数终止进程运行;
4、进程 segmentation fault,并且产生 core dump 文件。GDB 调试结果显示,进程异常退出的原因是 segmentation fault。
实例二:除 0 操作
实例二是除 0 操作,软件开发中也会引入这样的错误。当进程执行除 0 操作时,处理器上的浮点单元 FPU(Floating-point unit) 会产生 DEE 除 0 异常 (Divide Error Exception),异常号是 0。
清单 4. 除 0 操作 divide0.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
#include
int main()
{
int a = 1, b = 0, c;
printf( "Start running\n" );
c = a/b ;
printf( "About to quit\n" );
}
编译并运行:
[root@machine ~]# gcc -o divide0 divide0.c
[root@machine ~]# ./divide0 &
[1] 1229
[root@machine ~]# Start running
[1]+ Floating point exception(core dumped) ./divide0
[root@xbng103 ~]# gdb divide0 /corefiles/core.1229
( 已省略不相干文本 )
Core was generated by `./divide0'.
Program terminated with signal 8, Arithmetic exception.
|
分析:实例中,代码第 7 行会执行除 0 操作,导致异常出现,异常处理程序终止进程运行,并且输出错误提示:Floating point exception。
异常处理函数内幕
异常处理函数在实现上,是通过向挂起进程发送信号,进而通过信号的默认信号处理程序终止进程运行,所以异常处理函数是“间接”终止进程运行。详细过程如下:
1、进程执行非法指令或执行错误操作;
2、非法操作导致处理器异常产生;
3、系统挂起进程,读取异常号并且跳转到相应的异常处理函数;
a、异常处理函数首先查看异常是否可以恢复。如果无法恢复异常,异常处理函数向进程发送信号。发送的信号根据异常类型而定,比如内存保护异常 GPF 相对应的信号是 SIGSEGV,而除 0 异常 DEE 相对应的信号是 SIGFPE;
b、异常处理函数调用内核函数 issig() 和 psig() 来接收和处理信号。内核函数 psig() 执行默认信号处理程序,终止进程运行;
3、进程异常退出。
在此基础上,我们可以把图2进一步细化如下:
图3. 异常处理函数终止进程运行(细化)
异常处理函数执行时会检查异常号,然后根据异常类型发送相应的信号。
再来看一下实例一(代码清单 3)的运行结果:
1
2
3
4
5
6
|
[root@machine ~]# ./demoSegfault
Segmentation fault (core dumped)
[root@machine ~]# gdb demoSegfault core.24065
( 已省略不相干文本 )
Core was generated by `./demoSegfault'.
Program terminated with signal 11, Segmentation fault.
|
运行结果显示进程接收到信号 11 后异常退出,在 signal.h 的定义里,11 就是 SIGSEGV。MMU 产生内存保护异常 GPF(异常号 13)时,异常处理程序发送相应信号 SIGSEGV,SIGSEGV 的默认信号处理程序终止进程运行。
再来看实例二(代码清单 4)的运行结果
1
2
3
4
5
6
7
8
|
[root@machine ~]# ./divide0 &
[1] 1229
[root@machine ~]# Start running
[1]+ Floating point exception(core dumped) ./divide0
[root@xbng103 ~]# gdb divide0 /corefiles/core.1229
( 已省略不相干文本 )
Core was generated by `./divide0'.
Program terminated with signal 8, Arithmetic exception.
|
分析结果显示进程接收到信号 8 后异常退出,在 signal.h 的定义里,8 就是信号 SIGFPE。除 0 操作产生异常(异常号 0),异常处理程序发送相应信号 SIGFPE 给挂起进程,SIGFPE 的默认信号处理程序终止进程运行。
“信号”是进程异常退出的直接原因
信号与进程异常退出有着紧密的关系:第一类情况是因为外部环境向进程发送信号,这种情况下发送的信号是异步信号,信号的到来与进程的运行是异步的;第二类情况是进程非法操作触发处理器异常,然后异常处理函数在内核态向进程发送信号,这种情况下发送的信号是同步信号,信号的到来与进程的运行是同步的。这两种情况都有信号产生,并且最终都是信号处理程序终止进程运行。它们的区别是信号产生的信号源不同,前者是外部信号源产生异步信号,后者是进程自身作为信号源产生同步信号。
所以,信号是进程异常退出的直接原因。当进程异常退出时,进程必然接收到了信号。
避免和调试进程异常退出
建议
软件开发过程中,我们应当避免进程异常退出,针对导致进程异常退出的这两类问题,对软件开发者的几点建议:
1、通常情况无需屏蔽外部信号。信号作为进程间的一种通信方式,异步信号到来意味着外部要求进程的退出;
2、绑定自定义信号处理程序做清理工作,当外部信号到来时,确保进程异常退出前,自定义信号处理程序被调用做清理工作,比如删除创建的临时文件。
3、针对第二类情况,编程过程中确保进程不要做非法操作,尤其是在访问内存时,确保内存已经分配给进程(映射入进程虚拟地址空间),不要向只读区写入数据。
问题调试和定位
进程异常退出时,操作系统会产生 core dump 文件,cored ump 文件是进程异常退出前内存状态的快照,运行 GDB 分析 core dump 文件可以帮助调试和定位问题。
1) 首先,分析 core dump 查看导致进程异常退出的具体信号和退出原因。
使用 GDB 调试实例一(代码清单 3)的分析结果如下:
1
2
3
4
|
[root@machine ~]# gdb demoSegfault core.24065
( 已省略不相干文本 )
Core was generated by `./demoSegfault'.
Program terminated with signal 11, Segmentation fault.
|
分析结果显示,终止进程运行的信号是 11,SIGSEGV,原因是内存非法访问。
2) 然后,定位错误代码。
在 GDB 分析 core dump 时,输入“bt”指令打印进程退出时的代码调用链,即 backtrace,就可以定位到错误代码。
用 gcc 编译程序时加入参数 -g 可以生成符号文件,帮助调试。
重新编译、执行实例一,并且分析 core dump 文件,定位错误代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
[root@machine ~]# gcc -o demoSegfault demoSegfault.c -g
[root@machine ~]# ./demoSegfault &
[1] 28066
[1]+ Segmentation fault (core dumped) ./demoSegfault
[root@machine ~]# gdb demoSegfault /corefiles/core.28066
( 已省略不相干文本 )
Core was generated by `./demoSegfault'.
Program terminated with signal 11, Segmentation fault.
#0 0x0804835a in main () at demoSegfault.c:5
5 str[0] = 'H';
(gdb) bt
#0 0x0804835a in main () at demoSegfault.c:5
(gdb)
|
在加了参数 -g 编译后,我们可以用 gdb 解析出更多的信息帮助我们调试。在输入“bt”后,GDB 输出提示错误出现在第 5 行。
3) 最后,在定位到错误代码行后,就可以很快知道根本原因,并且修改错误代码。