本文关于Perl进程的内容主体来自于《Pro Perl》的第21章。
创建新进程
Perl中可以使用fork
函数来创建新的进程,它会调用操作系统的fork系统调用来创建新进程。
fork是Unix系统中的函数,在Windows中不原生支持fork。但从Perl 5.8开始,Perl提供了一个模拟的fork使其可以无视平台的差异,它是使用Perl解释器线程来实现的fork,因为解释器线程不自动共享数据,所以用来fork进程正好。换句话说,Perl 5.8开始fork是可以随意用来创建进程的。
fork函数会派生自己,通过自己克隆出一个子进程。这个克隆过程是完整的,因为子进程和父进程在克隆的过程中是完全一致的,子进程和父进程共享代码,克隆完成后才设置一些各进程独有的属性,比如有自己的文件句柄(已经文件句柄上的锁)、进程ID、优先级等等属性。
在fork新进程之后,就会有两个近乎完全一样的进程在并行运行。fork有两个返回值,一个是给父进程的返回值,这个返回值是fork出来的子进程的PID(如果fork失败,则返回undef),一个是给子进程的返回值,这个返回值为0。所以,通过fork的返回值可以判断出进程是子进程还是父进程。
if (my $pid = fork) {
print "parent process
";
print "child $pid: $pid
";
} else {
print "child process
";
exit 0;
}
这段程序的运行结果的顺序是随机的,这是因为无法保证多个进程的调度顺序。例如下面是某两次运行的结果:
[root]$ perl fork.pl
parent process
child $pid: 22
child process
[root]$ perl fork.pl
parent process
child process
child $pid: 24
由于fork可能会失败(例如达到了进程数量的最大限制值),所以上面的代码不太健壮,而且fork进程后,通常比较期待看到子进程的代码而非父进程的,父进程的代码通常在子进程的下面。所以改写成如下代码:
defined( my $pid = fork ) or die "Failed to fork: $!";
unless($pid) {
# 子进程在此
print "Child process
";
exit 0;
}
# 父进程在此
print "parent process
";
print "Child process PID: $pid";
fork为什么有两个返回值
fork奇特的地方就在于针对不同的进程返回了不同的值,更严格地说是返回了两次。但任何一个函数都只能返回一次,因为一个return语句就结束函数了,那fork是如何实现两次返回的?
对于$pid = fork
这个语句,将其分成两个部分,一个是fork操作,一个是返回值赋值操作。在fork克隆完但fork还没结束时就已经有了两个进程,这两个进程的代码都一样,都在运行fork,两个fork都要赋值给$pid
。可以认为是两个进程在执行fork,或者从程序的角度上看,是两个程序去调用了两次fork。
虽说fork返回了两次,但实际上fork函数的返回值只有一个,只不过在不同环境下返回不同的值,fork只需一个环境判断就可以知道该返回哪个值:父进程的fork函数返回值要赋值给父进程的$pid
,子进程的fork函数返回值要赋值给子进程的$pid
。
fork父子进程、文件句柄和文件锁的关系
如果需要搞懂这个细节,请参见fork、文件句柄、文件描述符和锁的关系。
fork + exec
fork出来的子进程通常需要有一个退出语句,例如exit,否则子进程在执行完自己的代码后,有可能会执行父进程的代码,因为子进程和父进程是共享代码的。
例如:
defined (my $pid = fork)
or die "Can't fork child process: $!";
unless ($pid) {
# 子进程代码段
print "In Child process
"; # (1)
}
# 父进程代码
print "parent process here
"; # (2)
print "The pid is: $pid
"; # (3)
子进程执行完(1)后,因为它也有(2)和(3)的代码,所以子进程会继续执行(2)和(3)。但实际上,(2)和(3)本该是给父进程执行的。
为了避免这样的问题,要么将父进程的代码放进unless的else语句块中,要么在子进程代码块中加入exit语句保证在执行完语句后退出进程。
defined (my $pid = fork)
or die "Can't fork child process: $!";
unless ($pid) {
# 子进程代码段
print "In Child process
"; # (1)
exit 0;
}
# 父进程代码
print "parent process here
"; # (2)
print "The pid is: $pid
"; # (3)
更经常地,fork会结合exec家族的函数来加载其它程序替换当前进程中的程序,exec家族函数有一个共同的特性:执行完所加载的程序后自动退出进程。所以,就不再需要在子进程中加入exit语句。
defined (my $pid = fork)
or die "Can't fork child process: $!";
unless ($pid) {
# 子进程代码段
print "In Child process
";
exec 'date +"%F %T"';
}
# 父进程代码
print "parent process here
";
print "The pid is: $pid
";
exec函数的返回值是多余的,从来都不需要检查exec的返回值,但exec是否成功调用某个程序是需要检查的,例如上面无法调用date命令。但因为exec是执行完后就立即退出的,所以可以直接在exec后面加上错误处理语句,如die,只要能运行到die,说明exec失败了。
unless ($pid) {
# 子进程代码段
print "In Child process
";
exec 'date +"%F %T"';
die "Exec failed: $!";
}
需要注意的是,exec COMMAND
的COMMAND失败不代表exec失败,exec是发起系统调用,只有这个系统调用的过程中失败才算是失败,例如无法发起调用。COMMAND执行失败和exec已经无关,例如date命令不存在也已经表示exec成功发起了系统调用,所以不会运行到die语句。
关于进程ID
当前进程的PID可以使用特殊变量$$
来获取,或者对应的英文形式$PID
、$PROCESS_ID
也可以获取。
print "my PID is $$
";
对于Unix,可以通过子进程找出其父进程的PID,在Perl中可以使用getppid
函数获取父进程的PID。
$parent_PID = getppid;
于是,可以发送HUP信号给父进程:
kill "HUP", getppid;
进程组和daemon
当想要将信号发送给多个进程而非单个进程时,进程组的重要性就体现出来了。每个进程在fork出来的时候,就加入了一个进程组,对于没有父进程的进程,它自己独立成组,组ID即为它自己的PID。对于有父进程的子进程,在被创建时会继承父进程的进程组。注意是继承父进程的进程组,而不是以父进程为进程组。当然,如果父进程是自己的进程组,那么子进程初始时会在父进程的组中。
但需要注意的是,并非子进程就一定在父进程所在的进程组中。如果真是这样的话,那么Linux下所有的进程都在init/systemd这个祖先进程的组中,但实际上并非如此。操作系统允许进程改变自己的进程组(稍后就介绍使用Perl如何改变进程组),例如自己成组。实际上,在shell下执行命令时,都是自己成立自己的进程组的(可pstree -g查看所属进程组号),尽管它们都有父进程。
查看进程组
使用getpgrp PID
可以获取PID进程所在的进程组。例如,获取当前进程所在的进程组:
getpgrp $$;
getpgrp; # 等价
对于获取当前进程的进程组,更具可移植性的方式是将一个false值(一般使用数值0)为getpgrp的参数。
getpgrp 0;
下面是一个检查子进程、父进程所在进程组的示例:
defined (my $pid = fork ) or die "Can't fork process:
";
unless($pid) {
print "(Child)->PID: $$
";
print "(Child)->PPID: @{ [ getppid ] }
";
print "(Child)->GroupID: @{ [ getpgrp $$ ] }
";
print "(Child)->ParentGroupID: @{ [ getpgrp getppid ] }
";
sleep 2; # 为了让后面的pstree收集子进程信息
exit 0;
}
print "(Parent)->GroupID: @{[ getpgrp $$ ]}
";
print "(Parent)->PPID: @{[ getppid ]}
";
system "pstree -p | grep 'perl'";
执行的结果:
(Parent)->GroupID: 155
(Parent)->PPID: 4
(Child)->PID: 156
(Child)->PPID: 155
(Child)->GroupID: 155
(Child)->ParentGroupID: 155
init(1)-+-init(3)---bash(4)---perl(155)-+-perl(156)
可见,子进程和父进程的进程组都是155,这个155正是父进程自身。
设置进程组
实际上查看进程组的需求不多,因为几乎已经可以知道进程和父进程在同一个进程组中,除非我们单独设置了进程所在的进程组。
设置进程所在进程组的方式是使用setpgrp
函数,第一个参数是要设置的进程ID,第二个参数是要加入到哪个进程组。
setpgrp $pid, $pgid;
进程不仅可以加入到任何已存在的进程组中,还可以自己成立一个进程组并加入到自己的组中,只需将setpgrp的两个参数都设置为相同的PID值即可。例如,当前进程加入自己的组:
setpgrp $$, $$;
setpgrp;
同样的,为了可移植性,使用false值作为setpgrp的参数:
setpgrp 0, 0;
daemon类进程
设置进程组一般用来隔离子进程和父进程,或者说让子进程脱离父进程,以免收到父进程发送的信号。比如让终端中的进程(它们是终端进程的子进程)脱离终端,这样发送信号给终端进程来终止终端时,只有脱离终端的子进程才能继续存活,终端进程自身以及其它终端子进程都将死亡。而在父进程死亡后,脱离了父进程的子进程都将成为孤儿进程(orphan process),孤儿进程都会转移到PID=1的init或systemd祖先进程下,但这些子进程仍然在自己的进程组中。
脱离父进程
子进程脱离了父进程所在进程组后,不会立即转移走,而是继续留在父进程下面,这是因为进程组和父子进程之间的关系不是完全对等关系,脱离进程组不代表子进程就不再是父进程的子进程了,它仍然是。只有在父进程终止时,子进程因为收不到信号而得以继续存活,但每个进程都必须有父进程(除了pid=1的init/systemd进程),所以操作系统会让子进程转移到进程的祖先init/systemd下由它们负责管理。所以,在shell中使用nohup类工具将进程脱离终端时,进程仍在bash进程的下面,只有关闭终端时,子进程才转移到init/systemd进程下。
更通用的,设置进程组可以用来实现所谓的daemon类进程:和创建它们的父进程分离并独立存活的进程。
要发送信号给进程组,只需使用kill函数,并传递一个负数的PID值作为第二个参数,这表示将信号发送给该PID所在的进程组,该组里所有的进程都将收到该信号。例如,发送HUP信号给当前进程所在的进程组,这样
kill "HUP", -$$;
下面是一个daemon类程序的示例:
#!/usr/bin/env perl
use strict;
use warnings;
defined (my $pid = fork) or die "Can't fork child: $!";
# 子进程
unless($pid) {
setpgrp 0,0; # 脱离组
alarm 10; # 计时器10秒
while(1){
foreach (0..2){
print "A
" if $_ == 0;
print "B
" if $_ == 1;
print "C
" if $_ == 2;
}
sleep 2;
}
}
# 父进程中
print "Daemon Process created: $pid
";
sleep 1; # 给子进程一点时间来脱离进程组
kill 9, -$$; # 杀掉自己以及没有脱离组的子进程
这段代码的逻辑很简单:父进程创建子进程后睡眠一秒钟以给子进程脱离组一点时间,然后父进程就自杀(发送终止信号给自己),而子进程自己加入自己的组,然后在后台运行一个循环,每个循环都输出A、B、C后睡眠2秒,并通过设置一个alarm计时器在10秒后终止子进程。
上面的示例中,重点就在于父进程自杀后,子进程仍然在运行。