第五篇:计算机内部悄悄话,进程间通信全解析

一、前言

二、进程间通信

2.1 如何实现进程间通信?

每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核,如下图:

在这里插入图片描述
Linux 内核提供了不少进程间通信的机制,我们来一起瞧瞧有哪些?

2.2 进程间通信的方式

进程间通信方式包括:管道、消息队列、共享内存、信号量、信号、Socket

为什么要进程间通信?如何实现进程间通信?
每个进程的用户空间都是独立的,不能互相访问的,不能通信;但是他们的内核空间是每个进程都共享的,可以通信,所以进程之间要通信必须通过内核。
进程间通信的方式有哪些?
进程间通信方式包括:管道、消息队列、共享内存、信号量、信号、Socket

三、进程间通信方式:管道

3.1 第一种管道:匿名管道

如果你学过 Linux 命令,那你肯定很熟悉「|」这个竖线。

$ ps auxf | grep mysql

上面命令行里的「|」竖线就是一个管道,它的功能是将前一个命令(ps auxf)的输出,作为后一个命令(grep mysql)的输入,从这功能描述,可以看出管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行。

同时,我们得知上面这种管道是没有名字,所以「|」表示的管道称为匿名管道,用完了就销毁。

3.2 第二种管道:命名管道

管道还有另外一个类型是命名管道,也被叫做 FIFO,因为数据是先进先出的传输方式。

在使用命名管道前,先需要通过 mkfifo 命令来创建,并且指定管道名字:

$ mkfifo myPipe

myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在,我们可以用 ls 看一下,这个文件的类型是 p,也就是 pipe(管道) 的意思:

$ ls -l
prw-r--r--. 1 root    root         0 Jul 17 02:45 myPipe

金手指:
ls //显示不隐藏的文件与文件夹
ls -a //显示当前目录下的所有文件及文件夹包括隐藏的.和…等
ls -l //显示不隐藏的文件与文件夹的详细信息
ls -al //显示当前目录下的所有文件及文件夹包括隐藏的.和…等的详细信息

3.3 管道读写(先将数据写入管道,再从管道中读出数据)

接下来,我们往 myPipe 这个管道写入数据:

$ echo "hello" > myPipe  // 将数据写进管道
                         // 停住了 ...

你操作了后,你会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。

于是,我们执行另外一个命令来读取这个管道里的数据:

$ cat < myPipe  // 读取管道里的数据
hello

可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo 那个命令也正常退出了。

3.4 进程间通信:管道的优缺点

缺点:效率低,不适合进程间频繁地交换数据

优点:简单,我们很容易得知管道里的数据已经被另一个进程读取了

面试官:介绍一下管道通信?
第一,管道包括两种,匿名管道和命名管道
匿名管道(管道新建、管道输入、管道输出)
匿名管道就是命令行中的 | ,| 前面的输出是 | 后面内容的输入,这样就使用匿名管道完成了进程间通信。
一句小结匿名管道:| 新建管道,| 前面的输出的管道的输入,然后管道将内容输出到 | 后面的进程去
windows和linux都适用
windows如:netstat -ano|findStr 8080
Linux如:ps auxf | grep mysql
命名管道(管道新建、管道输入、管道输出):
一句话总结:
$ mkfifo myPipe // 新建命名管道
$ echo “hello” > myPipe // 命名管道输入
$ cat < myPipe // 命名管道输出

第二,linux中一切都是文件,管道也是文件,管道是类型为p的文件
第三,管道作为一种进程间通信方式的优缺点
缺点:效率低,不适合进程间频繁地交换数据。
优点:简单,我们很容易得知管道里的数据已经被另一个进程读取了。

3.5 管道底层的系统调用

那管道如何创建呢,背后原理是什么?

匿名管道(新建、输入、输出)

匿名管道的创建,需要通过下面这个系统调用:

int pipe(int fd[2])

这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。注意,这个匿名管道是特殊的文件,和一般文件不同,它只存在于内存,不存于文件系统中

在这里插入图片描述

其实,所谓的管道,本质上是内核里面的一串缓存。从管道的一端写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限

看到这,你可能会有疑问了,这两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢?

我们可以使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0] 与 fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。

在这里插入图片描述
管道只能一端写入,另一端读出,所以上面这种模式容易造成混乱,因为父进程和子进程都可以同时写入,也都可以读出。那么,为了避免这种情况,通常的做法是:

(1)父进程关闭读取的 fd[0],只保留写入的 fd[1];

