认识Perl中的信号
2014-09-23 12:02:59 阿炯

在正文开始前,先了解一下Unix下对信号的定义。

先了解在linux中的信号,信号其实就是编程里俗称的中断,它使监视与控制其他进程变为有可能。中断信号(signal,又简称为信号)用来通知进程发 生了异步事件,进程之间可以互相通过系统调用kill发送软中断信号。内核也可以因为内部事件而给进程发送信号,通知进程发生了某个事件。注意,信号只是 用来通知某进程发生了什么事件,并不给该进程传递任何数据。

信号(signal)

正如文件句柄,理解信号是网络编程的基础。信号是通过操作系统发送给你的程序的一个消息,告诉它发生了重要的事情。信号可以指示程序自身的一个错误,比如尝试除0。事件要求立刻反应,例如用户尝试终止这个程序,或者一个非关键信息,如程序启动后,终止一个子进程。除了由操作系统发送之外,进程间也可以发送消息。例如,当用户按下 Ctrl + C 键时,发送一个中断信号(interrupt signal)给当前正在运行的程序,这个信号不是由操作系统发出,而是由shell(终端,命令提示符)处理并解析组合键。当然,一个进程给自己发信号也是可能的。

常见信号

POSIX标准定义了19个信号。每一个信号都拥有一个小的整数和一个符号名。在下面的表格显示它们。

表格的第三列表示,当一个进程接收到这个信号时,会发生什么,有些信号什么都不做。其它的则有些会立刻中断进程、有的则终止进程并导致主存储器信息转存。大部分信号可以被“捕获”。就是说,当接收到信号时,程序可以搭配一个句柄给信号并采取对应的处理。但是,有些信号不能用这种方式拦截。

不需要完全明白下表中的信号列表,因为有些并不在Perl脚本中发生,或者它们在Perl自身内部用于标示底层BUG你没有办法做任何相关的事。当然一大把信号是相对常见的,并且我们马上就能看到它们的详细信息。

HUP 信号是个挂断事件。它通常在一个用户从命令行运行程序 ,然后关闭命令行窗口或退出解析器时发生。这个信号的默认行为是结束这个程序。

INT 信号是个用户发送的中断信号。它通常是在用户按下中断键(通常是Ctrl + C)时发生,这个信号的默认行为是结束这个程序。QUIT 和 INT 相似,但会促使程序生成核心文件(在Unix系统)。当用户按下“退出”键(通常是 Ctrl + \)时触发该信号。

首先看看Unix/Linux中的常用信号,见如下列表:

信号名          值          标注          解释
————————————————————————
HUP             1           A             检测到挂起
INT               2           A             来自键盘的中断
QUIT            3           A             来自键盘的停止
ILL               4           A             非法指令
ABRT          6           C             失败
FPE             8           C             浮点异常
KILL             9           AF            终端信号
USR1          10          A             用户定义的信号1
SEGV          11          C             非法内存访问
USR2          12          A             用户定义的信号2
PIPE           13          A             写往没有读取者的管道
ALRM         14          A             来自闹钟的定时器信号
TERM         15          A             终端信号
CHLD          17          B             子进程终止
CONT         18          E             如果被停止则继续
STOP         19          DF            停止进程
TSTP          20          D             tty键入的停止命令
TTIN            21          D             对后台进程的tty输入
TTOU          22          D             对后台进程的tty输出
————————————————————————

注意:上表中‘值’列下没有列出的值所对应的信号为系统调用的非标准信号。上表中的第三列‘标注’定义了当进程接受到信号后的默认的操作
A—–终止进程
B—–忽略进程信号
C—–终止进程并卸下内核
D—–停止进程
E—–恢复进程
F—–不能截取或忽略进程信号

按照惯例, 在一个进程中使用TERM 和 KILL 来结束另一个进程。默认的TERM让程序直接结束,但程序可以搭配一个信号句柄给TERM,用来拦截结束请求以及可能在退出执行一些清理工作。

相比之下,KILL信号是无法捕获的,它会强制进程立刻结束。比如,当UINX系统关机时,shutdown(关机)进程首先发送TERM 给所有正在运行的进程,给机会给它们进行清理。如果少数进程在几秒后依然在运行,那么它将发送 KILL。

PIPE 信号,当一个程序写入到管道或套接字时,远程进程被关闭或退出时发送。这个信号在网络应用程序中很常见,因此我们可以在处理PIPE异常的时候检查它。

ALRM 通常与 alarm() 协同工作,在某一事先安排的时间将要来临的时候,将信号发送给程序。特殊的是, ALRM可以被时间输出块I/O调用。

