骏马金龙

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

Perl进程间数据共享

本文介绍的Perl进程间数据共享内容主体来自于《Pro Perl》的第21章。

IPC简介

通过fork创建多个子进程时,进程间的数据共享是个大问题,要么建立一个进程间通信的通道,要么找到一个两进程都引用的共享变量。本文将介绍Unix IPC的近亲System V IPC:message queues(消息队列)、semaphores(信号量)和shared memory-segments(共享内存段)。它们都是IPC结构,它们被非常广泛地应用于进程间通信。它们的帮助文档可参见:

$ perldoc IPC::Msg
$ perldoc IPC::Semaphore
$ perldoc IPC::SharedMem

但是,并非所有操作系统都支持System V IPC,对于那些不遵守POSIX规范的平台就不支持。当然,也并非一定要在Unix操作系统上才能使用IPC,只要操作系统支持IPC就可以,而且就算是Unix系统上也并非一定支持IPC,可以使用ipcs命令来查看是否支持:

$ ipcs

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status

------ Semaphore Arrays --------
key        semid      owner      perms      nsems

message queues、semaphores和shared memory segments的共同点在于它们的数据都持久存在于内存中,且只要知道资源的ID且有权限访问,就可以被任意一个进程(所以随意多个进程)访问到,不仅如此,还可以被其它程序访问到(例如两个perl程序文件)。由于数据在内存中持久,且数据资源进行了ID标识,这可以使得程序退出前保存状态,然后重启时再次获取到原来的状态。

严格地说,Perl支持的IPC在于可以调用一些IPC函数:msgctl、msgget、msgrcv、msgsnd、semctl、semget、semop、shmctl、shmget、shmread、shmwrite。它们几乎是对C对应函数的封装,非常底层。尽管这些函数的文档非常丰富,但这些函数并不容易使用,IPC::家族的模块提供了面向对象的IPC功能支持。

IPC::SysV模块

要使用IPC的一些函数,常常需要导入一些IPC::SysV模块中的常量,因此很多使用IPC的程序中,都会:

use IPC::SysV:

它里面定义了很多常量,完整的可参见perldoc -m IPC::SysV,下面是一些常见的常量:

Message Queue(消息队列)

曾经消息队列是进程间通信的唯一有效方式,它就像管道一样,一端写入一端读取。对于消息队列而言,我们写入和读取的数据都称之为消息(message)。

可以创建两种类型的消息队列:私有的(private)和公有的(public)

  • 私有队列只对创建它的进程和它的子进程可以访问,当然还可以通过权限控制的方式来改变访问权限
  • 公有队列只有有权限,且知道资源ID的进程可以访问

例如,创建一个私有的消息队列。

use IPC::SysV qw(IPC_PRIVATE IPC_CREAT S_IRWXU);
use IPC::Msg;

my $queue = IPC::Msg->new IPC_PRIVATE S_IRWXU | IPC_CREAT;

IPC_Msg的构造函数new有两个参数,第一个是要创建的消息队列的资源ID,在IPC SysV中常称为KEY,对于私有队列来说,KEY需要指定为IPC_PRIVATE,第二个参数是访问该队列的权限,S_IRWXU表示队列的所有者(U)可以对该队列进行读(R)、写(W)、执行(X)操作。此处还配合了IPC_CREAT,表示如果队列不存在就创建新队列。

权限部分也可以写成数值格式的:

my $queue = IPC::Msg->new IPC_PRIVATE 0700 | IPC_CREAT;

所以,这里创建的私有队列只有创建者进程和子进程可以执行读写执行的操作。

如果想要创建一个公有队列,需要为该公有队列提供一个资源ID,资源ID是一个数值。下例中给的资源ID是10023,权限是0722,表示创建队列的进程拥有读写执行操作,而资源所在组或其它用户进程只能写队列。

my $q = IPC::Msg->new 10023, 0722 | IPC_CREAT;

