该系列文章总纲链接:专题分纲目录 LinuxC 系统编程​​​​​​​


本章节思维导图如下所示(思维导图会持续迭代):

第一层:

Linux C 系统编程(09)进程管理 进程间通信_数据

第二层:

Linux C 系统编程(09)进程管理 进程间通信_#include_02


进程间通信的意义在于怎样让多个进程相互之间访问数据,在linux下有很多种方式来实现。

1 进程间通信概述

进程间通信就是可以让多个进程可以相互之间访问,包括运行时的数据和对方的代码段,在实际应用中这时很常见的。IPC机制仅仅是为数据通信提供了一种数据传输通道。    

进程运行期间,其地址空间相对于其他进程是不可见的(这只是传统进程的观念),在系统中它们是独立的,并且不能相互访问对方。

常见的进程间通信方式有:管道,FIFO管道,信号,信号量,消息队列,共享内存,socket。

对于管道而言有半双工管道和全双工管道:

  1. 半双工管道:匿名半双工管道FIFO
  2. 全双工管道:匿名全双工管道,命名全双工管道

2 管道

2.1 管道的概念

一种常见的通信方式之一,在两个进程间实现一个数据流通的管道,该管道可以使单向的,也可以是双向的;管道简单易用,但是有很多限制。匿名双工管道在系统中是没有实名的,不可以在文件系统中以任何方式看到该管道,它只是进程的一种资源,会随着进程的结束而被系统清除。管道通信要使用grep命令查找。如下

$ls |grep ipc

管道从数据流动方向上分为全双工管道和半双工管道。全双工管道在具体的实现过程中只是在文件打开的方式上有一点区别(在操作规则上也有些不同,全双工管道要比半双工复杂的多)。

2.2 匿名半双工管道

匿名管道没有名字,对于管道中使用的文件描述符没有路径名。也就是不存在任何意义的文件,只是在内存中跟某个索引点相关联的两个文件描述符,它的特点如下:

  1. 数据只能在一个方向上运动。
  2. 只能在具有公共祖先的进程间通信,即在父子进程/兄弟进程间通信。
  3. 尽管如此,半双工管道还是最常见的通信方式。

linux环境下使用pipe函数创建一个匿名半双工管道,函数原型如下:

#include <unistd.h>
int pipe(int pipefd[2]);

详细见linux函数参考手册。对于数组fd,并没有和任何又名文件相关联,这也是匿名管道名称的由来。

2.3 匿名半双工管道的读写操作

  • 当对管道进行读写操作的时候,使用read和write函数对管道进行操作,当对一个读端已经关闭操作的时候,会产生信号SIGPIPE,说明管道读端已经关闭;并且write操作返回-1,errno的值为EPIPE。对于SIGPIPE信号可以进行捕捉处理,如果写入进程不能捕捉/忽略SIGPIPE信号,则写入进程会中断。
  • 管道是可以继承的。一般情况下,pipe函数和fork函数一起使用,但是要注意:为了维护管道的顺序,当父进程创建了管道。只有子进程已经继承了管道,父进程才可以执行关闭管道的操作,如果在fork之前就已经关闭管道,子进程将不能继承到可以用的管道。
  • 对一个管道进行读操作的时候,read函数返回0有两种意义,一种是管道中无数据并且写入端已经关闭,另一种是管道中无数据,写入端依然存活,这两种情况要分别处理。

2.4 创建管道的标准库函数

一般情况下对管道操作都使用一套标准的流程,因此ANSI/ISO C中将以上操作定义在两个标准库函数popen和pclose中,在linux下的函数原型如下:

#include <stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);

详细见linux函数参考手册


3 FIFO管道

3.1 FIFO管道的概念

FIFO又称为有名管道,它是一种文件类型,在系统中可以看到。FIFO的通信方式类似于在进程中好似用文件来传输数据,只不过FIFO类型文件同时具有管道的特性,在数据读出时同时清除数据。在shell中mkfifo命令可以建立有名管道。

3.2 FIFO管道的创建

创建一个FIFO文件类似于创建文件,FIFO也可以通过路径名来访问。linux下使用mkfifo函数来实现FIFO,函数原型如下:

#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);

详细见linux函数参考手册

3.3 FIFO的读写操作

一般的I/O函数都可以用于FIFO文件。但是注意:在使用open函数打开一个FIFO文件时,open函数参数flag标志位的O_NONBLOCK标志,它关系到函数的返回状态。置位与否的逻辑关系如下:

  1. 如果置位:只读open立即返回。当只写open时,如果没有进程为读打开FIFO,则返回-1。也就是说非阻塞的情况下,读写必须同时有。
  2. 如果不置位:open视情况阻塞。只读open要阻塞到有进程为写打开FIFO,只写open阻塞到有进程为读打开FIFO。

当FIFO的所有进程都已经关闭,则为FIFO的读进程产生一个文件结束符。FIFO的出现,极好地解决了系统在应用过程中产生的大量的中间文件的问题。FIFO可以被shell调用使数据从一个进程到另一个进程,系统不必为该中间通道去烦恼清理不必要的垃圾,或者去释放该通道的资源,它可以被留在后来的进程所使用,并且规避了匿名管道在作用域的限制,可以应用于不相关的进程之间。

3.4 FIFO的缺点

对于服务器-客户端 架构 而言,可以对其进行处理,但前提是必须预先知道服务器提供的FIFO接口,如下:

  1. 客户端发送请求给服务器:需要知道服务器公共的FIFO。
  2. 服务器回传信息给客户端:需要为每个客户端创建一个专用的FIFO来应答,当数量达到一定程度的时候,FIFO会使服务器负载过大,从而会导致服务器崩溃。

4 system V IPC/POSIX IPC通信机制简介

system V IPC包括三种通信机制:消息队列、信号量、共享内存。这是一个古老的方式,在最近的版本中已经被POSIX IPC取代。POSIX IPC和System V IPC 所指一样, 但是所用的函数不同, 实现也不同。IPC机制不同于管道和FIFO。管道和FIFO是基于文件系统的,而IPC是基于内核的,可以使用ipcs来查看系统当前的IPC对象的状态。

4.1 IPC对象的概念

IPC对象是活动在内核级别的一种进程间通信的工具。存在的IPC对象通过它的标识符来引用和访问,这个标识符是一个非负整数,它唯一的标识了一个IPC对象,这个IPC对象可以是消息队列、信号量、共享内存中的任意一种类型。

在linux中标识符被声明为整数,所以可能存在的标识符的最大值是65535(2^16)。注意:这里的标识符与文件描述符有所不同,当用open函数打开文件的时候,返回的文件描述符的值是当前进程中最小可用的文件描述符数组的下标。IPC对象删除/创建时相应的标识符的值会不断增大,到最大值后,归零循环分配使用。

IPC的标识符只解决了内部访问一个IPC对象的问题,如何让多个进程都访问某一个特定的IPC对象还需要一个外部键(key),每一个IPC对象都与一个键相关。这样就解决了多进程在一个IPC对象上汇合的问题。

让多个进程都知道有这样的一个键值存在有以下几种方式:

  1. 使用文件来做中间的通道,创建IPC对象进程,使用键IPC_PRIVATE成功建立IPC对象之后,将返回的标识符存储在一个文件中。其他进程通过读这个标识符来引用IPC对象通信。
  2. 定义一个多进程都认可的键,每个进程使用这个键来引用IPC对象,对于创建IPC对象的进程,如果该键值已经和一个IPC对象结合,则应该删除该IPC对象,再创建一个新的IPC对象。
  3. 多进程通信中,对于指定键引用一个IPC对象而言,可能不具有拓展性,并且在该键值已经被一个IPC对象结合的情况下。所以必须删除这个存在对象之后再建立一个新的。但是这可能影响到其他正在使用这个对象的进程,函数ftok可以在一定程度上解决这个问题。

函数ftok可以使用两个参数生成一个键值,函数原型如下:

#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);

详细见linux函数参考手册。函数将参数pathname文件中stat结构的st_dev成员和st_ino成员的部分值与参数proj_id的第八位结合起来生成一个键值。由于只是使用st_dev成员和st_ino成员的部分值,所以会丢失信息,不排除两个不同文件使用同一个ID得到同样键值的情况。