(2)子进程关闭写入的 fd[1],只保留读取的 fd[0];

在这里插入图片描述

管道双向通信就要创建两个管道

所以说如果需要双向通信,则应该创建两个管道。

到这里,我们仅仅解析了使用管道进行父进程与子进程之间的通信,但是在我们 shell 里面并不是这样的。

在 shell 里面执行 A | B命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。

在这里插入图片描述
所以说,在 shell 里通过「|」匿名管道将多个命令连接在一起,实际上也就是创建了多个子进程,那么在我们编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样可以减少创建子进程的系统开销。

我们可以得知,对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件(匿名管道只存在于内存中而不存在文件系统中,命名管道存在文件系统中,是一个文件类型为p的文件),只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的

另外,对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信

不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

面试官:组织语言阐述进程间通信管道底层实现 匿名管道(新建、输入、输出)
第一,匿名管道的创建
从宏观命令 | 到底层 int pipe(int fd[2])
进程通过使用 | 命令创建匿名管道,底层,匿名管道创建对应的系统调用: int pipe(int fd[2]),这个 int pipe(int fd[2]) 的业务逻辑:
(1)创建一个匿名管道,(2)返回了两个描述符给调用的进程来操作新建创建的管道,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1] 用于该进程对应新创建的管道进行写操作。

附:新创建的匿名管道到底是个什么东西?
(1)linux上一切都是文件,所有这个新创建的管道是一个文件。
(2)这个新创建的匿名管道是一个特殊的文件,和一般文件不同,它只存在于内存,不存于文件系统中,就是一个没有被持久化的文件;
(3)这个新创建的匿名管道,本质上是内核里面的一串缓存。从管道的一端写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限

第二,如何使用这个新创建的匿名管道来实现进程间通信(匿名管道的输入输出)?
key:一个管道的通信是单向的,所以一个管道只能实现A进程到B进程的单向通信,要想同时实现A进程和B进程之间的相互通信,必须在A进程和B进程之间新建两个管道。
管道的单向通信的实现(父进程向子进程通信):
一个进程使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0] 与 fd[1]」,然后,
父进程关闭读取的 fd[0],只保留写入的 fd[1];子进程关闭写入的 fd[1],只保留读取的 fd[0];从而实现父子进程之间通信。
注意:双向通信一定要两个管道。
管道的双向通信的实现( | 命令,A进程和B进程之间相互通信):
所以说如果需要双向通信,则应该创建两个管道,
如果要进程A向进程B通信,执行命令 A | B
如果要进程B向进程A通信,执行命令 B | A
值得注意的是,在 shell 里面执行 A | B命令的时候,如上面将的一个父进程创建一个子进程不是一回事,而是一个父进程shell创建两个子进程 A 进程和 B 进程

命名管道(创建、输入、输出)
命名管道和匿名管道不仅在使用上不同,在底层也是不同的,
不同点1:管道文件是否持久化到文件系统中,匿名管道只存在于内存中而不存在文件系统中,命名管道存在文件系统中,是一个文件类型为p的文件
不同点2:匿名管道,它的通信范围是存在父子关系的进程,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的( | 命令为一个父进程创建两个子进程);命名管道,它可以在不相关的进程间(没有父子关系的进程)也能相互通信。因为命名管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信
相同点1:进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取;
相同点2:通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作

四、进程间通信:消息队列

管道通信的缺陷,引入消息队列作为进程间通信

管道进程间通信缺陷:效率低,不适合进程间频繁地交换数据。

消息队列(或者说邮件)作为进程间通信方式的优点

1、从一个个消息发送,到一批批消息发送来提高效率。对于管道通信效率低,消息队列的通信模式就可以解决。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。

从管道到消息队列
1、从一个个消息发送,到一批批消息发送来提高效率。
2、在提高效率的基础上,为保证管道通信(匿名管道+命名管道)的FIFO不被破坏,数据结构上使用了队列。

2、固定大小的消息体而不是无格式字节流数据:每次发送的是消息体,是固定大小的存储块,不像管道是无格式的字节流数据。(过程:消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,发送方发送消息体,接收方进程从消息队列中读取消息体,然后linux内核就会把这个消息体删除。)

3、生命周期:对于消息队列:消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在;对于匿名管道:匿名管道生命周期,是随进程的创建而建立,随进程的结束而销毁。

