概述

一个大型的应用系统,往往需要众多进程协作,进程(Linux进程概念见附1)间通信的重要性显而易见。本系列文章阐述了 Linux 环境下的几种主要进程间通信手段。

linux python 版本隔离 执行程序_数据

进程隔离

进程隔离是为保护操作系统中进程互不干扰而设计的一组不同硬件和软件的技术。这个技术是为了避免进程A写入进程B的情况发生。 进程的隔离实现,使用了虚拟地址空间。进程A的虚拟地址和进程B的虚拟地址不同,这样就防止进程A将数据信息写入进程B。

虚拟地址空间

当创建一个进程时,操作系统会为该进程分配一个 4GB 大小的虚拟进程地址空间。之所以是 4GB ,是因为在 32 位的操作系统中,一个指针长度是 4 字节,而 4 字节指针的寻址能力是从 0x00000000~0xFFFFFFFF ,最大值 0xFFFFFFFF 表示的即为 4GB 大小的容量。与虚拟地址空间相对的,还有一个物理地址空间,这个地址空间对应的是真实的物理内存。要注意的是这个 4GB 的地址空间是“虚拟”的,并不是真实存在的,而且每个进程只能访问自己虚拟地址空间中的数据,无法访问别的进程中的数据,通过这种方法实现了进程间的地址隔离。

针对 Linux 操作系统,将最高的1G字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF )供内核使用,称为内核空间,而较低的 3G 字节(从虚拟地址 0x00000000 到0xBFFFFFFF),供各个进程使用,称为用户空间。每个进程都可以通过系统调用进入到内核。其中在 Linux 系统中,进程的用户空间是独立的,而内核空间是共有的,进程切换时,用户空间切换,内核空间不变

创建虚拟地址空间目的是为了解决进程地址空间隔离的问题。但程序要想执行,必须运行在真实的内存上,所以,必须在虚拟地址与物理地址间建立一种映射关系。这样,通过映射机制,当程序访问虚拟地址空间上的某个地址值时,就相当于访问了物理地址空间中的另一个值。人们想到了一种分段、分页的方法,它的思想是在虚拟地址空间和物理地址空间之间做一一映射。这种思想理解起来并不难,操作系统保证不同进程的地址空间被映射到物理地址空间中不同的区域上,这样每个进程最终访问到的物理地址空间都是彼此分开的。通过这种方式,就实现了进程间的地址隔离。

linux python 版本隔离 执行程序_共享内存_02

 

系统调用/内核态/用户态

虽然从逻辑上抽离出用户空间和内核空间;但是不可避免的的是,总有那么一些用户空间需要访问内核的资源;比如应用程序访问文件,网络是很常见的事情,怎么办呢?

用户空间访问内核空间的唯一方式就是系统调用;通过这个统一入口接口,所有的资源访问都是在内核的控制下执行,以免导致对用户程序对系统资源的越权访问,从而保障了系统的安全和稳定。用户软件良莠不齐,要是它们乱搞把系统玩坏了怎么办?因此对于某些特权操作必须交给安全可靠的内核来执行。

当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)此时处理器处于特权级最高的(0级)内核代码中执行。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3级)用户代码中运行。处理器在特权等级高的时候才能执行那些特权CPU指令。

IPC 通信原理

理解了上面的几个概念,我们再来看看进程之间是如何实现通信的。

通常的做法是消息发送方将要发送的数据存放在内存缓存区中,通过系统调用进入内核态。然后内核程序在内核空间分配内存,开辟一块内核缓存区,调用 copy_from_user() 函数将数据从用户空间的内存缓存区拷贝到内核空间的内核缓存区中。同样的,接收方进程在接收数据时在自己的用户空间开辟一块内存缓存区,然后内核程序调用 copy_to_user() 函数将数据从内核缓存区拷贝到接收进程的内存缓存区。这样数据发送方进程和数据接收方进程就完成了一次数据传输,我们称完成了一次进程间通信。如下图:

linux python 版本隔离 执行程序_数据_03

进程间通信方式