系统为每个IPC对象保存一个ipc_perm结构体,该结构说明了IPC对象的权限和所有者,每个版本的内核内容各有不同的ipc_perm结构体,该结构说明了IPC对象的权限和所有者,每个版本都不同。对于每个IPC对象,系统共用一个struct ipc_perm的数据结构来存放权限信息,以确定一个ipc操作是否可以访问该IPC对象。ipc_perm结构体实现如下:

struct ipc_perm
{
    __kernel_key_t key;
    __kernel_uid_t uid;
    __kernel_gid_t gid;
    __kernel_uid_t cuid;
    __kernel_gid_t cgid;
    __kernel_mode_t mode;
    unsigned short seq;
}; 

只有root/创建IPC对象的进程有权改变ipc_perm结构的值。IPC对象的缺陷如下:

  1. 过于复杂的编程接口,比起使用其他通信方式,IPC所要求的代码量要明显增加很多。
  2. IPC不使用通用的文件系统。因此不能使用标准I/O操作;新增函数;由于不使用文件描述符,不能使用多路I/O函数select/poll函数来操作IPC对象。
  3. 缺少资源回收机制,一般只有在进程读出消息/IPC的所有者/超级用户删除了这个对象。这也是IPC相对于管道/FIFO所欠缺的资源回收机制。

4.2 IPC系统命令

在shell中使用ipcs命令显示IPC状态。ipcs输出的信息:

  • key标识的是IPC对象的外键
  • shmid标识的是IPC对象的标识符
  • owner标识的是IPC所属的用户
  • perms标识权限

删除一个IPC对象命令:

$ipcrm -m shmid号  

 查看当前系统IPC状态命令:

$ipcs -m

5 共享内存

共享内存是所有进程空间通信方式中最快的一种,它是存在于内核级别的资源。在文件系统/proc目录下有对其描述的相应文件。

5.1 共享内存的概念

共享内存的机制所依赖的原理:在系统内核为一个进程分配一个地址时,通过分页机制可以让一个进程的物理地址不连续,同时也可以让一段内存同时分配给不同的进程。对于每一个共享存储段,内核都为其维护一个shmid_ds类型的结构体,shmid_ds结构体的定义如下:

struct shmid_ds
{
    struct ipc_perm shm_perm;                /* operation perms */
    int shm_segsz;                               /* size of segment (bytes) */
    __kernel_time_t shm_atime;          /* last attach time */
    __kernel_time_t shm_dtime;           /* last detach time */
    __kernel_time_t shm_ctime;           /* last change time */
    __kernel_ipc_pid_t shm_cpid;           /* pid of creator */
    __kernel_ipc_pid_t shm_lpid;           /* pid of last operator */
    unsigned short shm_nattch;                /* no. of current attaches */
    unsigned short shm_unused;                /* compatibility */
    void *shm_unused2;                          /* ditto - used by DIPC */
    void *shm_unused3;                          /* unused */
}; 

结构体shmid_ds会根据不同的系统内核版本而略有不同,并且在不同的系统中会对共享存储段的大小有限制,在应用时要查询相关的手册。

5.2 共享内存的创建

linux下使用shmget函数创建/打开一块共享内存区。shmget函数函数的原型如下:

#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);

详细见linux函数参考手册

5.3 共享内存的连接

linux下使用shmat函数对一块共享内存区进行连接。shmat函数函数的原型如下:

#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg)

详细见linux函数参考手册

5.4 共享内存的操作

由于共享内存这一特殊的资源类型,操作上不同于普通文件,需要特有的操作函数。linux下使用共享内存进行多种操作。共享内存管理的函数原型如下:

#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

详细见linux函数参考手册。注意:

  1. fork后子进程继承已连接的共享内存地址。exec后该子进程与已连接的共享内存地址自动脱离(detach)。进程结束后,已连接的共享内存地址会自动脱离(detach)
  2. 函数执行后连接共享内存标识符为shmid的共享内存,连接成功后把共享内存区对象映射到调用进程的地址空间,随后可像本地空间一样访问。

