引言
每个进程都拥有自己的用户地址空间,任何一个进程的全局变量在另一个进程中完全不可见,但是内核空间中每个进程都是共享的,所以进程之间要交换数据必须通过内核空间进行。在内核中开辟一块缓冲区,进程1把数据从用户空间拷贝到内核缓冲区,进程2再从内核缓冲区把数据读取走,内核提供的这种机制称之为进程间通信(ICPC
,InterProcess Communication
)。
进程间通信机制共有七种,分别是匿名管道、命名管道、消息队列、共享内存、信号量、信号以及Scocket
。各有不同的优缺点。下面我们分别来解释一下。
匿名管道
管道的本质实际上就是一个内核缓冲区,进程以先进先出(FIFO)
的方式从缓冲区中存取数据。管道一端的进程将数据写入缓冲区中,写入的内容每次都添加在缓冲区的末尾;管道的另一端则从缓冲区中读取数据,每次读取的时候都是从缓冲区的头部读取数据,如下所示:
由此,也可以看出,管道是半双工的,数据只能向一个方向流动。当双方都需要进行通信时,就需要建立起两个管道。
在Linux
中,管道一个常见的表现形式就是「|
」这个竖线了,如常见的操作
ps -ef | grep redis
ps
是显示某个进程,grep
是查找,中间的「|
」就是一个创建管道的命令、这个「|
」的功能就是将前一个命令(ps -ef
)的输出当做是后一个命令的输入(grep redis
)。同时可以看出,这种管道是没有任何名字的,所以「|
」这种形式的管道也称之为匿名管道,当这条命令执行结束之后该匿名管道就会被销毁。
匿名管道的缺点
- 半双工,数据只能单向流动,要是想双向流动,必须创建两个管道;
- 匿名管道只适用于具有父子关系的进程间;
- 没有名字;
- 效率低下,不适合进程间频繁交换数据;
- 管道的缓冲区大小有限,在管道创建的时候,就会为缓冲区分配一个页面大小;
- 管道传输的数据都是无格式字节流,这就要求管道的写入端和读取端必须事先约定好数据的格式,便于写入和读取;
匿名管道的创建
在Linux
中,通过下面这个系统调用就可以创建一个匿名管道:
int pipe(int fd[2])
这里表示创建一个匿名管道,并返回两个文件描述符(PS
,在Linux
中一切皆文件),一个是管道的读取端描述符fd[0]
,一个是管道的写入端描述符fd[1]
。这个匿名管道在Linux
中构成一种特殊的文件系统,只存在于内存当中,不存在于文件系统中。
在调用pipe
函数创建匿名管道时,返回的两个文件描述符都是在同一个进程中,并没有起到进程间通信的作用,那么怎么样才能使得管道跨进程通信呢?
我们可以使用fork
命令来创建子进程,创建的子进程会复制父进程的文件描述符,这样两个进程分别就各有两个fd[0]
和fd[1]
,两个进程就可以通过各自的fd
写入和读取同一个管道文件实现跨进程通信了。
但是这样的话会造成混乱,因为父子进程都可以同时写入或者同时读取,但管道只能一端写入,另一端读取。因而,通常情况下会进行一些操作进行修正:
- 父进程关闭读取的
fd[0]
,只保留写入的fd[1]
; - 子进程关闭写入的
fd[1]
,只保留读取的fd[0]
;
这里也再次说明了管道半双工特性,要是想双向通信,需要创建两个管道。
shell中的进程通信
上面解释了使用管道进行父子进程间的通信过程,shell
中还不一样。对于shell
来说,当执行A|B
这类命令时,A
、B
进程其实都是shell
进程的子进程,A
与B
之间不存在父子关系。此时的通信过程如下所示:
在shell
中,通过「|
」匿名管道将多个命令连接在一起,实际上也就是创建了多个子进程,那么在我们编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样可以减少创建子进程的系统开销。
对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。
命名管道
匿名管道,由于没有名字,只能用于父子进程间的通信。为了克服这个缺陷,命名管道(FIFO
)应运而生。命名管道与匿名管道之间的区别在于,命令管道提供了一个路径名与之相关联,从而以文件的形式存在于文件系统中。这样,即使与创建命名管道进程不存在父子关系的进程,只要可以访问该路径,就能够通过彼此该命名管道进行通信。这样就实现了不存在父子进程间的通信。**命名管道的名字存在于文件系统当中,而内容存在于内存当中。下面是以Linux
为例创建命名管道的流程。
1.使用mkfifo
命令来创建命名管道
mkfifo mypipe
2.mypipe
就是这个命名管道的名称,在Linux
中,一切皆是文件,所以管道也是以文件的形式存在的。这个文件的类型是p
,即pipe
管道。
3.向管道中写入数据
echo "hello" > mypipe //将数据写入管道
// 停住了
在输入完回车之后,你会发现就停在这了,这是因为管道里的内容并没有被读取,只有当管道里的内容被读取走后,命令才可以正常退出。
4.读取数据
cat < mypipe //读取管道里的数据
hello
在执行完cat
命令后,数据显示在终端界面上,同时写命令echo
也正常退出了。
综上,对于命名管道,它可以实现不相关的进程间的通信。因为命名管道提前创建了一个类型为管道的设备文件,在进程中只要使用了这个设备文件,就可以相互通信。
匿名管道 vs 命名管道
- 管道是特殊类型的文件,在满足
FIFO
的原则下进行读写,但不能进行定位读写; - 匿名管道是单向的,只适用于具有父子关系的进程间通信;命令管道以磁盘文件的形式存在,可以实现不相关进程间的通信;
- 匿名管道阻塞问题:匿名管道创建时直接返回文件描述符,在读写时需要确认对方的存在,否则退出。如果当前进程向匿名管道的一端写入数据,必须确认另一端存在另一进程。如果写入的匿名管道数据超过其限制,写操作将会被阻塞;如果匿名管道中没有数据,读操作将会被阻塞;如果匿名管道的某一端退出,将自动退出;
- 命名管道的阻塞问题:命名管道在打开时需要确认对方的存在,否则将阻塞,即若是以读方式打开某管道,在此之前,必须有一个进程以写方式打开管道,否则阻塞。另外,还可以以读写(
O_RDWR
)模式打开命名管道,即当前进程读,当前进程写,不会出现阻塞。
消息队列
现有两个进程,A
和B
,使用消息队列进行数据传输时,A
进程只需要把数据放到相应的消息队列即可返回;B
进程在需要的时候只需要去消息队列中读取相关数据即可。同理,B
进程给A
进程发送消息也是如此。
消息队列本质上是保存在内核当中的消息链表,在发送数据时,会被分成一个一个独立的数据单元,称之为消息体。消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型。每个消息体都是固定大小的存储块。如果进程从消息队列中读取了消息,则内核会将该消息从消息队列中移除。
与管道的不同
- 生命周期不同:
- 管道的生命周期随着进程的创建而建立,随着进程的结束而销毁;
- 消息队列的生命周期与内核有关,除非是重启内核或者是显示地删除一个消息队列,否则消息队列一直存在;
- 数据不同:
- 管道传输的是无格式的字节流;
- 消息队列传输的是消息体,消息体都是固定大小的存储块;
消息队列缺点
- 通信不及时;
- 消息体大小有限制,不适合大数据的传输。在内核中每个消息体都有一个最大长度限制,同时所有队列所包含的消息体总长度也有上限。在
Linux
内核中,有两个宏定义MSGMAX
和MSGMNB
,以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度; - 涉及到用户态和内核态之间的数据拷贝开销。进程向消息队列写入数据时,会发生从用户态拷贝数据到内核态的过程;同理,进程从消息队列读取消息时,会发生内核态拷贝数据到用户态的过程;
共享内存
消息队列的读取过程会发生用户态和内核态之间的消息拷贝过程,使用共享内存这种方式就可以很好地解决这个问题。
共享内存可以使得多个进程可以直接读写在同一块内存空间中,这是效率最高的进程间通信方式。
现代操作系统中,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己的独立虚拟空间。不同进程的虚拟内存映射到不同的物理内存。因而为了在多个进程间交换信息,内核专门开辟了一块内存区域,不同的进程将其映射到自己的私有地址空间。这样A
进程写入的数据,另一个B
进程立刻就可以看到,避免了数据的拷贝,大大提高了进程间的通信速度。如下所示:
信号量
虽然共享内存大大提高进程间通信的速度,但也带来了新的问题。假如某个时刻多个进程同时对同一个变量进行修改就会产生冲突。为了防止多进程竞争共享资源,需要一些进程间同步机制,使得在某一时刻只有一个进程可以访问共享资源。信号量就是其中之一。
信号量其实就是一个整型的计数器,主要用于实现进程间的互斥和同步,而不是用于缓存进程间的通信数据。
信号量表示资源的数量,控制信号量的方式有两种原子操作:
P
操作:该操作会把信号量减去1。相减后如果信号量<0,表示该资源已被占用,进程需要被阻塞进行等待;相减后如果信号量>=0,表明资源还可被访问和使用,进程正常执行操作即可;V
操作:该操作会把信号量加上1。相加后信号量>=0,表明当前有被阻塞着的进程,将该进程唤醒运行;相加后如果信号量>0,表明当前没有被阻塞的进程;
P
操作用在进入共享资源之前,V
操作用在离开共享资源之后,这两个操作必须成对出现。
信号
在异常情况下,需要使用信号这种方式来通知进程。信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件。
在Linux
操作系统中,为了响应各种各样的事件,提供了很多种信号,分别代表不同的含义,如常见的SIGINT
信号,表示终止该进程;SIGTSTP
信号,表示停止该进程,但还未结束。
Linux
中有两种方式触发信号,一种是通过键盘组合键(如CTRL+C
)的方式,一种是命令(如KILL
)的方式。
信号是进程间通信唯一的异步通信机制,因为可以在任何时候发送信号给某一个进程。一旦接收到信号,用户有以下三种处理方式:
- 执行默认操作;
- 捕捉信号。可以为信号定义一个信号处理函数。当进程接收信号时,执行相应的信号处理函数;
- 忽略信号。当接收到信号时,进程可以选择不做处理。但有两个信号无法使用该功能,即
SIGKILL
和SEGSTOP
,他们用于在任何时候中断或者结束某一进程。
Socket
Socket
不仅可以用于跨网络与不同主机间的主机进行通讯,还可以在同主机上进程间通信。
在Linux
中创建socket
的函数如下:
int socket(int domain,int type,int protocol)
三个入参意义分别如下:
domain
:指定协议族,AF_INET
用于IPV4
,AF_INET
用于IPV6
,AF_LOCAL/AF_UNIX
用于本机;type
:指定通信类别,SOCK_STREAM
表示字节流,对应TCP
,SOCK_DGRAM
表示数据报,对应UDP
,SOCK_RAW
表示原始套接字;protocol
:指定通信协议的,但一般写作0,因为前两个参数基本确定了。
根据创建 socket 类型的不同,通信的方式也就不同:
- 实现 TCP 字节流通信:socket 类型是 AF_INET 和 SOCK_STREAM;
- 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;
- 实现本地进程间通信:「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket;
针对本地进程间的数据通信的socket
编程模型:
- 本地
socket
的编程接口和IPv4
、IPv6
套接字编程接口是一致的,可以支持字节流和数据报两种协议; - 本地
socket
的实现效率大大高于IPv4
和IPv6
的字节流、数据报socket
实现;
对于本地字节流 socket
,其 socket
类型是 AF_LOCAL
和 SOCK_STREAM
。
对于本地数据报 socket
,其 socket
类型是 AF_LOCAL
和 SOCK_DGRAM
。
本地字节流 socket
和 本地数据报 socket
在 bind
的时候,不像 TCP
和 UDP
要绑定 IP
地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。