进程间通信 (IPC) 的方式

InterProcess Communication

References

  1. 图解系统 https://xiaolincoding.com/os/
  2. https://www.cnblogs.com/kelamoyujuzhen/p/9389219.html

I. 独立进程与协同进程

独立进程:不会影响另一个进程的执行或被另一个进程的执行影响
协同进程:可能影响另一个进程的执行或被另一个进程执行影响(进程间需要通信)

II. 进程间通信方式

2.1 管道 Pipeline

管道:管道实际上就是内核里面的一串缓冲区(因此一定涉及到用户态到内核态的切换)。
管道的用途:两个进程之间的单向通信
管道的特点

  • 半双工:数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
  • 独立文件系统:管道对于管道两端的进程而言,就是一个文件,但它不是普通的文件,不属于某种文件系统,而是自立门户,单独构成一种文件系统,并且只存在于内存中;
  • 先进先出:一个进程向管道中写入的内容会被管道另一端的进程读出。写入的内容每次都添加在管道缓冲区的末尾,且每次都从缓冲区头部读取数据。

2.1.1 匿名管道

A. 创建和使用方式

匿名管道的创建,需要通过系统调用pipe完成,函数原型如下(注意阅读注释,了解参数和返回值):

文件描述符:文件描述符是内核为了高效管理已被打开的文件所创建的索引,用于指向被打开的文件,I/O操作的系统调用都需要通过文件描述符完成。(这里的文件描述符指向管道)

1
2
3
4
5
6
7
8
9
10
/*
功能:创建一个用于亲缘进程(父子进程、兄弟进程)之间通信的无名管道(缓冲区),
并将管道与两个读写文件描述符关联起来。
参数:缓存地址,缓存用于存放读写管道的文件描述符
1. fd[0] 用于读取管道的文件描述符
2. fd[1] 用于写入管道的文件描述符
返回值:匿名管道是否创建成功,成功返回0,失败返回-1
*/
#include <unistd.h>
int pipe(int fd[2]);

从上述系统调用参数和返回值来看,创建匿名管道后,只能拿到两个文件描述符无法获得管道的名字(不能通过某个引用或指针定位到该匿名管道,因此称为匿名管道)

B. 应用场景

以父子进程和兄弟进程间的通信为例说明:
父进程A执行了系统调用pipe,若操作成功,那么进程A可获得两个文件描述符**fd[0]****fd[1]**,分别用于管道的读取和写入。如下图所示,此时两个文件描述符fd[0]fd[1]都是在一个进程里,并没有起到进程间通信的作用。
image.png
B-1 父子进程通信
此时父进程A可以通过系统调用fork()创建子进程a(实际上是copyOnWrite复制父进程,这里不展开描述),且子进程a会复制父进程A的文件描述符fd[0]fd[1]
由于管道只能一端写入、另一端输出,因此父进程A与子进程a通信时,可以采用以下做法:

  • 父进程A保留fd[1]用于写入,关闭fd[0]
  • 子进程a保留fd[0]用于读取,关闭fd[1]

此时可以实现父进程A到子进程a的单向通信(A->a),如下图所示**。**
image.png
B-2 兄弟进程通信
父进程A可以通过多次执行fork()系统调用,创建多个子进程,例如创建了子进程a1子进程a2。同样通过关闭和保留部分文件描述符的方式,可以实现子进程a1和a2之间的单向通信,如下图所示。
image.png

C. 匿名管道实例

对于如下Linux命令,作用是将ps auxf的输出作为grep mysql命令的输入。

1
$ ps auxf | grep mysql

其中**|**的本质就是创建了一个匿名管道,功能是将前一个命令的输出,作为后一个命令的输入。从该描述中可以看出,管道中的数据传输是单向的,如果想互相通信,需要创建两个管道。

2.1.2 有名管道

匿名管道只支持具有亲缘关系的进程间的通信,Linux内核还提供了有名管道(也叫作FIFO,因为数据是先进先出的传输方式),可应用于本机任意两个进程之间的通信。与匿名管道不同,在有名管道中,进程可以通过管道文件的路径打开管道,对管道进行写入和读取操作。

A. 创建和使用方式

A-1 使用**mkfifo**创建有名管道文件
有名管道文件的创建通过系统调用mkfifo实现,函数原型如下:

1
2
3
4
5
6
7
8
9
10
11
#include <sys/types.h>
#include <sys/stat.h>
/*
功能:创建一个用于任意两个进程之间通信的有名管道文件,
创建完成后,可以通过open函数打开该有名管道文件,获得读取/写入的文件描述符
参数:
1. pathname:待创建有名管道文件的路径
2. mode:指定管道文件的原始权限,一般为0x0664,必须包含读写权限
返回值:有名管道是否创建成功,成功返回0,失败返回-1
*/
int mkfifo(const char *pathname, mode_t mode);