Linux 进程间基本的通信方式主要有:管道(pipe) (包括匿名管道和命名管道)、信号(signal)、消息队列(queue)、共享内存、信号量和套接字。

管道

管道的实质是一个内核缓冲区(调用 pipe 函数来开辟),管道的作用正如其名,需要通信的两个进程在管道的两端,进程利用管道传递信息。管道对于管道两端的进程而言,就是一个文件,但是这个文件比较特殊,它不属于文件系统并且只存在于内存中。 Linux一切皆文件,操作系统为管道提供操作的方法:文件操作,用 fork 来共享管道原理。

管道依据是否有名字分为匿名管道和命名管道(有名管道),这两种管道有一定的区别。

匿名管道有几个重要的限制:

  1. 管道是半双工的,数据只能在一个方向上流动,A进程传给B进程,不能反向传递
  2. 管道只能用于父子进程或兄弟进程之间的通信,即具有亲缘关系的进程。

命名管道允许没有亲缘关系的进程进行通信。命名管道不同于匿名管道之处在于它提供了一个路径名与之关联,这样一个进程即使与创建有名管道的进程不存在亲缘关系,只要可以访问该路径,就能通过有名管道互相通信。

linux python 版本隔离 执行程序_信号量_04

pipe 函数接受一个参数,是包含两个整数的数组,如果调用成功,会通过 pipefd[2] 传出给用户程序两个文件描述符,需要注意 pipefd[0] 指向管道的读端, pipefd[1] 指向管道的写端,那么此时这个管道对于用户程序就是一个文件,可以通过 read(pipefd [0]);或者 write(pipefd [1]) 进行操作。pipe 函数调用成功返回 0,否则返回 -1.

那么再来看看通过管道进行通信的步骤:

  • 父进程创建管道,得到两个文件描述符指向管道的两端

linux python 版本隔离 执行程序_信号量_05

  • 利用fork函数创建出子进程,则子进程也得到两个文件描述符指向同一管道

linux python 版本隔离 执行程序_数据_06

  • 父进程关闭读端(pipe[0]),子进程关闭写端pipe[1],则此时父进程可以往管道中进行写操作,子进程可以从管道中读,从而实现了通过管道的进程间通信。

     

linux python 版本隔离 执行程序_数据_07

管道的特点:

  • 只能单向通信

两个文件描述符,用一个,另一个不用,不用的文件描述符就要 close

  • 只能血缘关系的进程进行通信
  • 依赖于文件系统
  • 生命周期随进程
  • 面向字节流的服务

面向字节流:数据无规则,没有明显边界,收发数据比较灵活:对于用户态,可以一次性发送也可以分次发送,当然接受数据也如此;而面向数据报:数据有明显边界,数据只能整条接受 

  • 管道内部提供了同步机制

临界资源: 大家都能访问到的共享资源

临界区: 对临界资源进行操作的代码

同步: 临界资源访问的可控时序性(一个操作完另一个才可以操作)

互斥: 对临界资源同一时间的唯一访问性(保护临界资源安全)

说明:因为管道通信是单向的,在上面的例子中我们是通过子进程写父进程来读,如果想要同时父进程写而子进程来读,就需要再打开另外的管道;

管道的读写端通过打开的文件描述符来传递,因此要通信的两个进程必须从它们的公共祖先那里继承管道的件描述符。 上面的例子是父进程把文件描述符传给子进程之后父子进程之 间通信,也可以父进程fork两次,把文件描述符传给两个子进程,然后两个子进程之间通信, 总之 需要通过fork传递文件描述符使两个进程都能访问同一管道,它们才能通信。

四个特殊情况:

  1. 如果所有指向管道写端的文件描述符都关闭了,而仍然有进程从管道的读端读数据,那么管道中剩余的数据都被读取后,再次read会返回0,就像读到文件末尾一样
  2. 如果有指向管道写端的文件描述符没关闭,而持有管道写端的进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读取后,再次read会阻塞,直到管道中有数据可读了才读取数据并返回。
  3. 如果所有指向管道读端的文件描述符都关闭了,这时有进程指向管道的写端write,那么该进程会收到信号SIGPIPE,通常会导致进程异常终止。
  4. 如果有指向管道读端的文件描述符没关闭,而持有管道写端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再write会阻塞,直到管道中有空位置了才写入数据并返回。

