进程间通信(interprocess communication,简称 IPC)指两个进程之间的通信。系统中的每一个进程都有各自的地址空间,并且相互独立、隔离,每个进程都处于自己的地址空间中。所以同一个进程的不同模块(譬如不同的函数)之间进行通信都是很简单的,譬如使用全局变量等。
但是,两个不同的进程之间要进行通信通常是比较难的,因为这两个进程处于不同的地址空间中;通常情况下,大部分的程序是不要考虑进程间通信的,因为大家所接触绝大部分程序都是单进程程序(可以有多个线程),对于一些复杂、大型的应用程序,则会根据实际需要将其设计成多进程程序,譬如 GUI、服务区应用程序等。
在一些中小型应用程序中通常不会将应用程序设计成多进程程序,自然而然便不需要考虑进程间通信的问题,所以,本章内容以了解为主、了解进程间通信以及内核提供的进程间通信机制,并不详解介绍进程间通信,如果大家在今后的工作当中参与开发的应用程序是一个多进程程序、需要考虑进程间通信的问题,此时再去深入学习这方面的知识!
参考:
https://blog.csdn.net/weixin_51983604/article/details/123306293
进程间通信
1.目的
数据传输:一个进程需要将它的数据发送给另一个进程。
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如用gdb对一个进程debug),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
2.如何通信
进程运行时具有独立性(数据层面具有独立性,即使是父子进程操作数据时也要写时拷贝)。
进程间的通信一般要借助第三方资源(OS)
通信的本质是数据的“拷贝”(进程A将数据“拷贝”给操作系统,操作系统再将数据“拷贝”给进程B,所以操作系统一定要提供一段内存区域且两个进程都能看到这段区域)。
进程间通信的本质是让不同的进程能看到同一份资源(内存、文件缓冲等等)。这一个资源由不同的部分提供,就有了不同的进程间通信方式。
常见的通信方式
管道pipe:
管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
命名管道FIFO:
有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
消息队列MessageQueue:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
共享内存SharedMemory:
共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
信号量Semaphore:
信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
套接字Socket:
套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。
信号 ( sinal ) :
信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
Linux 内核提供了多种 IPC 机制,基本是从 UNIX 系统继承而来,而对 UNIX 发展做出重大贡献的两大主力 AT&T 的贝尔实验室及 BSD(加州大学伯克利分校的伯克利软件发布中心)在进程间通信方面的侧重点有所不同。前者对 UNIX 早期的进程间通信手段进行了系统的改进和扩充,形成了“System V IPC”,通信进程局限在单个计算机内;后者则跳过了该限制,形成了基于套接字(Socket,也就是网络)的进程间通信机制。Linux 则把两者继承了下来,如下如所示:
图 10.2.1 Linux 锁继承的进程间通信手段
其中,早期的 UNIX IPC 包括:管道、FIFO、信号;System V IPC 包括:System V 信号量、System V消息队列、System V 共享内存;上图中还出现了 POSIX IPC,事实上,较早的 System V IPC 存在着一些不足之处,而 POSIX IPC 则是在 System V IPC 的基础上进行改进所形成的,弥补了 System V IPC 的一些不足之处。POSIX IPC 包括:POSIX 信号量、POSIX 消息队列、POSIX 共享内存。
总结如下:
⚫ UNIX IPC:管道、FIFO、信号;
⚫ System V IPC:信号量、消息队列、共享内存;
⚫ POSIX IPC:信号量、消息队列、共享内存;
⚫ Socket IPC:基于 Socket 进程间通信。
针对消息队列、信号量、socket和信号,本人会用单独的文章来说明。
因此本文重点介绍管道和共享内存。
匿名管道
概念
管道是Unix中最古老的进程间通信的形式。把从一个进程连接到另一个进程的一个数据流称为一个“管道”。
管道通信本质是文件,操作系统没有做过多的工作。
管道只能进行单向通信。
匿名管道
实现父子进程间通信
首先大致了解一下pipe函数:
头文件:<unistd.h>
功能:创建一无名管道
原型:int pipe(int fd[2]);
参数:
fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
返回值:成功返回0,失败返回错误代码
下面简单使用一下pipe函数。
然后用代码实现子进程向父进程发送字符串。
几个小问题
可不可以定义一个全局的数据区,子进程向其中写入内容,然后父进程再从中读取。
不可以,因为进程间具有独立性,不能相互干扰,子进程在写入内容时会写时拷贝,这样代码中看起来是同一块数据区,但映射到物理内存后是不同的两块内容,互相不可访问。
既然会有写时拷贝,那为什么上面代码中的写入没有写时拷贝呢?
因为上面在write时将内容写进了操作系统提供的文件区内,而不是写入到了子进程自己的数据区内,写入的内容不属于父进程或子进程,不需要写时拷贝。
sleep(1)在子进程中才有,但父进程打印时却也是每隔一秒打印一次,为什么?
在多执行流(比如这里同时有父子进程)下,访问同一份资源,这个资源叫临界资源。
在上面的代码中,可能会发生子进程写入到一半,父进程就来读取,这显然是不被允许的,所以就需要同步与互斥来解决。
这里主要是互斥,即任何时刻都只能有一个进程正在使用某种资源,管道内部自动提供了同步与互斥机制。当子进程写入并sleep时,父进程阻塞地等待,由于子进程sleep,导致父进程也看起来sleep。
如果写端关闭,那么读端read就会返回0,代表读取结束。
图解
既然最后只能留一个读写端,那么父进程最开始为什么要把读写端都打开呢?
父进程打开读写端是为了创建子进程时子进程得到的读写端也都打开,而最后各自只剩一个读写端是因为进程间的通信是单向的。
通常规定fd[0]是读文件描述符,而fd[1]是写文件描述符。
匿名管道特点
只能用于具有共同祖先的进程之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
管道提供流式服务(在网络部分中会详谈)。
一般而言,进程退出,管道释放,所以管道的生命周期随进程变化而变化。
一般而言,内核会对管道操作进行同步与互斥。
管道的数据只能向一个方向流动(半双工);需要双方通信时(全双工),需要建立起两个管道。
四种情况
如果管道空间被写满,负责write的进程就会被挂起。
如果管道空间什么都没有,负责read的进程就会被挂起。
如果负责write的进程写完内容后关闭,负责read的进程的read返回值为0。
如果负责read的进程关闭,而负责write的进程一直写,那么负责write的进程就会被系统kill掉,因为继续写入也没有进程会来读,是没有意义的行为,所以直接kill掉,由于这是不正常退出,所以一定有信号参与。
命名管道
匿名管道使用的限制就是只能在具有共同祖先的进程间通信。如果想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。
命名管道本质是一种特殊类型的文件。
命令行使用命名管道
用mkfifo可以创建一个命名管道。
也就是文件类型之一的pipe管道文件。
代码使用命名管道
代码创建命名管道使用的仍是mkfifo,只不过这时它是个函数。
创建命名管道文件很简单,代码如下:
下面实现简单的server和client之间的通信,重点只有创建命名管道的部分,剩下的对文件进行读写都是常规内容
server.c的代码:
client.c的代码:
本质上就是对文件的操作。
命令行中的管道|
命令行中连接两条命令(运行起来也是进程)的管道是匿名管道。
|
管道符,当用此连接符连接多个命令时,前面命令执行的正确输出,会交给后面的命令继续处理。若前面的命令执行失败,则会报错,若后面的命令无法处理前面命令的输出,也会报错。
例 ls | grep *.txt
运行三条命令并用管道连接,查看它们的信息。
上面只能证明具备匿名管道的条件,但并不能证明就是匿名管道。但事实上确实是匿名管道,可以用这个例子来帮助记忆。
共享内存
共享内存区是最快的进程间通信形式。一旦这样的内存映射到共享它的进程的地址空间,进程间数据的传递不再涉及到内核,或者说进程不再通过执行进入内核的系统调用来传递彼此的数据。
共享内存和消息队列以传送数据为目的,而信号量是为了保证进程的同步与互斥而设计的,但也属于通信范畴。
共享内存本质就是修改页表的映射关系,在不同进程的虚拟地址空间中开辟空间,和同一块物理内存对应。完成开辟空间、建立映射、开辟虚拟空间、返回给用户信息等等一系列操作都有系统接口可用,是操作系统完成的。
基本原理
共享内存的基本原理如下:
共享内存建立过程:
申请共享内存(假设物理内存已经开辟好了)。
将共享内存挂接到进程地址空间(建立映射关系)。
去关联共享内存(修改页表,取消映射关系)。
释放共享内存,将内存归还给系统。
前面的管道文件是由某一个进程来创建,其他进程通过打开相同文件名使用即可。那么,共享内存呢?
多个进程使用共享内存时,都需要创建共享内存,只不过创建共享内存时,会通过唯一的键值key来标记使用的是同一段共享内存区域。接下来详细说明。
相关函数参考:
https://www.cnblogs.com/52php/p/5861372.html
特别提醒:共享内存并未提供同步机制,也就是说,在第一个进程结束对共享内存的写操作之前,并无自动机制可以阻止第二个进程开始对它进行读取。所以我们通常需要用其他的机制来同步对共享内存的访问,例如信号量。
在Linux中提供了一组函数接口用于使用共享内存,而且使用共享共存的接口还与信号量的非常相似,而且比使用信号量的接口来得简单。它们声明在头文件 sys/shm.h 中。
shmget()函数
该函数用来创建共享内存,它的原型为:
int shmget(key_t key, size_t size, int shmflg);
第一个参数,与信号量的semget函数一样,程序需要提供一个参数key(非0整数),它有效地为共享内存段命名,shmget()函数成功时返回一个与key相关的共享内存标识符(非负整数),用于后续的共享内存函数。调用失败返回-1.
不相关的进程可以通过该函数的返回值访问同一共享内存,它代表程序可能要使用的某个资源,程序对所有共享内存的访问都是间接的,程序先通过调用shmget()函数并提供一个键,再由系统生成一个相应的共享内存标识符(shmget()函数的返回值),只有shmget()函数才直接使用信号量键,所有其他的信号量函数使用由semget函数返回的信号量标识符。
显然系统中可能会同时存在多个共享内存,为了管理这些共享内存,操作系统就需要维护与其相关的内核数据结构。其次,保证共享内存的唯一性,这就需要上面shmget函数中的第一个参数key传入一个系统中独一无二的值。
不同的进程就是通过该key来标识唯一的一段共享内存的。
那么这个独一无二的key如何获得呢?需要再调用函数ftok。
ftok可以把一个已存在的路径名和一个整数标识符转换成IPC键值,通过ftok返回的是根据文件(pathname)信息和计划编号(proj_id)合成的IPC key键值,从而避免用户使用key值的冲突,保证了唯一性。
更多参考:https://blog.csdn.net/u013485792/article/details/50764224
第二个参数,size以字节为单位指定需要共享的内存容量
第三个参数,shmflg是权限标志,它的作用与open函数的mode参数一样,如果要想在key标识的共享内存不存在时,创建它的话,可以与IPC_CREAT做或操作。共享内存的权限标志与文件的读写权限一样,举例来说,0644,它表示允许一个进程创建的共享内存被内存创建者所拥有的进程向共享内存读取和写入数据,同时其他用户创建的进程只能读取共享内存。
shmat()函数
第一次创建完共享内存时,它还不能被任何进程访问,shmat()函数的作用就是用来启动对该共享内存的访问,并把共享内存连接到当前进程的地址空间。它的原型如下:
void *shmat(int shm_id, const void *shm_addr, int shmflg);
第一个参数,shm_id是由shmget()函数返回的共享内存标识。
第二个参数,shm_addr指定共享内存连接到当前进程中的地址位置,通常为空,表示让系统来选择共享内存的地址。
第三个参数,shm_flg是一组标志位,通常为0。
调用成功时返回一个指向共享内存第一个字节的指针,如果调用失败返回-1.
shmdt()函数
该函数用于将共享内存从当前进程中分离。注意,将共享内存分离并不是删除它,只是使该共享内存对当前进程不再可用。它的原型如下:
int shmdt(const void *shmaddr);
参数shmaddr是shmat()函数返回的地址指针,调用成功时返回0,失败时返回-1.
shmctl()函数
该函数用来控制共享内存,它的原型如下:
int shmctl(int shm_id, int command, struct shmid_ds *buf);
第一个参数,shm_id是shmget()函数返回的共享内存标识符。
第二个参数,command是要采取的操作,它可以取下面的三个值 :
IPC_STAT:把shmid_ds结构中的数据设置为共享内存的当前关联值,即用共享内存的当前关联值覆盖shmid_ds的值。
IPC_SET:如果进程有足够的权限,就把共享内存的当前关联值设置为shmid_ds结构中给出的值
IPC_RMID:删除共享内存段
第三个参数,buf是一个结构指针,它指向共享内存模式和访问权限的结构。
shmid_ds结构 至少包括以下成员:
struct shmid_ds {
uid_t shm_perm.uid;
uid_t shm_perm.gid;
mode_t shm_perm.mode;
};
说明:共享内存的生命周期是跟随内核的,也就是说除非进程主动删除或在命令行中用命令删除,否则共享内存就一直存在直到关机。
再进一步,System V包括的共享内存、消息队列、信号量的生命周期都是跟随内核的。所以这部分内存一定是操作系统提供并维护的。
示例:
下面两部分代码将上面几个过程结合起来,并简单地在进程间用字符串通信。
server.c代码如下:
client.c的代码如下:
注意上面向共享内存中读写时,并没有像使用管道时通过系统调用接口(read、write)实现,而是直接使用(像malloc申请的堆空间一样)。因此拷贝次数少、不提供同步与互斥,所以这也是进程间通信最快的方式。
使用共享内存的优缺点
1、优点:我们可以看到使用共享内存进行进程间的通信真的是非常方便,而且函数的接口也简单,数据的共享还使进程间的数据不用传送,而是直接访问内存,也加快了程序的效率。同时,它也不像匿名管道那样要求通信的进程有一定的父子关系。
2、缺点:共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段来进行进程间的同步工作。