进程间通信(InterProcess Communication)是指在不同进程之间传播或交换信息。
进程间通信都要经过内核,在内核中开辟一个缓存区,这个缓存区是所有与缓存区相关的进程都能访问的。
IPC的方式通常有管道(包括无名管道和命名管道)、消息队列、信号量、共享存储、Socket、Streams等。其中 Socket和Streams支持不同主机上的两个进程IPC。
每个IPC都会维护一个结构体,这个结构体包含了:
(1)进程id。系统中每个进程有唯一的id,在C语言中用pid_t类型表示,其实就是一个非负整数。
(2)进程的状态,有就绪、运行、挂起、停止等状态。
(3)进程切换时需要保存和恢复的一些CPU寄存器的值。
(4)描述虚拟地址空间的信息(如虚拟地址与物理地址之间的映射关系)。
(5)描述控制终端的信息(桌面终端、文字终端和设备终端,pts/0或者tty0等)。
(6)当前工作目录(当前进程的工作目录)。
(7)umask掩码(对文件的一种保护机制,文件权限)。
(8)文件描述符表,包含很多指向file结构体的指针。
(9)和信号相关的信息。
(10)用户id和组id。
(11)会话(Session)和进程组(功能相似的一些进程组成一个进程组)。
(12)进程可以使用的资源上限(Resource Limit)。(ulimit –a命令可以查看)
管道通信
可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。
只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)。
无名管道( pipe ):
一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用(因为管道是无名的,其他进程无法获取管道,具有继承关系的进程可访问)。进程的亲缘关系通常是指父子进程关系。
匿名管道:
一种半双工(在同一信道上,同一时刻只能接受或者发送数据)的通信方式,数据只能单向流动。匿名管道通过文件描述符(包括两个文件描述符,一个用于读取,一个用于改写)进行操作,子进程会继承父进程的文件描述符。所以匿名管道只能用于有血缘关系的进程的进程间通信。
开辟了管道之后如何实现两个进程间的通信呢?比如可以按下面的步骤通信。
1. 父进程调用 pipe 开辟管道,得到两个文件描述符指向管道的两端。
2. 父进程调用 fork 创建子进程,那么子进程也有两个文件描述符指向同一管道。
3. 父进程关闭管道读端,子进程关闭管道写端。父进程可以往管道里写,子进程可以从管道里读,
管道是用环形队列实现的,数据从写端流入从读端流出,这样就实现了进程间通信。
创建匿名管道的过程:
- 父进程通过pipe函数创建一个管道,得到两个文件描述符指向管道的两端。
- 父进程调用fork函数创建一个子进程,子进程会继承父进程PCB中的一部分数据,所以子进程也有两个文件描述符指向管道的两端。
- 父进程关闭管道读端,子进程关闭管道写端(close函数关闭读/写端)。
注:应该先写入数据再进行读操作(问题:难道不能父进程关闭写端,子进程关闭读端?)
答:是可以的,取决于想要实现什么数据流。
高级管道(popen):
将另一个程序当做一个新的进程在当前程序进程中启动,则它算是当前程序的子进程,这种方式我们成为高级管道方式。
有名管道 (named pipe) :
有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
命名管道
一种半双工的通信方式,但它是通过路径名与缓存区关联。因为路径名在系统中是全局的,所以不同的进程即使没有血缘关系也能进行通信。
消息队列(message queue)
消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标识。
消息队列是比较高级的一种进程间通信方式,因为它真的是可以在进程间传送message,传送普通字符串也可以。
一个消息队列可以被多个进程所共享(IPC((Inter-Process Communication,进程间通信))就是在这个基础上进行的);如果一个进程消息太多,一个消息队列放不下,也可以用多于一个的消息队列(不管管理可能会比较复杂)。共享消息队列的进程所发送的消息除了message本身外还有一个标志,这个标志可以指明该消息将由哪个进程或者哪类进程接受。每一个共享消息队列的进程针对这个队列也有自己的标志,可以用来申明自己的身份。
特性:
- 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。
- 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。
- 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。
进程间通过消息队列通信操作主要有:创建或打开消息队列,添加消息,读取消息和控制消息队列。
信号量(semaphore):是一个同步对象
信号量(semaphore),它是一个计数器。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
1、特点
信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。
信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作。
每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数。
支持信号量组。
计数讯号量具备两种操作动作,称为V(signal()
)与P(wait()
)。V操作会增加信号标S的数值,P操作会减少它。
运作方式:
- 初始化,给与它一个非负数的整数值。
- 执行P(
wait()
),信号标S的值将被减少。企图进入临界区段的行程,需要先执行P(wait()
)。当信号标S减为负值时,行程会被挡住,不能继续;当信号标S不为负值时,行程可以获准进入临界区段。 - 执行V(
signal()
),信号标S的值会被增加。结束离开临界区段的行程,将会执行V(signal()
)。当信号标S不为负值时,先前被挡住的其他行程,将可获准进入临界区段。
最简单的信号量是只能取 0 和 1 的变量,这也是信号量最常见的一种形式,叫做二值信号量(Binary Semaphore)。而可以取多个正整数的信号量被称为通用信号量。
Linux 下的信号量函数都是在通用的信号量数组上进行操作,而不是在一个单一的二值信号量上进行操作。
#include <sys/sem.h>
// 创建或获取一个信号量组:若成功返回信号量集ID,失败返回-1
int semget(key_t key, int num_sems, int sem_flags);
// 对信号量组进行操作,改变信号量的值:成功返回0,失败返回-1
int semop(int semid, struct sembuf semoparray[], size_t numops);
// 控制信号量的相关信息
int semctl(int semid, int sem_num, int cmd, ...);
共享内存通信(Shared Memory)
共享内存(Shared Memory),指两个或多个进程共享一个给定的存储区。共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。
共享内存有两种实现方式:1、内存映射 2、共享内存机制
共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
- 共享内存是最快的一种 IPC,因为进程是直接对内存进行存取。因为多个进程可以同时操作,所以需要进行同步。
- 信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问。
- 共享内存允许两个或多个进程共享一个给定的存储区,这一段存储区可以被两个或两个以上的进程映射至自身的地址空间中,一个进程写入共享内存的信息,可以被其他使用这个共享内存的进程,通过一个简单的内存读取,从而实现了进程间的通信。
- 采用共享内存进行通信的一个主要好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝,对于像管道和消息队里等通信方式,则需要再内核和用户空间进行四次的数据拷贝(
- 发送进程将数据从用户空间拷贝到内核空间的缓冲区中。
- 内核将数据从发送进程的缓冲区拷贝到接收进程的缓冲区中。
- 接收进程将数据从内核空间拷贝到用户空间的缓冲区中。
- 接收进程从用户空间的缓冲区中读取数据。),而共享内存则只拷贝两次:一次从输入文件到共享内存区,另一次从共享内存到输出文件。
一般而言,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时在重新建立共享内存区域;而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件,因此,采用共享内存的通信方式效率非常高。
套接字( socket )
套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。
Socket 就像一个电话插座,负责连通两端的电话,进行点对点通信,让电话可以进行通信,端口就像插座上的孔,端口不能同时被其他进程占用。而我们建立连接就像把插头插在这个插座上,创建一个 Socket 实例开始监听后,这个电话插座就时刻监听着消息的传入,谁拨通我这个“IP 地址和端口”,我就接通谁。
实际上,Socket 是在应用层和传输层之间的一个抽象层,它把 TCP/IP 层复杂的操作抽象为几个简单的接口,供应用层调用实现进程在网络中的通信。Socket 起源于 UNIX,在 UNIX 一切皆文件的思想下,进程间通信就被冠名为文件描述符(file descriptor)
,Socket 是一种“打开—读/写—关闭”模式的实现,服务器和客户端各自维护一个“文件”,在建立连接打开后,可以向文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。
流格式套接字(SOCK_STREAM)
流格式套接字(Stream Sockets)也叫“面向连接的套接字”,是一种可靠的、双向的通信数据流,数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送。
其特点:
- 数据在传输过程中不会消失;
- 数据是按照顺序传输的;
- 数据的发送和接收不是同步的(有的教程也称“不存在数据边界”)。
可以将 SOCK_STREAM 比喻成一条传送带,只要传送带本身没有问题(不会断网),就能保证数据不丢失;同时,较晚传送的数据不会先到达,较早传送的数据不会晚到达,这就保证了数据是按照顺序传递的。
为什么流格式套接字可以达到高质量的数据传输呢?这是因为它使用了 TCP 协议(The Transmission Control Protocol,传输控制协议),TCP 协议会控制你的数据按照顺序到达并且没有错误。
你也许见过 TCP,是因为你经常听说“TCP/IP”。TCP 用来确保数据的正确性,IP(Internet Protocol,网络协议)用来控制数据如何从源头到达目的地,也就是常说的“路由”。
假设传送带传送的是水果,接收者需要凑齐 100 个后才能装袋,但是传送带可能把这 100 个水果分批传送,比如第一批传送 20 个,第二批传送 50 个,第三批传送 30 个。接收者不需要和传送带保持同步,只要根据自己的节奏来装袋即可,不用管传送带传送了几批,也不用每到一批就装袋一次,可以等到凑够了 100 个水果再装袋。
流格式套接字的内部有一个缓冲区(也就是字符数组),通过 socket 传输的数据将保存到这个缓冲区。接收端在收到数据后并不一定立即读取,只要数据不超过缓冲区的容量,接收端有可能在缓冲区被填满以后一次性地读取,也可能分成好几次读取。
也就是说,不管数据分几次传送过来,接收端只需要根据自己的要求读取,不用非得在数据到达时立即读取。传送端有自己的节奏,接收端也有自己的节奏,它们是不一致的。
流格式套接字有什么实际的应用场景吗?浏览器所使用的 http 协议就基于面向连接的套接字,因为必须要确保数据准确无误,否则加载的 HTML 将无法解析。
数据报格式套接字(SOCK_DGRAM)
数据报格式套接字(Datagram Sockets)也叫“无连接的套接字”。计算机只管传输数据,不作数据校验,如果数据在传输中损坏,或者没有到达另一台计算机,是没有办法补救的。也就是说,数据错了就错了,无法重传。
因为数据报套接字所做的校验工作少,所以在传输效率方面比流格式套接字要高。
有以下特征:
- 强调快速传输而非传输顺序;
- 传输的数据可能丢失也可能损毁;
- 限制每次传输的数据大小;
- 数据的发送和接收是同步的
众所周知,速度是快递行业的生命。用摩托车发往同一地点的两件包裹无需保证顺序,只要以最快的速度交给客户就行。这种方式存在损坏或丢失的风险,而且包裹大小有一定限制。因此,想要传递大量包裹,就得分配发送。
另外,用两辆摩托车分别发送两件包裹,那么接收者也需要分两次接收,所以“数据的发送和接收是同步的”;换句话说,接收次数应该和发送次数相同。
总之,数据报套接字是一种不可靠的、不按顺序传递的、以追求速度为目的的套接字。
数据报套接字也使用 IP 协议作路由,但是它不使用 TCP 协议,而是使用 UDP 协议(User Datagram Protocol,用户数据报协议)。
QQ 视频聊天和语音聊天就使用 SOCK_DGRAM 来传输数据,因为首先要保证通信的效率,尽量减小延迟,而数据的正确性是次要的,即使丢失很小的一部分数据,视频和音频也可以正常解析,最多出现噪点或杂音,不会对通信质量有实质的影响。
注意:SOCK_DGRAM 不会频繁的丢失数据,数据错误只是小概率事件。