命名管道FIFO

在管道中,只有具有血缘关系的进程才能进行通信,对于后来的命名管道,就解决了这个问题。FIFO 不同于管道之处在于它提供一个路径名与之关联,以 FIFO 的文件形式存储于文件系统中。命名管道是一个设备文件,因此,即使进程与创建FIFO的进程不存在亲缘关系,只要可以访问该路径,就能够通过 FIFO 相互通信。值得注意的是, FIFO (first input first output) 总是按照先进先出的原则工作,第一个被写入的数据将首先从管道中读出。

命名管道的创建

创建命名管道的系统函数有两个: mknod 和 mkfifo。两个函数均定义在头文件 sys/stat.h,
函数原型如下:

#include <sys/types.h>
#include <sys/stat.h>
int mknod(const char *path,mode_t mod,dev_t dev);
int mkfifo(const char *path,mode_t mode);

函数 mknod 参数中 path 为创建的命名管道的全路径名: mod 为创建的命名管道的模指明其存取权限; dev 为设备值,该值取决于文件创建的种类,它只在创建设备文件时才会用到。这两个函数调用成功都返回 0,失败都返回 -1。

命名管道打开特性:

  1. 如果用只读打开命名管道,open 函数将阻塞等待直至有其他进程以写的方式打开这个命名管道,如果没有进程以写的方式发开这个命名管道,程序将停在此处
  2. 如果用只写打开命名管道,open 函数将阻塞等到直至有其他进程以读的方式打开这个命名管道,如果没有进程以读的方式发开这个命名管道,程序将停在此处;
  3. 如果用读写打开命名管道,则不会阻塞(但是管道是单向)

System V IPC   

IPC(Inter-Process Communication)是指多个进程之间相互通信,交换信息的方法,System V 是 Unix 操作系统最早的商业发行版,由 AT&T(American Telephone & Telegraph)开发。System V IPC 是指 Linux 引入自 System V 的进程通信机制,一共有三种:

  • 信号量,用来管理对共享资源的访问;
  • 共享内存,用来高效地实现进程间的数据共享;
  • 消息队列,用来实现进程间数据的传递。

这三种统称 IPC 资源,每个 IPC 资源都是请求时动态创建的,都是永驻内存,除非被进程显示释放,都是可以被任一进程使用。每个 IPC 资源都使用一个 32 位的 IPC 关键字和 32 位的 IPC 标识符,前者类似文件系统中的路径名,由程序自由定制,后者类似打开文件的文件描述符,由内核统一分配,在系统内部是唯一的,当多个进程使用同一个IPC资源通信时需要该资源的 IPC 标识符。     

创建新的 IPC 资源时需要指定 IPC 关键字,如果没有与之关联的 IPC 资源,则创建一个新的 IPC 资源;如果已经存在,则判断当前进程是否具有访问权限,是否超过资源使用限制等,如果符合条件则返回该资源的 IPC 标识符。为了避免两个不同的 IPC 资源使用相同的 IPC 关键字,创建时可以指定IPC关键字为 IPC_PRIVATE,由内核负责生成一个唯一的关键字。   

创建新的 IPC 资源时最后一个参数可以包括三个标志,PC_CREAT 说明如果IPC资源不存在则必须创建它,IPC_EXCL 说明如果资源已经存在且设置了 PC_CREAT 标志则创建失败,IPC_NOWAIT 说明访问 IPC 资源时进程从不阻塞。 

信号量

信号量(semaphore)是一种用于提供不同进程之间或者一个给定的不同线程间同步手段的原语。信号量多用于进程间的同步与互斥,简单的说一下同步和互斥的意思:

同步:处理竞争就是同步,安排进程执行的先后顺序就是同步,每个进程都有一定的先后执行顺序。