如果其它进程想要访问这个公有队列,只需通过new方法指定这个公有队列的KEY即可即表示构建这个已有的队列,不要指定IPC_CREAT修饰符,否则表示创建动作(尽管IPC结构存在时不会创建,但他代表了创建这个动作,而非访问动作)。如果要获取的公有队列不存在,则返回undef。如下:

my $q = IPC::Msg->new 10023, 0200;

而对于私有队列,想要知道它的KEY,可以使用id()方法:

$KEY = $queue->id

发送和接收消息队列

有了消息队列的对象结构之后,就可以操作这个消息队列,比如发送消息,接收消息等。相关文档参见man msgsnd

向队列发送消息和从队列中接收消息的方式为:

$queue->snd($type, $wr_msg, [ $flags ]);
$queue->rcv(\$rd_msg, $length, $type, [ $flags ]);
  • $wr_msg为想要发送的消息
  • $rd_msg是从消息队列中读取消息保存到哪个标量变量中
  • $type是一个正整数,表示消息队列的类型,可在rcv方法中指定这个正整数表示选择接收哪个数值类型的消息
  • $length表示消息队列中最多允许接收多少条消息。如果消息长度大于该值,则rcv返回undef,并且$!设置为E2BIG
  • $flags是可选的,如果设置为IPC_NOWAIT,则这两个方法不会阻塞,而是立即返回($!设置为EAGAIN)

关于rcv type和flag的规则,参考如下解释。

rcv Type的解释:
整数值             意义
-----------------------------
 0           rcv总是读取队列的第一条消息,无视type

 >0          rcv总是读取该类型的第一条消息。例如,
             type=2,则只读取type=2的消息,如果不存在,
             则一直阻塞直到有type=2的消息。但是可以设
             置IPC_NOWAIT和MSG_EXCEPT常量改变这种模式

 <0          rcv读取类型不大于type绝对值(从小到大)的第
             一条消息。不严谨,但可看示例描述:如果rcv的
             type=-2,则首先读取type=0的第一条消息,如
             果不存在type=0的消息,则继续读取type=1的第
             一条消息,不存在则继续读取type=2的第一条消息
flag的解释:
flag值              意义
------------------------------
MSG_EXCEPT         rcv读取第一条非type值的消息。例如,rcv
                   的type=1,则读取第一条type不为1的消息

MSG_NOERROR        允许消息过长超过$length时截断超出的部分,
                   而不是在这种情况下返回E2BIG错误  

IPC_NOWAIT         rcv在请求的消息类型不存在时不要阻塞等待,
                   而是立即返回,且设置$!的值为EAGAIN  

将上面的解释合并起来,很明确的意思是我们可以通过设置不同的type来实现多级通信的消息队列,这一切都交给我们自己来决定,例如对不同子进程或线程发送不同的消息。

获取和设置消息队列的属性

可以使用set方法来修改消息队列的权限,它需要一个key-value格式的参数。

例如:

$queue->set(
    uid => $user_id,           # chown
    gid => $group_id,          # chgrp
    mode => $perm,             # 8进制权限位或S_格式的权限
    qbytes => $queue_size,     # 队列最大容量(capacity)
);

另外,可以使用stat方法获取队列的属性对象,通过这个属性对象,可以直接修改队列的对应属性。只是需要注意的是,当通过stat对象更改属性时,不会立即应用到消息队列上生效,只有通过set方法设置后,设置才会立即生效。

my $stat = $queue->stat;
$stat->mode(0722);
$queue->set($stat);

最后,如果拥有队列的执行权限,可以通过remove方法销毁这个队列:

$queue->remove;

如果无法删除队列,则remove返回undef,并设置$!。其实删除队列挺重要的,因为如果程序退出,队列可能会继续保留在内存中(前文已经说过了,IPC对象都是持久化在内存中的)。

信号量(semaphore)

信号量也称为信号灯,典故来源于荷兰:火车根据旗标来决定是否通行。就像红灯停、绿灯行的意思,红绿灯就是信号灯,车子就是被阻塞或通行的进程/线程。