CHLD 在你的进程启动一个子进程后出现,并且子进程的状态在某种程度上已发生改变。状态的代表性的改变是子进程已退出,但每次子进程停止或继续之后, CHLD 依然产生。

STOP 和 TSTP 两个信号都有停止当前进程的效果。使进程无限期地假死;可以通过发送 CONT 信号让它恢复。 STOP 通常用于一个程序停止另一个程序。当用户在终端按下停止键(UNIX系统上是Ctrl+Z)时,产生TSTP 信号。两者的另一个区别是, TSTP 可以被捕获,而 STOP 不能被捕获或忽略。

Perl 中可以以不同的方法来创建进程,使用特殊变量 $$ 或 $PROCESS_ID 来获取进程 ID。
%ENV 哈希存放了父进程,也就是shell中的环境变量,在Perl中可以修改这些变量。
exit() 通常用于退出子进程,主进程在子进程全部退出后再退出。
所有打开的句柄会在子程序中被 dup() 函数复制, 所有关闭进程所有句柄不会影响其他进程 。

反引号运算符
使用反引号运算符可以很容易的执行 Unix 命令,可以在反引号中插入一些简单的命令。

system 函数
使用 system() 函数执行 Unix 命令,执行该命令将直接输出结果。默认情况下会送到目前Perl的STDOUT指向的地方,一般是屏幕。也可以使用重定向运算符 > 输出到指定文件。

fork 函数

Perl fork() 函数用于创建一个新进程。在父进程中返回子进程的PID,在子进程中返回0;如果发生错误(比如,内存不足)返回undef,并将$!设为对应的错误信息。fork 可以和 exec 配合使用。exec 函数执行完引号中的命令后进程即结束。

#!/usr/bin/perl
if(!defined($pid = fork())){
   # fork 发生错误返回 undef
   die "无法创建子进程: $!";
}elsif($pid == 0) {
   print "通过子进程输出\n";
   exec("date") || die "无法输出日期: $!";
}else{
   # 在父进程中
   print "通过父进程输出\n";
   $ret = waitpid($pid, 0);
   print "完成的进程ID: $ret\n";
}

1;

执行以上程序,输出结果如下:
通过父进程输出
通过子进程输出
2023年 03月 04日 星期六 22:01:22 CST
完成的进程ID: 9490

如果进程退出时会向父进程发送一个CHLD的信号后,就会变成僵死的进程,需要父进程使用wait和waitpid来终止。当然也可以设置$SIG{CHLD}为IGNORG:
#!/usr/bin/perl
local $SIG{CHLD} = "IGNORE";
if(!defined($pid = fork())) {
   # fork 发生错误返回 undef
   die "无法创建子进程: $!";
}elsif ($pid == 0) {
   print "通过子进程输出\n";
   exec("date") || die "无法输出日期: $!";
}else{
   # 在父进程中
   print "通过父进程输出\n";
   $ret = waitpid($pid, 0);
   print "完成的进程ID: $ret\n";
}

1;

执行以上程序,输出结果如下:
通过父进程输出
通过子进程输出
2023年 03月 04日 星期六 22:06:15 CST
完成的进程ID: -1

kill 函数

Perl kill('signal', (Process List))给一组进程发送信号。signal是发送的数字信号,9为杀掉进程。

Linux中的常用信号,见上表。


Perl中命令信号的原理

Perl 提供了%SIG 这个特殊的默认HASH,调用需要使用到系统保留全局HASH数组%SIG,即使用’$SIG{信号名}’截取信号,相当于在perl程序中出现这个信 号时,执行我们自己定义某段代码(子函数)的地址值(定义信号响应函数),这代码就是截取这个信息后要执行的结果了。

使用一种简单的信号处理模型:在%SIG散列里包含指向用户定义信号句柄的引用(符号或者硬引用)。某些事件促使操作系统发送一个信号给相关的进程。这时候对应该事件的句柄就会被调用,给该句柄的参数中就有一个包含触发它的信号名字。要想给另外一个进程发送一个信号,你可以用kill函数。把这个过程想象成是一个给其他进程发送一个二进制位信息的动作。

(注:实际上,更有可能是五或者六个二进制位,取决于你的OS定义的信号数目以及其他进程是否利用了你不发送别的信号的这个情况)。如果另外一个进程安装了一个处理该信号的信号句柄,那么如果收到该信号,它就能够执行代码。不过发送进程没有任何办法获取任何形式的返回,它只能知道该信号已经合法发送出去了。发送者也接收不到任何接收进程对该信号做的处理的信息。