互斥:互斥访问不可共享的临界资源,同时会引发两个新的控制问题(互斥可以说是特殊的同步)。

竞争:当并发进程竞争使用同一个资源的时候,我们就称为竞争进程。

共享资源通常分为两类:一类是互斥共享资源,即任一时刻只允许一个进程访问该资源;另一类是同步共享资源,即同一时刻允许多个进程访问该资源;信号量是解决互斥共享资源的同步问题而引入的机制。

下面说一下信号量的工作机制,可以直接理解成计数器(当然其实加锁的时候肯定不能这么简单,不只只是信号量了),信号量会有初值(>0),每当有进程申请使用信号量,通过一个 P 操作来对信号量进行-1操作,当计数器减到 0 的时候就说明没有资源了,其他进程要想访问就必须等待(具体怎么等还有说法,比如忙等待或者睡眠),当该进程执行完这段工作(我们称之为临界区)之后,就会执行 V 操作来对信号量进行 +1 操作。

  • 临界区:临界区指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源又无法同时被多个线程访问的特性。
  • 临界资源:只能被一个进程同时使用(不可以多个进程共享),要用到互斥。

我们可以说信号量也是进程间通信的一种方式,比如互斥锁的简单实现就是信号量,一个进程使用互斥锁,并通知(通信)其他想要该互斥锁的进程,阻止他们的访问和使用。

linux python 版本隔离 执行程序_数据_08

当有进程要求使用共享资源时,需要执行以下操作:

  1. 系统首先要检测该资源的信号量;
  2. 若该资源的信号量值大于 0,则进程可以使用该资源,此时,进程将该资源的信号量值减1;
  3. 若该资源的信号量值为 0,则进程进入休眠状态,直到信号量值大于 0 时进程被唤醒,访问该资源;

       当进程不再使用由一个信号量控制的共享资源时,该信号量值增加 1,如果此时有进程处于休眠状态等待此信号量,则该进程会被唤醒

每个信号量集都有一个与其相对应的结构,该结构定义如下:

/* Data structure describing a set of semaphores.  */  
struct semid_ds  
{  
    struct ipc_perm sem_perm;   /* operation permission struct */  
    struct sem *sem_base;       /* ptr to array of semaphores in set */  
    unsigned short sem_nsems;   /* # of semaphores in set */  
    time_t sem_otime;           /* last-semop() time */  
    time_t sem_ctime;           /* last-change time */  
};  
  
/* Data structure describing each of semaphores.  */  
struct sem  
{  
    unsigned short semval;  /* semaphore value, always >= 0 */  
    pid_t          sempid;  /* pid for last successful semop(), SETVAL, SETALL */  
    unsigned short semncnt; /* # processes awaiting semval > curval */  
    unsigned short semzcnt; /* # processes awaiting semval == 0 */  
};

信号量集的结构图如下所示:

linux python 版本隔离 执行程序_信号量_09

消息队列

消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列 ID)来标识。其具有以下特点:

  1. 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。
  2. 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。
  3. 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。

原型

1 #include <sys/msg.h>
2 // 创建或打开消息队列:成功返回队列ID,失败返回-1
3 int msgget(key_t key, int flag);
4 // 添加消息:成功返回0,失败返回-1
5 int msgsnd(int msqid, const void *ptr, size_t size, int flag);
6 // 读取消息:成功返回消息数据的长度,失败返回-1
7 int msgrcv(int msqid, void *ptr, size_t size, long type,int flag);
8 // 控制消息队列:成功返回0,失败返回-1
9 int msgctl(int msqid, int cmd, struct msqid_ds *buf);

在以下两种情况下,msgget 将创建一个新的消息队列:

  • 如果没有与键值key相对应的消息队列,并且flag中包含了IPC_CREAT标志位。
  • key参数为IPC_PRIVATE

函数msgrcv在读取消息队列时,type参数有下面几种情况:

  • type == 0,返回队列中的第一个消息;
  • type > 0,返回队列中消息类型为 type 的第一个消息;
  • type < 0,返回队列中消息类型值小于或等于 type 绝对值的消息,如果有多个,则取类型值最小的消息。