在编程语言中,信号量是一个整数值,如果是正数,表示有多少个信号量,也表示可使用的信号灯数量,也可以是0或负数。信号量要结合PV操作(两个原语)才能真正起作用,P是减一个信号灯操作,V是加一个信号灯操作

信号量的规则是这样的:

  • 如果一个进程请求P操作(减1操作,即请求一个信号灯),如果减去之后为负数,则该进程被阻塞,如果减去之后为0或正数,则放行该进程
  • 如果一个进程请求V操作(加1操作,即释放或增加一个信号灯),进程直接放行
  • 如果请求V操作,如果加1之后仍为0或负数,则放行该进程的同时还唤醒另一个被阻塞的进程。如果加1后为正数,则直接添加一个信号灯资源

总结起来很简单:如果当前没有信号灯资源(小于或等于0),那么请求信号灯(P原语)的进程就会被阻塞;如果有信号灯资源(大于0),就直接放行。如果一个进程本来就是来增加信号灯资源的(V原语),那么这个进程当然要放行,因为添加了一个信号灯,那么还可以拥有唤醒一个被阻塞进程的能力(如果有被阻塞进程的话)。

如果限制只使用1个信号灯,那么信号量就实现了锁的机制:P是申请锁操作,只有在有值为1的时候才能申请锁,否则被阻塞;V是释放锁,一直被放行

其实,只要把信号灯理解为一种有限的资源就很容易理解信号量的机制。

当然,具体到信号量的实现上就不一定遵守上面的操作,例如可以一次加N或一次减N,而不是以1作为操作单位。