我们把这个设施归类为IPC的一种,但实际上信号可以来自许多源头,而不仅仅是其他进程。一个信号也可能来自你自己的进程,或者是用户在键盘上敲入了某种特定键盘序列,比如 Control-C 或者 Control-Z 造成的,也可能是内核在处理某些特殊事件的时候产生的,比如子进程退出或者你的进程用光堆栈或者达到了文件尺寸或内存的极限等。不过你自己的进程可以很容 易区别这些场合。信号就好象一个送到你家门口的没有返回地址的神秘包裹,你打开的时候最好小心一点。

Perl uses a simple signal handling model: the %SIG hash contains names or references of user-installed signal handlers. These handlers will be called with an argument which is the name of the signal that triggered it. A signal may be generated intentionally from a particular keyboard sequence like control-C or control-Z, sent to you from an another process, or triggered automatically by the kernel when special events transpire, like a child process exiting, your process running out of stack space, or hitting file size limit.

因为在 %SIG 里的记录可能是硬链接,所以通常把匿名函数用做信号句柄:
$SIG{INT} = sub {die "\nOutta here1\n"};
$SIG{ALRM} = sub { die "Your alarm clock gone off" };

或者你可以创建一个命名函数,并且把它的名字或者引用放在散列里的合适的槽位里。比如,要截获中断和退出信号(通常和你的键盘的 Control-C 和 Control-\z 绑在一起),你可以这样设置句柄:
sub catch_zap {
 my $signame = shift;
 our $shucks++;
 die "Somebody sent me a SIG$signame!";
}

$shucks = 0;
$SIG{INT} = 'catch_zap'; # 意思总是 &main::catch_zap
$SIG{INT} = \&catch_zap; # 最好的方法
$SIG{QUIT} = \&catch_zap; # 把另外一个信号也捕获上

捕获信号

你可以通过在全局哈希 %SIG 中添加一个信号句柄来捕获一个信号。使用你想捕获的信号名作为哈希的键。例如,使用 $SIG{INT} 来获取或设置 INT 信号句柄。使用引用作为值:一个匿名函数或指向已命名函数的引用。例如,下面的例子是一个设定 INT 信号句柄的小脚本。

除了引用代码之外, %SIG 接受两个特例。字符串“DEFAULT”恢复默认信号的行为。例如,将 $SIG{INT} 设定为“DEFAULT”,将让 INT 信号再次结束当前脚本。字符串“IGNORE”将让该信号完全被忽略。

不要为前面提到过的 KILL 或 STOP 设定信号处理程序感到紧张。这些信号既不可以捕获也不可以忽略,并且它们的默认操作将永远执行。

如果你希望使用相同的方法来捕获多个不同的信号,并且希望在处理程序里识别信号,可以检查处理程序的第一个参数,它包含信号的名字。例如,对于 INT 信号,其处理将其称为字符串“INT”。

$SIG{TERM} = $SIG{HUP} = $SIG{INT} = \&handler;
sub handler {
 my $sig = shift;
 warn "处理 $sig 信号.\n";
}
sleep(20);

$SIG{TERM}=$SIG{INT}=\&freeoasub;
my$i=1;
while(1){
 sleep1;
 $i=$i+1;
  print$i."\n";
}
sub freeoasub{
 print "exit ... \n";
 exit 0;
}

我们给$SIG{TERM}和$SIG{INT}二个hash放一个子函数的引用(地址),当有终端信号或来自键盘的中断时,上面的while中的就不在运行,就开始运行yoursub这个函数。

BEGIN{
 # fatal handler setting.
 $SIG{__DIE__}=$SIG{__WARN__}=\&handler_fatal;
}  
sub handler_fatal {
 print " Die here.\n";
 print "@_&";
}

发送信号

Perl脚本可以使用 kill() 函数发送一个信号到其它进程。

$count = kill($signal, @processes)

kill() 函数发送信号$signal给一个或多个进程。你可以通过数字(比如:2)或符号名(比如: INT)来指定要发送的信号。@processes是将信号发送过去的一个或多个进程的PID列表。成功执行信号的进程数将作为 kill() 函数的结果返回。

一个进程只能发送一个信号给其它进程,并且需要对应的权限。一般而言,进程以普通用户权限运行,那么该进程也只能给普通用户权限及普通用户以下权限运行的进程发送信号。是的,以root或超级用户权限运行的进程可以给任何进程发送信号。