消息队列三个优点:
1、接收进程自己到消息队列中取,发送进程放到消息队列中就好了,和方式上和管道还是一样的(A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了)
1.1 从一个个消息发送,到一批批消息发送来提高效率。
1.2 在提高效率的基础上,为保证管道通信(匿名管道+命名管道)的FIFO不被破坏,数据结构上使用了队列。
2、固定大小的消息体而不是无格式字节流数据
3、生命周期:
对于消息队列:消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在;
对于匿名管道:匿名管道生命周期,是随进程的创建而建立,随进程的结束而销毁。

消息这种模型,两个进程之间的通信就像平时发邮件一样,你来一封,我回一封,可以频繁沟通了。

消息队列(或者说邮件)作为进程间通信方式的缺陷

一是通信不及时,二是附件也有大小限制

linux对于消息体的大小限制:消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。

消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程

消息队列两个缺陷:
1、通信不及时:类似邮件,接收进程自己到消息队列中取,发送进程放到消息队列中就好了,如果接收进程不能及时取出消息体,造成通信不及时。
1、通信不及时,用户态与内核态之间的数据拷贝开销:两个通信进程在用户态,消息队列在内核态,所以,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程
2、消息体大小限制和一个队列大小限制和全部队列大小限制:消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限

五、进程间通信:共享内存

从消息队列的不足到共享内存的引入

消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式,就很好的解决了这一问题。

从每个进程独立的虚拟内存空间到共享内存

现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。

共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度

在这里插入图片描述

共享内存作为进程间通信的定义和优点:
1、虚拟内存技术:虚拟内存=实际内存+一部分硬盘;
2、每个进程的虚拟内存:每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中
进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响
3、共享内存定义:共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。
4、优点:进程间可见性:一个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度

六、进程间通信:信号量(处理共享内存多进程写冲突,类似Java中处理共享变量多线程写冲突)

共享内存多进程写冲突,类似Java中处理共享变量多线程写冲突

用了共享内存通信方式,带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。

共享内存多进程写冲突解决方式:使用信号量处理

为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。

信号量PV操作,处理进程同步和进程通信的基础

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

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

信号量表示资源的数量,控制信号量的方式有两种原子操作:

(1)一个是 P 操作,这个操作会把信号量减去 -1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。

(2)另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;

从Java多线程理解PV操作:
P操作完成两件事,信号量减少1,不符合条件则阻塞进程(相对于java中wait()),符合条件不操作。
V操作完成两件事,信号量增加1,调用notify(),有阻塞线程则唤醒,无阻塞线程无用。

P 操作是用在进入共享资源之前,V 操作是作用在离开共享资源之后,这两个操作是必须成对出现的。

资源信号量实现进程同步(资源的互斥访问)

接下来,举个例子,如果要使得两个进程互斥访问共享内存,为保证原子性,任意时候只能允许一个进程访问共享内存,我们可以初始化信号量为 1。

在这里插入图片描述
具体的过程如下:

(1)进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。

(2)若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。

(3)直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。

可以发现,信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。

通信信号量,进程之间通信

另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个进程能密切合作,以实现一个共同的任务。

例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。

那么这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为 0。

在这里插入图片描述

具体过程:

(1)如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待wait();

(2)接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;

(3)最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。

可以发现,信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行

P减一并自带wait
V加一并自带notify
这里进程A只有V,没有P,保证进程A不会阻塞,进程B只有P,没有V,保证进程B不会唤醒自己,一定要进程A才能唤醒进程B(因为只有进程A才有V操作)

信号量小结:
信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问
在这里插入图片描述
信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行
P减一并自带wait
V加一并自带notify
这里进程A只有V,没有P,保证进程A不会阻塞,进程B只有P,没有V,保证进程B不会唤醒自己,一定要进程A才能唤醒进程B(因为只有进程A才有V操作) 在这里插入图片描述

七、进程间通信:信号

7.1 信号引入

对于异常情况下的工作模式,就需要用「信号」的方式来通知进程

上面说的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。

信号跟信号量虽然名字相似度 66.66%,但两者用途完全不一样,就好像 Java 和 JavaScript 的区别。

在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号:

$ 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

7.2 信号事件的两种来源

运行在 shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如

(1)Ctrl+C 产生 SIGINT 信号,表示终止该进程;

(2)Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;

如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:

kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来立即结束该进程;

所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。

7.3 信号响应的三种方式

信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式

  1. 执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。Core 的意思是 Core Dump,也即终止进程后,通过 Core Dump 将当前进程的运行状态保存在文件里面,方便程序员事后进行分析问题在哪里。

  2. 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。

  3. 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,只能执行默认操作,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。