可以看出,type 值非 0 时用于以非先进先出次序读消息。也可以把 type 看做优先级的权值。

共享内存

共享内存是 System V 版本的最后一个进程间通信方式。共享内存,顾名思义就是允许两个不相关的进程访问同一个逻辑内存,共享内存是两个正在运行的进程之间共享和传递数据的一种非常有效的方式。不同进程之间共享的内存通常为同一段物理内存。进程可以将同一段物理内存连接到他们自己的地址空间中,所有的进程都可以访问共享内存中的地址。如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。

特别提醒:共享内存并未提供同步机制,也就是说,在第一个进程结束对共享内存的写操作之前,并无自动机制可以阻止第二个进程开始对它进行读取,所以我们通常需要用其他的机制来同步对共享内存的访问,例如信号量。

共享内存的通信原理
在 Linux 中,每个进程都有属于自己的进程控制块(PCB)和地址空间(Addr Space),并且都有一个与之对应的页表,负责将进程的虚拟地址与物理地址进行映射,通过内存管理单元(MMU)进行管理。两个不同的虚拟地址通过页表映射到物理空间的同一区域,它们所指向的这块区域即共享内存。

共享内存的通信原理示意图:

 

linux python 版本隔离 执行程序_信号量_10

对于上图我的理解是:当两个进程通过页表将虚拟地址映射到物理地址时,在物理地址中有一块共同的内存区,即共享内存,这块内存可以被两个进程同时看到。这样当一个进程进行写操作,另一个进程读操作就可以实现进程间通信。但是,我们要确保一个进程在写的时候不能被读,因此我们使用信号量来实现同步与互斥。

对于一个共享内存,实现采用的是引用计数的原理,当进程脱离共享存储区后,计数器减一,挂架成功时,计数器加一,只有当计数器变为零时,才能被删除。当进程终止时,它所附加的共享存储区都会自动脱离。

为什么共享内存速度最快?

借助上图说明:Proc A 进程给内存中写数据, Proc B 进程从内存中读取数据,在此期间一共发生了两次复制

(1)Proc A 到共享内存       (2)共享内存到 Proc B

因为直接在内存上操作,所以共享内存的速度也就提高了。

共享内存的接口函数以及指令

查看系统中的共享存储段

ipcs -m

删除系统中的共享存储段

ipcrm -m [shmid]

shmget ( ):创建共享内存

int shmget(key_t key, size_t size, int shmflg);

[参数key]:由ftok生成的key标识,标识系统的唯一IPC资源。

[参数size]:需要申请共享内存的大小。在操作系统中,申请内存的最小单位为页,一页是4k字节,为了避免内存碎片,我们一般申请的内存大小为页的整数倍。

[参数shmflg]:如果要创建新的共享内存,需要使用IPC_CREAT,IPC_EXCL,如果是已经存在的,可以使用IPC_CREAT或直接传0。

[返回值]:成功时返回一个新建或已经存在的的共享内存标识符,取决于shmflg的参数。失败返回-1并设置错误码。


shmat ( ):挂接共享内存

void *shmat(int shmid, const void *shmaddr, int shmflg);

[参数shmid]:共享存储段的标识符。

[参数*shmaddr]:shmaddr = 0,则存储段连接到由内核选择的第一个可以地址上(推荐使用)。

[参数shmflg]:若指定了SHM_RDONLY位,则以只读方式连接此段,否则以读写方式连接此段。

[返回值]:成功返回共享存储段的指针(虚拟地址),并且内核将使其与该共享存储段相关的shmid_ds结构中的shm_nattch计数器加1(类似于引用计数);出错返回-1。


shmdt ( ):去关联共享内存:当一个进程不需要共享内存的时候,就需要去关联。该函数并不删除所指定的共享内存区,而是将之前用shmat函数连接好的共享内存区脱离目前的进程。

int shmdt(const void *shmaddr);

[参数*shmaddr]:连接以后返回的地址。

[返回值]:成功返回0,并将shmid_ds结构体中的 shm_nattch计数器减1;出错返回-1。