kill() 函数提供了一些技巧。如果你使用特殊的信号编号0,那么 kill() 将返回能发送信号的进程数,而不会实际发送信号。如果你使用负数作为PID, kill() 将处理该负数绝对值对应的进程组ID并将信号发送到该组所有的成员。

脚本可以通过传递变量 $$ 给 sill() 来给自己发送信号。该变量保存着当前进程的ID,例如:
kill INT => $$; # 等效于 kill('INT',$$)

让慢的系统调用超时

当Perl执行系统调用时,信号可能产生。大多数情况下,Perl自动重启并严密监控调用。

少数系统调用不适用此规则。 sleep() 就是其中之一,它根据指示数暂停脚本执行对应秒数。如果一个信号中断 sleep(),它将过早地结束,返回它完成休眠前的秒数。sleep() 的这个属性很有用,因为它可以让脚本一直暂停,直到有预期的事件发生。

$slept = sleep([$seconds])

根据指定的秒数暂停,或一直暂停直到接收到一个信号。如果没有给定参数,该函数将永远暂停。 sleep() 将返回其实际暂停的秒数。

另一个例外是四个参数的 select(),它可用于定时等待,直到一个或多个设定的I/O文件句柄准备就绪。该函数将在以后文章中描述。

有时候,自动重启系统调用不是你想要的。比如,一个应用程序提示用户输入密码,并尝试从标准输入读取用户输入。你可能希望,读取工作在一段时间后超时退出,以避免用户已离开,程序却还在等待输入。

use v5.12;
my $timed_out = 0;
$SIG{ALRM} = sub{ $timed_out = 1 };

print STDERR "输入密码: ";
alarm (5);    # 5秒超时
my $password = <STDIN>;
alarm (0);
say STDERR "操作已超时" if $timed_out;

这里我们使用 alarm() 函数来设计定时器。当定时器过期,操作系统生成一个 ALRM信号,我们拦截这个信号并进行处理:设置全局变量 $timed_out 为真。在这个代码里,我们用5秒超时来调用 alarm() 函数,然后从标准输入读取一行。读取完成之后,我们以零为参数再次调用 alarm(),以关闭定时器。就是说,用户要在5秒钟的时间内输入密码,否则定时器将失效,我们也不再重启该程序。

$seconds_lef = alarm($seconds)

为把 ALRM 信号在$seconds秒之后传递给进程做准备。如果参数为零,将使定时器失效。

Perl自动重启使用系统调用变慢的问题中,包括 <>。即使闹钟停止了,我们还停留在<>调用,等待用户的键盘输入。

这个问题的解决方法是使用 eval{} 以及让 ALRM 成为 local 变量,取消读取。
use v5.12;
print STDERR "输入密码: ";
my $password =eval {
    local $SIG{ALRM} = sub { die "Timeout!\n" };
    alarm (3);    # 3秒超时
    return <STDIN>;
  };

alarm (0);
say STDERR "操作已超时" if $@ =~ /timeout/;

这个程序中,我们命名 eval{} 块让 ALRM处理程序局部化(localize)。eval{} 块设定闹钟,跟前面一样,尝试从 STDIN 读取。如果在 <> 返回之前已超时,用户输入将从eval{} 块返回,并赋值给 $password。

如果在超时前完成输入, ALRM 处理程序将被执行。

另一个常用的用法使用$SIG{ALRM},设置等待超时一般都这样做:
local$SIG{ALRM}=sub{alarm0;die "TIMEOUT";};#超时处理过程
eval{
 alarm(10);#设定10秒钟后如果下面的代码没处理完,则进入超时处理过程
 $input=<>;#处理过程
 alarm(0);#如果处理完了,取消超时处理设置
};
if($@=~/TIMEOUT/){...}

注意:这里alarm(10)一定要放在eval内。否则,万一程序执行完alarm后发生任务切换,而程序再次获得时间片时,ALRM信号已经发生,这时程序还没有执行到eval内就产生die,程序就会退出。

要杀死所有的子进程,需要用到向进程组发送信息

在perl中,进程组的ID就是$$。如果程序想给所有由它启动的所有子进程发送一个挂起的信号号,现实的方法,先调用setpgrp(0,0),使自己成为新的进程组的领头。这样不管是fork还是open,还是system都无所谓,当然先排除自己。
{
 local$SIG{HUP}='IGNORE';# 排除自己
 kill(HUP,-$$);#通知自己的进程组
}

参考来源

Perl进程(Processes)、管道(Pipes)和信号(Signals)之信号

Perl SIG信号处理

perlipc