骏马金龙

网名骏马金龙,钟情于IT世界里的各种原理和实现机制,强迫症重症患者。爱研究、爱翻译、爱分享。特借此一亩三分田记录自己成长点滴!!!
我本问道人,道心不坚,必将与道无缘!

Perl多进程

本文关于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\n";
    print "child \$pid: $pid\n";
} else {
    print "child process\n";
    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\n";
    exit 0;
}

# 父进程在此
print "parent process\n";
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\n";   # (1)
}

# 父进程代码
print "parent process here\n";   # (2)
print "The pid is: $pid\n";      # (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\n";   # (1)
    exit 0;
}

# 父进程代码
print "parent process here\n";   # (2)
print "The pid is: $pid\n";      # (3)

更经常地,fork会结合exec家族的函数来加载其它程序替换当前进程中的程序,exec家族函数有一个共同的特性:执行完所加载的程序后自动退出进程。所以,就不再需要在子进程中加入exit语句。

defined (my $pid = fork) 
    or die "Can't fork child process: $!";

unless ($pid) {
    # 子进程代码段
    print "In Child process\n";
    exec 'date +"%F %T"';
}

# 父进程代码
print "parent process here\n";
print "The pid is: $pid\n";

exec函数的返回值是多余的,从来都不需要检查exec的返回值,但exec是否成功调用某个程序是需要检查的,例如上面无法调用date命令。但因为exec是执行完后就立即退出的,所以可以直接在exec后面加上错误处理语句,如die,只要能运行到die,说明exec失败了。

unless ($pid) {
    # 子进程代码段
    print "In Child process\n";
    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 $$\n";

对于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:\n";

unless($pid) {
        print "(Child)->PID: $$\n";
        print "(Child)->PPID: @{ [ getppid ] }\n";
        print "(Child)->GroupID: @{ [ getpgrp $$ ] }\n";
        print "(Child)->ParentGroupID: @{ [ getpgrp getppid ] }\n";
        sleep 2;   # 为了让后面的pstree收集子进程信息
        exit 0;
}

print "(Parent)->GroupID: @{[ getpgrp $$ ]}\n";
print "(Parent)->PPID: @{[ getppid ]}\n";

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\n" if $_ == 0;
            print "B\n" if $_ == 1;
            print "C\n" if $_ == 2;
        }
        sleep 2;
    }
}

# 父进程中
print "Daemon Process created: $pid\n";
sleep 1; # 给子进程一点时间来脱离进程组
kill 9, -$$;  # 杀掉自己以及没有脱离组的子进程

这段代码的逻辑很简单:父进程创建子进程后睡眠一秒钟以给子进程脱离组一点时间,然后父进程就自杀(发送终止信号给自己),而子进程自己加入自己的组,然后在后台运行一个循环,每个循环都输出A、B、C后睡眠2秒,并通过设置一个alarm计时器在10秒后终止子进程。

上面的示例中,重点就在于父进程自杀后,子进程仍然在运行。

posted @ 2019-02-16 10:32 骏马金龙 阅读(...) 评论(...) 编辑 收藏