shmctl ( ):销毁共享内存

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

[参数shmid]:共享存储段标识符。

[参数cmd]:指定的执行操作,设置为IPC_RMID时表示可以删除共享内存。

[参数*buf]:设置为NULL即可。

[返回值]:成功返回0,失败返回-1。

POSIX 消息队列   

POSIX 消息队列是 POSIX 标准在 2001 年定义的一种 IPC 机制,与 System V 中的消息队列相比有如下差异:

  • 更简单的基于文件的应用接口,Linux 通过 mqueue 的特殊文件系统来实现消息队列,队列名跟文件名类似,必须以"/"开头,每个消息队列在文件系统内都有一个对应的索引节点,返回的队列描述符实际是一个文件描述符
  • 完全支持消息优先级,消息在队列中是按照优先级倒序排列的(即0表示优先级最低)。当一条消息被添加到队列中时,它会被放置在队列中具有相同优先级的所有消息之后。如果一个应用程序无需使用消息优先级,那么只需要将msg_prio指定为0即可。
  • 完全支持消息到达的异步通知,当新消息到达且当前队列为空时会通知之前注册过表示接受通知的进程。在任何一个时刻都只有一个进程能够向一个特定的消息队列注册接收通知。如果一个消息队列上已经存在注册进程了,那么后续在该队列上的注册请求将会失败。可以给进程发送信号或者另起一个线程调用通知函数完成通知。当通知完成时,注册即被撤销,进程需要继续接受通知则必须重新注册。
  • 用于阻塞发送与接收操作的超时机制,可以指定阻塞的最长时间,超时自动返回 

套接字:

套接字是更为基础的进程间通信机制,与其他方式不同的是,套接字可用于不同机器之间的进程间通信。

有两种类型的套接字:基于文件的和面向网络的。

  • Unix 套接字是基于文件的,并且拥有一个“家族名字”--AF_UNIX,它代表地址家族 (address family):UNIX。
  • 第二类型的套接字是基于网络的,它也有自己的家族名字--AF_INET,代表地址家族 (address family):INTERNET

不管采用哪种地址家族,都有两种不同的套接字连接:面向连接的和无连接的。

  • 面向连接的套接字 (SOCK_STREAM):进行通信前必须建立一个连接,面向连接的通信提供序列化的、可靠地和不重复的数据交付,而没有记录边界。

这意味着每条信息可以被拆分成多个片段,并且每个片段都能确保到达目的地,然后在目的地将信息拼接起来。

实现这种连接类型的主要协议是传输控制协议 (TCP)。

  • 无连接的套接字 (SOCK_DGRAM):在通信开始之前并不需要建立连接,在数据传输过程中并无法保证它的顺序性、可靠性或重复性。

然而,数据报确实保存了记录边界,这就意味着消息是以整体发送的,而并非首先分成多个片段。

由于面向连接的套接字所提供的保证,因此它们的设置以及对虚拟电路连接的维护需要大量的开销。然而,数据报不需要这些开销,即它的成本更加“低廉”

实现这种连接类型的主要协议是用户数据报协议 (UDP)。

信号

信号是软件层次上对中断机制的一种模拟,是一种异步通信方式,进程不必通过任何操作来等待信号的到达。信号可以在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件。

信号来源:

信号事件的发生有两个来源:硬件来源,比如我们按下了键盘或者其它硬件故障;软件来源,最常用发送信号的系统函数是 kill, raise, alarm 和 setitimer 以及 sigqueue 函数,软件来源还包括一些非法运算等操作。

进程对信号的响应:

进程可以通过三种方式来响应信号:

  • 忽略信号,即对信号不做任何处理,但是有两个信号是不能忽略的:SIGKLL 和 SIGSTOP;
  • 捕捉信号,定义信号处理函数,当信号发生时,执行相应的处理函数;
  • 执行缺省操作,Linux 对每种信号都规定了默认操作。

树林美丽、幽暗而深邃,但我有诺言尚待实现,还要奔行百里方可沉睡。 -- 罗伯特·弗罗斯特