小结:
信号定义:对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。
信号两种来源:信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。
信号三种响应:
信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式

  1. 执行默认操作。Linux 对每种信号都规定了默认操作,直接执行信号默认操作。
  2. 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
  3. 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,只能执行默认操作,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。

八、跨网络不同主机之间进程通信:Socket

8.1 Socket通信的系统调用(int socket(int domain, int type, int protocal),前两个参数有用)

为什么需要Socket,Socket的意义?

前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。

实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。

我们来看看创建 socket 的系统调用:

int socket(int domain, int type, int protocal)

三个参数分别代表:

(1)第一个参数 domain 参数用来指定协议族,

AF_INET 用于 IPV4、

AF_INET6 用于 IPV6、(暂时不用)

AF_LOCAL/AF_UNIX 用于本机;

(2)第二个参数 type 参数用来指定通信特性,

SOCK_STREAM 表示的是字节流 对应 TCP,

SOCK_DGRAM 表示的是数据报 对应 UDP,

SOCK_RAW 表示的是原始套接字;(没用过)

(3)第三个参数 protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可;

根据创建 socket 类型的不同,通信的方式也就不同

(1)不同主机的进程之间远程通信:实现 TCP 字节流通信:socket 类型是 AF_INET (第一个参数domain ipv4)和 SOCK_STREAM(第二个参数type TCP);

(2)不同主机的进程之间远程通信:实现 UDP 数据报通信:socket 类型是 AF_INET (第一个参数domain ipv4)和 SOCK_DGRAM (第二个参数type UDP);

(3)同一主机的进程之间本地通信:实现本地进程间通信:

「本地字节流 socket 」类型是 AF_LOCAL (第一个参数 domain 本机)和 SOCK_STREAM(第二个参数type tcp),

「本地数据报 socket 」类型是 AF_LOCAL (第一参数domain 本机)和 SOCK_DGRAM(第二个参数type udp)。

另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket;

小结:
Socket通信的系统调用(int socket(int domain, int type, int protocal),前两个参数有用
第一个参数domain AF_INET 用于 IPV4、 AF_LOCAL/AF_UNIX 用于本机;
第二个参数type SOCK_STREAM 表示的是字节流 对应 TCP、SOCK_DGRAM 表示的是数据报 对应 UDP;
两两组合四种情况。

接下来,简单说一下这三种通信的编程模式。

8.2 针对 TCP 协议通信的 socket 编程模型

在这里插入图片描述
(1)服务端和客户端初始化 socket,得到文件描述符;

(2)服务端调用 bind,将绑定在 IP 地址和端口;

(3)服务端调用 listen,进行监听;(事件注入事件触发者、事件监听注入事件,保证事件发生时,监听者执行相关操作)

(4)服务端调用 accept,等待客户端连接;(服务端阻塞)

(5)客户端调用 connect,向服务器端的地址和端口发起连接请求;

(6)服务端 accept 返回用于传输的 socket 的文件描述符;

(7)客户端调用 write 写入数据;服务端调用 read 读取数据;

(8)客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后(金手指:这就是为什么建立连接要三个报文,但是释放连接要四个报文的原因,因为释放的时候另一端不一定处理完成了,要等待),服务端调用 close,表示连接关闭。

注意1:两个socket,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。

注意2:双方都是read write,所以上图中类似循环:成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。

8.3 针对 UDP 协议通信的 socket 编程模型

在这里插入图片描述
UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind

对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。

另外,每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。

8.4 针对本地进程间通信的 socket 编程模型

本地 socket 被用于在同一台主机上进程间通信的场景:

(1)本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;

(2)本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现;

对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。

对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。

本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别

九、面试金手指(面试语言组织,需要背下来的东西)

9.0 进程间通信

为什么要进程间通信?如何实现进程间通信?
每个进程的用户空间都是独立的,不能互相访问的,不能通信;但是他们的内核空间是每个进程都共享的,可以通信,所以进程之间要通信必须通过内核。
进程间通信的方式有哪些?
进程间通信方式包括:管道、消息队列、共享内存、信号量、信号、Socket

9.1 管道

面试官:介绍一下管道通信?
第一,管道包括两种,匿名管道和命名管道
匿名管道(管道新建、管道输入、管道输出)
匿名管道就是命令行中的 | ,| 前面的输出是 | 后面内容的输入,这样就使用匿名管道完成了进程间通信。
一句小结匿名管道:| 新建管道,| 前面的输出的管道的输入,然后管道将内容输出到 | 后面的进程去
windows和linux都适用
windows如:netstat -ano|findStr 8080
Linux如:ps auxf | grep mysql
命名管道(管道新建、管道输入、管道输出):
一句话总结:
$ mkfifo myPipe // 新建命名管道
$ echo “hello” > myPipe // 命名管道输入
$ cat < myPipe // 命名管道输出

第二,linux中一切都是文件,管道也是文件,管道是类型为p的文件
第三,管道作为一种进程间通信方式的优缺点
缺点:效率低,不适合进程间频繁地交换数据。
优点:简单,我们很容易得知管道里的数据已经被另一个进程读取了。

面试官:组织语言阐述进程间通信管道底层实现 匿名管道(新建、输入、输出)
第一,匿名管道的创建
从宏观命令 | 到底层 int pipe(int fd[2])
进程通过使用 | 命令创建匿名管道,底层,匿名管道创建对应的系统调用: int pipe(int fd[2]),这个 int pipe(int fd[2]) 的业务逻辑:
(1)创建一个匿名管道,(2)返回了两个描述符给调用的进程来操作新建创建的管道,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1] 用于该进程对应新创建的管道进行写操作。