@3 当对共享内存段操作结束时,应调用shmdt函数,断开共享内存连接,函数原型如下:

#include <sys/types.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr);

详细见linux函数参考手册

5.5 共享内存的使用注意事项

  • 共享内存相比其他方式,数据在读写过程中会更透明。当成功导入一块共享内存后,它只是相当于一个字符串指针来指向一块内存,在当前用户下可以随意访问。但是这样做的缺点是在数据写入/数据读出的过程中需要附加的结构控制,同时,在多进程同步/互斥上也需要附加的代码来辅助共享内存机制。
  • 在共享内存段中都是以字符串的默认结束符为一条信息的结尾,每个进程都遵循这个规则,就不会破坏数据的完整性。

6 信号量

6.1 信号量的概念

信号量本身不具有数据传输的功能,它只是一种外部资源的标识,通过该标识可以判断外部资源是否可用,信号量在此过程中负责数据的互斥、同步等操作。当请求一个使用信号量来表示的资源时,进程需要先读取信号量的值,以判断相应的资源是否可用;

  1. 当信号量的值大于0时,表示有资源可以请求;
  2. 当等于0时,表示现在无可用资源,所以进程会进入睡眠状态直至有可用的资源时。

当进程不再使用一个信号量控制的共享资源时,此信号量的值+1,对信号量的增减均为原子操作,这是由于信号量的主要作用是维护资源的互斥/多进程的同步访问,而在信号量的创建/初始化时,不能保证为原子操作。内核对每个信号集都会设置一个shmid_ds结构,同时用一个无名结构来标识一个信号量。定义因里linux环境的不同而不同。shmid_ds结构定义如下:

struct shmid_ds {
    struct ipc_perm    shm_perm;                /* operation perms */
    int    shm_segsz;                              /* size of segment (bytes) */
    __kernel_time_t    shm_atime;         /* last attach time */
    __kernel_time_t    shm_dtime;         /* last detach time */
    __kernel_time_t    shm_ctime;         /* last change time */
    __kernel_ipc_pid_t shm_cpid;         /* pid of creator */
    __kernel_ipc_pid_t shm_lpid;          /* pid of last operator */
    unsigned short     shm_nattch;              /* no. of current attaches */
    unsigned short     shm_unused;              /* compatibility */
    void               *shm_unused2;                /* ditto - used by DIPC */
    void               *shm_unused3;                /* unused */
};

shmid_ds数据结构表示每个新建的共享内存。当shmget()创建了一块新的共享内存后,返回一个可以用于引用该共享内存的shmid_ds数据结构的标识符。

6.2 信号量的创建

同共享内存一样,系统中同样需要为信号量定制一系列专有的操作函数(semget、semctl等)。linux下使用函数semget创建/获得一个信号量集ID,原型如下:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);

详细见linux函数参考手册

6.3 信号量的操作

3个IPC对象类型中,信号量集的操作函数相对于其他两个类型的操作函数而言要复杂的多,同样信号量的使用也比其他两个更加广泛。信号量也有自己的专属操作。linux下使用semctl函数来操作,函数原型如下:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ...);

详细见linux函数参考手册


7 消息队列

7.1 消息队列的概念

消息队列是一种以链表式的结构组织的一组数据,存放在内核中,是由各个进程通过消息队列标识符来引用的一种数据传输方式。它也是由内核来维护,是3个IPC对象中最具有数据操作性的数据传输方式,在消息队列中可以随意根据特定的数据类型来检索消息。当然,为了维护链表,需要更多的内存资源,而且在数据读写上比起共享内存也复杂一些,时间开销也更大一些。

7.2 消息队列的创建

linux下使用msgget函数创建/打开一个队列。函数原型如下:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);

详细见linux函数参考手册。  

7.3 消息队列的控制操作

linux下使用msgctl函数来对消息队列进行控制操作,函数原型如下:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgctl(int msqid, int cmd, struct msqid_ds *buf);

详细见linux函数参考手册

7.4 消息队列的读写

linux下使用msgsnd、msgrcv函数来对消息队列进行读写,函数原型如下:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg); //消息队列写操作
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg); //消息队列读操作

详细见linux函数参考手册