A-2 使用**open**打开有名管道
有名管道文件创建完成后,进程可以通过系统调用open打开管道并获得对应的读取和写入文件描述符,函数原型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
/*
功能:指定打开方式(读取/写入, 可选择是否阻塞) 打开该有名管道文件,
获得读取/写入的文件描述符
参数:
1. pathname:本地已创建的有名管道文件的路径
2. flags:有名管道的打开方式
O_RDONLY:open将会调用阻塞,除非有另外一个进程以写的方式打开同一个FIFO,否则一直等待。
O_WRONLY:open将会调用阻塞,除非有另外一个进程以读的方式打开同一个FIFO,否则一直等待。
O_RDONLY|O_NONBLOCK:如果此时没有其他进程以写的方式打开FIFO,此时open也会成功返回,此时FIFO被读打开,而不会返回错误。
O_WRONLY:立即返回,如果此时没有其他进程以读的方式打开,open会失败打开,此时FIFO没有被打开,返回-1。
返回值:若打开成功,则返回文件描述符;否则返回-1
*/
int open(const char *pathname, int flags);

A-3 使用**read/write**读写管道进行通信
打开有名管道后,进程可以通过read/write系统调用读取或写入管道(注意管道是单向的)。函数原型如下:

1
2
3
4
#include <unistd.h>
/*主要关注参数fd,fd为open返回的文件描述符*/
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

A-4 使用**close**单向关闭有名管道(文件描述符)
若进程此时不需要对有名管道进行读取或写入操作,可以通过系统调用close关闭指定文件描述符,之后需要读写时,再通过open获取文件描述符。close的函数原型如下:

1
2
3
#include <unistd.h>
/*fd:读/写文件描述符*/
int close(int fd);

B. 应用场景

在使用匿名管道的场景下,由于进程无法通过管道文件打开管道获取文件描述符,因此匿名管道只支持具有亲缘关系的进程间通信。而命名管道不存在这一限制,可以实现任意进程间的单向通信,如下图所示
image.png

C. 有名管道实例

Linux下的shell命令mkfifo可以创建有名管道,且打开管道时默认采用阻塞的方式。
C-1 使用**mkfifo**创建有名管道文件/testPipe

1
$ mkfifo /testPipe

C-2 读取管道消息(实际上读取管道文件对应的管道里的内容)
注意:由于管道里没有内容,因此读取管道消息的命名会被阻塞。

1
2
$ cat /testPipe
$

C-3 写入消息到管道
新开shell窗口,写执行echo写入消息到管道。同时可以在上一个shell窗口看到C-2中的管道消息读取成功,获得test msg

1
$ echo "test msg" > /testPipe

2.1.3 管道的生命周期

无论是匿名管道,还是有名管道,都随着进程的创建而建立,随进程的结束而销毁。

2.1.4 管道通信方式的缺点

管道的通信效率较低

  1. 进程可能由于管道当前无数据读取而被阻塞,不适合进程间频繁地交换数据。
  2. 管道的本质是内核中的一段缓冲区,因此用户进程通过管道通信时,需要从内核中拷贝和写入数据,涉及用户态与内核态的切换,也会导致通信效率降低。

2.2 消息队列 (信箱)

2.2.1 消息队列的本质

消息队列是保存在内核中的消息链表(同样涉及到用户态与内核态的切换),在发送数据时,会分成一个个独立的数据单元,即消息体-数据块,消息体是用户自定义的数据类型。

2.2.2 消息队列与管道传输数据格式的区别

  • 消息队列通信方式下,消息的发送方和接收方要约定好消息体的数据类型
  • 管道通信方式下,发送方和接收方通过无格式的字节流进行数据传输

2.2.3 消息队列通信方式

两个进程A和B共用一个消息队列(信箱),发送方发送消息时,并不直接发送给接收方,而是先发送给消息队列,之后接收方从消息队列中读取数据。同时,发送方发送消息成功后可以直接返回(非阻塞式,不需要像管道一样需要等待进程读取),效率较高。

2.2.4 消息队列的生命周期

与管道不同,消息队列的生命周期跟随内核,只要没有释放消息队列或关闭操作系统,消息队列会一直存在。

2.2.5 消息队列通信方式的缺点

  1. 不适合传输较大数据

消息队列的总大小存在限制,同时每个消息体也会有最大长度限制。Linux内核中,会有两个宏定义MSGMAXMSGMNB,以字节为单位,定义了消息体的最大长度整个消息队列的最大长度

  1. 传输效率低

由于消息队列的本质是存储在内核中的消息链表,因此用户进程之间通过消息队列通信时,必然需要在用户态与内核态之间进行数据拷贝,涉及用户态与内核态的切换,开销较大。这一缺点和管道是类似的。

2.3 共享内存

从前面的描述来看,无论是管道还是消息队列,他们都存储在内核中,在读取和写入时都会发生用户态与内核态之间的数据拷贝过程。而共享内存解决了这一问题,不存在用户态与内核态的切换

2.3.1 共享内存机制

共享内存机制,就是多个进程都拿出一块虚拟地址空间,映射到相同的物理内存中。如下图所示:
image.png