附:新创建的匿名管道到底是个什么东西?
(1)linux上一切都是文件,所有这个新创建的管道是一个文件。
(2)这个新创建的匿名管道是一个特殊的文件,和一般文件不同,它只存在于内存,不存于文件系统中,就是一个没有被持久化的文件;
(3)这个新创建的匿名管道,本质上是内核里面的一串缓存。从管道的一端写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限

第二,如何使用这个新创建的匿名管道来实现进程间通信(匿名管道的输入输出)?
key:一个管道的通信是单向的,所以一个管道只能实现A进程到B进程的单向通信,要想同时实现A进程和B进程之间的相互通信,必须在A进程和B进程之间新建两个管道。
管道的单向通信的实现(父进程向子进程通信):
一个进程使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0] 与 fd[1]」,然后,
父进程关闭读取的 fd[0],只保留写入的 fd[1];子进程关闭写入的 fd[1],只保留读取的 fd[0];从而实现父子进程之间通信。
注意:双向通信一定要两个管道。
管道的双向通信的实现( | 命令,A进程和B进程之间相互通信):
所以说如果需要双向通信,则应该创建两个管道,
如果要进程A向进程B通信,执行命令 A | B
如果要进程B向进程A通信,执行命令 B | A
值得注意的是,在 shell 里面执行 A | B命令的时候,如上面将的一个父进程创建一个子进程不是一回事,而是一个父进程shell创建两个子进程 A 进程和 B 进程

命名管道(创建、输入、输出)
命名管道和匿名管道不仅在使用上不同,在底层也是不同的,
不同点1:管道文件是否持久化到文件系统中,匿名管道只存在于内存中而不存在文件系统中,命名管道存在文件系统中,是一个文件类型为p的文件
不同点2:匿名管道,它的通信范围是存在父子关系的进程,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的( | 命令为一个父进程创建两个子进程);命名管道,它可以在不相关的进程间(没有父子关系的进程)也能相互通信。因为命名管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信
相同点1:进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取;
相同点2:通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作

9.2 消息队列

消息队列的通信流程?
消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。

消息队列三个优点:
1、接收进程自己到消息队列中取,发送进程放到消息队列中就好了,和方式上和管道还是一样的(A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了)
1.1 从一个个消息发送,到一批批消息发送来提高效率。
1.2 在提高效率的基础上,为保证管道通信(匿名管道+命名管道)的FIFO不被破坏,数据结构上使用了队列。
2、固定大小的消息体而不是无格式字节流数据
3、生命周期:
对于消息队列:消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在;
对于匿名管道:匿名管道生命周期,是随进程的创建而建立,随进程的结束而销毁。

消息队列两个缺陷:
1、通信不及时:类似邮件,接收进程自己到消息队列中取,发送进程放到消息队列中就好了,如果接收进程不能及时取出消息体,造成通信不及时。
1、通信不及时,用户态与内核态之间的数据拷贝开销:两个通信进程在用户态,消息队列在内核态,所以,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程
2、消息体大小限制和一个队列大小限制和全部队列大小限制:消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限

9.3 共享内存

过渡问题:共享内存是如何处理消息队列的缺陷的(用户态与内核态之间切换开销)?
共享内存可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。