对于Sys V IPC中信号量来说

  1. 每一个信号量结构(或信号量对象,通过KEY来标识)可以有多路信号量,每一路信号量通过信号量序号semnum标识分类。信号量序号从0开始,每一号的信号量上都是互相独立的,都有自己的信号量值(信号灯的数量,semval)以及控制进程是否阻塞的信号量操作,操作0号信号量不会影响1号信号量
  2. 使用semop函数来操作某个信号量结构,semop函数可以一次性操作多路信号量,每一路信号量都要求3个值:sem_num, sem_op, flag
    • sem_num:指定要操作哪路信号量
    • sem_op:是一个整数值,用来表示信号量的操作模式,可以是0、正数、负数
      • 正数N:表示增加N个信号灯资源
      • 0:表示等待信号灯的数量为0,等待过程中一直阻塞
      • 负数-N:表示等待信号灯的数量大于等于N,等待过程中一直阻塞
    • flag:可被信号量识别的flag除了0外只有两个:IPC_NOWAITSEM_UNDO
      • 0:如果该op不能成功,则一直等待(阻塞)直到可以成功
      • IPC_NOWAIT:不阻塞,而是立即返回并设置操作信息为EAGAIN(对于Perl来说设置$!
      • SEM_UNDO:以该flag执行op时,在进程退出时(无论是正常还是异常退出)自动归还信号灯。例如已有信号灯10,以undo方式执行减2、加3,最后信号灯数量为11,当退出时反向操作,又变回10。使用sem_undo可以有效避免进程异常时永久锁住资源不释放的问题
  3. 由于减法操作"-N"在减的过程中一直没有减下去,而是一直阻塞,所以不会出现负数信号灯,而是以等待加法操作的进程数来衡量,这和前面的信号灯机制是不一样的
  4. semop的操作是原子的,要么多路信号量全部操作成功,要么全部失败

多路信号量的模式如下图所示:

SysV IPC通过这样的信号量规则,可以让进程之间进行协作,例如一个进程可以通过设置信号量的值来控制另一个进程是执行还是阻塞,不仅如此,还可以控制进程间的共享资源。这是一个非常大的话题,这里给个简单的信号量控制进程协作的示例来解释进程间如何访问共享资源。

1.进程A创建一个信号量,其值为1,并创建一个共享资源(如一个文件或IPC共享内存段)。由于这个资源可能涉及到很多初始化,所以现在不立即访问这个资源
2.进程B启动,将信号量的值减为0(即获取锁),然后访问共享资源
3.进程A现在尝试减少信号量的值(即申请锁)并访问共享资源,由于当前信号量的值为0,不足以完成减法,所以进程A被阻塞
4.进程B完成了共享资源的访问,并增加信号量(释放锁)的值,这个操作是一定会成功的
5.进程A现在可以执行减法操作(获取锁)了,因为信号量的值已经变成了1,于是能够访问共享资源
6.进程B尝试第二次访问共享资源,但它会被阻塞,因为信号量的值被进程A减为0
7.进程A完成共享资源的访问,并增加信号量的值
8.进程B访问共享资源并减少信号量

尽管共享资源和信号量没有直接的关联关系,但是信号量在这里充当了看门狗,只要想访问共享资源,都需要从信号量这里获取访问权限。

从上面的操作上可以发现,减法操作和加法操作顺序必须不能错(先减后加,即PV),而且减法、执行和加法的操作必须在同一个临界区内执行,即是一个原子操作

创建信号量

创建信号量需要通过IPC::Semaphore模块,当然,还需要导入IPC::SysV提供使用IPC结构时需要的常量。

同样,Semaphore作为一种SysV IPC结构,它也有公有和私有两种信号量类型,且也使用KEY来标识信号量资源,使用权限来控制访问、修改信号量。其实创建和获取消息队列、信号量和共享内存这3种IPC结构的方式都是一致的,都有公有私有的区别,都是用KEY来标识,都使用权限位来控制访问能力,

例如,创建私有信号量并获取它的id标识符:

use IPC::SysV qw(IPC_CREAT IPC_PRIVATE S_IRWXU);
use IPC::Semaphore;

my $size = 4;
my $sem = IPC::Semaphore->new $size, IPC_CREAT | S_IRWXU;

$id = $sem->id;

这里的$size=4表示初始化该信号量对象时有4路信号量。

创建公有信号量,并设置其KEY为10023:

my $sem = IPC::Semaphore->new 10023, 4, 0744 | IPC_CREAT;

有了信号量ID,其它进程就可以获取到对应的信号量结构,注意不要加上IPC_CREAT修饰符:

my $sem = IPC::Semaphore->new 10023, 0400;

操作信号量

有了信号量结构,就可以操作这个信号量。有以下几个常见方法:

getall    返回当前信号量对象中所有路信号量的信
          号量值(即信号灯数量)放进一个列表
          my @semval = $sem->getall;

getval    返回当前信号量对象指定序号的信号量值
          例如返回第4路信号量的信号灯数量
          my $semval = $sem->getval(3);

setall    设置当前信号量对象中所有路信号量的
          信号量值。例如清空上例创建的4路信号量
          $sem->setall( (0) x 4 );

setval    设置指定某路信号量的信号量值
          例如设置第4路信号量的信号灯为1
          $sem->setval(3, 1);

set       设置信号量对象的UseID、GroupID以及权限
          例如 $sem->set(
              uid => $usr_id,
              gid => $grp_id,
              mode => $perm,
          );

stat      获取当前信号量对象的stat对象,可以通过stat
          对象简单地修改信号量属性。例如
          $semstat = $sem->stat;
          $semstat->mode(0744);
          $sem->set($semstat);

getpid    返回在此信号量对象上最近一次执行semop操作的进程PID
          PID that did last op

getncnt   返回等待某路信号量的值增加的进程数量
          waiting for increase
          例如 $ncnt = $sem->getncnt;

getzcnt   返回等待某路信号量的值为0的进程数量
          waiting for zero
          例如 $zcnt = $sem->getzcnt;

op        信号量操作,见下文

对于Perl而言,有了信号量,还需要结合op方法来执行"PV"操作,规则在前面介绍SysV IPC信号量规则的时候已经介绍过了。给个示例:

$sem->op(
    0, -1, 0,
    1, 1, 0,
    3, 0, 0,
);

op可以一次性操作某信号量对象的多路信号量,每一路信号量由3个元素组成一个小列表,例如上面的0, -1, 0表示操作第一路信号量,其中第一个元素表示sem_num,即第几路信号量,-1是sem_op,值为-1表示要等待信号量的值至少为1之后才不会阻塞,最后一个元素0表示flag(flag的解释见前文)。

例如,想要使用信号量来实现锁机制,锁机制只需一路信号量且一个信号灯即可:

sub access_resource {
    # 访问资源,执行减法操作来获取锁,如果已经为0,则自己被阻塞
    $sem->op(0, -1, 0);

    ... 访问资源 ...

    # 访问完成,执行加法操作来释放锁
    $sem->op(0, 1, 0);
}

最后,信号量和消息队列类似,都应该在不需要的时候清空它(比如最后一个进程退出且确定不再使用它的时候)。

共享内存段

Shared Memory Segments是IPC的第三种结构,和IPC::MsgIPC::Semaphore对应的是IPC::SharedMem,但是它们都太底层了。对于共享内存来说,Perl中有更高层次的IPC::Shareable模块,使得共享内存操作更加方便,它的实现使用了tie机制,可以简单地附加(attach)一个变量到共享内存段上并轻松地访问它。但可能需要先安装它:

> cpan IPC::Shareable

tie方法有4个参数:

  • (1).一个待附加的变量(变量、数组、hash等,但它们中可以有更复杂的数据结构,如引用)
  • (2).IPC::Shareable
  • (3).一个IPC结构的key,key可以是一个数值或字符串,但最多只能是4个字符,超出的字符将忽略,所以abcd和abcde代表的是同一个key
  • (4).Options,它是可选的hash引用,该hash引用中包含了一个或多个key/value对,稍后解释

例如,下面的代码中创建并tie了一个Hash变量(local_hash)到共享内存段上。

use IPC::SysV;
use IPC::Shareable;

our %local_hash;
tie %local_hash, "IPC::Shareable", "mykey", {create => 1};

$local_hash{hashkey} = "hashvalue";

tie一个共享内存段后,在该tied变量之下会有一个tie对象,它可以通过tie的返回值或tied()函数来获取。下面两种获取tie对象的方式是等价的:

$mytie = tie $sv, 'IPC::Shareable', 'mykey', {...};
$mytie = tied $sv;

下面是关于tie方法第四个参数Options的说明,它是一个hash引用,该hash中可定义的key包括以及它们的默认值为:

{
    key       => IPC_PRIVATE,
    create    => 0,
    exclusive => 0,
    destroy   => 0,
    mode      => 0666,
    size      => IPC::Shareable::SHM_BUFSIZ(),
}

一个个解释这6个key,有些布尔值类型的,只需提供Perl中的false值或true值即可,例如数值的0和空字符串都可以表示false。

key
在前面介绍tie方法时解释了有4个参数,但是其实可以将第三个参数KEY放进这个hash引用中,并使用key来指定KEY。例如使用3个参数创建一个共享内存变量:
tie %myhash, "IPC::Shareable", {key => "mykey"};
默认值为IPC_PRIVATE,其它进程无法访问该共享内存变量

create
当key不存在时就创建,默认为false,表示不会尝试创建key,所以必须要求key是已经存在的,否则将失败并返回undef

exclusive
如果key存在时,则不创建,并且失败返回undef。默认值为false,这时即使key已存在也不会失败  

mode
八进制的权限位,控制key被创建时的权限。例如0666表示对owner、group、other都可读、可写,而0600表示只对owner可读可写。默认值为0666

destroy
设置为true时,当调用tie的进程退出时将自动销毁该tie创建的共享内存段。默认值为false

size
指定共享内存段分配的大小,默认值为IPC::Shareable::SHM_BUFSIZ(),默认值为65536字节

关于IPC::Shareable的锁机制

IPC::Shareable提供了程序级别的锁机制,它直接拷贝了IPC::ShareLite中的锁机制,但它们的底层是使用IPC::Semaphore实现的,所以如果想要实现自己的锁机制,可以直接使用IPC::Semaphore。可以直接调用shlock()shunlock()方法来获取锁和释放锁。但在使用它们之前,需要先获取到tie对象,前面说过如何获取tie对象。

例如,下面两种加锁方式是等价的:

$mytie = tie $sv, 'IPC::Shareable', 'mykey', {...};
...
$mytie->shlock;


tie $sv, 'IPC::Shareable', 'mykey', {...};
...
(tied $sv)->shlock;

IPC::Shareable提供了独占锁(LOCK_EX)、共享锁(LOCK_SH)、非阻塞锁(LOCK_NB,无法获取锁的时候立即返回0)三种锁,只要将它们作为shlock方法的参数即可申请对应模式的锁。多个共享锁可以共存,但独占锁和独占锁、共享锁都互斥。此外,还可以为shlock()提供LOCK_UN参数来实现shunlock(),它们是等价的。如果shlock()不提供任何参数,则默认为LOCK_EX。

在使用这几个常量之前,需要先导入(all或lock或flock标签都可以)。例如:

use IPC::Shareable qw(:all);

if ( (tied $sv)->shlock(LOCK_SH | LOCK_NB) ){
    print "The value is $sv\n";
    (tied $sv)->shlock(LOCK_UN);   # (tied $sv)->shunlock;
} else {
    print "Another process has an exclusive lock now\n";
}

上面的示例中结合了共享锁和非阻塞锁,其实独占锁和共享锁都可以结合非阻塞锁:

shlock(LOCK_EX | LOCK_NB)   # 获取独占锁失败时立即返回0,表示资源已锁定
shlock(LOCK_SH | LOCK_NB)   # 获取共享锁失败时立即返回0,表示资源已被独占锁锁定

清除共享内存段

tie的第四个参数中,可以设置destory选项,该选项使得调用tie的进程退出时自动删除对应的tie对象以及其对应的共享内存段。

除了destory,还有remove、clean_up和clean_up_all可以用来移除共享内存段。

(tied $sv)->remove;
IPC::Shareable->clean_up;
IPC::Shareable->clean_up_all;

remove方法可以删除tie对象对应的共享内存段。无视destory选项的设置,无视是哪个进程。

clean_up是一个类方法,只删除调用该方法的进程所创建的共享内存段。非该进程所创建的,clean_up不会删除。

clean_up_all移除该进程所能看见的所有共享内存段,而不限于该创建者进程。

如果要清除共享内存段、消息队列、信号量,可以使用ipcrm命令。

共享内存段示例

在server.pl文件中:

#!usr/bin/perl -w

use strict;
use IPC::Shareable;

my $key = 'data';
my %options = (
        create => 1,
        exclusive => 1,
        mode => 0644,
        destory => 1,
);

my %colors;
tie %colors, 'IPC::Shareable', $key, { %options } 
    or die "Sever: tied failed";

%colors = (
        red => [
                'fire truck',
                'leaves in the fall',
        ],
        blue => [
                'sky',
                'police cars',
        ],
);

((print "Server: there are 2 colors\n"), sleep 2) while scalar keys %colors == 2;

print "Server: here are all my colors:\n";
foreach my $c (keys %colors){
        print "Server: these are $c: ",
                join(', ', @{$colors{$c}}), "\n";
}

exit;

在client.pl文件中:

#!/usr/bin/perl -w
#
use strict;

use IPC::Shareable;

my $key = 'data';

my %options = (
        create => 0,      # 不创建,直接获取data
        exclusive => 0, 
        mode => 0644, 
        destory => 0,
);

my %colors;

tie %colors, "IPC::Shareable", $key, { %options } or
        die "Client: tied failed\n";

foreach my $c (keys %colors){
        print "Client: these are $c: ",
                join(', ', @{$colors{$c}}), "\n";
}

delete $colors{'red'};  # 删除一个key/value
exit;

逻辑很简单,只为了证明不同进程间可以获取同一个共享数据段,且进程退出之后数据还能够继续保留在共享内存中。

执行它们:

$ perl server.pl &
$ perl client.pl  # 将输出
$ ipcs
posted @ 2019-02-20 00:32 骏马金龙 阅读(...) 评论(...) 编辑 收藏