2.3.2 共享内存的优点

  1. 开销小

共享内存机制下,进程之间共享一块相同的物理内存,消息传递时不需要经过内核,不涉及用户态与内核态之间的数据拷贝。

  1. 通信速度快

由于进程之间共享读写速率很快的物理内存,一个进程写入的数据,另一个进程可以马上看到,大大提升了进程间的通信速度。

2.4 信号量

2.4.1 信号量的作用

在共享内存通信的方式下,如果多个进程同时修改同一个共享内存,很有可能存在读-写/写-写冲突。为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。信号量实现了这一保护机制

2.4.2 信号量的本质

信号量本质是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。

2.4.3 信号量的原子操作

P操作用于进入共享资源之前,V操作用于离开共享资源之后,P|V操作必须成对出现。

  • **P 操作:**将信号量减1,相减后如果信号量<0,则表明资源已被占用,进程需要阻塞等待;若相减后信号量>=0,则表明还有资源可使用,进程可以正常继续执行。
  • **V 操作:**将信号量加1,相加后如果信号量<=0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量>0,则表明当前没有阻塞中的进程。

2.4.4 信号量的初始值

  • 信号量初始值为1时,代表互斥信号量,保证共享内存在任何时刻只有一个进程在访问;

image.png

  • 信号量初始值为0时,代表同步信号量,保证进程A应在进程B之前执行

image.png

2.5 信号

以上介绍的管道、消息队列、共享内存、信号量,都是常规状态下进程间的通信方式。对于异常情况下的通信,需要使用信号的方式通知进程。
值得一提的是:信号是进程间通信机制中唯一的异步通信机制,因为可以在任意时刻发送信号给某一进程,一旦有信号产生,即可对信号进行以下处理

  • 执行信号对应的事件操作
  • 捕捉信号(SIGKILL和SIGSTOP无法捕捉和忽略)
  • 忽略信号(SIGKILL和SIGSTOP无法捕捉和忽略)

2.5.1 Linux中的信号类型

通过shell命令kill -l可查看Linux系统中定义的64种事件信号类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
  • 事件信号类型举例 —— Ctrl+C 产生SIGINT信号,表示终止该进程(INTERUPT)

  • 事件信号类型举例 —— kill -9 PID 产生SIGKILL信号,表示强制终止该进程(KILL)

  • 事件信号类型举例 —— Ctrl+Z 产生SIGSTP信号,表示停止该进程,但还未结束(STOP)

    • Step1. 编写以下while.c文件并编译执行,会执行while(1)循环
1
2
3
4
5
6
7
8
#include<stdio.h>
int main(){
while(1){

}
return 0;
}
$ ./while
    • Step2. 此时在shell窗口中按下Ctrl+Z,会向当前正在执行的进程发送SIGSTP信号,代表停止该进程,但未结束,为暂停状态且转为后台。
1
2
^Z
[1]+ Stopped ./while
    • Step3. 通过jobs命令可查看当前shell会话下的后台进程
1
2
$ jobs
[1]+ Stopped ./while
    • Step4. 通过以下命令可以实现对挂起进程的操作
      • bg % n 将在后台暂停的进程转为继续执行。n为挂起进程的序号(不是进程ID)
1
2
$ bg
[1]+ ./while &
      • fg % n 将后台中的进程调至前台继续执行。
1
2
$ fg
./while
      • kill % n强制结束挂起的进程
1
2
3
4
5
$ kill % 1
[1]+ Stopped ./while
$ jobs
[1]+ Terminated ./while
$ jobs

2.5.2 事件信号的来源

  • **硬件来源:**如 Ctrl+Z/C
  • **软件来源:**如 kill 命令

2.6 Socket 套接字

以上提到的管道、消息队列、共享内存、信号量、信号是同一台主机上的进程之间的通信方式,若要实现不同主机上的进程间通信,必须使用Socket
注意:Socket不仅可以跨网络与不同主机的进程间通信,还可以在同主机LOCAL上的进程间通信

2.6.1 创建和使用方式

创建socket的系统调用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
功能:创建socket连接
参数:
1. domain:用于指定协议族(IPV4/IPV6/本机)
(a) AF_INET:用于IPV4
(b) AF_INET6:用于IPV6
(c) AF_LOCAL/AF_UNIX:用于本机
2. type:用于指定通信特性(TCP字节流/UDP数据报)
(a) SOCK_STREAM:表示TCP字节流
(b) SOCK_DGRAM:表示UDP数据报
(c) SOCK_RAW:表示原始套接字
3. protocal [已废弃]:用于指定通信协议(已通过domain和type指定),设为0即可
返回值:sokcet是否创建成功,成功返回0,失败返回-1
*/
int socket (int domain, int type, int protocal)

2.6.2 TCP、UDP、本地进程通信的socket编程模型

A. 针对TCP协议通信的socket编程模型
image.png
B. 针对UDP协议通信的socket编程模型
image.png
C. 针对本地进程间通信的socket编程模型