共享内存作为进程间通信的定义和优点:
1、虚拟内存技术:虚拟内存=实际内存+一部分硬盘;
2、每个进程的虚拟内存:每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中
进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响
3、共享内存定义:共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。
4、优点:进程间可见性:一个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度

9.4 信号量(共享内存的补充)

过渡问题:信号量如何处理共享内存的多进程写问题? 信号量保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。

信号量小结:
信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问
在这里插入图片描述
信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行
P减一并自带wait
V加一并自带notify
这里进程A只有V,没有P,保证进程A不会阻塞,进程B只有P,没有V,保证进程B不会唤醒自己,一定要进程A才能唤醒进程B(因为只有进程A才有V操作) 在这里插入图片描述

9.5 信号(共享内存的补充)

小结:
信号定义:对于异常情况下的工作模式,就需要用「信号」的方式来通知进程,信号是进程间通信机制中唯一的异步通信机制,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件
信号两种来源:信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。
信号三种响应:
在任何时候发送信号给某一进程,一旦有信号产生,我们就有几种用户进程对信号的处理方式:

  1. 执行默认操作。Linux 对每种信号都规定了默认操作,直接执行信号默认操作。
  2. 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
  3. 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,只能执行默认操作,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。

9.6 Socket通信

Socket 第一种情况
Socket通信的系统调用(int socket(int domain, int type, int protocal),前两个参数有用
第一个参数domain AF_INET 用于 IPV4、 AF_LOCAL/AF_UNIX 用于本机;
第二个参数type SOCK_STREAM 表示的是字节流 对应 TCP、SOCK_DGRAM 表示的是数据报 对应 UDP;
两两组合四种情况。
Socket 不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。

在这里插入图片描述
(1)服务端和客户端初始化 socket,得到文件描述符;
(2)服务端调用 bind,将绑定在 IP 地址和端口;
(3)服务端调用 listen,进行监听;(事件注入事件触发者、事件监听注入事件,保证事件发生时,监听者执行相关操作)
(4)服务端调用 accept,等待客户端连接;(服务端阻塞)
(5)客户端调用 connect,向服务器端的地址和端口发起连接请求;
(6)服务端 accept 返回用于传输的 socket 的文件描述符;
(7)客户端调用 write 写入数据;服务端调用 read 读取数据;
(8)客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后(金手指:这就是为什么建立连接要三个报文,但是释放连接要四个报文的原因,因为释放的时候另一端不一定处理完成了,要等待),服务端调用 close,表示连接关闭。
注意1:两个socket,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。
注意2:双方都是read write,所以上图中类似循环:成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。

在这里插入图片描述
1、bind:UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind
2、每一个 UDP 的 socket 都需要 bind:对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。
3、sendto和recvfrom:每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。

本地 socket 被用于在同一台主机上进程间通信的场景:
(1)本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;
(2)本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现;
对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。
对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。
本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别

9.7 附加问题:线程间通信

附加问题:线程之间通信?
同个进程下的线程之间都是共享进程的资源,只要是共享变量都可以做到线程间通信,比如全局变量,所以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源的问题,信号量也同样可以在线程间实现互斥与同步:

互斥的方式,可保证任意时刻只有一个线程访问共享资源;

同步的方式,可保证线程 A 应在线程 B 之前执行;

十、小结

谈一谈你对进程间通信的理解?完成了。

天天打码,天天进步!!!

参考:https://mp.weixin.qq.com/s?__biz=MzAwNDA2OTM1Ng==&mid=2453145217&idx=2&sn=9f7e20a4837c11870a8e715f43b993d4&chksm=8cfd2402bb8aad140f0958bb38c136c5e3d552101e1d3fc620be4012458ed2e0f85ba4abe955&scene=126&sessionid=1597023041&key=041bb01ba83758f981a9a7a6d18865c8f728e62d55ebca0c7b7219674dbe59af84ea6e9d1ebc3abd0a11545da81ef344d3a24103e2d67c0cca4d71e871d3d9a063eb0497bccf0eb571d919e3f98fb92b&ascene=1&uin=MjA2MzM0NTY2OA%3D%3D&devicetype=Windows+10+x64&version=62090529&lang=zh_CN&exportkey=A%2FA%2BA47b95JZt7y5A8XYBw8%3D&pass_ticket=5U5MXaGCShax3W0jxUiaVzq7r8yGJHn3sG4s%2FbqJbwFUMKOY7%2F4ho8k8kZa7IJmM

  • 0
    点赞
  • 0
    收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

毛奇志

打赏一下

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值