本系列文章讨论Perl的三个关键功能:进程(Processes)管道(Pipes)信号(Signals)。通过建立一个新进程,Perl程序可以运行另一个程序甚至是它自己的拷贝。管道允许Perl脚本刚其它进行交换数据,而信号使Perl脚本监视和控制其它进程成为可能。本文讨论的是其中的:信号。

信号( signal


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

常见信号


POSIX标准定义了19个信号。每一个信号都拥有一个小的整数和一个符号名。我们在下面的表格显示它们。
表格的第三列表示,当一个进程接收到这个信号时,会发生什么,有些信号什么都不做。其它的则有些会立刻中断进程、有的则终止进程并导致主存储器信息转存。大部分信号可以被“捕获”。就是说,当接收到信号时,程序可以搭配一个句柄给信号并采取对应的处理。但是,有些信号不能用这种方式拦截。
你不需要完全明白下表中的信号列表,因为有些并不在Perl脚本中发生,或者它们在Perl自身内部用于标示底层BUG你没有办法做任何相关的事。当然,一大把信号是相对常见的,并且我们马上就能看到它们的详细信息。
HUP 信号是个挂断事件。它通常在一个用户从命令行运行程序 ,然后关闭命令行窗口或退出解析器时发生。这个信号的默认行为是结束这个程序。
INT 信号是个用户发送的中断信号。它通常是在用户按下中断键(通常是Ctrl + C)时发生。这个信号的默认行为是结束这个程序。 QUITINT 相似,但但会促使程序生成核心文件(在Unix系统)。当用户按下“退出”键(通常是 Ctrl + \)时触发该信号。




























































































































信号名称 注解 描述
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:信号无法被捕获或忽略。



按照惯例, 在一个进程中使用TERMKILL 来结束另一个进程。默认的, TERM让程序直接结束,但程序可以搭配一个信号句柄给 TERM,用来拦截结束请求以及可能在退出执行一些清理工作。 相比之下,KILL 信号是无法捕获的,它会强制进程立刻结束。比如,当UINX系统关机时,shutdown(关机)进程首先发送 TERM 给所有正在运行的进程,给机会给它们进行清理。如果少数进程在几秒后依然在运行,那么它将发送 KILL
PIPE 信号,当一个程序写入到管道或套接字时,远程进程被关闭或退出时发送。这个信号在网络应用程序中很常见,因此我们可以在处理PIPE异常的时候检查它。
ALRM 通常与 alarm() 协同工作,在某一事先安排的时间将要来临的时候,将信号发送给程序。特殊的是, ALRM可以被时间输出块I/O调用。
CHLD 在你的进程启动一个子进程后出现,并且子进程的状态在某种程度上已发生改变。状态的代表性的改变是子进程已退出,但每次子进程停止或继续之后, CHLD 依然产生。
STOPTSTP 两个信号都有停止当前进程的效果。使进程无限期地假死;可以通过发送 CONT 信号让它恢复。 STOP 通常用于一个程序停止另一个程序。当用户在终端按下停止键(UNIX系统上是Ctrl+Z)时,产生TSTP 信号。两者的另一个区别是, TSTP 可以被捕获,而 STOP 不能被捕获或忽略。

捕获信号


你可以通过在全局哈希 %SIG 中添加一个信号句柄来捕获一个信号。使用你想捕获的信号名作为哈希的键。例如,使用 $SIG{INT} 来获取或设置 INT 信号句柄。使用引用作为值:一个匿名函数或指向已命名函数的引用。例如,下面的例子是一个设定 INT 信号句柄的小脚本。当我们按下中断键的时候,它打印一条短信息并增加计数器。脚本如此运行下去,直到计数到三次中断,说明真正要结束了。在下面的例子中,当我们按下Ctrl+C时,打印一条“别打断我!”的信息。
#!/usr/bin/perl
#文件:interrupt.pl #1
##############################################
# (c) 2011 LoRui(i@lorui.com, www.lorui.com) #
##############################################

use strict; #2
use warnings;

my $interruptions = 0; #3
$SIG{INT} = \&hanlde_interruptions; #4

while($interruptions < 3) { #5
print "休息一下……\n"; #6
sleep 5; #7
} #8

sub hanlde_interruptions { #9
$interruptions++; #10
warn "别打断我!你已经打断我 $interruption 次了!\n"; #11
} #12

来看看这个脚本的详细信息。

  • 1到3行:初始化脚本。开启严格的语法检测并声明一个名为 $interruptions 的全局计数器。这个计数器将保持对 INT 接收次数的跟踪。

  • 第4行:设定 INT 句柄。我们通过设定 $SIG{INT} 来将 INT 信号的处理程序关联到 handle_interruptions() 函数。

  • 5到8行:主循环。程序的主循环只是简单的打印一条信息以及用5为参数调用 sleep() 函数。这会让程序暂停5秒钟,或者直到接收到一个信号。它只在计数器小于3时才会继续循环。

  • 9到12行:信号处理程序。当 INT 信号发生时,将调用handle_interruptions() 函数,即使这个程序此间正在处理别的事情。因此,我们的信号处理程序让计数器递增了一次,并打印一条警告。


对于很短的信号处理程序,你可以使用匿名函数进行处理。比如,下面的代码片段和刚刚的行将,但我们不需要给信号处理程序命名:
$SIG{INT} = sub {
$interruptions++;
warn "别打断我!你已经打断我 $interruption 次了!\n";
};

除了引用代码之外, %SIG 接受两个特例。字符串“DEFAULT”恢复默认信号的行为。例如,将 $SIG{INT} 设定为“DEFAULT”,将让 INT 信号再次结束当前脚本。字符串“IGNORE”将让该信号完全被忽略。
不要为前面提到过的 KILLSTOP 设定信号处理程序感到紧张。这些信号既不可以捕获也不可以忽略,并且它们的默认操作将永远执行。
如果你希望使用相同的方法来捕获多个不同的信号,并且希望在处理程序里识别信号,可以检查处理程序的第一个参数,它包含信号的名字。例如,对于 INT 信号,其处理将其称为字符串“INT”:
$SIG{TERM} = $SIG{HUP} = $SIG{INT} = \&handler
sub handler {
my $sig = shift;
warn "处理 $sig 信号.\n";
}


处理 PIPE 异常


现在我们拥有了处理PIPE异常所需要的知识。回想《Perl进程、管道和信号之二:管道》里的示例代码 write_ten.plread_three.pl中那恐怖的PIPE错误。write_ten.pl打开一个到read_three.pl的管道并尝试向其写入10行文本,但read_three.pl只期望接受3行之后就退出并结束管道。write_ten.pl 并不知道到对方的连接已经被关闭,尝试写入第4行的时候, PIPE 信号产生了。
现在,我们要修改write_ten.pl,让它检测并温和的处理 PIPE 错误。
#!/usr/bin/perl
#文件:write_ten_ph.pl #1
##############################################
# (c) 2011 LoRui(i@lorui.com, www.lorui.com) #
##############################################

use strict; #2
use warnings;

my $ok = 1; #3
$SIG{PIPE} = sub { undef $ok };
open(PIPE, "| read_three.pl") or die ("无法打开管道:$!");
select PIPE; $| = 1; select STDOUT;

my $count = 0;
for($_ = 1; $ok && $_ <= 10; $_++) {
warn "写入第 $_ 行\n";
print PIPE "这是第 $_ 行\n" and $count++;
sleep 1;
}

close PIPE or die "无法关闭管道:$!";
print "共写入 $count 行文本\n";


$SIG{PIPE} = sub { undef $ok }; 当接受到一个PIPE信号时,该处理程序将取消$ok的定义,让其为假。
其他修改是,替换原始版本的 for() 循环到更加高雅的版本,它同时检测$ok。如果$ok为假,退出循环。当我们运行修改后的代码时,可以看到程序正常结束,而且正确的报告了成功写入的行数:
% write_ten_ph.pl
写入第 1 行
read_three.pl 获取:这是第 1 行
写入第 2 行
read_three.pl 获取:这是第 2 行
写入第 3 行
read_three.pl 获取:这是第 3 行
写入第 4 行
共写入 3 行文本

另一种常用方法是设置 $SIG{INT} 为“IGNORE”,以完整的忽略 PIPE信号。现在,我们的职责是检查出了什么错,我们可以通过 print() 的返回值进行检测。如果 print() 返回假值,我们退出循环。
下面的 write_ten_i.pl 代码展示了这种方法。这个脚本开始于将 $SIG{INT} 设定为字符串“IGNORE”,阻止 PIPE 信号。另外,我们修改了打印循环体:如果 printf() 成功,我们让计数器递增,否则我们输警告并通过 last 退出循环。
#!/usr/bin/perl
#文件:write_ten_i.pl #1
##############################################
# (c) 2011 LoRui(i@lorui.com, www.lorui.com) #
##############################################

use strict; #2
use warnings;

$SIG{PIPE} = 'IGNORE'; #3

open(PIPE, "| read_three.pl") or die "无法打开管道:$!"; #4
select PIPE; $| = 1; select STDOUT; #5

my $count = 0; #6
for(1..10) { #7
warn "写入第 $_ 行\n"; #8
if(print PIPE "这是第 $_ 行\n") { #9
$count++; #10
} else { #11
warn "写入数据时有错误发生:$!\n"; #12
last; #13
} #14
sleep 1; #15
} #16
close PIPE or die "无法关闭管道:$!"; #17

print "共写入 $count 行文本\n"; #18

运行结果:
% write_ten_i.pl
写入第 1 行
read_three.pl 获取:这是第 1 行
写入第 2 行
read_three.pl 获取:这是第 2 行
写入第 3 行
read_three.pl 获取:这是第 3 行
写入第 4 行
写入数据时有错误发生:Broken pipe
共写入 3 行文本

注意,如果失败,错误信息中的 $! 处将显示 “Broken pipe”。如果你希望将这个错误与其它I/O错误分别处理,我们可以通过正则表达式来明确地测试它的值。或用更好的方法:通过它的数字值与EPIPE的错误常量进行比对,例如:
use Errno ':POSIX';
...
unless (print PIPE "这是第 $_ 行\n") { # 处理写入错误
last if $! == EPIPE; # PIPE错误,终止循环
die "I/O 错误: $!"; # 其它错误,打印错误信息
}


发送信号


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文件句柄准备就绪。该函数将在以后文章中描述。

有时候,自动重启系统调用不是你想要的。比如,一个应用程序提示用户输入密码,并尝试从标准输入读取用户输入。你可能希望,读取工作在一段时间后超时退出,以避免用户已离开,程序却还在等待输入。下面的代码片段看上去好像能胜任这个工作:
my $timed_out = 0; 

$SIG{ALRM} = sub { $timed_out = 1 };

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

print STDERR "操作已超时\n" if $timed_out;

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

$seconds_lef = alarm($seconds)

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

Perl自动重启使用系统调用变慢的问题中,包括 <>。即使闹钟停止了,我们还停留在<>调用,等待用户的键盘输入。
这个问题的解决方法是使用 eval{} 以及让 ALRM 成为 local 变量,取消读取。
print STDERR "输入密码: ";
my $password =
eval {
local $SIG{ALRM} = sub { die "超时\n" };
alarm (5); # 5秒超时
return <STDIN>;
};
alarm (0);
print STDERR "操作已超时\n" if $@ =~ /timeout/;

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

知识共享许可协议
本作品采用知识共享署名 3.0 Unported许可协议进行许可。

上一篇 下一篇